im-pickle-rick 0.1.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.
Files changed (128) hide show
  1. package/README.md +242 -0
  2. package/bin.js +3 -0
  3. package/dist/pickle +0 -0
  4. package/dist/worker-executor.js +207 -0
  5. package/package.json +53 -0
  6. package/src/games/GameSidebarManager.test.ts +64 -0
  7. package/src/games/GameSidebarManager.ts +78 -0
  8. package/src/games/gameboy/GameboyView.test.ts +25 -0
  9. package/src/games/gameboy/GameboyView.ts +100 -0
  10. package/src/games/gameboy/gameboy-polyfills.ts +313 -0
  11. package/src/games/index.test.ts +9 -0
  12. package/src/games/index.ts +4 -0
  13. package/src/games/snake/SnakeGame.test.ts +35 -0
  14. package/src/games/snake/SnakeGame.ts +145 -0
  15. package/src/games/snake/SnakeView.test.ts +25 -0
  16. package/src/games/snake/SnakeView.ts +290 -0
  17. package/src/index.test.ts +24 -0
  18. package/src/index.ts +141 -0
  19. package/src/services/commands/worker.test.ts +14 -0
  20. package/src/services/commands/worker.ts +262 -0
  21. package/src/services/config/index.ts +2 -0
  22. package/src/services/config/settings.test.ts +42 -0
  23. package/src/services/config/settings.ts +220 -0
  24. package/src/services/config/state.test.ts +88 -0
  25. package/src/services/config/state.ts +130 -0
  26. package/src/services/config/types.ts +39 -0
  27. package/src/services/execution/index.ts +1 -0
  28. package/src/services/execution/pickle-source.test.ts +88 -0
  29. package/src/services/execution/pickle-source.ts +264 -0
  30. package/src/services/execution/prompt.test.ts +93 -0
  31. package/src/services/execution/prompt.ts +322 -0
  32. package/src/services/execution/sequential.test.ts +91 -0
  33. package/src/services/execution/sequential.ts +422 -0
  34. package/src/services/execution/worker-client.ts +94 -0
  35. package/src/services/execution/worker-executor.ts +41 -0
  36. package/src/services/execution/worker.test.ts +73 -0
  37. package/src/services/git/branch.test.ts +147 -0
  38. package/src/services/git/branch.ts +128 -0
  39. package/src/services/git/diff.test.ts +113 -0
  40. package/src/services/git/diff.ts +323 -0
  41. package/src/services/git/index.ts +4 -0
  42. package/src/services/git/pr.test.ts +104 -0
  43. package/src/services/git/pr.ts +192 -0
  44. package/src/services/git/worktree.test.ts +99 -0
  45. package/src/services/git/worktree.ts +141 -0
  46. package/src/services/providers/base.test.ts +86 -0
  47. package/src/services/providers/base.ts +438 -0
  48. package/src/services/providers/codex.test.ts +39 -0
  49. package/src/services/providers/codex.ts +208 -0
  50. package/src/services/providers/gemini.test.ts +40 -0
  51. package/src/services/providers/gemini.ts +169 -0
  52. package/src/services/providers/index.test.ts +28 -0
  53. package/src/services/providers/index.ts +41 -0
  54. package/src/services/providers/opencode.test.ts +64 -0
  55. package/src/services/providers/opencode.ts +228 -0
  56. package/src/services/providers/types.ts +44 -0
  57. package/src/skills/code-implementer.md +105 -0
  58. package/src/skills/code-researcher.md +78 -0
  59. package/src/skills/implementation-planner.md +105 -0
  60. package/src/skills/plan-reviewer.md +100 -0
  61. package/src/skills/prd-drafter.md +123 -0
  62. package/src/skills/research-reviewer.md +79 -0
  63. package/src/skills/ruthless-refactorer.md +52 -0
  64. package/src/skills/ticket-manager.md +135 -0
  65. package/src/types/index.ts +2 -0
  66. package/src/types/rpc.ts +14 -0
  67. package/src/types/tasks.ts +50 -0
  68. package/src/types.d.ts +9 -0
  69. package/src/ui/common.ts +28 -0
  70. package/src/ui/components/FilePickerView.test.ts +79 -0
  71. package/src/ui/components/FilePickerView.ts +161 -0
  72. package/src/ui/components/MultiLineInput.test.ts +27 -0
  73. package/src/ui/components/MultiLineInput.ts +233 -0
  74. package/src/ui/components/SessionChip.test.ts +69 -0
  75. package/src/ui/components/SessionChip.ts +481 -0
  76. package/src/ui/components/ToyboxSidebar.test.ts +36 -0
  77. package/src/ui/components/ToyboxSidebar.ts +329 -0
  78. package/src/ui/components/refactor_plan.md +35 -0
  79. package/src/ui/controllers/DashboardController.integration.test.ts +43 -0
  80. package/src/ui/controllers/DashboardController.ts +650 -0
  81. package/src/ui/dashboard.test.ts +43 -0
  82. package/src/ui/dashboard.ts +309 -0
  83. package/src/ui/dialogs/DashboardDialog.test.ts +146 -0
  84. package/src/ui/dialogs/DashboardDialog.ts +399 -0
  85. package/src/ui/dialogs/Dialog.test.ts +50 -0
  86. package/src/ui/dialogs/Dialog.ts +241 -0
  87. package/src/ui/dialogs/DialogSidebar.test.ts +60 -0
  88. package/src/ui/dialogs/DialogSidebar.ts +71 -0
  89. package/src/ui/dialogs/DiffViewDialog.test.ts +57 -0
  90. package/src/ui/dialogs/DiffViewDialog.ts +510 -0
  91. package/src/ui/dialogs/PRPreviewDialog.test.ts +50 -0
  92. package/src/ui/dialogs/PRPreviewDialog.ts +346 -0
  93. package/src/ui/dialogs/test-utils.ts +232 -0
  94. package/src/ui/file-picker-utils.test.ts +71 -0
  95. package/src/ui/file-picker-utils.ts +200 -0
  96. package/src/ui/input-chrome.test.ts +62 -0
  97. package/src/ui/input-chrome.ts +172 -0
  98. package/src/ui/logger.test.ts +68 -0
  99. package/src/ui/logger.ts +45 -0
  100. package/src/ui/mock-factory.ts +6 -0
  101. package/src/ui/spinner.test.ts +65 -0
  102. package/src/ui/spinner.ts +41 -0
  103. package/src/ui/test-setup.ts +300 -0
  104. package/src/ui/theme.test.ts +23 -0
  105. package/src/ui/theme.ts +16 -0
  106. package/src/ui/views/LandingView.integration.test.ts +21 -0
  107. package/src/ui/views/LandingView.test.ts +24 -0
  108. package/src/ui/views/LandingView.ts +221 -0
  109. package/src/ui/views/LogView.test.ts +24 -0
  110. package/src/ui/views/LogView.ts +277 -0
  111. package/src/ui/views/ToyboxView.test.ts +46 -0
  112. package/src/ui/views/ToyboxView.ts +323 -0
  113. package/src/utils/clipboard.test.ts +86 -0
  114. package/src/utils/clipboard.ts +100 -0
  115. package/src/utils/index.test.ts +68 -0
  116. package/src/utils/index.ts +95 -0
  117. package/src/utils/persona.test.ts +12 -0
  118. package/src/utils/persona.ts +8 -0
  119. package/src/utils/project-root.test.ts +38 -0
  120. package/src/utils/project-root.ts +22 -0
  121. package/src/utils/resources.test.ts +64 -0
  122. package/src/utils/resources.ts +92 -0
  123. package/src/utils/search.test.ts +48 -0
  124. package/src/utils/search.ts +103 -0
  125. package/src/utils/session-tracker.test.ts +46 -0
  126. package/src/utils/session-tracker.ts +67 -0
  127. package/src/utils/spinner.test.ts +54 -0
  128. package/src/utils/spinner.ts +87 -0
