@tintinweb/pi-subagents 0.4.0 → 0.4.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
@@ -5,6 +5,15 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [0.4.1] - 2026-03-11
9
+
10
+ ### Fixed
11
+ - **Graceful shutdown in headless mode** — the CLI now waits for all running and queued background agents to complete before exiting (`waitForAll` on `session_shutdown`). Previously, background agents could be silently killed mid-execution when the session ended. Only affects headless/non-interactive mode; interactive sessions already kept the process alive.
12
+
13
+ ### Added
14
+ - `hasRunning()` / `waitForAll()` methods on `AgentManager`.
15
+ - **Cross-package manager access** — agent manager exposed via `Symbol.for("pi-subagents:manager")` on `globalThis` for other extensions to check status or await completion.
16
+
8
17
  ## [0.4.0] - 2026-03-11
9
18
 
10
19
  ### Added
@@ -188,6 +197,7 @@ Initial release.
188
197
  - **Thinking level** — per-agent extended thinking control
189
198
  - **`/agent` and `/agents` commands**
190
199
 
200
+ [0.4.1]: https://github.com/tintinweb/pi-subagents/compare/v0.4.0...v0.4.1
191
201
  [0.4.0]: https://github.com/tintinweb/pi-subagents/compare/v0.3.1...v0.4.0
192
202
  [0.3.1]: https://github.com/tintinweb/pi-subagents/compare/v0.3.0...v0.3.1
193
203
  [0.3.0]: https://github.com/tintinweb/pi-subagents/compare/v0.2.7...v0.3.0
