micode 0.7.6 → 0.8.0

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.
@@ -1,241 +1,195 @@
1
1
  import type { PluginInput } from "@opencode-ai/plugin";
2
+ import { getContextLimit } from "../utils/model-limits";
3
+ import { mkdir, writeFile } from "node:fs/promises";
4
+ import { join } from "node:path";
2
5
 
3
- interface TokenLimitError {
4
- currentTokens?: number;
5
- maxTokens?: number;
6
- providerID?: string;
7
- modelID?: string;
8
- }
9
-
10
- // Parse Anthropic token limit errors
11
- function parseTokenLimitError(error: unknown): TokenLimitError | null {
12
- if (!error) return null;
13
-
14
- const errorStr = typeof error === "string" ? error : JSON.stringify(error);
15
-
16
- // Check for Anthropic-specific token limit messages
17
- const patterns = [
18
- /prompt is too long.*?(\d+)\s*tokens.*?maximum.*?(\d+)/i,
19
- /context.*?(\d+).*?exceeds.*?(\d+)/i,
20
- /token limit.*?(\d+).*?max.*?(\d+)/i,
21
- ];
22
-
23
- for (const pattern of patterns) {
24
- const match = errorStr.match(pattern);
25
- if (match) {
26
- return {
27
- currentTokens: parseInt(match[1], 10),
28
- maxTokens: parseInt(match[2], 10),
29
- };
30
- }
31
- }
6
+ // Compact when this percentage of context is used
7
+ const COMPACT_THRESHOLD = 0.50;
32
8
 
33
- // Check for generic rate limit / context errors
34
- if (
35
- errorStr.includes("context_length_exceeded") ||
36
- errorStr.includes("token") ||
37
- errorStr.includes("prompt is too long")
38
- ) {
39
- return {};
40
- }
41
-
42
- return null;
43
- }
9
+ const LEDGER_DIR = "thoughts/ledgers";
44
10
 
45
11
  interface AutoCompactState {
46
- pendingCompact: Set<string>;
47
- errorData: Map<string, TokenLimitError>;
48
- retryCount: Map<string, number>;
49
12
  inProgress: Set<string>;
13
+ lastCompactTime: Map<string, number>;
50
14
  }
51
15
 