@@ -0,0 +1,422 @@
1
+ import type { SessionState } from "../config/types.js";
2
+ import { saveState, loadState } from "../config/state.js";
3
+ import type { AIProvider } from "../providers/types.js";
4
+ import { buildPrompt } from "./prompt.js";
5
+ import pc from "picocolors";
6
+ import { join, basename } from "node:path";
7
+ import { mkdir, appendFile, writeFile } from "node:fs/promises";
8
+ import { existsSync } from "node:fs";
9
+ import { execCommand } from "../providers/base.js";
10
+ import { getConfiguredModel } from "../providers/index.js";
11
+ import { PickleTaskSource } from "./pickle-source.js";
12
+ import { createPickleWorktree, cleanupPickleWorktree, createPullRequest, getCurrentBranch, isGhAvailable, generatePRDescription } from "../git/index.js";
13
+ import type { Task, WorktreeInfo } from "../../types/tasks.js";
14
+ import { createInterface } from "node:readline";
15
+
16
+ export interface ProgressReport {
17
+ iteration: number;
18
+ taskTitle?: string;
19
+ step?: string;
20
+ }
21
+
22
+ export interface ExecutionResult {
23
+ worktreeInfo?: WorktreeInfo;
24
+ }
25
+
26
+ export type ProgressCallback = (report: ProgressReport) => void;
27
+
28
+ export type QuestionHandler = (query: string) => Promise<string>;
29
+
30
+ function askQuestion(query: string): Promise<string> {
31
+ const rl = createInterface({
32
+ input: process.stdin,
33
+ output: process.stdout,
34
+ });
35
+ return new Promise(resolve => rl.question(query, ans => {
36
+ rl.close();
37
+ resolve(ans);
38
+ }));
39
+ }
40
+
41
+ export class SequentialExecutor {
42
+ private progressCallback?: ProgressCallback;
43
+ private currentTaskTitle?: string;
44
+ private questionHandler: QuestionHandler;
45
+
46
+ constructor(
47
+ private state: SessionState,
48
+ private provider: AIProvider,
49
+ questionHandler?: QuestionHandler,
50
+ private verbose = false,
51
+ private tuiMode = false
52
+ ) {
53
+ this.questionHandler = questionHandler || askQuestion;
54
+ }
55
+
56
+ onProgress(callback: ProgressCallback) {
57
+ this.progressCallback = callback;
58
+ return this;
59
+ }
60
+
61
+ private emitProgress(step?: string) {
62
+ if (this.progressCallback) {
63
+ this.progressCallback({
64
+ iteration: this.state.iteration,
65
+ taskTitle: this.currentTaskTitle,
66
+ step
67
+ });
68
+ }
69
+ }
70
+
71
+ private async syncFiles(src: string, dest: string, excludes: string[] = []) {
72
+ try {
73
+ // Use rsync for efficiency and exclusion support (standard on macOS/Linux)
74
+ await execCommand("rsync", ["-a", ...excludes.map(e => `--exclude=${e}`), `${src}/`, `${dest}/`], process.cwd());
75
+ } catch (e) {
76
+ // Fallback to cp -R if rsync fails
77
+ try {
78
+ await mkdir(dest, { recursive: true });
79
+ await execCommand("sh", ["-c", `cp -R "${src}/"* "${dest}/"`], process.cwd());
80
+ } catch (cpError) {
81
+ console.error(pc.red(`⚠️ Sync failed: ${cpError}`));
82
+ }
83
+ }
84
+ }
85
+
86
+ async run(): Promise<ExecutionResult> {
87
+ this.state.cli_mode = true;
88
+ this.state.active = true;
89
+ await saveState(this.state.session_dir, this.state);
90
+
91
+ let executionResult: ExecutionResult = {};
92
+
93
+ const taskSource = new PickleTaskSource(this.state.session_dir);
94
+ const logFile = join(this.state.session_dir, "session.log");
95
+
96
+ const log = async (msg: string) => {
97
+ try { await appendFile(logFile, msg + "\n"); } catch (e) {}
98
+ if (this.verbose) {
99
+ console.log(msg);
100
+ }
101
+ };
102
+
103
+ const logRaw = async (msg: string) => {
104
+ try { await appendFile(logFile, msg); } catch (e) {}
105
+ if (this.verbose) {
106
+ process.stdout.write(msg);
107
+ }
108
+ };
109
+
110
+ const baseBranch = await getCurrentBranch(this.state.working_dir) || "main";
111
+ let sessionWorktree: { worktreeDir: string; branchName: string } | null = null;
112
+ let localSessionDir: string | null = null;
113
+ const sessionName = basename(this.state.session_dir);
114
+
115
+ try {
116
+ while (this.state.active) {
117
+ await log(pc.bold(pc.green(`
118
+ 🥒 Iteration ${this.state.iteration}`)));
119
+ this.emitProgress();
120
+
121
+ // Check limits
122
+ if (this.state.max_iterations > 0 && this.state.iteration > this.state.max_iterations) {
123
+ await log(pc.yellow("Max iterations reached."));
124
+ this.state.active = false;
125
+ await saveState(this.state.session_dir, this.state);
126
+ break;
127
+ }
128
+
129
+ // Get Next Task
130
+ const task = await taskSource.getNextTask();
131
+ if (!task) {
132
+ await log(pc.bold(pc.green("✅ All Tasks Complete!")));
133
+ this.state.active = false;
134
+ await saveState(this.state.session_dir, this.state);
135
+ break;
136
+ }
137
+
138
+ this.currentTaskTitle = task.title;
139
+ await log(pc.cyan(`📋 Current Task: ${task.title}`));
140
+ this.emitProgress();
141
+
142
+ // Determine Working Context
143
+ let engineWorkDir = this.state.working_dir;
144
+ let engineSessionDir = this.state.session_dir;
145
+
146
+ // If it's a TICKET (not a phase), ensure we are in the Session Worktree
147
+ if (task.id.startsWith("phase-") === false) {
148
+ if (!sessionWorktree) {
149
+ try {
150
+ sessionWorktree = await createPickleWorktree(sessionName, baseBranch, this.state.working_dir);
151
+ await log(pc.dim(`🏗️ Session Worktree: ${sessionWorktree.worktreeDir}`));
152
+
153
+ // Replicate the entire project state to the worktree (including uncommitted files)
154
+ // We EXCLUDE .git (to keep worktree metadata) and .pickle (to avoid infinite recursion)
155
+ await log(pc.dim("🔄 Syncing project state to worktree..."));
156
+ await this.syncFiles(this.state.working_dir, sessionWorktree.worktreeDir, [".git", ".pickle"]);
157
+
158
+ // Clear Gemini session ID when switching execution context
159
+ this.state.gemini_session_id = undefined;
160
+ await saveState(this.state.session_dir, this.state);
161
+ } catch (e) {
162
+ await log(pc.red(`⚠️ Failed to initialize worktree: ${e}`));
163
+ }
164
+ }
165
+
166
+ if (sessionWorktree) {
167
+ engineWorkDir = sessionWorktree.worktreeDir;
168
+
169
+ // Mirror the Session Directory inside the worktree to bypass sandbox
170
+ localSessionDir = join(sessionWorktree.worktreeDir, ".pickle", "sessions", sessionName);
171
+ await mkdir(localSessionDir, { recursive: true });
172
+
173
+ await log(pc.dim("🔄 Syncing session context to worktree..."));
174
+ await this.syncFiles(this.state.session_dir, localSessionDir);
175
+ engineSessionDir = localSessionDir;
176
+ }
177
+ }
178
+
179
+ // Build Prompt with local path overrides
180
+ const prompt = await buildPrompt(this.state, task, {
181
+ sessionDir: engineSessionDir,
182
+ workingDir: engineWorkDir
183
+ });
184
+
185
+ // Debug: Save prompt and prepare iteration log
186
+ const debugDir = join(this.state.session_dir, "debug");
187
+ const iterationLogFile = join(debugDir, `iteration_${this.state.iteration}_log.txt`);
188
+ try {
189
+ await mkdir(debugDir, { recursive: true });
190
+ await writeFile(
191
+ join(debugDir, `iteration_${this.state.iteration}_prompt.txt`),
192
+ prompt,
193
+ "utf-8"
194
+ );
195
+ // Initialize iteration log file
196
+ await writeFile(iterationLogFile, `=== Iteration ${this.state.iteration} Log ===\nTask: ${task.title}\nStarted: ${new Date().toISOString()}\n\n`, "utf-8");
197
+ } catch (err) {}
198
+
199
+ // Helper to log to iteration-specific file
200
+ const logIteration = async (msg: string) => {
201
+ try { await appendFile(iterationLogFile, msg); } catch (e) {}
202
+ };
203
+
204
+ let lastStepLog = "";
205
+
206
+ const configuredModel = await getConfiguredModel();
207
+ const modelOverride = configuredModel?.trim()
208
+ ? configuredModel.trim()
209
+ : undefined;
210
+
211
+ const options = {
212
+ resumeSessionId: this.state.gemini_session_id,
213
+ // Ensure engine has access to the local mirrored paths
214
+ extraIncludes: [engineSessionDir, engineWorkDir],
215
+ } as {
216
+ resumeSessionId?: string;
217
+ extraIncludes?: string[];
218
+ modelOverride?: string;
219
+ };
220
+
221
+ if (modelOverride) {
222
+ options.modelOverride = modelOverride;
223
+ }
224
+
225
+ const result = await this.provider.executeStreaming!(
226
+ prompt,
227
+ engineWorkDir,
228
+ async (step, content) => {
229
+ if (content) {
230
+ await logRaw(content);
231
+ await logIteration(content);
232
+ } else if (step !== "thinking" && step !== lastStepLog) {
233
+ await log(pc.dim(`Rick is ${step}...`));
234
+ lastStepLog = step;
235
+ this.emitProgress(step);
236
+ const stepMsg = `[STEP] Rick is ${step}...\n`;
237
+ try { await appendFile(logFile, stepMsg); } catch(e) {}
238
+ await logIteration(stepMsg);
239
+ }
240
+ },
241
+ options
242
+ );
243
+
244
+ // Finalize iteration log
245
+ await logIteration(`\n\n=== Iteration ${this.state.iteration} Completed: ${new Date().toISOString()} ===\n`);
246
+
247
+ if (!result.success) {
248
+ const singleLineError = result.error?.replace(/\s+/g, " ").trim();
249
+ await log(pc.red(`
250
+ ❌ Engine Error: ${singleLineError}`));
251
+ try { await appendFile(logFile, `
252
+ ❌ Engine Error: ${singleLineError}\n`); } catch(e) {}
253
+ await logIteration(`ERROR: ${singleLineError}\n`);
254
+ await log(""); // spacing to separate status/info lines visually
255
+ this.state.active = false;
256
+ await saveState(this.state.session_dir, this.state);
257
+ throw new Error(singleLineError || "Engine error");
258
+ }
259
+
260
+ if (result.sessionId && !this.state.gemini_session_id) {
261
+ this.state.gemini_session_id = result.sessionId;
262
+ }
263
+
264
+ // Sync Back: If we are in a worktree, sync the local session state back to the master session dir
265
+ if (localSessionDir) {
266
+ await log(pc.dim("🔄 Syncing changes back to master session..."));
267
+ await this.syncFiles(localSessionDir, this.state.session_dir);
268
+ }
269
+
270
+ // Check for completion
271
+ const promiseFulfilled = this.state.completion_promise && result.response.includes(`<promise>${this.state.completion_promise}</promise>`);
272
+ const explicitDone = result.response.includes("I AM DONE");
273
+ const stopTurn = result.response.includes("[STOP_TURN]");
274
+
275
+ if (promiseFulfilled || explicitDone) {
276
+ await log(pc.bold(pc.green(`
277
+ 🎯 Task Completed!`)));
278
+ await taskSource.markComplete(task.id);
279
+
280
+ // Preserve worktree info for TUI review as soon as a task completes
281
+ if (sessionWorktree) {
282
+ executionResult.worktreeInfo = {
283
+ worktreeDir: sessionWorktree.worktreeDir,
284
+ branchName: sessionWorktree.branchName,
285
+ baseBranch,
286
+ };
287
+ await log(pc.dim("Worktree preserved for TUI review."));
288
+ }
289
+
290
+ // Check if this was the last ticket (retain existing messaging)
291
+ const remaining = await taskSource.countRemaining();
292
+ if (remaining === 0 && sessionWorktree) {
293
+ await log(pc.bold(pc.green(`
294
+ ✅ All project tasks complete!`)));
295
+
296
+ // In TUI mode, return worktree info and let the TUI handle the choice
297
+ if (!this.tuiMode) {
298
+ // CLI mode: ask user interactively
299
+ await log(pc.yellow(`
300
+ What would you like to do with the changes in '${sessionWorktree.branchName}'?`));
301
+ await log(pc.dim(` [m] Merge into '${baseBranch}' locally`));
302
+ await log(pc.dim(` [p] Create a Pull Request`));
303
+ await log(pc.dim(` [s] Skip (keep worktree for later)`));
304
+
305
+ const answer = await this.questionHandler(pc.yellow(`
306
+ Your choice (m/p/s): `));
307
+ const choice = answer.toLowerCase().trim();
308
+
309
+ if (choice === 'm' || choice === 'merge') {
310
+ await log(pc.dim("Syncing files from worktree..."));
311
+ await this.syncFiles(sessionWorktree.worktreeDir, this.state.working_dir, [".git", ".pickle"]);
312
+
313
+ await log(pc.dim("Merging worktree..."));
314
+ try {
315
+ await execCommand("git", ["merge", sessionWorktree.branchName], this.state.working_dir);
316
+ await log(pc.green("Merge successful."));
317
+ } catch (e) {
318
+ await log(pc.red(`⚠️ Merge failed: ${e}`));
319
+ }
320
+
321
+ // Cleanup after merge
322
+ await log(pc.dim("Deleting worktree..."));
323
+ try {
324
+ await cleanupPickleWorktree(sessionWorktree.worktreeDir, this.state.working_dir);
325
+ await log(pc.green("Worktree deleted."));
326
+ } catch (e) {
327
+ await log(pc.red(`⚠️ Cleanup failed: ${e}`));
328
+ }
329
+ sessionWorktree = null;
330
+ localSessionDir = null;
331
+ } else if (choice === 'p' || choice === 'pr') {
332
+ // Generate PR description from session artifacts
333
+ const prDesc = await generatePRDescription(
334
+ this.state.session_dir,
335
+ sessionWorktree.branchName,
336
+ baseBranch
337
+ );
338
+
339
+ // Check if gh CLI is available
340
+ const ghAvailable = await isGhAvailable();
341
+
342
+ if (ghAvailable) {
343
+ await log(pc.dim("Creating Pull Request..."));
344
+ try {
345
+ const prUrl = await createPullRequest(
346
+ sessionWorktree.branchName,
347
+ baseBranch,
348
+ prDesc.title,
349
+ prDesc.body,
350
+ false,
351
+ sessionWorktree.worktreeDir
352
+ );
353
+ if (prUrl) {
354
+ await log(pc.green(`✅ Pull Request created: ${prUrl}`));
355
+ } else {
356
+ await log(pc.red("⚠️ Failed to create PR. Saving description to file..."));
357
+ const prDescPath = join(this.state.session_dir, "pr_description.md");
358
+ await writeFile(prDescPath, `# ${prDesc.title}\n\n${prDesc.body}`, "utf-8");
359
+ await log(pc.yellow(`📄 PR description saved to: ${prDescPath}`));
360
+ }
361
+ } catch (e) {
362
+ await log(pc.red(`⚠️ PR creation failed: ${e}`));
363
+ const prDescPath = join(this.state.session_dir, "pr_description.md");
364
+ await writeFile(prDescPath, `# ${prDesc.title}\n\n${prDesc.body}`, "utf-8");
365
+ await log(pc.yellow(`📄 PR description saved to: ${prDescPath}`));
366
+ }
367
+ } else {
368
+ await log(pc.yellow("⚠️ GitHub CLI (gh) not installed or not authenticated."));
369
+ await log(pc.dim("Saving PR description to file..."));
370
+ const prDescPath = join(this.state.session_dir, "pr_description.md");
371
+ await writeFile(prDescPath, `# ${prDesc.title}\n\n${prDesc.body}`, "utf-8");
372
+ await log(pc.yellow(`📄 PR description saved to: ${prDescPath}`));
373
+ await log(pc.dim(`To create PR manually, push the branch and use:`));
374
+ await log(pc.dim(` git push -u origin ${sessionWorktree.branchName}`));
375
+ await log(pc.dim(` gh pr create --base ${baseBranch} --head ${sessionWorktree.branchName}`));
376
+ }
377
+
378
+ // Cleanup worktree after PR
379
+ await log(pc.dim("Deleting worktree..."));
380
+ try {
381
+ await cleanupPickleWorktree(sessionWorktree.worktreeDir, this.state.working_dir);
382
+ await log(pc.green("Worktree deleted."));
383
+ } catch (e) {
384
+ await log(pc.red(`⚠️ Cleanup failed: ${e}`));
385
+ }
386
+ sessionWorktree = null;
387
+ localSessionDir = null;
388
+ } else {
389
+ await log(pc.dim("Skipping. Worktree preserved at:"));
390
+ await log(pc.dim(` ${sessionWorktree.worktreeDir}`));
391
+ }
392
+ }
393
+ }
394
+ } else if (stopTurn) {
395
+ await log(pc.bold(pc.yellow(`
396
+ 🛑 Turn Complete (STOP_TURN). Continuing...`)));
397
+ }
398
+
399
+ // Reload state from disk (crucial for capturing markComplete updates)
400
+ const freshState = await loadState(this.state.session_dir);
401
+ if (freshState) {
402
+ const currentSessionId = this.state.gemini_session_id;
403
+ this.state = freshState;
404
+ if (!this.state.gemini_session_id && currentSessionId) {
405
+ this.state.gemini_session_id = currentSessionId;
406
+ }
407
+ }
408
+
409
+ // Increment
410
+ this.state.iteration++;
411
+ await saveState(this.state.session_dir, this.state);
412
+ }
413
+ } catch (fatalError) {
414
+ const msg = fatalError instanceof Error ? fatalError.stack : String(fatalError);
415
+ await log(pc.red(`
416
+ 💥 FATAL EXCEPTION: ${msg}`));
417
+ throw fatalError;
418
+ }
419
+
420
+ return executionResult;
421
+ }
422
+ }
@@ -0,0 +1,94 @@
1
+ import type { SessionState } from "../config/types.js";
2
+ import type { WorkerRequest, WorkerEvent } from "../../types/rpc.js";
3
+ import type { ProgressCallback, QuestionHandler, ExecutionResult } from "./sequential.js";
4
+
5
+ export class WorkerExecutorClient {
6
+ private worker: Worker;
7
+ private progressCallback?: ProgressCallback;
8
+ private inputHandler?: QuestionHandler;
9
+
10
+ constructor() {
11
+ let workerPath: string;
12
+
13
+ // Try to resolve relative to the current file (works in dev)
14
+ try {
15
+ workerPath = new URL("./worker-executor.ts", import.meta.url).href;
16
+
17
+ // If we are in a compiled bundle, import.meta.url might be a bunfs: path
18
+ // and the worker-executor.js should be next to the executable.
19
+ if (workerPath.startsWith("bunfs:") || !import.meta.url.endsWith(".ts")) {
20
+ const processPath = process.execPath;
21
+ // Simple string manipulation to find dirname since we can't await path.dirname
22
+ const lastSlash = processPath.lastIndexOf("/");
23
+ const dirname = lastSlash !== -1 ? processPath.substring(0, lastSlash) : ".";
24
+ workerPath = `${dirname}/worker-executor.js`;
25
+ }
26
+ } catch (e) {
27
+ // Fallback for extreme cases
28
+ workerPath = "./worker-executor.js";
29
+ }
30
+
31
+ try {
32
+ this.worker = new Worker(workerPath, {
33
+ env: process.env as Record<string, string>
34
+ });
35
+ } catch (e) {
36
+ console.error("[WorkerClient] Failed to spawn worker at " + workerPath, e);
37
+ throw e;
38
+ }
39
+
40
+ this.worker.onerror = (err) => {
41
+ console.error("[WorkerClient] Worker Error:", err);
42
+ };
43
+ }
44
+
45
+ onProgress(cb: ProgressCallback) {
46
+ this.progressCallback = cb;
47
+ return this;
48
+ }
49
+
50
+ onInput(handler: QuestionHandler) {
51
+ this.inputHandler = handler;
52
+ return this;
53
+ }
54
+
55
+ async run(state: SessionState): Promise<ExecutionResult> {
56
+ // console.log("[WorkerClient] Sending START request to worker...");
57
+ return new Promise((resolve, reject) => {
58
+ const messageHandler = async (event: MessageEvent<WorkerEvent>) => {
59
+ const msg = event.data;
60
+ // console.log("[WorkerClient] Received message from worker:", msg.type);
61
+ if (!msg || typeof msg !== "object") return;
62
+
63
+ switch (msg.type) {
64
+ case "PROGRESS":
65
+ this.progressCallback?.(msg.report);
66
+ break;
67
+ case "DONE":
68
+ this.worker.removeEventListener("message", messageHandler);
69
+ resolve({ worktreeInfo: msg.worktreeInfo });
70
+ break;
71
+ case "ERROR":
72
+ this.worker.removeEventListener("message", messageHandler);
73
+ reject(new Error(msg.message));
74
+ break;
75
+ case "INPUT_REQUEST":
76
+ if (this.inputHandler) {
77
+ const answer = await this.inputHandler(msg.query);
78
+ this.worker.postMessage({ type: "INPUT_RESPONSE", answer });
79
+ } else {
80
+ this.worker.postMessage({ type: "INPUT_RESPONSE", answer: "n" });
81
+ }
82
+ break;
83
+ }
84
+ };
85
+ this.worker.addEventListener("message", messageHandler);
86
+ this.worker.postMessage({ type: "START", state });
87
+ });
88
+ }
89
+
90
+ stop() {
91
+ this.worker.postMessage({ type: "STOP" });
92
+ this.worker.terminate();
93
+ }
94
+ }
@@ -0,0 +1,41 @@
1
+ import { SequentialExecutor } from "./sequential.js";
2
+ import { createProvider } from "../providers/index.js";
3
+ import type { WorkerRequest, WorkerEvent } from "../../types/rpc.js";
4
+
5
+ declare var self: Worker;
6
+
7
+ let executor: SequentialExecutor | null = null;
8
+
9
+ self.onmessage = async (event: MessageEvent<WorkerRequest>) => {
10
+ const msg = event.data;
11
+ if (msg.type === "START") {
12
+ const provider = await createProvider();
13
+
14
+ const questionHandler = (query: string) => {
15
+ self.postMessage({ type: "INPUT_REQUEST", query });
16
+ return new Promise<string>((resolve) => {
17
+ const handler = (e: MessageEvent<WorkerRequest>) => {
18
+ if (e.data.type === "INPUT_RESPONSE") {
19
+ self.removeEventListener("message", handler);
20
+ resolve(e.data.answer);
21
+ }
22
+ };
23
+ self.addEventListener("message", handler);
24
+ });
25
+ };
26
+
27
+ // Pass tuiMode=true to return worktree info instead of asking interactively
28
+ executor = new SequentialExecutor(msg.state, provider, questionHandler, false, true);
29
+ executor.onProgress((report) => self.postMessage({ type: "PROGRESS", report }));
30
+
31
+ try {
32
+ const result = await executor.run();
33
+ self.postMessage({ type: "DONE", worktreeInfo: result.worktreeInfo });
34
+ } catch (err: unknown) {
35
+ const message = err instanceof Error ? err.message : String(err);
36
+ self.postMessage({ type: "ERROR", message });
37
+ }
38
+ } else if (msg.type === "STOP") {
39
+ process.exit(0);
40
+ }
41
+ };
@@ -0,0 +1,73 @@
1
+ import { expect, test, describe, mock, beforeEach } from "bun:test";
2
+ import { WorkerExecutorClient } from "./worker-client.js";
3
+ import type { SessionState } from "../config/types.js";
4
+
5
+ // Mock global Worker
6
+ class MockWorker {
7
+ onmessage: ((event: any) => void) | null = null;
8
+ onerror: ((err: any) => void) | null = null;
9
+ addEventListener = mock((type: string, listener: any) => {
10
+ if (type === "message") this.onmessage = listener;
11
+ });
12
+ removeEventListener = mock(() => {});
13
+ postMessage = mock((msg: any) => {
14
+ if (msg.type === "START") {
15
+ // Simulate worker finishing successfully
16
+ setTimeout(() => {
17
+ this.onmessage!({ data: { type: "PROGRESS", report: { iteration: 1 } } });
18
+ this.onmessage!({ data: { type: "DONE", worktreeInfo: { worktreeDir: "/mock/wt" } } });
19
+ }, 10);
20
+ }
21
+ });
22
+ terminate = mock(() => {});
23
+ }
24
+
25
+ (globalThis as any).Worker = MockWorker;
26
+
27
+ describe("WorkerExecutorClient", () => {
28
+ let baseState: SessionState;
29
+
30
+ beforeEach(() => {
31
+ baseState = {
32
+ active: true,
33
+ working_dir: "/mock/working",
34
+ session_dir: "/mock/session",
35
+ step: "prd",
36
+ iteration: 1,
37
+ max_iterations: 1,
38
+ max_time_minutes: 30,
39
+ worker_timeout_seconds: 300,
40
+ start_time_epoch: Date.now(),
41
+ completion_promise: "DONE",
42
+ original_prompt: "Test",
43
+ current_ticket: null,
44
+ history: [],
45
+ started_at: new Date().toISOString()
46
+ };
47
+ });
48
+
49
+ test("should run worker and receive DONE", async () => {
50
+ const client = new WorkerExecutorClient();
51
+ const progressReports: any[] = [];
52
+ client.onProgress((p) => progressReports.push(p));
53
+
54
+ const result = await client.run(baseState);
55
+
56
+ expect(result.worktreeInfo?.worktreeDir).toBe("/mock/wt");
57
+ expect(progressReports.length).toBe(1);
58
+ });
59
+
60
+ test("should handle worker errors", async () => {
61
+ const client = new WorkerExecutorClient();
62
+
63
+ // Override postMessage to simulate error
64
+ const worker = (client as any).worker;
65
+ worker.postMessage = (msg: any) => {
66
+ setTimeout(() => {
67
+ worker.onmessage({ data: { type: "ERROR", message: "Boom" } });
68
+ }, 10);
69
+ };
70
+
71
+ expect(client.run(baseState)).rejects.toThrow("Boom");
72
+ });
73
+ });