@bacnh85/pi-subagent 0.9.0 → 0.9.2

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,18 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.9.2 (2026-07-16)
4
+
5
+ ### Pi SDK compatibility
6
+
7
+ - Removed use of the deleted `AuthStorage.inMemory()` API so delegated planner and other subagents start on Pi 0.80.10.
8
+
9
+ ## 0.9.1 (2026-07-16)
10
+
11
+ ### Activity-aware timeouts
12
+
13
+ - 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.
14
+ - `/agent` distinguishes real activity from transport heartbeats and reports idle versus hard timeouts.
15
+
3
16
  ## 0.9.0 (2026-07-16)
4
17
 
5
18
  ### 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
 
@@ -14,17 +14,14 @@
14
14
  */
15
15
 
16
16
  import * as path from "node:path";
17
- import type { Model } from "@earendil-works/pi-ai";
18
17
  import { StringEnum } from "@earendil-works/pi-ai";
19
18
  import {
20
- AuthStorage,
21
19
  CONFIG_DIR_NAME,
22
20
  DynamicBorder,
23
21
  type ExtensionAPI,
24
22
  type ExtensionContext,
25
23
  getAgentDir,
26
24
  getMarkdownTheme,
27
- ModelRegistry,
28
25
  type ThemeColor,
29
26
  } from "@earendil-works/pi-coding-agent";
30
27
  import { Container, Markdown, SelectList, Spacer, Text } from "@earendil-works/pi-tui";
@@ -32,6 +29,7 @@ import { Type } from "typebox";
32
29
 
33
30
  import { type AgentColor, type AgentConfig, type AgentScope, discoverAgents, formatAgentList, getModelCandidates, invalidateAgentCache } from "./agents.ts";
