opencodekit 0.14.3 → 0.14.5

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,22 +1,45 @@
1
- import { execSync } from "node:child_process";
1
+ /**
2
+ * @experimental Background Task Tool
3
+ *
4
+ * LIMITATION: Creates SEPARATE sessions, NOT child sessions of the parent.
5
+ * This means background tasks don't inherit context from the build agent's session.
6
+ *
7
+ * USE CASES:
8
+ * - True fire-and-forget async execution
9
+ * - When you need to continue working before results are ready
10
+ *
11
+ * FOR MOST CASES: Use the Task tool instead. Multiple Task calls in one message
12
+ * run in parallel AND create proper child sessions.
13
+ *
14
+ * Example with Task (recommended):
15
+ * Task({ subagent_type: "scout", prompt: "..." }) // ┐
16
+ * Task({ subagent_type: "explore", prompt: "..." }) // ┘ Parallel, proper sessions
17
+ *
18
+ * Example with Background (fire-and-forget):
19
+ * background_start({ agent: "scout", prompt: "..." }) // → taskId
20
+ * // ... continue other work ...
21
+ * background_output({ taskId }) // collect later
22
+ */
23
+
24
+ import { type ChildProcess, execSync, spawn } from "node:child_process";
2
25
  import fs from "node:fs/promises";
3
26
  import path from "node:path";
4
27
  import { tool } from "@opencode-ai/plugin";
5
- import { createOpencodeClient } from "@opencode-ai/sdk";
6
28
 
7
29
  const TASKS_FILE = ".opencode/.background-tasks.json";
30
+ const OUTPUT_DIR = ".opencode/.background-output";
8
31
 
9
32
  interface BackgroundTask {
10
33
  taskId: string;
11
- sessionId: string;
12
- parentSessionId: string; // Track parent for debugging
34
+ pid: number;
35
+ outputFile: string;
13
36
  agent: string;
14
37
  prompt: string;
15
38
  started: number;
16
- status: "running" | "completed" | "cancelled";
39
+ status: "running" | "completed" | "cancelled" | "failed";
17
40
  // Beads integration
18
41
  beadId?: string;
19
- autoCloseBead?: boolean; // Only allowed for safe agents (explore, scout)
42
+ autoCloseBead?: boolean;
20
43
  }
21
44
 