16
+ // Cooldown between compaction attempts (prevent rapid re-triggering)
17
+ const COMPACT_COOLDOWN_MS = 30_000; // 30 seconds
18
+
52
19
  export function createAutoCompactHook(ctx: PluginInput) {
53
20
  const state: AutoCompactState = {
54
- pendingCompact: new Set(),
55
- errorData: new Map(),
56
- retryCount: new Map(),
57
21
  inProgress: new Set(),
22
+ lastCompactTime: new Map(),
58
23
  };
59
24
 
60
- const MAX_RETRIES = 3;
25
+ async function writeSummaryToLedger(sessionID: string): Promise<void> {
26
+ try {
27
+ // Fetch session messages to find the summary
28
+ const resp = await ctx.client.session.messages({
29
+ path: { id: sessionID },
30
+ query: { directory: ctx.directory },
31
+ });
32
+
33
+ const messages = (resp as { data?: unknown[] }).data;
34
+ if (!Array.isArray(messages)) return;
35
+
36
+ // Find the summary message (has summary: true)
37
+ const summaryMsg = [...messages].reverse().find((m) => {
38
+ const msg = m as Record<string, unknown>;
39
+ const info = msg.info as Record<string, unknown> | undefined;
40
+ return info?.role === "assistant" && info?.summary === true;
41
+ }) as Record<string, unknown> | undefined;
42
+
43
+ if (!summaryMsg) return;
44
+
45
+ // Extract text parts from the summary
46
+ const parts = summaryMsg.parts as Array<{ type: string; text?: string }> | undefined;
47
+ if (!parts) return;
48
+
49
+ const summaryText = parts
50
+ .filter((p) => p.type === "text" && p.text)
51
+ .map((p) => p.text)
52
+ .join("\n\n");
53
+
54
+ if (!summaryText.trim()) return;
55
+
56
+ // Create ledger directory if needed
57
+ const ledgerDir = join(ctx.directory, LEDGER_DIR);
58
+ await mkdir(ledgerDir, { recursive: true });
59
+
60
+ // Write ledger file - summary is already structured (Factory.ai/pi-mono format)
61
+ const timestamp = new Date().toISOString();
62
+ const sessionName = sessionID.slice(0, 8); // Use first 8 chars of session ID
63
+ const ledgerPath = join(ledgerDir, `CONTINUITY_${sessionName}.md`);
64
+
65
+ // Add metadata header, then the structured summary as-is
66
+ const ledgerContent = `---
67
+ session: ${sessionName}
68
+ updated: ${timestamp}
69
+ ---
70
+
71
+ ${summaryText}
72
+ `;
73
+
74
+ await writeFile(ledgerPath, ledgerContent, "utf-8");
75
+ } catch (e) {
76
+ // Don't fail the compaction flow if ledger write fails
77
+ console.error("[auto-compact] Failed to write ledger:", e);
78
+ }
79
+ }
80
+
81
+ async function triggerCompaction(
82
+ sessionID: string,
83
+ providerID: string,
84
+ modelID: string,
85
+ usageRatio: number,
86
+ ): Promise<void> {
87
+ if (state.inProgress.has(sessionID)) {
88
+ return;
89
+ }
90
+
91
+ // Check cooldown
92
+ const lastCompact = state.lastCompactTime.get(sessionID) || 0;
93
+ if (Date.now() - lastCompact < COMPACT_COOLDOWN_MS) {
94
+ return;
95
+ }
61
96
 
62
- async function attemptRecovery(sessionID: string, providerID?: string, modelID?: string): Promise<void> {
63
- if (state.inProgress.has(sessionID)) return;
64
97
  state.inProgress.add(sessionID);
65
98
 
66
- const retries = state.retryCount.get(sessionID) || 0;
99
+ try {
100
+ const usedPercent = Math.round(usageRatio * 100);
101
+ const thresholdPercent = Math.round(COMPACT_THRESHOLD * 100);
67
102
 
68
- if (retries >= MAX_RETRIES) {
69
103
  await ctx.client.tui
70
104
  .showToast({
71
105
  body: {
72
- title: "Auto Compact Failed",
73
- message: "Max retries reached. Please start a new session or manually compact.",
74
- variant: "error",
75
- duration: 5000,
106
+ title: "Auto Compacting",
107
+ message: `Context at ${usedPercent}% (threshold: ${thresholdPercent}%). Summarizing...`,
108
+ variant: "warning",
109
+ duration: 3000,
76
110
  },
77
111
  })
78
112
  .catch(() => {});
79
- state.inProgress.delete(sessionID);
80
- state.pendingCompact.delete(sessionID);
81
- return;
82
- }
83
113
 
84
- try {
114
+ await ctx.client.session.summarize({
115
+ path: { id: sessionID },
116
+ body: { providerID, modelID },
117
+ query: { directory: ctx.directory },
118
+ });
119
+
120
+ state.lastCompactTime.set(sessionID, Date.now());
121
+
122
+ // Write summary to ledger file
123
+ await writeSummaryToLedger(sessionID);
124
+
85
125
  await ctx.client.tui
86
126
  .showToast({
87
127
  body: {
88
- title: "Context Limit Hit",
89
- message: `Attempting to summarize session (attempt ${retries + 1}/${MAX_RETRIES})...`,
90
- variant: "warning",
128
+ title: "Compaction Complete",
129
+ message: "Session summarized and ledger updated.",
130
+ variant: "success",
91
131
  duration: 3000,
92
132
  },
93
133
  })
94
134
  .catch(() => {});
95
-
96
- // Try to summarize the session
97
- if (providerID && modelID) {
98
- await ctx.client.session.summarize({
99
- path: { id: sessionID },
100
- body: { providerID, modelID },
101
- query: { directory: ctx.directory },
102
- });
103
-
104
- await ctx.client.tui
105
- .showToast({
106
- body: {
107
- title: "Session Compacted",
108
- message: "Context has been summarized. Continuing...",
109
- variant: "success",
110
- duration: 3000,
111
- },
112
- })
113
- .catch(() => {});
114
-
115
- // Clear state on success
116
- state.pendingCompact.delete(sessionID);
117
- state.errorData.delete(sessionID);
118
- state.retryCount.delete(sessionID);
119
-
120
- // Send continue prompt
121
- setTimeout(async () => {
122
- try {
123
- await ctx.client.session.prompt({
124
- path: { id: sessionID },
125
- body: { parts: [{ type: "text", text: "Continue" }] },
126
- query: { directory: ctx.directory },
127
- });
128
- } catch {}
129
- }, 500);
130
- } else {
131
- await ctx.client.tui
132
- .showToast({
133
- body: {
134
- title: "Cannot Auto-Compact",
135
- message: "Missing model info. Please compact manually with /compact.",
136
- variant: "error",
137
- duration: 5000,
138
- },
139
- })
140
- .catch(() => {});
141
- }
142
- } catch (_e) {
143
- state.retryCount.set(sessionID, retries + 1);
144
-
145
- // Exponential backoff
146
- const delay = Math.min(1000 * 2 ** retries, 10000);
147
- setTimeout(() => {
148
- state.inProgress.delete(sessionID);
149
- attemptRecovery(sessionID, providerID, modelID);
150
- }, delay);
151
- return;
135
+ } catch (e) {
136
+ const errorMsg = e instanceof Error ? e.message : String(e);
137
+ await ctx.client.tui
138
+ .showToast({
139
+ body: {
140
+ title: "Compaction Failed",
141
+ message: errorMsg.slice(0, 100),
142
+ variant: "error",
143
+ duration: 5000,
144
+ },
145
+ })
146
+ .catch(() => {});
147
+ } finally {
148
+ state.inProgress.delete(sessionID);
152
149
  }
153
-
154
- state.inProgress.delete(sessionID);
155
150
  }
156
151
 
157
152
  return {
158
153
  event: async ({ event }: { event: { type: string; properties?: unknown } }) => {
159
154
  const props = event.properties as Record<string, unknown> | undefined;
160
155
 
161
- // Clean up on session delete
156
+ // Cleanup on session delete
162
157
  if (event.type === "session.deleted") {
163
158
  const sessionInfo = props?.info as { id?: string } | undefined;
164
159
  if (sessionInfo?.id) {
165
- state.pendingCompact.delete(sessionInfo.id);
166
- state.errorData.delete(sessionInfo.id);
167
- state.retryCount.delete(sessionInfo.id);
168
160
  state.inProgress.delete(sessionInfo.id);
161
+ state.lastCompactTime.delete(sessionInfo.id);
169
162
  }
170
163
  return;
171
164
  }
172
165
 
173
- // Detect token limit errors
174
- if (event.type === "session.error") {
175
- const sessionID = props?.sessionID as string | undefined;
176
- const error = props?.error;
177
-
178
- if (!sessionID) return;
179
-
180
- const parsed = parseTokenLimitError(error);
181
- if (parsed) {
182
- state.pendingCompact.add(sessionID);
183
- state.errorData.set(sessionID, parsed);
184
-
185
- // Get last assistant message for provider/model info
186
- const lastAssistant = await getLastAssistantInfo(sessionID);
187
- const providerID = parsed.providerID || lastAssistant?.providerID;
188
- const modelID = parsed.modelID || lastAssistant?.modelID;
189
-
190
- attemptRecovery(sessionID, providerID, modelID);
191
- }
192
- }
193
-
194
- // Also check message.updated for errors
166
+ // Monitor usage on assistant message completion
195
167
  if (event.type === "message.updated") {
196
168
  const info = props?.info as Record<string, unknown> | undefined;
197
169
  const sessionID = info?.sessionID as string | undefined;
198
170
 
199
- if (sessionID && info?.role === "assistant" && info.error) {
200
- const parsed = parseTokenLimitError(info.error);
201
- if (parsed) {
202
- parsed.providerID = info.providerID as string | undefined;
203
- parsed.modelID = info.modelID as string | undefined;
171
+ if (!sessionID || info?.role !== "assistant") return;
172
+
173
+ // Skip if this is already a summary message
174
+ if (info?.summary === true) return;
175
+
176
+ const tokens = info?.tokens as { input?: number; cache?: { read?: number } } | undefined;
177
+ const inputTokens = tokens?.input || 0;
178
+ const cacheRead = tokens?.cache?.read || 0;
179
+ const totalUsed = inputTokens + cacheRead;
204
180
 
205
- state.pendingCompact.add(sessionID);
206
- state.errorData.set(sessionID, parsed);
181
+ if (totalUsed === 0) return;
207
182
 
208
- attemptRecovery(sessionID, parsed.providerID, parsed.modelID);
209
- }
183
+ const modelID = (info?.modelID as string) || "";
184
+ const providerID = (info?.providerID as string) || "";
185
+ const contextLimit = getContextLimit(modelID);
186
+ const usageRatio = totalUsed / contextLimit;
187
+
188
+ // Trigger compaction if over threshold
189
+ if (usageRatio >= COMPACT_THRESHOLD) {
190
+ triggerCompaction(sessionID, providerID, modelID, usageRatio);
210
191
  }
211
192
  }
212
193
  },
213
194
  };
214
-
215
- async function getLastAssistantInfo(sessionID: string): Promise<{ providerID?: string; modelID?: string } | null> {
216
- try {
217
- const resp = await ctx.client.session.messages({
218
- path: { id: sessionID },
219
- query: { directory: ctx.directory },
220
- });
221
-
222
- const data = (resp as { data?: unknown[] }).data;
223
- if (!Array.isArray(data)) return null;
224
-
225
- const lastAssistant = [...data].reverse().find((m) => {
226
- const msg = m as Record<string, unknown>;
227
- const info = msg.info as Record<string, unknown> | undefined;
228
- return info?.role === "assistant";
229
- });
230
-
231
- if (!lastAssistant) return null;
232
- const info = (lastAssistant as { info?: Record<string, unknown> }).info;
233
- return {
234
- providerID: info?.providerID as string | undefined,
235
- modelID: info?.modelID as string | undefined,
236
- };
237
- } catch {
238
- return null;
239
- }
240
- }
241
195
  }
@@ -2,11 +2,11 @@ import type { PluginInput } from "@opencode-ai/plugin";
2
2
  import { readFile } from "node:fs/promises";
3
3
  import { join, dirname, resolve } from "node:path";
4
4
 
5
- // Files to inject at project root level
6
- const ROOT_CONTEXT_FILES = ["ARCHITECTURE.md", "CODE_STYLE.md", "AGENTS.md", "CLAUDE.md", "README.md"] as const;
5
+ // Files to inject at project root level (AGENTS.md and CLAUDE.md handled by OpenCode natively)
6
+ const ROOT_CONTEXT_FILES = ["ARCHITECTURE.md", "CODE_STYLE.md", "README.md"] as const;
7
7
 
8
- // Files to collect when walking up directories
9
- const DIRECTORY_CONTEXT_FILES = ["AGENTS.md", "README.md"] as const;
8
+ // Files to collect when walking up directories (AGENTS.md handled by OpenCode natively)
9
+ const DIRECTORY_CONTEXT_FILES = ["README.md"] as const;
10
10
 
11
11
  // Tools that trigger directory-aware context injection
12
12
  const FILE_ACCESS_TOOLS = ["Read", "read", "Edit", "edit"];
@@ -68,9 +68,9 @@ export function createContextWindowMonitorHook(ctx: PluginInput) {
68
68
 
69
69
  if (!sessionID || info?.role !== "assistant") return;
70
70
 
71
- const usage = info.usage as Record<string, unknown> | undefined;
72
- const inputTokens = (usage?.inputTokens as number) || 0;
73
- const cacheRead = (usage?.cacheReadInputTokens as number) || 0;
71
+ const tokens = info.tokens as { input?: number; cache?: { read?: number } } | undefined;
72
+ const inputTokens = tokens?.input || 0;
73
+ const cacheRead = tokens?.cache?.read || 0;
74
74
  const totalUsed = inputTokens + cacheRead;
75
75
 
76
76
  const modelID = (info.modelID as string) || "";
package/src/index.ts CHANGED
@@ -9,6 +9,7 @@ import { ast_grep_search, ast_grep_replace, checkAstGrepAvailable } from "./tool
9
9
  import { btca_ask, checkBtcaAvailable } from "./tools/btca";
10
10
  import { look_at } from "./tools/look-at";
11
11
  import { artifact_search } from "./tools/artifact-search";
12
+ import { createSpawnAgentTool } from "./tools/spawn-agent";
12
13
 
13
14
  // Hooks
14
15
  import { createAutoCompactHook } from "./hooks/auto-compact";
@@ -17,10 +18,9 @@ import { createSessionRecoveryHook } from "./hooks/session-recovery";
17
18
  import { createTokenAwareTruncationHook } from "./hooks/token-aware-truncation";
18
19
  import { createContextWindowMonitorHook } from "./hooks/context-window-monitor";
19
20
  import { createCommentCheckerHook } from "./hooks/comment-checker";
20
- import { createAutoClearLedgerHook } from "./hooks/auto-clear-ledger";
21
21
  import { createLedgerLoaderHook } from "./hooks/ledger-loader";
22
22
  import { createArtifactAutoIndexHook } from "./hooks/artifact-auto-index";
23
- import { createFileOpsTrackerHook } from "./hooks/file-ops-tracker";
23
+ import { createFileOpsTrackerHook, getFileOps } from "./hooks/file-ops-tracker";
24
24
 
25
25
  // PTY System
26
26
  import { PTYManager, createPtyTools } from "./tools/pty";
@@ -84,7 +84,6 @@ const OpenCodeConfigPlugin: Plugin = async (ctx) => {
84
84
  // Hooks
85
85
  const autoCompactHook = createAutoCompactHook(ctx);
86
86
  const contextInjectorHook = createContextInjectorHook(ctx);
87
- const autoClearLedgerHook = createAutoClearLedgerHook(ctx);
88
87
  const ledgerLoaderHook = createLedgerLoaderHook(ctx);
89
88
  const sessionRecoveryHook = createSessionRecoveryHook(ctx);
90
89
  const tokenAwareTruncationHook = createTokenAwareTruncationHook(ctx);
@@ -97,6 +96,9 @@ const OpenCodeConfigPlugin: Plugin = async (ctx) => {
97
96
  const ptyManager = new PTYManager();
98
97
  const ptyTools = createPtyTools(ptyManager);
99
98
 
99
+ // Spawn agent tool (for subagents to spawn other subagents)
100
+ const spawn_agent = createSpawnAgentTool(ctx);
101
+
100
102
  return {
101
103
  // Tools
102
104
  tool: {
@@ -105,6 +107,7 @@ const OpenCodeConfigPlugin: Plugin = async (ctx) => {
105
107
  btca_ask,
106
108
  look_at,
107
109
  artifact_search,
110
+ spawn_agent,
108
111
  ...ptyTools,
109
112
  },
110
113
 
@@ -194,6 +197,62 @@ const OpenCodeConfigPlugin: Plugin = async (ctx) => {
194
197
  }
195
198
  },
196
199
 
200
+ // Structured compaction prompt (Factory.ai / pi-mono best practices)
201
+ "experimental.session.compacting": async (
202
+ input: { sessionID: string },
203
+ output: { context: string[]; prompt?: string },
204
+ ) => {
205
+ // Get file operations for this session
206
+ const fileOps = getFileOps(input.sessionID);
207
+ const readPaths = Array.from(fileOps.read).sort();
208
+ const modifiedPaths = Array.from(fileOps.modified).sort();
209
+
210
+ const fileOpsSection = `
211
+ ## File Operations
212
+ ### Read
213
+ ${readPaths.length > 0 ? readPaths.map((p) => `- \`${p}\``).join("\n") : "- (none)"}
214
+
215
+ ### Modified
216
+ ${modifiedPaths.length > 0 ? modifiedPaths.map((p) => `- \`${p}\``).join("\n") : "- (none)"}`;
217
+
218
+ output.prompt = `Create a structured summary for continuing this conversation. Use this EXACT format:
219
+
220
+ # Session Summary
221
+
222
+ ## Goal
223
+ {The core objective being pursued - one sentence describing success criteria}
224
+
225
+ ## Constraints & Preferences
226
+ {Technical requirements, patterns to follow, things to avoid - or "(none)"}
227
+
228
+ ## Progress
229
+ ### Done
230
+ - [x] {Completed items with specific details}
231
+
232
+ ### In Progress
233
+ - [ ] {Current work - what's actively being worked on}
234
+
235
+ ### Blocked
236
+ - {Issues preventing progress, if any - or "(none)"}
237
+
238
+ ## Key Decisions
239
+ - **{Decision}**: {Rationale - why this choice was made}
240
+
241
+ ## Next Steps
242
+ 1. {Ordered list of what to do next - be specific}
243
+
244
+ ## Critical Context
245
+ - {Data, examples, references, or findings needed to continue work}
246
+ - {Important discoveries or insights from this session}
247
+ ${fileOpsSection}
248
+
249
+ IMPORTANT:
250
+ - Preserve EXACT file paths and function names
251
+ - Focus on information needed to continue seamlessly
252
+ - Be specific about what was done, not vague summaries
253
+ - Include any error messages or issues encountered`;
254
+ },
255
+
197
256
  // Tool output processing
198
257
  "tool.execute.after": async (
199
258
  input: { tool: string; sessionID: string; callID: string; args?: Record<string, unknown> },
@@ -218,6 +277,20 @@ const OpenCodeConfigPlugin: Plugin = async (ctx) => {
218
277
  );
219
278
  },
220
279
 
280
+ // Filter out CLAUDE.md/AGENTS.md from system prompt for our agents
281
+ "experimental.chat.system.transform": async (_input, output) => {
282
+ output.system = output.system.filter((s) => {
283
+ // Keep entries that don't come from CLAUDE.md or AGENTS.md
284
+ if (s.startsWith("Instructions from:")) {
285
+ const path = s.split("\n")[0];
286
+ if (path.includes("CLAUDE.md") || path.includes("AGENTS.md")) {
287
+ return false;
288
+ }
289
+ }
290
+ return true;
291
+ });
292
+ },
293
+
221
294
  event: async ({ event }) => {
222
295
  // Session cleanup (think mode + PTY)
223
296
  if (event.type === "session.deleted") {
@@ -230,7 +303,6 @@ const OpenCodeConfigPlugin: Plugin = async (ctx) => {
230
303
 
231
304
  // Run all event hooks
232
305
  await autoCompactHook.event({ event });
233
- await autoClearLedgerHook.event({ event });
234
306
  await sessionRecoveryHook.event({ event });
235
307
  await tokenAwareTruncationHook.event({ event });
236
308
  await contextWindowMonitorHook.event({ event });
@@ -0,0 +1,93 @@
1
+ import { tool } from "@opencode-ai/plugin/tool";
2
+ import type { PluginInput } from "@opencode-ai/plugin";
3
+
4
+ interface SessionCreateResponse {
5
+ data?: { id?: string };
6
+ }
7
+
8
+ interface MessagePart {
9
+ type: string;
10
+ text?: string;
11
+ }
12
+
13
+ interface SessionMessage {
14
+ info?: { role?: "user" | "assistant" };
15
+ parts?: MessagePart[];
16
+ }
17
+
18
+ interface SessionMessagesResponse {
19
+ data?: SessionMessage[];
20
+ }
21
+
22
+ export function createSpawnAgentTool(ctx: PluginInput) {
23
+ return tool({
24
+ description: `FOR SUBAGENTS ONLY - Primary agents (commander, brainstormer) should use the built-in Task tool instead.
25
+ Spawn a subagent to execute a task synchronously. The agent runs to completion and returns its result.
26
+ Use this when you are a SUBAGENT (executor, planner, project-initializer) and need to spawn other subagents.
27
+ For parallel execution, call spawn_agent multiple times in ONE message.`,
28
+ args: {
29
+ agent: tool.schema.string().describe("Agent to spawn (e.g., 'implementer', 'reviewer')"),
30
+ prompt: tool.schema.string().describe("Full prompt/instructions for the agent"),
31
+ description: tool.schema.string().describe("Short description of the task"),
32
+ },
33
+ execute: async (args) => {
34
+ const { agent, prompt, description } = args;
35
+
36
+ try {
37
+ // Create new session for the subagent
38
+ const sessionResp = (await ctx.client.session.create({
39
+ body: {},
40
+ query: { directory: ctx.directory },
41
+ })) as SessionCreateResponse;
42
+
43
+ const sessionID = sessionResp.data?.id;
44
+ if (!sessionID) {
45
+ return `## spawn_agent Failed\n\nFailed to create session for agent "${agent}"`;
46
+ }
47
+
48
+ // Run the prompt synchronously (waits for completion)
49
+ await ctx.client.session.prompt({
50
+ path: { id: sessionID },
51
+ body: {
52
+ parts: [{ type: "text", text: prompt }],
53
+ agent: agent,
54
+ },
55
+ query: { directory: ctx.directory },
56
+ });
57
+
58
+ // Get the result from session messages
59
+ const messagesResp = (await ctx.client.session.messages({
60
+ path: { id: sessionID },
61
+ query: { directory: ctx.directory },
62
+ })) as SessionMessagesResponse;
63
+
64
+ // Find the last assistant message
65
+ const messages = messagesResp.data || [];
66
+ const lastAssistant = messages
67
+ .filter((m) => m.info?.role === "assistant")
68
+ .pop();
69
+
70
+ const result =
71
+ lastAssistant?.parts
72
+ ?.filter((p) => p.type === "text" && p.text)
73
+ .map((p) => p.text)
74
+ .join("\n") || "(No response from agent)";
75
+
76
+ // Clean up session
77
+ await ctx.client.session
78
+ .delete({
79
+ path: { id: sessionID },
80
+ query: { directory: ctx.directory },
81
+ })
82
+ .catch(() => {
83
+ // Ignore cleanup errors
84
+ });
85
+
86
+ return `## ${description}\n\n**Agent**: ${agent}\n\n### Result\n\n${result}`;
87
+ } catch (error) {
88
+ const errorMsg = error instanceof Error ? error.message : String(error);
89
+ return `## spawn_agent Failed\n\n**Agent**: ${agent}\n**Error**: ${errorMsg}`;
90
+ }
91
+ },
92
+ });
93
+ }