34
31
  import {
32
+ type SubAgentProgress,
35
33
  type SubAgentResult,
36
34
  getFinalOutput,
37
35
  getResultOutput,
@@ -94,14 +92,14 @@ const TaskItem = Type.Object({
94
92
  agent: Type.String({ description: "Name of the agent to invoke" }),
95
93
  task: Type.String({ description: "Task to delegate to the agent" }),
96
94
  cwd: Type.Optional(Type.String({ description: "Working directory for the agent" })),
97
- timeout: Type.Optional(Type.Number({ description: "Timeout in milliseconds for this task" })),
95
+ 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
96
  });
99
97
 
100
98
  const ChainItem = Type.Object({
101
99
  agent: Type.String({ description: "Name of the agent to invoke" }),
102
100
  task: Type.String({ description: "Task with optional {previous} placeholder for prior output" }),
103
101
  cwd: Type.Optional(Type.String({ description: "Working directory for the agent" })),
104
- timeout: Type.Optional(Type.Number({ description: "Timeout in milliseconds for this step" })),
102
+ 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
103
  });
106
104
 
107
105
  const AgentScopeSchema = StringEnum(["user", "project", "both"] as const, {
@@ -126,7 +124,7 @@ const SubagentParams = Type.Object({
126
124
  // Project-agent confirmation is enforced via trusted configuration.
127
125
  // See Security model section in README.
128
126
  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)" })),
127
+ timeout: Type.Optional(Type.Number({ description: "Global inactivity timeout in milliseconds (default 3 minutes; real activity resets it; fixed 20-minute absolute cap)" })),
130
128
  instructions: Type.Optional(Type.String({ description: "Bounded repository/task instructions passed to each child (max 16 KB)" })),
131
129
  abortOnFailure: Type.Optional(Type.Boolean({ description: "In parallel mode, cancel remaining tasks when one fails. Default: false.", default: false })),
132
130
  });
@@ -208,6 +206,7 @@ export default function (pi: ExtensionAPI) {
208
206
  instructions: request.instructions,
209
207
  signal: request.signal,
210
208
  onMessage: (result) => threadStore.updateThread(thread.id, { result }),
209
+ onProgress: (progress) => { threadStore.updateProgress(thread.id, progress); request.onProgress?.(progress); },
211
210
  }).then((result) => {
212
211
  threadStore.updateThread(thread.id, {
213
212
  status: isFailedResult(result) ? (result.stopReason === "aborted" ? "aborted" : "failed") : "completed",
@@ -461,20 +460,9 @@ export default function (pi: ExtensionAPI) {
461
460
  }
462
461
  }
463
462
 
464
- // Shared auth/model setup for SDK sessions
465
- // ponytail: reuse parent modelRegistry instead of a fresh copy — avoids
466
- // internal API casts (storeModelHeaders) and preserves env/headers/OAuth.
467
- const authStorage = AuthStorage.inMemory();
468
463
  const modelRegistry = ctx.modelRegistry;
469
-
470
- // Helper: inject parent's API key into child auth storage
471
- async function injectApiKey(model: Model<any>): Promise<void> {
472
- const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model);
473
- if (auth.ok) {
474
- if (auth.apiKey) authStorage.setRuntimeApiKey(model.provider, auth.apiKey);
475
- // ponytail: headers/env stay on the parent registry — no copy needed.
476
- }
477
- }
464
+ const modelRuntime = (modelRegistry as any).runtime;
465
+ const authStorage = (modelRegistry as any).authStorage;
478
466
 
479
467
  // Helper: resolve a safe child working directory.
480
468
  function resolveChildCwd(childCwd: string | undefined): string {
@@ -520,7 +508,9 @@ export default function (pi: ExtensionAPI) {
520
508
  parentSignal?: AbortSignal,
521
509
  timeoutMs?: number,
522
510
  onProgress?: (partial: SubAgentResult) => void,
511
+ onActivity?: (progress: SubAgentProgress) => void,
523
512
  heartbeatDetails?: () => SubagentDetails,
513
+ onHeartbeat?: () => void,
524
514
  isReadOnly?: boolean,
525
515
  ): Promise<SubAgentResult> {
526
516
  const agent = agents.find((a) => a.name === agentName);
@@ -562,8 +552,6 @@ export default function (pi: ExtensionAPI) {
562
552
  let effectiveTimeoutMs: number | undefined;
563
553
  let safeCwd: string;
564
554
  try {
565
- // Inject parent's API key so --api-key and other runtime overrides work
566
- await injectApiKey(resolved.model);
567
555
  tools = resolveChildTools(agent.tools, agent.sandbox, isReadOnly);
568
556
  effectiveTimeoutMs = resolveChildTimeout(timeoutMs, params.timeout);
569
557
  safeCwd = resolveChildCwd(cwd);
@@ -582,10 +570,10 @@ export default function (pi: ExtensionAPI) {
582
570
  };
583
571
  }
584
572
 
585
- const stopHeartbeat = onUpdate ? startHeartbeat(() => onUpdate({
586
- content: [{ type: "text", text: `Subagent ${agentName} is still running…` }],
587
- details: heartbeatDetails?.() ?? makeDetails("single")([]),
588
- })) : undefined;
573
+ const stopHeartbeat = onUpdate ? startHeartbeat(() => {
574
+ onHeartbeat?.();
575
+ onUpdate({ content: [{ type: "text", text: `Subagent ${agentName} is still running…` }], details: heartbeatDetails?.() ?? makeDetails("single")([]) });
576
+ }) : undefined;
589
577
  try {
590
578
  return await runSubAgent({
591
579
  cwd: safeCwd,
@@ -595,6 +583,7 @@ export default function (pi: ExtensionAPI) {
595
583
  task,
596
584
  tools,
597
585
  model: resolved.model,
586
+ modelRuntime,
598
587
  authStorage,
599
588
  modelRegistry,
600
589
  signal: parentSignal,
@@ -602,6 +591,7 @@ export default function (pi: ExtensionAPI) {
602
591
  agentName,
603
592
  thinkingLevel: agent.thinking,
604
593
  onMessage: onProgress,
594
+ onProgress: onActivity,
605
595
  });
606
596
  } finally {
607
597
  stopHeartbeat?.();
@@ -628,7 +618,9 @@ export default function (pi: ExtensionAPI) {
628
618
  step.agent, taskWithContext, step.cwd,
629
619
  signal, step.timeout ?? params.timeout,
630
620
  (partial) => threadStore.updateThread(thread.id, { result: partial }),
621
+ (progress) => threadStore.updateProgress(thread.id, progress),
631
622
  () => makeDetails("chain")(results),
623
+ () => threadStore.refreshHeartbeat(thread.id),
632
624
  );
633
625
  threadStore.updateThread(thread.id, {
634
626
  status: isFailedResult(result) ? (result.stopReason === "aborted" ? "aborted" : "failed") : "completed",
@@ -782,7 +774,9 @@ export default function (pi: ExtensionAPI) {
782
774
  t.agent, t.task, t.cwd,
783
775
  parallelController.signal, t.timeout ?? params.timeout,
784
776
  (partial) => threadStore.updateThread(parallelThreads[index].id, { result: partial }),
777
+ (progress) => threadStore.updateProgress(parallelThreads[index].id, progress),
785
778
  () => makeDetails("parallel")([...allResults]),
779
+ () => threadStore.refreshHeartbeat(parallelThreads[index].id),
786
780
  );
787
781
  allResults[index] = result;
788
782
  threadStore.updateThread(parallelThreads[index].id, {
@@ -838,7 +832,9 @@ export default function (pi: ExtensionAPI) {
838
832
  params.agent, params.task, params.cwd,
839
833
  signal, params.timeout,
840
834
  (partial) => threadStore.updateThread(thread.id, { result: partial }),
835
+ (progress) => threadStore.updateProgress(thread.id, progress),
841
836
  () => makeDetails("single")([]),
837
+ () => threadStore.refreshHeartbeat(thread.id),
842
838
  );
843
839
  threadStore.updateThread(thread.id, {
844
840
  status: isFailedResult(result) ? (result.stopReason === "aborted" ? "aborted" : "failed") : "completed",
@@ -17,10 +17,8 @@
17
17
  import type { Message, Model } from "@earendil-works/pi-ai";
18
18
  import type { AgentMessage } from "@earendil-works/pi-agent-core";
19
19
  import {
20
- AuthStorage,
21
20
  createAgentSession,
22
21
  createExtensionRuntime,
23
- ModelRegistry,
24
22
  type ResourceLoader,
25
23
  SessionManager,
26
24
  SettingsManager,
@@ -45,6 +43,18 @@ export interface UsageStats {
45
43
  turns: number;
46
44
  }
47
45
 
46
+ export const DEFAULT_INACTIVITY_TIMEOUT_MS = 3 * 60 * 1000;
47
+ export const HARD_TIMEOUT_MS = 20 * 60 * 1000;
48
+
49
+ export interface SubAgentProgress {
50
+ label: string;
51
+ at: number;
52
+ elapsedMs: number;
53
+ inactivityDeadline: number;
54
+ hardDeadline: number;
55
+ result: SubAgentResult;
56
+ }
57
+
48
58
  export interface SubAgentResult {
49
59
  agent: string;
50
60
  task: string;
@@ -75,237 +85,124 @@ export async function runSubAgent(options: {
75
85
  task: string;
76
86
  tools: string[];
77
87
  model: Model<any>;
78
- authStorage: AuthStorage;
79
- modelRegistry: ModelRegistry;
88
+ /** Pi 0.80.10's canonical credential/model runtime. */
89
+ modelRuntime?: unknown;
90
+ /** Legacy Pi SDK session options retained for 0.80.6 tests and hosts. */
91
+ authStorage?: unknown;
92
+ modelRegistry?: unknown;
80
93
  signal?: AbortSignal;
81
94
  agentName?: string;
82
95
  thinkingLevel?: "off" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max";
83
96
  onMessage?: (partialResult: SubAgentResult) => void;
84
- /** Pre-validated timeout in ms. When provided, an abort signal will be created. */
97
+ onProgress?: (progress: SubAgentProgress) => void;
85
98
  timeoutMs?: number;
99
+ hardTimeoutMs?: number;
86
100
  }): Promise<SubAgentResult> {
87
101
  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,
102
+ cwd, systemPrompt, task, tools, model, modelRuntime, authStorage, modelRegistry, signal,
103
+ agentName = "subagent", thinkingLevel = "off", onMessage, onProgress,
104
+ timeoutMs = DEFAULT_INACTIVITY_TIMEOUT_MS, hardTimeoutMs = HARD_TIMEOUT_MS,
100
105
  } = options;
101
-
102
106
  const result: SubAgentResult = {
103
- agent: agentName,
104
- task,
105
- exitCode: 0,
106
- messages: [],
107
- stderr: "",
107
+ agent: agentName, task, exitCode: 0, messages: [], stderr: "",
108
108
  usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 },
109
- model: `${model.provider}/${model.id}`,
110
- status: undefined,
109
+ model: `${model.provider}/${model.id}`, status: undefined,
111
110
  };
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
111
  const resourceLoader: ResourceLoader = {
116
112
  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 () => {},
113
+ getSkills: () => ({ skills: [], diagnostics: [] }), getPrompts: () => ({ prompts: [], diagnostics: [] }),
114
+ getThemes: () => ({ themes: [], diagnostics: [] }), getAgentsFiles: () => ({ agentsFiles: [] }),
115
+ getSystemPrompt: () => systemPrompt, getAppendSystemPrompt: () => [], extendResources: () => {}, reload: async () => {},
125
116
  };
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;
117
+ const settingsManager = SettingsManager.inMemory({ compaction: { enabled: false }, retry: { enabled: true, maxRetries: 1 } });
118
+ const startedAt = Date.now();
119
+ let inactivityDeadline = startedAt + timeoutMs;
120
+ const hardDeadline = startedAt + hardTimeoutMs;
121
+ let timeoutKind: "idle" | "hard" | undefined;
122
+ const timeoutController = new AbortController();
123
+ let idleTimer: ReturnType<typeof setTimeout> | undefined;
124
+ let hardTimer: ReturnType<typeof setTimeout> | undefined;
135
125
  let cleanupCombined: (() => void) | undefined;
136
-
126
+ const clearTimers = () => { if (idleTimer) clearTimeout(idleTimer); if (hardTimer) clearTimeout(hardTimer); };
127
+ const armIdle = () => {
128
+ if (idleTimer) clearTimeout(idleTimer);
129
+ inactivityDeadline = Date.now() + timeoutMs;
130
+ idleTimer = setTimeout(() => { timeoutKind = "idle"; timeoutController.abort(new Error(`Idle timeout after ${timeoutMs}ms`)); }, timeoutMs);
131
+ };
132
+ const snapshot = (label: string): SubAgentProgress => ({ label, at: Date.now(), elapsedMs: Date.now() - startedAt, inactivityDeadline, hardDeadline, result: { ...result, messages: [...result.messages] } });
137
133
  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
-
134
+ armIdle();
135
+ hardTimer = setTimeout(() => { timeoutKind = "hard"; timeoutController.abort(new Error(`Hard timeout after ${hardTimeoutMs}ms`)); }, hardTimeoutMs);
136
+ const { signal: combinedSignal, cleanup } = createCombinedAbortSignal([signal, timeoutController.signal]);
137
+ cleanupCombined = cleanup;
151
138
  if (combinedSignal.aborted) {
152
139
  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);
140
+ const timedOut = timeoutController.signal.aborted && !signal?.aborted;
141
+ result.stopReason = timedOut ? "timeout" : "aborted";
142
+ result.errorMessage = timedOut ? `${timeoutKind === "idle" ? "Idle" : "Hard"} timeout after ${timeoutKind === "idle" ? timeoutMs : hardTimeoutMs}ms` : "Sub-agent aborted before start";
143
+ result.status = classifyStopReason(result.stopReason, !timedOut, timedOut);
159
144
  return result;
160
145
  }
161
-
162
146
  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
-
147
+ cwd, model, thinkingLevel, resourceLoader, tools, sessionManager: SessionManager.inMemory(cwd), settingsManager,
148
+ ...(modelRuntime ? { modelRuntime } : {}),
149
+ // Pi 0.80.10 owns credentials in ModelRuntime; older SDKs still accept these.
150
+ ...(authStorage ? { authStorage, modelRegistry } : {}),
151
+ } as any);
152
+ let unsubscribe: (() => void) | undefined;
153
+ let removeAbort: (() => void) | undefined;
180
154
  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;
155
+ const eventDone = new Promise<void>((resolve, reject) => {
156
+ let done = false;
157
+ const finish = (fn: () => void) => { if (!done) { done = true; unsubscribe?.(); fn(); } };
204
158
  unsubscribe = session.subscribe((event) => {
205
159
  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;
160
+ // Any SDK session lifecycle event is actual child activity, unlike a parent heartbeat.
161
+ armIdle(); onProgress?.(snapshot(event.type));
162
+ if (event.type === "message_end") {
163
+ const msg = event.message as AgentMessage;
164
+ if (msg.role === "assistant") {
165
+ result.usage.turns++;
166
+ 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; }
167
+ if (!result.model && msg.model) result.model = `${msg.provider || "?"}/${msg.model}`;
168
+ if (msg.stopReason) result.stopReason = msg.stopReason;
169
+ result.errorMessage = msg.errorMessage;
241
170
  }
171
+ result.messages.push(msg as unknown as Message);
172
+ onMessage?.({ ...result, messages: [...result.messages] });
173
+ } else if (event.type === "agent_end" && !event.willRetry) {
174
+ if (!result.messages.length && event.messages) result.messages = event.messages as unknown as Message[];
175
+ finish(resolve);
242
176
  }
243
- } catch (err) {
244
- finish(() => {
245
- unsubscribe?.();
246
- reject(err);
247
- });
248
- }
177
+ } catch (error) { finish(() => reject(error)); }
249
178
  });
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);
179
+ const abort = () => finish(resolve);
180
+ combinedSignal.addEventListener("abort", abort, { once: true });
181
+ removeAbort = () => combinedSignal.removeEventListener("abort", abort);
263
182
  });
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.
183
+ const abortSession = () => session.abort();
184
+ combinedSignal.addEventListener("abort", abortSession, { once: true });
185
+ const removeSessionAbort = () => combinedSignal.removeEventListener("abort", abortSession);
186
+ await Promise.race([session.prompt(task), eventDone]);
187
+ removeSessionAbort();
188
+ const timedOut = timeoutController.signal.aborted && !signal?.aborted;
189
+ if (timedOut) { result.stopReason = "timeout"; result.errorMessage = `${timeoutKind === "idle" ? "Idle" : "Hard"} timeout after ${timeoutKind === "idle" ? timeoutMs : hardTimeoutMs}ms`; }
190
+ else if (combinedSignal.aborted) { result.stopReason = "aborted"; result.errorMessage ||= "Sub-agent aborted"; }
283
191
  result.status = classifyStopReason(result.stopReason, result.stopReason === "aborted", result.stopReason === "timeout");
284
192
  result.exitCode = result.status === "success" || result.status === "partial" ? 0 : 1;
285
-
286
193
  return result;
287
194
  } 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
- }
195
+ unsubscribe?.(); removeAbort?.();
196
+ try { session.dispose(); } catch { /* best effort */ }
298
197
  }
299
- } catch (err) {
300
- const message = err instanceof Error ? err.message : String(err);
198
+ } catch (error) {
301
199
  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);
200
+ result.errorMessage = error instanceof Error ? error.message : String(error);
201
+ result.stopReason ||= "error";
202
+ result.status = classifyStopReason(result.stopReason, false, false);
308
203
  return result;
204
+ } finally {
205
+ clearTimers(); cleanupCombined?.();
309
206
  }
310
207
  }
311
208
 
@@ -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
- import { AuthStorage, type ExtensionContext } from "@earendil-works/pi-coding-agent";
1
+ import { 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,17 +39,14 @@ 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"})`);
44
46
 
45
- const authStorage = AuthStorage.inMemory();
46
47
  const modelRegistry = options.ctx.modelRegistry;
47
- const auth = await options.ctx.modelRegistry.getApiKeyAndHeaders(model);
48
- if (auth.ok) {
49
- if (auth.apiKey) authStorage.setRuntimeApiKey(model.provider, auth.apiKey);
50
- // ponytail: env and headers stay on the parent modelRegistry — reuse it directly.
51
- }
48
+ const modelRuntime = (modelRegistry as any).runtime;
49
+ const authStorage = (modelRegistry as any).authStorage;
52
50
 
53
51
  // Security: validate and normalise timeout.
54
52
  const effectiveTimeoutMs = normalizeTimeout({ requested: options.timeout }).timeoutMs;
@@ -81,6 +79,7 @@ export async function runNamedAgent(options: {
81
79
  task: options.task,
82
80
  tools: toolValidation.tools,
83
81
  model,
82
+ modelRuntime,
84
83
  authStorage,
85
84
  modelRegistry,
86
85
  signal: options.signal,
@@ -88,6 +87,7 @@ export async function runNamedAgent(options: {
88
87
  agentName: options.agent.name,
89
88
  thinkingLevel: options.agent.thinking,
90
89
  onMessage: options.onMessage,
90
+ onProgress: options.onProgress,
91
91
  });
92
92
  return result;
93
93
  } 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.2",
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",