@@ -0,0 +1,70 @@
1
+ /**
2
+ * agent-manager.ts — Tracks agents, background execution, resume support.
3
+ *
4
+ * Background agents are subject to a configurable concurrency limit (default: 4).
5
+ * Excess agents are queued and auto-started as running agents complete.
6
+ * Foreground agents bypass the queue (they block the parent anyway).
7
+ */
8
+ import type { ExtensionContext, ExtensionAPI } from "@mariozechner/pi-coding-agent";
9
+ import type { Model } from "@mariozechner/pi-ai";
10
+ import type { AgentSession } from "@mariozechner/pi-coding-agent";
11
+ import { type ToolActivity } from "./agent-runner.js";
12
+ import type { SubagentType, AgentRecord, ThinkingLevel } from "./types.js";
13
+ export type OnAgentComplete = (record: AgentRecord) => void;
14
+ interface SpawnOptions {
15
+ description: string;
16
+ model?: Model<any>;
17
+ maxTurns?: number;
18
+ isolated?: boolean;
19
+ inheritContext?: boolean;
20
+ thinkingLevel?: ThinkingLevel;
21
+ isBackground?: boolean;
22
+ /** Called on tool start/end with activity info (for streaming progress to UI). */
23
+ onToolActivity?: (activity: ToolActivity) => void;
24
+ /** Called on streaming text deltas from the assistant response. */
25
+ onTextDelta?: (delta: string, fullText: string) => void;
26
+ /** Called when the agent session is created (for accessing session stats). */
27
+ onSessionCreated?: (session: AgentSession) => void;
28
+ }
29
+ export declare class AgentManager {
30
+ private agents;
31
+ private cleanupInterval;
32
+ private onComplete?;
33
+ private maxConcurrent;
34
+ /** Queue of background agents waiting to start. */
35
+ private queue;
36
+ /** Number of currently running background agents. */
37
+ private runningBackground;
38
+ constructor(onComplete?: OnAgentComplete, maxConcurrent?: number);
39
+ /** Update the max concurrent background agents limit. */
40
+ setMaxConcurrent(n: number): void;
41
+ getMaxConcurrent(): number;
42
+ /**
43
+ * Spawn an agent and return its ID immediately (for background use).
44
+ * If the concurrency limit is reached, the agent is queued.
45
+ */
46
+ spawn(pi: ExtensionAPI, ctx: ExtensionContext, type: SubagentType, prompt: string, options: SpawnOptions): string;
47
+ /** Actually start an agent (called immediately or from queue drain). */
48
+ private startAgent;
49
+ /** Start queued agents up to the concurrency limit. */
50
+ private drainQueue;
51
+ /**
52
+ * Spawn an agent and wait for completion (foreground use).
53
+ * Foreground agents bypass the concurrency queue.
54
+ */
55
+ spawnAndWait(pi: ExtensionAPI, ctx: ExtensionContext, type: SubagentType, prompt: string, options: Omit<SpawnOptions, "isBackground">): Promise<AgentRecord>;
56
+ /**
57
+ * Resume an existing agent session with a new prompt.
58
+ */
59
+ resume(id: string, prompt: string, signal?: AbortSignal): Promise<AgentRecord | undefined>;
60
+ getRecord(id: string): AgentRecord | undefined;
61
+ listAgents(): AgentRecord[];
62
+ abort(id: string): boolean;
63
+ private cleanup;
64
+ /** Whether any agents are still running or queued. */
65
+ hasRunning(): boolean;
66
+ /** Wait for all running and queued agents to complete (including queued ones). */
67
+ waitForAll(): Promise<void>;
68
+ dispose(): void;
69
+ }
70
+ export {};
@@ -0,0 +1,236 @@
1
+ /**
2
+ * agent-manager.ts — Tracks agents, background execution, resume support.
3
+ *
4
+ * Background agents are subject to a configurable concurrency limit (default: 4).
5
+ * Excess agents are queued and auto-started as running agents complete.
6
+ * Foreground agents bypass the queue (they block the parent anyway).
7
+ */
8
+ import { randomUUID } from "node:crypto";
9
+ import { runAgent, resumeAgent } from "./agent-runner.js";
10
+ /** Default max concurrent background agents. */
11
+ const DEFAULT_MAX_CONCURRENT = 4;
12
+ export class AgentManager {
13
+ agents = new Map();
14
+ cleanupInterval;
15
+ onComplete;
16
+ maxConcurrent;
17
+ /** Queue of background agents waiting to start. */
18
+ queue = [];
19
+ /** Number of currently running background agents. */
20
+ runningBackground = 0;
21
+ constructor(onComplete, maxConcurrent = DEFAULT_MAX_CONCURRENT) {
22
+ this.onComplete = onComplete;
23
+ this.maxConcurrent = maxConcurrent;
24
+ // Cleanup completed agents after 10 minutes (but keep sessions for resume)
25
+ this.cleanupInterval = setInterval(() => this.cleanup(), 60_000);
26
+ }
27
+ /** Update the max concurrent background agents limit. */
28
+ setMaxConcurrent(n) {
29
+ this.maxConcurrent = Math.max(1, n);
30
+ // Start queued agents if the new limit allows
31
+ this.drainQueue();
32
+ }
33
+ getMaxConcurrent() {
34
+ return this.maxConcurrent;
35
+ }
36
+ /**
37
+ * Spawn an agent and return its ID immediately (for background use).
38
+ * If the concurrency limit is reached, the agent is queued.
39
+ */
40
+ spawn(pi, ctx, type, prompt, options) {
41
+ const id = randomUUID().slice(0, 17);
42
+ const abortController = new AbortController();
43
+ const record = {
44
+ id,
45
+ type,
46
+ description: options.description,
47
+ status: options.isBackground ? "queued" : "running",
48
+ toolUses: 0,
49
+ startedAt: Date.now(),
50
+ abortController,
51
+ };
52
+ this.agents.set(id, record);
53
+ const args = { pi, ctx, type, prompt, options };
54
+ if (options.isBackground && this.runningBackground >= this.maxConcurrent) {
55
+ // Queue it — will be started when a running agent completes
56
+ this.queue.push({ id, args });
57
+ return id;
58
+ }
59
+ this.startAgent(id, record, args);
60
+ return id;
61
+ }
62
+ /** Actually start an agent (called immediately or from queue drain). */
63
+ startAgent(id, record, { pi, ctx, type, prompt, options }) {
64
+ record.status = "running";
65
+ record.startedAt = Date.now();
66
+ if (options.isBackground)
67
+ this.runningBackground++;
68
+ const promise = runAgent(ctx, type, prompt, {
69
+ pi,
70
+ model: options.model,
71
+ maxTurns: options.maxTurns,
72
+ isolated: options.isolated,
73
+ inheritContext: options.inheritContext,
74
+ thinkingLevel: options.thinkingLevel,
75
+ signal: record.abortController.signal,
76
+ onToolActivity: (activity) => {
77
+ if (activity.type === "end")
78
+ record.toolUses++;
79
+ options.onToolActivity?.(activity);
80
+ },
81
+ onTextDelta: options.onTextDelta,
82
+ onSessionCreated: (session) => {
83
+ record.session = session;
84
+ options.onSessionCreated?.(session);
85
+ },
86
+ })
87
+ .then(({ responseText, session, aborted, steered }) => {
88
+ // Don't overwrite status if externally stopped via abort()
89
+ if (record.status !== "stopped") {
90
+ record.status = aborted ? "aborted" : steered ? "steered" : "completed";
91
+ }
92
+ record.result = responseText;
93
+ record.session = session;
94
+ record.completedAt ??= Date.now();
95
+ if (options.isBackground) {
96
+ this.runningBackground--;
97
+ this.onComplete?.(record);
98
+ this.drainQueue();
99
+ }
100
+ return responseText;
101
+ })
102
+ .catch((err) => {
103
+ // Don't overwrite status if externally stopped via abort()
104
+ if (record.status !== "stopped") {
105
+ record.status = "error";
106
+ }
107
+ record.error = err instanceof Error ? err.message : String(err);
108
+ record.completedAt ??= Date.now();
109
+ if (options.isBackground) {
110
+ this.runningBackground--;
111
+ this.onComplete?.(record);
112
+ this.drainQueue();
113
+ }
114
+ return "";
115
+ });
116
+ record.promise = promise;
117
+ }
118
+ /** Start queued agents up to the concurrency limit. */
119
+ drainQueue() {
120
+ while (this.queue.length > 0 && this.runningBackground < this.maxConcurrent) {
121
+ const next = this.queue.shift();
122
+ const record = this.agents.get(next.id);
123
+ if (!record || record.status !== "queued")
124
+ continue;
125
+ this.startAgent(next.id, record, next.args);
126
+ }
127
+ }
128
+ /**
129
+ * Spawn an agent and wait for completion (foreground use).
130
+ * Foreground agents bypass the concurrency queue.
131
+ */
132
+ async spawnAndWait(pi, ctx, type, prompt, options) {
133
+ const id = this.spawn(pi, ctx, type, prompt, { ...options, isBackground: false });
134
+ const record = this.agents.get(id);
135
+ await record.promise;
136
+ return record;
137
+ }
138
+ /**
139
+ * Resume an existing agent session with a new prompt.
140
+ */
141
+ async resume(id, prompt, signal) {
142
+ const record = this.agents.get(id);
143
+ if (!record?.session)
144
+ return undefined;
145
+ record.status = "running";
146
+ record.startedAt = Date.now();
147
+ record.completedAt = undefined;
148
+ record.result = undefined;
149
+ record.error = undefined;
150
+ try {
151
+ const responseText = await resumeAgent(record.session, prompt, {
152
+ onToolActivity: (activity) => {
153
+ if (activity.type === "end")
154
+ record.toolUses++;
155
+ },
156
+ signal,
157
+ });
158
+ record.status = "completed";
159
+ record.result = responseText;
160
+ record.completedAt = Date.now();
161
+ }
162
+ catch (err) {
163
+ record.status = "error";
164
+ record.error = err instanceof Error ? err.message : String(err);
165
+ record.completedAt = Date.now();
166
+ }
167
+ return record;
168
+ }
169
+ getRecord(id) {
170
+ return this.agents.get(id);
171
+ }
172
+ listAgents() {
173
+ return [...this.agents.values()].sort((a, b) => b.startedAt - a.startedAt);
174
+ }
175
+ abort(id) {
176
+ const record = this.agents.get(id);
177
+ if (!record)
178
+ return false;
179
+ // Remove from queue if queued
180
+ if (record.status === "queued") {
181
+ this.queue = this.queue.filter(q => q.id !== id);
182
+ record.status = "stopped";
183
+ record.completedAt = Date.now();
184
+ return true;
185
+ }
186
+ if (record.status !== "running")
187
+ return false;
188
+ record.abortController?.abort();
189
+ record.status = "stopped";
190
+ record.completedAt = Date.now();
191
+ return true;
192
+ }
193
+ cleanup() {
194
+ const cutoff = Date.now() - 10 * 60_000;
195
+ for (const [id, record] of this.agents) {
196
+ if (record.status === "running" || record.status === "queued")
197
+ continue;
198
+ if ((record.completedAt ?? 0) >= cutoff)
199
+ continue;
200
+ // Dispose and clear session so memory can be reclaimed
201
+ if (record.session) {
202
+ record.session.dispose();
203
+ record.session = undefined;
204
+ }
205
+ this.agents.delete(id);
206
+ }
207
+ }
208
+ /** Whether any agents are still running or queued. */
209
+ hasRunning() {
210
+ return [...this.agents.values()].some(r => r.status === "running" || r.status === "queued");
211
+ }
212
+ /** Wait for all running and queued agents to complete (including queued ones). */
213
+ async waitForAll() {
214
+ // Loop because drainQueue respects the concurrency limit — as running
215
+ // agents finish they start queued ones, which need awaiting too.
216
+ while (true) {
217
+ this.drainQueue();
218
+ const pending = [...this.agents.values()]
219
+ .filter(r => r.status === "running" || r.status === "queued")
220
+ .map(r => r.promise)
221
+ .filter(Boolean);
222
+ if (pending.length === 0)
223
+ break;
224
+ await Promise.allSettled(pending);
225
+ }
226
+ }
227
+ dispose() {
228
+ clearInterval(this.cleanupInterval);
229
+ // Clear queue
230
+ this.queue = [];
231
+ for (const record of this.agents.values()) {
232
+ record.session?.dispose();
233
+ }
234
+ this.agents.clear();
235
+ }
236
+ }
@@ -0,0 +1,60 @@
1
+ /**
2
+ * agent-runner.ts — Core execution engine: creates sessions, runs agents, collects results.
3
+ */
4
+ import { type AgentSession, type ExtensionAPI } from "@mariozechner/pi-coding-agent";
5
+ import type { ExtensionContext } from "@mariozechner/pi-coding-agent";
6
+ import type { Model } from "@mariozechner/pi-ai";
7
+ import type { SubagentType, ThinkingLevel } from "./types.js";
8
+ /** Get the default max turns value. */
9
+ export declare function getDefaultMaxTurns(): number;
10
+ /** Set the default max turns value (minimum 1). */
11
+ export declare function setDefaultMaxTurns(n: number): void;
12
+ /** Get the grace turns value. */
13
+ export declare function getGraceTurns(): number;
14
+ /** Set the grace turns value (minimum 1). */
15
+ export declare function setGraceTurns(n: number): void;
16
+ /** Info about a tool event in the subagent. */
17
+ export interface ToolActivity {
18
+ type: "start" | "end";
19
+ toolName: string;
20
+ }
21
+ export interface RunOptions {
22
+ /** ExtensionAPI instance — used for pi.exec() instead of execSync. */
23
+ pi: ExtensionAPI;
24
+ model?: Model<any>;
25
+ maxTurns?: number;
26
+ signal?: AbortSignal;
27
+ isolated?: boolean;
28
+ inheritContext?: boolean;
29
+ thinkingLevel?: ThinkingLevel;
30
+ /** Called on tool start/end with activity info. */
31
+ onToolActivity?: (activity: ToolActivity) => void;
32
+ /** Called on streaming text deltas from the assistant response. */
33
+ onTextDelta?: (delta: string, fullText: string) => void;
34
+ onSessionCreated?: (session: AgentSession) => void;
35
+ }
36
+ export interface RunResult {
37
+ responseText: string;
38
+ session: AgentSession;
39
+ /** True if the agent was hard-aborted (max_turns + grace exceeded). */
40
+ aborted: boolean;
41
+ /** True if the agent was steered to wrap up (hit soft turn limit) but finished in time. */
42
+ steered: boolean;
43
+ }
44
+ export declare function runAgent(ctx: ExtensionContext, type: SubagentType, prompt: string, options: RunOptions): Promise<RunResult>;
45
+ /**
46
+ * Send a new prompt to an existing session (resume).
47
+ */
48
+ export declare function resumeAgent(session: AgentSession, prompt: string, options?: {
49
+ onToolActivity?: (activity: ToolActivity) => void;
50
+ signal?: AbortSignal;
51
+ }): Promise<string>;
52
+ /**
53
+ * Send a steering message to a running subagent.
54
+ * The message will interrupt the agent after its current tool execution.
55
+ */
56
+ export declare function steerAgent(session: AgentSession, message: string): Promise<void>;
57
+ /**
58
+ * Get the subagent's conversation messages as formatted text.
59
+ */
60
+ export declare function getAgentConversation(session: AgentSession): string;
@@ -0,0 +1,265 @@
1
+ /**
2
+ * agent-runner.ts — Core execution engine: creates sessions, runs agents, collects results.
3
+ */
4
+ import { createAgentSession, DefaultResourceLoader, SessionManager, SettingsManager, } from "@mariozechner/pi-coding-agent";
5
+ import { getToolsForType, getConfig, getAgentConfig } from "./agent-types.js";
6
+ import { buildAgentPrompt } from "./prompts.js";
7
+ import { buildParentContext, extractText } from "./context.js";
8
+ import { detectEnv } from "./env.js";
9
+ /** Names of tools registered by this extension that subagents must NOT inherit. */
10
+ const EXCLUDED_TOOL_NAMES = ["Agent", "get_subagent_result", "steer_subagent"];
11
+ /** Default max turns to prevent subagents from looping indefinitely. */
12
+ let defaultMaxTurns = 50;
13
+ /** Get the default max turns value. */
14
+ export function getDefaultMaxTurns() { return defaultMaxTurns; }
15
+ /** Set the default max turns value (minimum 1). */
16
+ export function setDefaultMaxTurns(n) { defaultMaxTurns = Math.max(1, n); }
17
+ /** Additional turns allowed after the soft limit steer message. */
18
+ let graceTurns = 5;
19
+ /** Get the grace turns value. */
20
+ export function getGraceTurns() { return graceTurns; }
21
+ /** Set the grace turns value (minimum 1). */
22
+ export function setGraceTurns(n) { graceTurns = Math.max(1, n); }
23
+ /**
24
+ * Try to find the right model for an agent type.
25
+ * Priority: explicit option > config.model > parent model.
26
+ */
27
+ function resolveDefaultModel(parentModel, registry, configModel) {
28
+ if (configModel) {
29
+ const slashIdx = configModel.indexOf("/");
30
+ if (slashIdx !== -1) {
31
+ const provider = configModel.slice(0, slashIdx);
32
+ const modelId = configModel.slice(slashIdx + 1);
33
+ // Build a set of available model keys for fast lookup
34
+ const available = registry.getAvailable?.();
35
+ const availableKeys = available
36
+ ? new Set(available.map((m) => `${m.provider}/${m.id}`))
37
+ : undefined;
38
+ const isAvailable = (p, id) => !availableKeys || availableKeys.has(`${p}/${id}`);
39
+ const found = registry.find(provider, modelId);
40
+ if (found && isAvailable(provider, modelId))
41
+ return found;
42
+ }
43
+ }
44
+ return parentModel;
45
+ }
46
+ /**
47
+ * Subscribe to a session and collect the last assistant message text.
48
+ * Returns an object with a `getText()` getter and an `unsubscribe` function.
49
+ */
50
+ function collectResponseText(session) {
51
+ let text = "";
52
+ const unsubscribe = session.subscribe((event) => {
53
+ if (event.type === "message_start") {
54
+ text = "";
55
+ }
56
+ if (event.type === "message_update" && event.assistantMessageEvent.type === "text_delta") {
57
+ text += event.assistantMessageEvent.delta;
58
+ }
59
+ });
60
+ return { getText: () => text, unsubscribe };
61
+ }
62
+ /**
63
+ * Wire an AbortSignal to abort a session.
64
+ * Returns a cleanup function to remove the listener.
65
+ */
66
+ function forwardAbortSignal(session, signal) {
67
+ if (!signal)
68
+ return () => { };
69
+ const onAbort = () => session.abort();
70
+ signal.addEventListener("abort", onAbort, { once: true });
71
+ return () => signal.removeEventListener("abort", onAbort);
72
+ }
73
+ export async function runAgent(ctx, type, prompt, options) {
74
+ const config = getConfig(type);
75
+ const agentConfig = getAgentConfig(type);
76
+ const env = await detectEnv(options.pi, ctx.cwd);
77
+ // Get parent system prompt for append-mode agents
78
+ const parentSystemPrompt = ctx.getSystemPrompt();
79
+ // Build system prompt from agent config
80
+ let systemPrompt;
81
+ if (agentConfig) {
82
+ systemPrompt = buildAgentPrompt(agentConfig, ctx.cwd, env, parentSystemPrompt);
83
+ }
84
+ else {
85
+ // Unknown type fallback: general-purpose (defensive — unreachable in practice
86
+ // since index.ts resolves unknown types to "general-purpose" before calling runAgent)
87
+ systemPrompt = buildAgentPrompt({
88
+ name: type,
89
+ description: "General-purpose agent",
90
+ systemPrompt: "",
91
+ promptMode: "append",
92
+ extensions: true,
93
+ skills: true,
94
+ inheritContext: false,
95
+ runInBackground: false,
96
+ isolated: false,
97
+ }, ctx.cwd, env, parentSystemPrompt);
98
+ }
99
+ const tools = getToolsForType(type, ctx.cwd);
100
+ // Resolve extensions/skills: isolated overrides to false
101
+ const extensions = options.isolated ? false : config.extensions;
102
+ const skills = options.isolated ? false : config.skills;
103
+ // Load extensions/skills: true or string[] → load; false → don't
104
+ const loader = new DefaultResourceLoader({
105
+ cwd: ctx.cwd,
106
+ noExtensions: extensions === false,
107
+ noSkills: skills === false,
108
+ noPromptTemplates: true,
109
+ noThemes: true,
110
+ systemPromptOverride: () => systemPrompt,
111
+ });
112
+ await loader.reload();
113
+ // Resolve model: explicit option > config.model > parent model
114
+ const model = options.model ?? resolveDefaultModel(ctx.model, ctx.modelRegistry, agentConfig?.model);
115
+ // Resolve thinking level: explicit option > agent config > undefined (inherit)
116
+ const thinkingLevel = options.thinkingLevel ?? agentConfig?.thinking;
117
+ const sessionOpts = {
118
+ cwd: ctx.cwd,
119
+ sessionManager: SessionManager.inMemory(ctx.cwd),
120
+ settingsManager: SettingsManager.create(),
121
+ modelRegistry: ctx.modelRegistry,
122
+ model,
123
+ tools,
124
+ resourceLoader: loader,
125
+ };
126
+ if (thinkingLevel) {
127
+ sessionOpts.thinkingLevel = thinkingLevel;
128
+ }
129
+ // createAgentSession's type signature may not include thinkingLevel yet
130
+ const { session } = await createAgentSession(sessionOpts);
131
+ // Filter active tools: remove our own tools to prevent nesting,
132
+ // and apply extension allowlist if specified
133
+ if (extensions !== false) {
134
+ const builtinToolNames = new Set(tools.map(t => t.name));
135
+ const activeTools = session.getActiveToolNames().filter((t) => {
136
+ if (EXCLUDED_TOOL_NAMES.includes(t))
137
+ return false;
138
+ if (builtinToolNames.has(t))
139
+ return true;
140
+ if (Array.isArray(extensions)) {
141
+ return extensions.some(ext => t.startsWith(ext) || t.includes(ext));
142
+ }
143
+ return true;
144
+ });
145
+ session.setActiveToolsByName(activeTools);
146
+ }
147
+ options.onSessionCreated?.(session);
148
+ // Track turns for graceful max_turns enforcement
149
+ let turnCount = 0;
150
+ const maxTurns = options.maxTurns ?? agentConfig?.maxTurns ?? defaultMaxTurns;
151
+ let softLimitReached = false;
152
+ let aborted = false;
153
+ let currentMessageText = "";
154
+ const unsubTurns = session.subscribe((event) => {
155
+ if (event.type === "turn_end") {
156
+ turnCount++;
157
+ if (!softLimitReached && turnCount >= maxTurns) {
158
+ softLimitReached = true;
159
+ session.steer("You have reached your turn limit. Wrap up immediately — provide your final answer now.");
160
+ }
161
+ else if (softLimitReached && turnCount >= maxTurns + graceTurns) {
162
+ aborted = true;
163
+ session.abort();
164
+ }
165
+ }
166
+ if (event.type === "message_start") {
167
+ currentMessageText = "";
168
+ }
169
+ if (event.type === "message_update" && event.assistantMessageEvent.type === "text_delta") {
170
+ currentMessageText += event.assistantMessageEvent.delta;
171
+ options.onTextDelta?.(event.assistantMessageEvent.delta, currentMessageText);
172
+ }
173
+ if (event.type === "tool_execution_start") {
174
+ options.onToolActivity?.({ type: "start", toolName: event.toolName });
175
+ }
176
+ if (event.type === "tool_execution_end") {
177
+ options.onToolActivity?.({ type: "end", toolName: event.toolName });
178
+ }
179
+ });
180
+ const collector = collectResponseText(session);
181
+ const cleanupAbort = forwardAbortSignal(session, options.signal);
182
+ // Build the effective prompt: optionally prepend parent context
183
+ let effectivePrompt = prompt;
184
+ if (options.inheritContext) {
185
+ const parentContext = buildParentContext(ctx);
186
+ if (parentContext) {
187
+ effectivePrompt = parentContext + prompt;
188
+ }
189
+ }
190
+ try {
191
+ await session.prompt(effectivePrompt);
192
+ }
193
+ finally {
194
+ unsubTurns();
195
+ collector.unsubscribe();
196
+ cleanupAbort();
197
+ }
198
+ return { responseText: collector.getText(), session, aborted, steered: softLimitReached };
199
+ }
200
+ /**
201
+ * Send a new prompt to an existing session (resume).
202
+ */
203
+ export async function resumeAgent(session, prompt, options = {}) {
204
+ const collector = collectResponseText(session);
205
+ const cleanupAbort = forwardAbortSignal(session, options.signal);
206
+ const unsubToolUse = options.onToolActivity
207
+ ? session.subscribe((event) => {
208
+ if (event.type === "tool_execution_start")
209
+ options.onToolActivity({ type: "start", toolName: event.toolName });
210
+ if (event.type === "tool_execution_end")
211
+ options.onToolActivity({ type: "end", toolName: event.toolName });
212
+ })
213
+ : () => { };
214
+ try {
215
+ await session.prompt(prompt);
216
+ }
217
+ finally {
218
+ collector.unsubscribe();
219
+ unsubToolUse();
220
+ cleanupAbort();
221
+ }
222
+ return collector.getText();
223
+ }
224
+ /**
225
+ * Send a steering message to a running subagent.
226
+ * The message will interrupt the agent after its current tool execution.
227
+ */
228
+ export async function steerAgent(session, message) {
229
+ await session.steer(message);
230
+ }
231
+ /**
232
+ * Get the subagent's conversation messages as formatted text.
233
+ */
234
+ export function getAgentConversation(session) {
235
+ const parts = [];
236
+ for (const msg of session.messages) {
237
+ if (msg.role === "user") {
238
+ const text = typeof msg.content === "string"
239
+ ? msg.content
240
+ : extractText(msg.content);
241
+ if (text.trim())
242
+ parts.push(`[User]: ${text.trim()}`);
243
+ }
244
+ else if (msg.role === "assistant") {
245
+ const textParts = [];
246
+ const toolCalls = [];
247
+ for (const c of msg.content) {
248
+ if (c.type === "text" && c.text)
249
+ textParts.push(c.text);
250
+ else if (c.type === "toolCall")
251
+ toolCalls.push(` Tool: ${c.toolName ?? "unknown"}`);
252
+ }
253
+ if (textParts.length > 0)
254
+ parts.push(`[Assistant]: ${textParts.join("\n")}`);
255
+ if (toolCalls.length > 0)
256
+ parts.push(`[Tool Calls]:\n${toolCalls.join("\n")}`);
257
+ }
258
+ else if (msg.role === "toolResult") {
259
+ const text = extractText(msg.content);
260
+ const truncated = text.length > 200 ? text.slice(0, 200) + "..." : text;
261
+ parts.push(`[Tool Result (${msg.toolName})]: ${truncated}`);
262
+ }
263
+ }
264
+ return parts.join("\n\n");
265
+ }