@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 +13 -0
- package/README.md +6 -6
- package/extensions/index.ts +21 -25
- package/extensions/runner.ts +95 -198
- package/extensions/security.ts +3 -4
- package/extensions/service.ts +8 -8
- 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,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:**
|
|
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
|
@@ -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: "
|
|
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: "
|
|
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
|
|
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
|
-
|
|
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(() =>
|
|
586
|
-
|
|
587
|
-
details: heartbeatDetails?.() ?? makeDetails("single")([])
|
|
588
|
-
})
|
|
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",
|
package/extensions/runner.ts
CHANGED
|
@@ -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
|
-
|
|
79
|
-
|
|
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
|
-
|
|
97
|
+
onProgress?: (progress: SubAgentProgress) => void;
|
|
85
98
|
timeoutMs?: number;
|
|
99
|
+
hardTimeoutMs?: number;
|
|
86
100
|
}): Promise<SubAgentResult> {
|
|
87
101
|
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,
|
|
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
|
-
|
|
119
|
-
|
|
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
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
let
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
154
|
-
result.stopReason =
|
|
155
|
-
result.errorMessage =
|
|
156
|
-
result.status = classifyStopReason(result.stopReason, !
|
|
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
|
-
|
|
165
|
-
|
|
166
|
-
authStorage,
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
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
|
-
|
|
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;
|
|
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
|
-
|
|
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;
|
|
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 (
|
|
244
|
-
finish(() => {
|
|
245
|
-
unsubscribe?.();
|
|
246
|
-
reject(err);
|
|
247
|
-
});
|
|
248
|
-
}
|
|
177
|
+
} catch (error) { finish(() => reject(error)); }
|
|
249
178
|
});
|
|
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);
|
|
179
|
+
const abort = () => finish(resolve);
|
|
180
|
+
combinedSignal.addEventListener("abort", abort, { once: true });
|
|
181
|
+
removeAbort = () => combinedSignal.removeEventListener("abort", abort);
|
|
263
182
|
});
|
|
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.
|
|
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
|
-
|
|
289
|
-
|
|
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 (
|
|
300
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
198
|
+
} catch (error) {
|
|
301
199
|
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);
|
|
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
|
|
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
|
-
import {
|
|
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
|
|
48
|
-
|
|
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) {
|
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