Skip to content

Commit fbafc93

Browse files
feat: add session continuation support to launch_opencode tool
- Add continueSession() and getTaskBySessionId() methods to TaskManager - Add spawnContinueProcess() helper to opencode.tool.ts for --session flag - Add sessionId parameter to launch_opencode with mixed array support - Support mixing new tasks and continuations in single call - Default continuation message to "continue" when no task provided
1 parent c0229f0 commit fbafc93

5 files changed

Lines changed: 625 additions & 10 deletions

File tree

src/tasks/taskManager.ts

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -340,6 +340,56 @@ export class TaskManager {
340340
return Array.from(this.tasks.values()).map(state => ({ ...state.metadata }));
341341
}
342342

343+
/**
344+
* Gets a task by its session ID.
345+
* Useful for session continuation when only the sessionId is known.
346+
*
347+
* @param sessionId - The OpenCode session ID to look up
348+
* @returns The taskId, or undefined if session not found
349+
*/
350+
getTaskBySessionId(sessionId: string): string | undefined {
351+
for (const [taskId, state] of this.tasks) {
352+
if (state.metadata.sessionId === sessionId) {
353+
return taskId;
354+
}
355+
}
356+
return undefined;
357+
}
358+
359+
/**
360+
* Continues an existing task for a session.
361+
* Reuses the existing task ID and metadata, but updates to working status.
362+
*
363+
* @param sessionId - The OpenCode session ID to continue
364+
* @returns The existing taskId for the session
365+
* @throws Error if session not found or task is already terminal
366+
*/
367+
async continueSession(sessionId: string): Promise<string> {
368+
// Find the task with this session ID
369+
const taskId = this.getTaskBySessionId(sessionId);
370+
if (!taskId) {
371+
throw new Error(`Session ${sessionId} not found`);
372+
}
373+
374+
const state = this.tasks.get(taskId);
375+
if (!state) {
376+
throw new Error(`Task ${taskId} not found (internal error)`);
377+
}
378+
379+
// Check if task is already terminal
380+
if (this.isTerminalStatus(state.status)) {
381+
throw new Error(`Cannot continue session ${sessionId}: task is already ${state.status}`);
382+
}
383+
384+
// Reset to working status and clear any pending timers
385+
this.clearInputRequiredTimer(taskId);
386+
this.updateStatus(taskId, "working");
387+
388+
Logger.debug(`Continuing session ${sessionId} with task ${taskId}`);
389+
390+
return taskId;
391+
}
392+
343393
/**
344394
* Removes a task from the manager.
345395
* Useful for cleanup after tasks complete.

src/tools/index.ts

Lines changed: 6 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,14 @@
11
// Tool Registry Index - Registers all tools
22
import { toolRegistry } from './registry.js';
3-
import { pingTool, helpTool } from './simple-tools.js';
4-
import { opencodeTool } from './opencode.tool.js';
3+
import { launchOpencodeTool } from './launch-opencode.tool.js';
54
import { opencodeSessionsTool } from './opencode-sessions.tool.js';
6-
import { opencodeRespondTool } from './opencode-respond.tool.js';
5+
import { waitForCompletionTool } from './wait-for-completion.tool.js';
76

87
toolRegistry.push(
9-
// Async OpenCode tools
10-
opencodeTool,
8+
// OpenCode tools
9+
launchOpencodeTool,
1110
opencodeSessionsTool,
12-
opencodeRespondTool,
13-
// Simple utility tools
14-
pingTool,
15-
helpTool
11+
waitForCompletionTool,
1612
);
1713

18-
export * from './registry.js';
14+
export * from './registry.js';

src/tools/launch-opencode.tool.ts

Lines changed: 292 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,292 @@
1+
/**
2+
* Launch OpenCode Tool - Launches 1 or more OpenCode tasks in parallel.
3+
* Returns immediately with task IDs for all launched tasks.
4+
* @module launch-opencode.tool
5+
*/
6+
7+
import { z } from "zod";
8+
import { UnifiedTool } from "./registry.js";
9+
import { getTaskManager } from "../tasks/sharedTaskManager.js";
10+
import { getServerConfig } from "../config.js";
11+
import { Logger } from "../utils/logger.js";
12+
13+
// Import spawnOpenCodeProcess and spawnContinueProcess from opencode.tool
14+
import {
15+
spawnOpenCodeProcess,
16+
spawnContinueProcess,
17+
} from "./opencode.tool.js";
18+
19+
// ============================================================================
20+
// Schema - Task OR session continuation
21+
// ============================================================================
22+
23+
// New task definition schema
24+
const newTaskSchema = z.object({
25+
task: z
26+
.string()
27+
.min(1)
28+
.describe("The task/prompt to send to OpenCode for autonomous execution"),
29+
agent: z
30+
.enum(["explore", "plan", "build"])
31+
.optional()
32+
.describe("OpenCode agent mode: 'explore' for investigation, 'plan' for structured analysis, 'build' for immediate execution"),
33+
outputGuidance: z
34+
.string()
35+
.optional()
36+
.describe("Instructions for how OpenCode should format its output"),
37+
model: z
38+
.string()
39+
.optional()
40+
.describe("Override default model (e.g., 'google/gemini-2.5-pro')"),
41+
sessionTitle: z
42+
.string()
43+
.optional()
44+
.describe("Human-readable session name for tracking"),
45+
});
46+
47+
// Session continuation definition schema
48+
const continueSessionSchema = z.object({
49+
sessionId: z
50+
.string()
51+
.min(1)
52+
.describe("Existing OpenCode session ID to continue"),
53+
task: z
54+
.string()
55+
.optional()
56+
.describe("Follow-up message/instruction for the session"),
57+
model: z
58+
.string()
59+
.optional()
60+
.describe("Override default model for continuation"),
61+
});
62+
63+
// Discriminated union: either a new task OR a session continuation
64+
const taskOrContinueSchema = z.discriminatedUnion("type", [
65+
z.object({
66+
type: z.literal("new"),
67+
...newTaskSchema.shape,
68+
}),
69+
z.object({
70+
type: z.literal("continue"),
71+
...continueSessionSchema.shape,
72+
}),
73+
]);
74+
75+
// Main args schema
76+
const launchOpencodeArgsSchema = z.object({
77+
// Convenience: single new task (type inferred)
78+
task: z.string().min(1).optional().describe("Launch a single new task - what you want OpenCode to do"),
79+
agent: z.enum(["explore", "plan", "build"]).optional().describe("Agent mode for single new task"),
80+
outputGuidance: z.string().optional().describe("Output guidance for single new task"),
81+
model: z.string().optional().describe("Model override for single new task"),
82+
sessionTitle: z.string().optional().describe("Session title for single new task"),
83+
84+
// Convenience: single session continuation
85+
sessionId: z.string().optional().describe("Continue existing OpenCode session ID (single continuation mode)"),
86+
87+
// Generic: mixed array of new tasks and/or continuations
88+
tasks: z.array(taskOrContinueSchema).optional().describe("Launch multiple tasks - array mixing new tasks (type:'new') and continuations (type:'continue')"),
89+
}).refine(
90+
(data) => {
91+
// Valid patterns:
92+
// 1. Single new task: task set, tasks and sessionId not set
93+
// 2. Single continuation: sessionId set, tasks not set
94+
// 3. Multiple tasks: tasks set, task and sessionId not set
95+
const hasTask = data.task !== undefined && typeof data.task === 'string';
96+
const hasTasks = data.tasks !== undefined && Array.isArray(data.tasks);
97+
const hasSessionId = data.sessionId !== undefined && typeof data.sessionId === 'string';
98+
99+
const setCount = [hasTask, hasTasks, hasSessionId].filter(Boolean).length;
100+
return setCount === 1;
101+
},
102+
{
103+
message: "Must provide exactly one of: 'task' (single new task), 'sessionId' (single continuation), or 'tasks' (array mixing new tasks and continuations)",
104+
}
105+
);
106+
107+
// ============================================================================
108+
// Types
109+
// ============================================================================
110+
111+
type NewTaskDef = z.infer<typeof newTaskSchema>;
112+
type ContinueSessionDef = z.infer<typeof continueSessionSchema>;
113+
type TaskOrContinueDef = z.infer<typeof taskOrContinueSchema>;
114+
115+
// ============================================================================
116+
// Tool Implementation
117+
// ============================================================================
118+
119+
export const launchOpencodeTool: UnifiedTool = {
120+
name: "launch_opencode",
121+
description: `Launch OpenCode tasks (1 or more) for autonomous execution. Returns immediately with task IDs.
122+
123+
USE THIS TOOL when you need to:
124+
- Run code analysis, generation, or modification tasks
125+
- Delegate work to another model for parallel processing
126+
- Execute long-running operations asynchronously
127+
- Launch multiple independent tasks concurrently
128+
- Continue an existing OpenCode session with follow-up instructions
129+
- Mix new tasks and session continuations in a single call
130+
131+
WORKFLOW:
132+
1. Call launch_opencode with task definition(s) or sessionId
133+
2. Receive task ID(s) immediately (status: "working")
134+
3. Monitor progress with opencode_sessions tool
135+
4. Call wait_for_completion to get results
136+
137+
INPUTS (single new task):
138+
- task: What you want OpenCode to do (required)
139+
- agent: "explore", "plan", or "build" (optional)
140+
- model: Override default model (optional)
141+
- outputGuidance: Instructions for output formatting (optional)
142+
- sessionTitle: Human-readable name (optional)
143+
144+
INPUTS (single continuation):
145+
- sessionId: Existing OpenCode session ID (required)
146+
- task: Follow-up message (optional)
147+
- model: Override default model (optional)
148+
149+
INPUTS (mixed array):
150+
- tasks: Array of items, each with type:'new' (new task) or type:'continue' (continuation)
151+
For type:'new': same fields as single new task above
152+
For type:'continue': sessionId (required), task (optional), model (optional)
153+
154+
RETURNS: { results: [{taskId, sessionId, status}], count: number }
155+
156+
NOTE: This is an async operation. Tasks continue running after this tool returns. Use opencode_sessions to check completion status, and wait_for_completion to get final results.`,
157+
zodSchema: launchOpencodeArgsSchema,
158+
category: "opencode",
159+
160+
execute: async (args): Promise<string> => {
161+
const taskManager = getTaskManager();
162+
const config = getServerConfig();
163+
const input = args as any;
164+
165+
const results: Array<{
166+
taskId: string;
167+
sessionId: string;
168+
status: "working";
169+
}> = [];
170+
171+
// Handle single continuation mode
172+
if (input.sessionId && typeof input.sessionId === 'string') {
173+
const sessionId = input.sessionId;
174+
const message = input.task || "continue";
175+
const model = input.model;
176+
177+
const taskId = await taskManager.continueSession(sessionId);
178+
Logger.debug(`Continuing session ${sessionId} with task ${taskId}`);
179+
180+
spawnContinueProcess(taskId, sessionId, message, model);
181+
182+
const metadata = taskManager.getTaskMetadata(taskId);
183+
results.push({
184+
taskId,
185+
sessionId: metadata?.sessionId || sessionId,
186+
status: "working",
187+
});
188+
}
189+
// Handle single new task mode
190+
else if (input.task && typeof input.task === 'string') {
191+
const taskDef: NewTaskDef = {
192+
task: input.task,
193+
agent: input.agent,
194+
outputGuidance: input.outputGuidance,
195+
model: input.model,
196+
sessionTitle: input.sessionTitle,
197+
};
198+
199+
if (!taskDef.task?.trim()) {
200+
Logger.warn("Skipping task with empty task description");
201+
return JSON.stringify({ results: [], count: 0 }, null, 2);
202+
}
203+
204+
const effectiveModel = taskDef.model || config.primaryModel;
205+
const title = taskDef.sessionTitle || `OpenCode task: ${taskDef.task.slice(0, 50)}${taskDef.task.length > 50 ? "..." : ""}`;
206+
207+
const taskId = await taskManager.createTask({
208+
title,
209+
model: effectiveModel,
210+
agent: taskDef.agent,
211+
});
212+
213+
Logger.debug(`Created task ${taskId}: ${title}`);
214+
spawnOpenCodeProcess(taskId, taskDef.task, effectiveModel, taskDef.agent, taskDef.outputGuidance);
215+
216+
const metadata = taskManager.getTaskMetadata(taskId);
217+
results.push({
218+
taskId,
219+
sessionId: metadata?.sessionId || "",
220+
status: "working",
221+
});
222+
}
223+
// Handle mixed array mode
224+
else if (input.tasks && Array.isArray(input.tasks)) {
225+
for (const item of input.tasks) {
226+
const validated = taskOrContinueSchema.safeParse(item);
227+
if (!validated.success) {
228+
Logger.warn(`Skipping invalid task item: ${validated.error.message}`);
229+
continue;
230+
}
231+
232+
if (validated.data.type === "continue") {
233+
// Handle continuation
234+
const { sessionId, task: message, model } = validated.data;
235+
236+
try {
237+
const taskId = await taskManager.continueSession(sessionId);
238+
Logger.debug(`Continuing session ${sessionId} with task ${taskId}`);
239+
240+
spawnContinueProcess(taskId, sessionId, message || "continue", model);
241+
242+
const metadata = taskManager.getTaskMetadata(taskId);
243+
results.push({
244+
taskId,
245+
sessionId: metadata?.sessionId || sessionId,
246+
status: "working",
247+
});
248+
} catch (error) {
249+
const message = error instanceof Error ? error.message : String(error);
250+
Logger.error(`Failed to continue session ${sessionId}: ${message}`);
251+
}
252+
} else {
253+
// Handle new task
254+
const taskDef = validated.data;
255+
256+
if (!taskDef.task?.trim()) {
257+
Logger.warn("Skipping task with empty task description");
258+
continue;
259+
}
260+
261+
const effectiveModel = taskDef.model || config.primaryModel;
262+
const title = taskDef.sessionTitle || `OpenCode task: ${taskDef.task.slice(0, 50)}${taskDef.task.length > 50 ? "..." : ""}`;
263+
264+
const taskId = await taskManager.createTask({
265+
title,
266+
model: effectiveModel,
267+
agent: taskDef.agent,
268+
});
269+
270+
Logger.debug(`Created task ${taskId}: ${title}`);
271+
spawnOpenCodeProcess(taskId, taskDef.task, effectiveModel, taskDef.agent, taskDef.outputGuidance);
272+
273+
const metadata = taskManager.getTaskMetadata(taskId);
274+
results.push({
275+
taskId,
276+
sessionId: metadata?.sessionId || "",
277+
status: "working",
278+
});
279+
}
280+
}
281+
}
282+
283+
const result = {
284+
results,
285+
count: results.length,
286+
};
287+
288+
Logger.debug(`Launched ${results.length} tasks in parallel`);
289+
290+
return JSON.stringify(result, null, 2);
291+
},
292+
};

0 commit comments

Comments
 (0)