22
45
  interface TasksStore {
@@ -37,8 +60,8 @@ async function saveTasks(store: TasksStore): Promise<void> {
37
60
  await fs.writeFile(TASKS_FILE, JSON.stringify(store, null, 2));
38
61
  }
39
62
 
40
- function createClient() {
41
- return createOpencodeClient({ baseUrl: "http://localhost:4096" });
63
+ async function ensureOutputDir(): Promise<void> {
64
+ await fs.mkdir(OUTPUT_DIR, { recursive: true });
42
65
  }
43
66
 
44
67
  /**
@@ -46,7 +69,6 @@ function createClient() {
46
69
  */
47
70
  function findBdPath(): string {
48
71
  try {
49
- // Try to find bd in PATH using shell
50
72
  const result = execSync("which bd || command -v bd", {
51
73
  encoding: "utf-8",
52
74
  timeout: 5000,
@@ -54,7 +76,6 @@ function findBdPath(): string {
54
76
  }).trim();
55
77
  if (result) return result;
56
78
  } catch {
57
- // Fallback to common locations
58
79
  const commonPaths = [
59
80
  `${process.env.HOME}/.local/bin/bd`,
60
81
  `${process.env.HOME}/.bun/bin/bd`,
@@ -70,11 +91,9 @@ function findBdPath(): string {
70
91
  }
71
92
  }
72
93
  }
73
- // Last resort - assume it's in PATH
74
94
  return "bd";
75
95
  }
76
96
 
77
- // Cache the bd path
78
97
  let bdPath: string | null = null;
79
98
  function getBdPath(): string {
80
99
  if (!bdPath) {
@@ -90,11 +109,9 @@ async function runBeadsCommand(
90
109
  args: string[],
91
110
  ): Promise<{ success: boolean; output: string }> {
92
111
  try {
93
- // Quote arguments that contain spaces
94
112
  const quotedArgs = args.map((arg) =>
95
113
  arg.includes(" ") ? `"${arg}"` : arg,
96
114
  );
97
- // Use dynamically detected bd path with shell for proper PATH resolution
98
115
  const output = execSync(`${getBdPath()} ${quotedArgs.join(" ")}`, {
99
116
  encoding: "utf-8",
100
117
  timeout: 30000,
@@ -111,10 +128,19 @@ async function runBeadsCommand(
111
128
  }
112
129
  }
113
130
 
131
+ /**
132
+ * Check if a process is still running by PID
133
+ */
134
+ function isProcessRunning(pid: number): boolean {
135
+ try {
136
+ process.kill(pid, 0); // Signal 0 = check if process exists
137
+ return true;
138
+ } catch {
139
+ return false;
140
+ }
141
+ }
142
+
114
143
  // 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
144
  const ALLOWED_AGENTS = [
119
145
  "explore",
120
146
  "scout",
@@ -127,7 +153,6 @@ const ALLOWED_AGENTS = [
127
153
  type AllowedAgent = (typeof ALLOWED_AGENTS)[number];
128
154
 
129
155
  // Agents safe for autoCloseBead (pure research, no side effects)
130
- // These only return information, don't make changes that need verification
131
156
  const SAFE_AUTOCLOSE_AGENTS: readonly string[] = [
132
157
  "explore",
133
158
  "scout",
@@ -135,8 +160,8 @@ const SAFE_AUTOCLOSE_AGENTS: readonly string[] = [
135
160
  ] as const;
136
161
 
137
162
  /**
138
- * Start a background subagent task.
139
- * Creates a child session that runs independently.
163
+ * Start a background subagent task using `opencode run`.
164
+ * Spawns a separate process that runs independently.
140
165
  */
141
166
  export const start = tool({
142
167
  description:
@@ -145,7 +170,7 @@ export const start = tool({
145
170
  agent: tool.schema
146
171
  .string()
147
172
  .describe(
148
- "Agent type: explore, scout, review, planner, vision, looker, rush (NOT build - build is the orchestrator)",
173
+ "Agent type: explore, scout, review, planner, vision, looker, rush (NOT build)",
149
174
  ),
150
175
  prompt: tool.schema.string().describe("Task prompt for the agent"),
151
176
  title: tool.schema
@@ -160,15 +185,15 @@ export const start = tool({
160
185
  .boolean()
161
186
  .optional()
162
187
  .describe(
163
- "Auto-close bead on completion. Only allowed for safe agents (explore, scout). Blocked for rush/review/planner/vision/looker.",
188
+ "Auto-close bead on completion. Only allowed for safe agents (explore, scout, looker).",
164
189
  ),
165
190
  },
166
- execute: async (args, context) => {
167
- // Validate agent type - build cannot delegate to itself
191
+ execute: async (args) => {
192
+ // Validate agent type
168
193
  if (args.agent === "build") {
169
194
  return JSON.stringify({
170
195
  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.",
196
+ "Cannot delegate to 'build' agent. Build is the orchestrator. Use subagents instead.",
172
197
  });
173
198
  }
174
199
 
@@ -178,56 +203,61 @@ export const start = tool({
178
203
  });
179
204
  }
180
205
 
181
- // Validate autoCloseBead - only allowed for safe agents
206
+ // Validate autoCloseBead
182
207
  if (args.autoCloseBead && !SAFE_AUTOCLOSE_AGENTS.includes(args.agent)) {
183
208
  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.`,
209
+ error: `autoCloseBead not allowed for '${args.agent}' agent. Only safe for: ${SAFE_AUTOCLOSE_AGENTS.join(", ")}`,
185
210
  });
186
211
  }
187
212
 
188
- const client = createClient();
213
+ await ensureOutputDir();
214
+
189
215
  const taskId = `bg_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
190
- const title = args.title || `bg-${args.agent}-${Date.now()}`;
216
+ const title = args.title || `bg-${args.agent}-${taskId}`;
217
+ const outputFile = path.join(OUTPUT_DIR, `${taskId}.txt`);
191
218
 
192
219
  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: {
220
+ // Create output file to capture stdout
221
+ const outFd = await fs.open(outputFile, "w");
222
+
223
+ // Spawn opencode run with the agent and prompt
224
+ // Using --format default for readable output (json is too verbose for collection)
225
+ const proc = spawn(
226
+ "opencode",
227
+ [
228
+ "run",
229
+ "--agent",
230
+ args.agent,
231
+ "--title",
197
232
  title,
198
- parentID: context.sessionID, // Link to parent session for context inheritance
233
+ "--format",
234
+ "default",
235
+ args.prompt,
236
+ ],
237
+ {
238
+ detached: true, // Run independently of parent
239
+ stdio: ["ignore", outFd.fd, outFd.fd], // Redirect stdout/stderr to file
240
+ cwd: process.cwd(),
241
+ env: { ...process.env },
199
242
  },
200
- });
243
+ );
201
244
 
202
- if (!session.data?.id) {
203
- return JSON.stringify({ error: "Failed to create session" });
204
- }
245
+ // Don't wait for completion - let it run in background
246
+ proc.unref();
205
247
 
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
- });
248
+ // Close our handle to the output file (process keeps its own)
249
+ await outFd.close();
250
+
251
+ if (!proc.pid) {
252
+ return JSON.stringify({ error: "Failed to spawn opencode process" });
253
+ }
224
254
 
225
255
  // Persist task info
226
256
  const store = await loadTasks();
227
257
  store.tasks[taskId] = {
228
258
  taskId,
229
- sessionId: session.data.id,
230
- parentSessionId: context.sessionID,
259
+ pid: proc.pid,
260
+ outputFile,
231
261
  agent: args.agent,
232
262
  prompt: args.prompt,
233
263
  started: Date.now(),
@@ -249,7 +279,7 @@ export const start = tool({
249
279
 
250
280
  return JSON.stringify({
251
281
  taskId,
252
- sessionId: session.data.id,
282
+ pid: proc.pid,
253
283
  agent: args.agent,
254
284
  beadId: args.beadId,
255
285
  status: "started",
@@ -266,7 +296,7 @@ export const start = tool({
266
296
 
267
297
  /**
268
298
  * Get output from a background task.
269
- * Retrieves the last assistant message from the child session.
299
+ * Reads the output file and checks if the process has completed.
270
300
  */
271
301
  export const output = tool({
272
302
  description:
@@ -275,7 +305,6 @@ export const output = tool({
275
305
  taskId: tool.schema.string().describe("Task ID from background_start"),
276
306
  },
277
307
  execute: async (args) => {
278
- const client = createClient();
279
308
  const store = await loadTasks();
280
309
  const task = store.tasks[args.taskId];
281
310
 
@@ -287,54 +316,47 @@ export const output = tool({
287
316
  }
288
317
 
289
318
  try {
290
- const messages = await client.session.messages({
291
- path: { id: task.sessionId },
292
- });
319
+ // Check if process is still running
320
+ const running = isProcessRunning(task.pid);
293
321
 
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
- });
322
+ // Read output file
323
+ let output = "";
324
+ try {
325
+ output = await fs.readFile(task.outputFile, "utf-8");
326
+ } catch {
327
+ output = "(no output yet)";
300
328
  }
301
329
 
302
- // Find last assistant message
303
- const assistantMessages = messages.data.filter(
304
- (m) => m.info?.role === "assistant",
305
- );
306
-
307
- if (!assistantMessages.length) {
330
+ // If still running and no substantial output
331
+ if (running && output.length < 100) {
308
332
  return JSON.stringify({
309
333
  taskId: args.taskId,
334
+ agent: task.agent,
310
335
  status: "running",
311
- message: "Task running - no response yet",
336
+ message: "Task still running - no complete response yet",
337
+ partialOutput: output.slice(0, 500),
312
338
  });
313
339
  }
314
340
 
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);
341
+ // Process completed or has substantial output
342
+ if (!running) {
343
+ // Update status
344
+ task.status = output.length > 0 ? "completed" : "failed";
345
+ await saveTasks(store);
346
+ }
324
347
 
325
348
  // Build result object
326
349
  const result: Record<string, unknown> = {
327
350
  taskId: args.taskId,
328
351
  agent: task.agent,
329
- status: "completed",
330
- output: textParts || "(empty response)",
352
+ status: running ? "running" : task.status,
353
+ output: output || "(empty response)",
331
354
  };
332
355
 
333
- // Handle bead closing
334
- if (task.beadId) {
356
+ // Handle bead closing (only if completed)
357
+ if (task.beadId && !running && task.status === "completed") {
335
358
  result.beadId = task.beadId;
336
359
 
337
- // Auto-close for safe agents (explore, scout)
338
360
  if (task.autoCloseBead && SAFE_AUTOCLOSE_AGENTS.includes(task.agent)) {
339
361
  const closeResult = await runBeadsCommand([
340
362
  "close",
@@ -347,8 +369,7 @@ export const output = tool({
347
369
  result.beadCloseError = closeResult.output;
348
370
  }
349
371
  } 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 "..." `;
372
+ result.beadAction = `VERIFY output, then run: bd close ${task.beadId} --reason "..."`;
352
373
  }
353
374
  }
354
375
 
@@ -364,8 +385,7 @@ export const output = tool({
364
385
  });
365
386
 
366
387
  /**
367
- * Cancel background tasks.
368
- * Aborts running sessions and cleans up task records.
388
+ * Cancel background tasks by killing the process.
369
389
  */
370
390
  export const cancel = tool({
371
391
  description:
@@ -381,7 +401,6 @@ export const cancel = tool({
381
401
  .describe("Specific task ID to cancel"),
382
402
  },
383
403
  execute: async (args) => {
384
- const client = createClient();
385
404
  const store = await loadTasks();
386
405
  const cancelled: string[] = [];
387
406
  const errors: string[] = [];
@@ -405,12 +424,24 @@ export const cancel = tool({
405
424
 
406
425
  for (const task of tasksToCancel) {
407
426
  try {
408
- await client.session.abort({ path: { id: task.sessionId } });
427
+ // Kill the process and its children (negative PID kills process group)
428
+ try {
429
+ process.kill(-task.pid, "SIGTERM");
430
+ } catch {
431
+ // Try without process group
432
+ process.kill(task.pid, "SIGTERM");
433
+ }
409
434
  task.status = "cancelled";
410
435
  cancelled.push(task.taskId);
411
436
  } catch (e) {
412
437
  const error = e instanceof Error ? e.message : String(e);
413
- errors.push(`${task.taskId}: ${error}`);
438
+ // Process might already be dead
439
+ if (error.includes("ESRCH")) {
440
+ task.status = "cancelled";
441
+ cancelled.push(task.taskId);
442
+ } else {
443
+ errors.push(`${task.taskId}: ${error}`);
444
+ }
414
445
  }
415
446
  }
416
447
 
@@ -433,7 +464,7 @@ export const list = tool({
433
464
  description: "List all background tasks with their status.",
434
465
  args: {
435
466
  status: tool.schema
436
- .enum(["running", "completed", "cancelled", "all"])
467
+ .enum(["running", "completed", "cancelled", "failed", "all"])
437
468
  .optional()
438
469
  .default("all")
439
470
  .describe("Filter by status"),
@@ -442,6 +473,20 @@ export const list = tool({
442
473
  const store = await loadTasks();
443
474
  const tasks = Object.values(store.tasks);
444
475
 
476
+ // Update status of running tasks
477
+ for (const task of tasks) {
478
+ if (task.status === "running" && !isProcessRunning(task.pid)) {
479
+ // Process finished, check if it has output
480
+ try {
481
+ const output = await fs.readFile(task.outputFile, "utf-8");
482
+ task.status = output.length > 0 ? "completed" : "failed";
483
+ } catch {
484
+ task.status = "failed";
485
+ }
486
+ }
487
+ }
488
+ await saveTasks(store);
489
+
445
490
  const filtered =
446
491
  args.status === "all"
447
492
  ? tasks
@@ -453,8 +498,11 @@ export const list = tool({
453
498
  taskId: t.taskId,
454
499
  agent: t.agent,
455
500
  status: t.status,
501
+ pid: t.pid,
456
502
  started: new Date(t.started).toISOString(),
503
+ running: t.status === "running" ? isProcessRunning(t.pid) : false,
457
504
  prompt: t.prompt.slice(0, 100) + (t.prompt.length > 100 ? "..." : ""),
505
+ beadId: t.beadId,
458
506
  })),
459
507
  });
460
508
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencodekit",
3
- "version": "0.14.3",
3
+ "version": "0.14.5",
4
4
  "description": "CLI tool for bootstrapping and managing OpenCodeKit projects",
5
5
  "type": "module",
6
6
  "repository": {
@@ -34,23 +34,23 @@
34
34
  },
35
35
  "dependencies": {
36
36
  "@clack/prompts": "^0.7.0",
37
- "@opencode-ai/plugin": "^1.1.2",
38
- "@opentui/core": "^0.1.69",
39
- "@opentui/solid": "^0.1.69",
37
+ "@opencode-ai/plugin": "^1.1.12",
38
+ "@opentui/core": "^0.1.72",
39
+ "@opentui/solid": "^0.1.72",
40
40
  "beads-village": "^1.3.3",
41
41
  "cac": "^6.7.14",
42
42
  "cli-table3": "^0.6.5",
43
43
  "ora": "^9.0.0",
44
44
  "picocolors": "^1.1.1",
45
45
  "solid-js": "^1.9.10",
46
- "zod": "^3.23.8"
46
+ "zod": "^3.25.76"
47
47
  },
48
48
  "devDependencies": {
49
49
  "@beads/bd": "^0.29.0",
50
50
  "@biomejs/biome": "^1.9.4",
51
51
  "@types/bun": "latest",
52
- "@types/node": "^22.10.1",
53
- "typescript": "^5.7.2"
52
+ "@types/node": "^22.19.5",
53
+ "typescript": "^5.9.3"
54
54
  },
55
55
  "trustedDependencies": ["@beads/bd"]
56
56
  }
@@ -1,97 +0,0 @@
1
- ---
2
- description: Start Ralph Wiggum autonomous loop for task completion
3
- argument-hint: "<task> [--prd <file>] [--max <iterations>] [--afk]"
4
- agent: build
5
- ---
6
-
7
- # Ralph Wiggum Loop
8
-
9
- You are starting a Ralph Wiggum autonomous loop. This pattern enables you to work autonomously on a task list until completion.
10
-
11
- ## Task
12
-
13
- $ARGUMENTS
14
-
15
- ## Setup
16
-
17
- 1. **Start the loop** by calling the `ralph-start` tool:
18
-
19
- ```typescript
20
- ralph -
21
- start({
22
- task: "$1",
23
- prdFile: "$2" || null, // Optional: PRD.md, tasks.md, etc.
24
- progressFile: "progress.txt",
25
- maxIterations: 50,
26
- mode: "hitl", // or "afk" for autonomous
27
- });
28
- ```
29
-
30
- 2. **Create progress.txt** if it doesn't exist:
31
-
32
- ```markdown
33
- # Progress Log
34
-
35
- ## Session Started: [date]
36
-
37
- ### Completed Tasks
38
-
39
- (none yet)
40
-
41
- ### Notes for Next Iteration
42
-
43
- - Starting fresh
44
- ```
45
-
46
- ## Loop Behavior
47
-
48
- After each iteration, the loop will automatically:
49
-
50
- 1. Check if you output `<promise>COMPLETE</promise>`
51
- 2. If yes → Loop ends, success!
52
- 3. If no → Send continuation prompt for next iteration
53
- 4. Repeat until completion or max iterations
54
-
55
- ## Your Instructions
56
-
57
- For each iteration:
58
-
59
- 1. **Review** the PRD/task list and progress file
60
- 2. **Choose** the highest-priority incomplete task (YOU decide, not first in list)
61
- 3. **Implement** ONE feature only (small steps prevent context rot)
62
- 4. **Validate** with feedback loops:
63
- - `npm run typecheck` (must pass)
64
- - `npm run test` (must pass)
65
- - `npm run lint` (must pass)
66
- 5. **Commit** if all pass
67
- 6. **Update** progress.txt with:
68
- - Task completed
69
- - Key decisions made
70
- - Files changed
71
- - Notes for next iteration
72
-
73
- ## Exit Conditions
74
-
75
- Output `<promise>COMPLETE</promise>` when:
76
-
77
- - ALL tasks in the PRD are complete
78
- - ALL feedback loops pass
79
- - Code is committed
80
-
81
- The loop will also stop if:
82
-
83
- - Max iterations reached
84
- - You call `ralph-stop` tool
85
- - An error occurs
86
-
87
- ## Best Practices
88
-
89
- - **Small steps**: One feature per iteration
90
- - **Quality over speed**: Never skip tests
91
- - **Explicit scope**: Vague tasks loop forever
92
- - **Track progress**: Update progress.txt every iteration
93
- - **Prioritize risk**: Hard tasks first, easy wins last
94
-
95
- ## Start Now
96
-
97
- Call `ralph-start` with the task description to begin the loop.
@@ -1,37 +0,0 @@
1
- /**
2
- * OpenCode Handoff Plugin
3
- * Injects the most recent handoff file into session compaction
4
- *
5
- * Workflow:
6
- * 1. User creates handoff markdown file in .opencode/memory/handoffs/
7
- * 2. Session compaction (Ctrl+K) includes handoff context
8
- * 3. New session resumes from previous state
9
- */
10
-
11
- import type { Plugin } from "@opencode-ai/plugin";
12
-
13
- export const HandoffPlugin: Plugin = async ({ $, directory }) => {
14
- const HANDOFF_DIR = `${directory}/.opencode/memory/handoffs`;
15
-
16
- return {
17
- "experimental.session.compacting": async (_input, output) => {
18
- // Find most recent handoff file
19
- const result =
20
- await $`ls -t ${HANDOFF_DIR}/*.md 2>/dev/null | head -1`.quiet();
21
-
22
- if (!result.stdout) return;
23
-
24
- const handoffPath = result.stdout.toString().trim();
25
- const handoffContent = await $`cat ${handoffPath}`.text();
26
-
27
- // Inject into compaction context
28
- output.context.push(`
29
- ## Previous Session Handoff
30
-
31
- ${handoffContent}
32
-
33
- **IMPORTANT**: Resume work from where previous session left off.
34
- `);
35
- },
36
- };
37
- };