opencodekit 0.14.1 → 0.14.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.
@@ -0,0 +1,182 @@
1
+ /**
2
+ * Ralph Wiggum Plugin for OpenCode
3
+ *
4
+ * Handles the session.idle event to continue the Ralph loop.
5
+ * Tools are defined separately in .opencode/tool/ralph.ts
6
+ *
7
+ * Based on: https://ghuntley.com/ralph/
8
+ */
9
+
10
+ import fs from "node:fs/promises";
11
+ import type { Plugin } from "@opencode-ai/plugin";
12
+
13
+ const STATE_FILE = ".opencode/.ralph-state.json";
14
+ const IDLE_DEBOUNCE_MS = 2000;
15
+ let lastIdleTime = 0;
16
+
17
+ interface RalphState {
18
+ active: boolean;
19
+ sessionID: string | null;
20
+ iteration: number;
21
+ maxIterations: number;
22
+ completionPromise: string;
23
+ task: string;
24
+ prdFile: string | null;
25
+ progressFile: string;
26
+ startedAt: number | null;
27
+ mode: "hitl" | "afk";
28
+ }
29
+
30
+ async function loadState(): Promise<RalphState | null> {
31
+ try {
32
+ const content = await fs.readFile(STATE_FILE, "utf-8");
33
+ return JSON.parse(content);
34
+ } catch {
35
+ return null;
36
+ }
37
+ }
38
+
39
+ async function saveState(state: RalphState): Promise<void> {
40
+ await fs.writeFile(STATE_FILE, JSON.stringify(state, null, 2));
41
+ }
42
+
43
+ async function resetState(): Promise<void> {
44
+ try {
45
+ await fs.unlink(STATE_FILE);
46
+ } catch {
47
+ // File doesn't exist, that's fine
48
+ }
49
+ }
50
+
51
+ export const RalphWiggum: Plugin = async ({ client }) => {
52
+ const log = async (
53
+ message: string,
54
+ level: "info" | "warn" | "error" = "info",
55
+ ) => {
56
+ await client.app
57
+ .log({
58
+ body: { service: "ralph-wiggum", level, message },
59
+ })
60
+ .catch(() => {});
61
+ };
62
+
63
+ const showToast = async (
64
+ title: string,
65
+ message: string,
66
+ variant: "info" | "success" | "warning" | "error" = "info",
67
+ ) => {
68
+ await client.tui
69
+ .showToast({
70
+ body: {
71
+ title: `Ralph: ${title}`,
72
+ message,
73
+ variant,
74
+ duration: variant === "error" ? 8000 : 5000,
75
+ },
76
+ })
77
+ .catch(() => {});
78
+ };
79
+
80
+ const buildContinuationPrompt = (state: RalphState): string => {
81
+ const prdRef = state.prdFile ? `@${state.prdFile} ` : "";
82
+ const progressRef = `@${state.progressFile}`;
83
+
84
+ return `
85
+ ${prdRef}${progressRef}
86
+
87
+ ## Ralph Wiggum Loop - Iteration ${state.iteration}/${state.maxIterations}
88
+
89
+ You are in an autonomous loop. Continue working on the task.
90
+
91
+ **Task:** ${state.task}
92
+
93
+ **Instructions:**
94
+ 1. Review the PRD/task list and progress file
95
+ 2. Choose the highest-priority INCOMPLETE task
96
+ 3. Implement ONE feature/change only
97
+ 4. Run feedback loops: typecheck, test, lint
98
+ 5. Commit if all pass
99
+ 6. Update ${state.progressFile}
100
+ 7. If ALL tasks complete, output: ${state.completionPromise}
101
+
102
+ **Constraints:** ONE feature per iteration. Quality over speed.
103
+ `.trim();
104
+ };
105
+
106
+ const handleSessionIdle = async (sessionID: string): Promise<void> => {
107
+ const now = Date.now();
108
+ if (now - lastIdleTime < IDLE_DEBOUNCE_MS) return;
109
+ lastIdleTime = now;
110
+
111
+ const state = await loadState();
112
+ if (!state?.active || state.sessionID !== sessionID) return;
113
+
114
+ try {
115
+ const messagesResponse = await client.session.messages({
116
+ path: { id: sessionID },
117
+ });
118
+ const messages = messagesResponse.data || [];
119
+ const lastMessage = messages[messages.length - 1];
120
+
121
+ const lastText =
122
+ lastMessage?.parts
123
+ ?.filter((p) => p.type === "text")
124
+ .map((p) => ("text" in p ? (p.text as string) : ""))
125
+ .join("") || "";
126
+
127
+ if (lastText.includes(state.completionPromise)) {
128
+ const duration = state.startedAt
129
+ ? Math.round((Date.now() - state.startedAt) / 1000 / 60)
130
+ : 0;
131
+ await showToast(
132
+ "Complete!",
133
+ `Finished in ${state.iteration} iterations (${duration} min)`,
134
+ "success",
135
+ );
136
+ await log(`Loop completed in ${state.iteration} iterations`);
137
+ await resetState();
138
+ return;
139
+ }
140
+
141
+ state.iteration++;
142
+ if (state.iteration >= state.maxIterations) {
143
+ await showToast(
144
+ "Stopped",
145
+ `Max iterations (${state.maxIterations}) reached`,
146
+ "warning",
147
+ );
148
+ await log(`Max iterations reached: ${state.maxIterations}`, "warn");
149
+ await resetState();
150
+ return;
151
+ }
152
+
153
+ await saveState(state);
154
+
155
+ await client.session.prompt({
156
+ path: { id: sessionID },
157
+ body: {
158
+ parts: [{ type: "text", text: buildContinuationPrompt(state) }],
159
+ },
160
+ });
161
+
162
+ await log(`Iteration ${state.iteration}/${state.maxIterations}`);
163
+ } catch (error) {
164
+ await log(`Error in Ralph loop: ${error}`, "error");
165
+ await resetState();
166
+ }
167
+ };
168
+
169
+ return {
170
+ event: async ({ event }) => {
171
+ if (event.type === "session.idle") {
172
+ const sessionID = (event as { properties?: { sessionID?: string } })
173
+ .properties?.sessionID;
174
+ if (sessionID) {
175
+ await handleSessionIdle(sessionID);
176
+ }
177
+ }
178
+ },
179
+ };
180
+ };
181
+
182
+ export default RalphWiggum;
@@ -0,0 +1,461 @@
1
+ import { execSync } from "node:child_process";
2
+ import fs from "node:fs/promises";
3
+ import path from "node:path";
4
+ import { tool } from "@opencode-ai/plugin";
5
+ import { createOpencodeClient } from "@opencode-ai/sdk";
6
+
7
+ const TASKS_FILE = ".opencode/.background-tasks.json";
8
+
9
+ interface BackgroundTask {
10
+ taskId: string;
11
+ sessionId: string;
12
+ parentSessionId: string; // Track parent for debugging
13
+ agent: string;
14
+ prompt: string;
15
+ started: number;
16
+ status: "running" | "completed" | "cancelled";
17
+ // Beads integration
18
+ beadId?: string;
19
+ autoCloseBead?: boolean; // Only allowed for safe agents (explore, scout)
20
+ }
21
+
22
+ interface TasksStore {
23
+ tasks: Record<string, BackgroundTask>;
24
+ }
25
+
26
+ async function loadTasks(): Promise<TasksStore> {
27
+ try {
28
+ const content = await fs.readFile(TASKS_FILE, "utf-8");
29
+ return JSON.parse(content);
30
+ } catch {
31
+ return { tasks: {} };
32
+ }
33
+ }
34
+
35
+ async function saveTasks(store: TasksStore): Promise<void> {
36
+ await fs.mkdir(path.dirname(TASKS_FILE), { recursive: true });
37
+ await fs.writeFile(TASKS_FILE, JSON.stringify(store, null, 2));
38
+ }
39
+
40
+ function createClient() {
41
+ return createOpencodeClient({ baseUrl: "http://localhost:4096" });
42
+ }
43
+
44
+ /**
45
+ * Find the bd binary path dynamically
46
+ */
47
+ function findBdPath(): string {
48
+ try {
49
+ // Try to find bd in PATH using shell
50
+ const result = execSync("which bd || command -v bd", {
51
+ encoding: "utf-8",
52
+ timeout: 5000,
53
+ shell: "/bin/sh",
54
+ }).trim();
55
+ if (result) return result;
56
+ } catch {
57
+ // Fallback to common locations
58
+ const commonPaths = [
59
+ `${process.env.HOME}/.local/bin/bd`,
60
+ `${process.env.HOME}/.bun/bin/bd`,
61
+ "/usr/local/bin/bd",
62
+ "/opt/homebrew/bin/bd",
63
+ ];
64
+ for (const p of commonPaths) {
65
+ try {
66
+ execSync(`test -x "${p}"`, { timeout: 1000 });
67
+ return p;
68
+ } catch {
69
+ continue;
70
+ }
71
+ }
72
+ }
73
+ // Last resort - assume it's in PATH
74
+ return "bd";
75
+ }
76
+
77
+ // Cache the bd path
78
+ let bdPath: string | null = null;
79
+ function getBdPath(): string {
80
+ if (!bdPath) {
81
+ bdPath = findBdPath();
82
+ }
83
+ return bdPath;
84
+ }
85
+
86
+ /**
87
+ * Helper to run beads CLI commands
88
+ */
89
+ async function runBeadsCommand(
90
+ args: string[],
91
+ ): Promise<{ success: boolean; output: string }> {
92
+ try {
93
+ // Quote arguments that contain spaces
94
+ const quotedArgs = args.map((arg) =>
95
+ arg.includes(" ") ? `"${arg}"` : arg,
96
+ );
97
+ // Use dynamically detected bd path with shell for proper PATH resolution
98
+ const output = execSync(`${getBdPath()} ${quotedArgs.join(" ")}`, {
99
+ encoding: "utf-8",
100
+ timeout: 30000,
101
+ shell: "/bin/sh",
102
+ env: { ...process.env },
103
+ });
104
+ return { success: true, output };
105
+ } catch (e) {
106
+ const error = e as { stderr?: string; message?: string };
107
+ return {
108
+ success: false,
109
+ output: error.stderr || error.message || String(e),
110
+ };
111
+ }
112
+ }
113
+
114
+ // Allowed agents for background delegation
115
+ // - Subagents: explore, scout, review, planner, vision, looker (stateless workers)
116
+ // - Primary: rush (autonomous execution)
117
+ // - NOT allowed: build (build is the orchestrator that uses this tool)
118
+ const ALLOWED_AGENTS = [
119
+ "explore",
120
+ "scout",
121
+ "review",
122
+ "planner",
123
+ "vision",
124
+ "looker",
125
+ "rush",
126
+ ] as const;
127
+ type AllowedAgent = (typeof ALLOWED_AGENTS)[number];
128
+
129
+ // Agents safe for autoCloseBead (pure research, no side effects)
130
+ // These only return information, don't make changes that need verification
131
+ const SAFE_AUTOCLOSE_AGENTS: readonly string[] = [
132
+ "explore",
133
+ "scout",
134
+ "looker",
135
+ ] as const;
136
+
137
+ /**
138
+ * Start a background subagent task.
139
+ * Creates a child session that runs independently.
140
+ */
141
+ export const start = tool({
142
+ description:
143
+ "Start a background subagent task. Returns a task_id to collect results later. Use for parallel research/exploration or executing beads subtasks. NOTE: Cannot delegate to 'build' agent (build is the orchestrator).",
144
+ args: {
145
+ agent: tool.schema
146
+ .string()
147
+ .describe(
148
+ "Agent type: explore, scout, review, planner, vision, looker, rush (NOT build - build is the orchestrator)",
149
+ ),
150
+ prompt: tool.schema.string().describe("Task prompt for the agent"),
151
+ title: tool.schema
152
+ .string()
153
+ .optional()
154
+ .describe("Optional task title for identification"),
155
+ beadId: tool.schema
156
+ .string()
157
+ .optional()
158
+ .describe("Bead ID to associate with this task (e.g., bd-abc123)"),
159
+ autoCloseBead: tool.schema
160
+ .boolean()
161
+ .optional()
162
+ .describe(
163
+ "Auto-close bead on completion. Only allowed for safe agents (explore, scout). Blocked for rush/review/planner/vision/looker.",
164
+ ),
165
+ },
166
+ execute: async (args, context) => {
167
+ // Validate agent type - build cannot delegate to itself
168
+ if (args.agent === "build") {
169
+ return JSON.stringify({
170
+ error:
171
+ "Cannot delegate to 'build' agent. Build is the orchestrator that uses this tool. Use subagents (explore, scout, review, planner, vision, looker) or rush instead.",
172
+ });
173
+ }
174
+
175
+ if (!ALLOWED_AGENTS.includes(args.agent as AllowedAgent)) {
176
+ return JSON.stringify({
177
+ error: `Invalid agent type: ${args.agent}. Allowed: ${ALLOWED_AGENTS.join(", ")}`,
178
+ });
179
+ }
180
+
181
+ // Validate autoCloseBead - only allowed for safe agents
182
+ if (args.autoCloseBead && !SAFE_AUTOCLOSE_AGENTS.includes(args.agent)) {
183
+ return JSON.stringify({
184
+ error: `autoCloseBead not allowed for '${args.agent}' agent. Only safe for: ${SAFE_AUTOCLOSE_AGENTS.join(", ")}. Build agent must verify output from ${args.agent} before closing beads.`,
185
+ });
186
+ }
187
+
188
+ const client = createClient();
189
+ const taskId = `bg_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
190
+ const title = args.title || `bg-${args.agent}-${Date.now()}`;
191
+
192
+ try {
193
+ // Create child session linked to parent (build agent's session)
194
+ // This enables context inheritance from the main session
195
+ const session = await client.session.create({
196
+ body: {
197
+ title,
198
+ parentID: context.sessionID, // Link to parent session for context inheritance
199
+ },
200
+ });
201
+
202
+ if (!session.data?.id) {
203
+ return JSON.stringify({ error: "Failed to create session" });
204
+ }
205
+
206
+ // Fire the prompt (this returns immediately, session runs async)
207
+ // Use the agent field and AgentPartInput to properly route to the specified agent
208
+ await client.session.prompt({
209
+ path: { id: session.data.id },
210
+ body: {
211
+ agent: args.agent, // Specify agent type directly in body
212
+ parts: [
213
+ {
214
+ type: "agent" as const,
215
+ name: args.agent, // AgentPartInput triggers agent routing
216
+ },
217
+ {
218
+ type: "text" as const,
219
+ text: args.prompt,
220
+ },
221
+ ],
222
+ },
223
+ });
224
+
225
+ // Persist task info
226
+ const store = await loadTasks();
227
+ store.tasks[taskId] = {
228
+ taskId,
229
+ sessionId: session.data.id,
230
+ parentSessionId: context.sessionID,
231
+ agent: args.agent,
232
+ prompt: args.prompt,
233
+ started: Date.now(),
234
+ status: "running",
235
+ beadId: args.beadId,
236
+ autoCloseBead: args.autoCloseBead,
237
+ };
238
+ await saveTasks(store);
239
+
240
+ // If beadId provided, mark it as in_progress
241
+ if (args.beadId) {
242
+ await runBeadsCommand([
243
+ "update",
244
+ args.beadId,
245
+ "--status",
246
+ "in_progress",
247
+ ]);
248
+ }
249
+
250
+ return JSON.stringify({
251
+ taskId,
252
+ sessionId: session.data.id,
253
+ agent: args.agent,
254
+ beadId: args.beadId,
255
+ status: "started",
256
+ message: `Background task started. Use background_output(taskId="${taskId}") to get results.`,
257
+ });
258
+ } catch (e) {
259
+ const error = e instanceof Error ? e.message : String(e);
260
+ return JSON.stringify({
261
+ error: `Failed to start background task: ${error}`,
262
+ });
263
+ }
264
+ },
265
+ });
266
+
267
+ /**
268
+ * Get output from a background task.
269
+ * Retrieves the last assistant message from the child session.
270
+ */
271
+ export const output = tool({
272
+ description:
273
+ "Get output from a background task. Returns the agent's response or 'still running' if not complete.",
274
+ args: {
275
+ taskId: tool.schema.string().describe("Task ID from background_start"),
276
+ },
277
+ execute: async (args) => {
278
+ const client = createClient();
279
+ const store = await loadTasks();
280
+ const task = store.tasks[args.taskId];
281
+
282
+ if (!task) {
283
+ return JSON.stringify({
284
+ error: `Task not found: ${args.taskId}`,
285
+ availableTasks: Object.keys(store.tasks),
286
+ });
287
+ }
288
+
289
+ try {
290
+ const messages = await client.session.messages({
291
+ path: { id: task.sessionId },
292
+ });
293
+
294
+ if (!messages.data?.length) {
295
+ return JSON.stringify({
296
+ taskId: args.taskId,
297
+ status: "running",
298
+ message: "No messages yet - task still initializing",
299
+ });
300
+ }
301
+
302
+ // Find last assistant message
303
+ const assistantMessages = messages.data.filter(
304
+ (m) => m.info?.role === "assistant",
305
+ );
306
+
307
+ if (!assistantMessages.length) {
308
+ return JSON.stringify({
309
+ taskId: args.taskId,
310
+ status: "running",
311
+ message: "Task running - no response yet",
312
+ });
313
+ }
314
+
315
+ const lastMessage = assistantMessages[assistantMessages.length - 1];
316
+ const textParts = lastMessage.parts
317
+ ?.filter((p) => p.type === "text")
318
+ .map((p) => p.text)
319
+ .join("\n");
320
+
321
+ // Update status
322
+ task.status = "completed";
323
+ await saveTasks(store);
324
+
325
+ // Build result object
326
+ const result: Record<string, unknown> = {
327
+ taskId: args.taskId,
328
+ agent: task.agent,
329
+ status: "completed",
330
+ output: textParts || "(empty response)",
331
+ };
332
+
333
+ // Handle bead closing
334
+ if (task.beadId) {
335
+ result.beadId = task.beadId;
336
+
337
+ // Auto-close for safe agents (explore, scout)
338
+ if (task.autoCloseBead && SAFE_AUTOCLOSE_AGENTS.includes(task.agent)) {
339
+ const closeResult = await runBeadsCommand([
340
+ "close",
341
+ task.beadId,
342
+ "--reason",
343
+ `Auto-closed: ${task.agent} task completed (${task.taskId})`,
344
+ ]);
345
+ result.beadClosed = closeResult.success;
346
+ if (!closeResult.success) {
347
+ result.beadCloseError = closeResult.output;
348
+ }
349
+ } else {
350
+ // For unsafe agents or when autoClose not requested, remind to verify
351
+ result.beadAction = `VERIFY output, then run: bd close ${task.beadId} --reason "..." `;
352
+ }
353
+ }
354
+
355
+ return JSON.stringify(result);
356
+ } catch (e) {
357
+ const error = e instanceof Error ? e.message : String(e);
358
+ return JSON.stringify({
359
+ taskId: args.taskId,
360
+ error: `Failed to get output: ${error}`,
361
+ });
362
+ }
363
+ },
364
+ });
365
+
366
+ /**
367
+ * Cancel background tasks.
368
+ * Aborts running sessions and cleans up task records.
369
+ */
370
+ export const cancel = tool({
371
+ description:
372
+ "Cancel background tasks. Use all=true to cancel all, or specify taskId.",
373
+ args: {
374
+ all: tool.schema
375
+ .boolean()
376
+ .optional()
377
+ .describe("Cancel all background tasks"),
378
+ taskId: tool.schema
379
+ .string()
380
+ .optional()
381
+ .describe("Specific task ID to cancel"),
382
+ },
383
+ execute: async (args) => {
384
+ const client = createClient();
385
+ const store = await loadTasks();
386
+ const cancelled: string[] = [];
387
+ const errors: string[] = [];
388
+
389
+ const tasksToCancel = args.all
390
+ ? Object.values(store.tasks).filter((t) => t.status === "running")
391
+ : args.taskId && store.tasks[args.taskId]
392
+ ? [store.tasks[args.taskId]]
393
+ : [];
394
+
395
+ if (!tasksToCancel.length) {
396
+ return JSON.stringify({
397
+ message: args.all
398
+ ? "No running tasks to cancel"
399
+ : `Task not found: ${args.taskId}`,
400
+ activeTasks: Object.values(store.tasks)
401
+ .filter((t) => t.status === "running")
402
+ .map((t) => t.taskId),
403
+ });
404
+ }
405
+
406
+ for (const task of tasksToCancel) {
407
+ try {
408
+ await client.session.abort({ path: { id: task.sessionId } });
409
+ task.status = "cancelled";
410
+ cancelled.push(task.taskId);
411
+ } catch (e) {
412
+ const error = e instanceof Error ? e.message : String(e);
413
+ errors.push(`${task.taskId}: ${error}`);
414
+ }
415
+ }
416
+
417
+ await saveTasks(store);
418
+
419
+ return JSON.stringify({
420
+ cancelled,
421
+ errors: errors.length ? errors : undefined,
422
+ remaining: Object.values(store.tasks)
423
+ .filter((t) => t.status === "running")
424
+ .map((t) => t.taskId),
425
+ });
426
+ },
427
+ });
428
+
429
+ /**
430
+ * List all background tasks with their status.
431
+ */
432
+ export const list = tool({
433
+ description: "List all background tasks with their status.",
434
+ args: {
435
+ status: tool.schema
436
+ .enum(["running", "completed", "cancelled", "all"])
437
+ .optional()
438
+ .default("all")
439
+ .describe("Filter by status"),
440
+ },
441
+ execute: async (args) => {
442
+ const store = await loadTasks();
443
+ const tasks = Object.values(store.tasks);
444
+
445
+ const filtered =
446
+ args.status === "all"
447
+ ? tasks
448
+ : tasks.filter((t) => t.status === args.status);
449
+
450
+ return JSON.stringify({
451
+ total: filtered.length,
452
+ tasks: filtered.map((t) => ({
453
+ taskId: t.taskId,
454
+ agent: t.agent,
455
+ status: t.status,
456
+ started: new Date(t.started).toISOString(),
457
+ prompt: t.prompt.slice(0, 100) + (t.prompt.length > 100 ? "..." : ""),
458
+ })),
459
+ });
460
+ },
461
+ });