ralph-cli-sandboxed 0.5.0 → 0.6.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.
package/README.md CHANGED
@@ -44,6 +44,7 @@ ralph docker run
44
44
  | `ralph clean` | Remove all passing entries from PRD |
45
45
  | `ralph fix-prd [opts]` | Validate and recover corrupted PRD file |
46
46
  | `ralph prompt [opts]` | Display resolved prompt |
47
+ | `ralph branch <sub>` | Manage PRD branches (list, merge, pr, delete) |
47
48
  | `ralph docker <sub>` | Manage Docker sandbox environment |
48
49
  | `ralph daemon <sub>` | Manage host daemon for sandbox notifications |
49
50
  | `ralph notify [msg]` | Send notification (from sandbox to host) |
@@ -427,6 +428,20 @@ The PRD (`prd.json`) is an array of requirements:
427
428
 
428
429
  Categories: `setup`, `feature`, `bugfix`, `refactor`, `docs`, `test`, `release`, `config`, `ui`
429
430
 
431
+ ### Branching
432
+
433
+ PRD items can be tagged with a `branch` field to group work onto separate git branches. Ralph uses git worktrees to isolate branch work from the main checkout, so the host's working directory stays untouched.
434
+
435
+ ```yaml
436
+ - category: feature
437
+ description: Add login page
438
+ branch: feat/auth
439
+ steps: [...]
440
+ passes: false
441
+ ```
442
+
443
+ See [docs/BRANCHING.md](docs/BRANCHING.md) for the full architecture, configuration, and branch management commands.
444
+
430
445
  ### Advanced: File References
431
446
 
432
447
  PRD steps can include file contents using the `@{filepath}` syntax:
@@ -0,0 +1,4 @@
1
+ /**
2
+ * Main branch command dispatcher.
3
+ */
4
+ export declare function branch(args: string[]): Promise<void>;
@@ -0,0 +1,408 @@
1
+ import { execSync } from "child_process";
2
+ import { existsSync, readFileSync, writeFileSync } from "fs";
3
+ import { extname, join } from "path";
4
+ import { getRalphDir, getPrdFiles, loadBranchState, getProjectName } from "../utils/config.js";
5
+ import { readPrdFile, writePrdAuto } from "../utils/prd-validator.js";
6
+ import { promptConfirm } from "../utils/prompt.js";
7
+ import YAML from "yaml";
8
+ /**
9
+ * Converts a branch name to a worktree directory name, prefixed with the project name.
10
+ * e.g., "feat/login" -> "myproject_feat-login"
11
+ * The project prefix avoids conflicts when multiple projects share the same worktrees directory.
12
+ */
13
+ function branchToWorktreeName(branch) {
14
+ const projectName = getProjectName();
15
+ return `${projectName}_${branch.replace(/\//g, "-")}`;
16
+ }
17
+ /**
18
+ * Gets the worktrees base path from config or defaults to /worktrees.
19
+ */
20
+ function getWorktreesBase() {
21
+ return "/worktrees";
22
+ }
23
+ /**
24
+ * Loads PRD entries from the primary PRD file.
25
+ */
26
+ function loadPrdEntries() {
27
+ const prdFiles = getPrdFiles();
28
+ if (!prdFiles.primary) {
29
+ console.error("\x1b[31mError: No PRD file found. Run 'ralph init' first.\x1b[0m");
30
+ return null;
31
+ }
32
+ const parsed = readPrdFile(prdFiles.primary);
33
+ if (!parsed || !Array.isArray(parsed.content)) {
34
+ console.error("\x1b[31mError: PRD file is corrupted. Run 'ralph fix-prd' to repair.\x1b[0m");
35
+ return null;
36
+ }
37
+ return { entries: parsed.content, prdPath: prdFiles.primary };
38
+ }
39
+ /**
40
+ * Gets the base branch (the branch that /workspace is on).
41
+ */
42
+ function getBaseBranch() {
43
+ try {
44
+ return execSync("git -C /workspace rev-parse --abbrev-ref HEAD", { encoding: "utf-8" }).trim();
45
+ }
46
+ catch {
47
+ return "main";
48
+ }
49
+ }
50
+ /**
51
+ * Checks if a git branch exists.
52
+ */
53
+ function branchExists(branch) {
54
+ try {
55
+ execSync(`git rev-parse --verify "${branch}"`, { stdio: "pipe" });
56
+ return true;
57
+ }
58
+ catch {
59
+ return false;
60
+ }
61
+ }
62
+ /**
63
+ * List all branches referenced in the PRD and their status.
64
+ * Shows item counts, pass/fail status, worktree existence, and active branch indicator.
65
+ */
66
+ function branchList() {
67
+ const result = loadPrdEntries();
68
+ if (!result)
69
+ return;
70
+ const { entries } = result;
71
+ const worktreesBase = getWorktreesBase();
72
+ const activeBranch = loadBranchState();
73
+ // Group items by branch
74
+ const branchGroups = new Map();
75
+ const noBranchItems = [];
76
+ for (const entry of entries) {
77
+ if (entry.branch) {
78
+ const group = branchGroups.get(entry.branch) || [];
79
+ group.push(entry);
80
+ branchGroups.set(entry.branch, group);
81
+ }
82
+ else {
83
+ noBranchItems.push(entry);
84
+ }
85
+ }
86
+ if (branchGroups.size === 0 && noBranchItems.length === 0) {
87
+ console.log("No PRD items found.");
88
+ return;
89
+ }
90
+ console.log("\x1b[1mBranches:\x1b[0m\n");
91
+ // Sort branches alphabetically
92
+ const sortedBranches = [...branchGroups.keys()].sort();
93
+ for (const branchName of sortedBranches) {
94
+ const items = branchGroups.get(branchName);
95
+ const passing = items.filter((e) => e.passes).length;
96
+ const total = items.length;
97
+ const allPassing = passing === total;
98
+ // Check if worktree exists on disk
99
+ const dirName = branchToWorktreeName(branchName);
100
+ const worktreePath = join(worktreesBase, dirName);
101
+ const hasWorktree = existsSync(worktreePath);
102
+ // Check if this is the active branch
103
+ const isActive = activeBranch?.currentBranch === branchName;
104
+ // Build the line
105
+ const statusIcon = allPassing ? "\x1b[32m✅\x1b[0m" : "\x1b[33m○\x1b[0m";
106
+ const activeIndicator = isActive ? " \x1b[36m◀ active\x1b[0m" : "";
107
+ const worktreeStatus = hasWorktree ? " \x1b[32m[worktree]\x1b[0m" : "";
108
+ const countStr = `${passing}/${total}`;
109
+ console.log(` ${statusIcon} \x1b[1m${branchName}\x1b[0m ${countStr}${worktreeStatus}${activeIndicator}`);
110
+ }
111
+ // Show no-branch group
112
+ if (noBranchItems.length > 0) {
113
+ const passing = noBranchItems.filter((e) => e.passes).length;
114
+ const total = noBranchItems.length;
115
+ const allPassing = passing === total;
116
+ const statusIcon = allPassing ? "\x1b[32m✅\x1b[0m" : "\x1b[33m○\x1b[0m";
117
+ const countStr = `${passing}/${total}`;
118
+ console.log(` ${statusIcon} \x1b[2m(no branch)\x1b[0m ${countStr}`);
119
+ }
120
+ console.log();
121
+ }
122
+ /**
123
+ * Merge a completed branch worktree back into the base branch.
124
+ * Handles merge conflicts by aborting and showing conflicting files.
125
+ */
126
+ async function branchMerge(args) {
127
+ const branchName = args[0];
128
+ if (!branchName) {
129
+ console.error("Usage: ralph branch merge <branch-name>");
130
+ console.error("\nExample: ralph branch merge feat/login");
131
+ process.exit(1);
132
+ }
133
+ // Verify the branch exists
134
+ if (!branchExists(branchName)) {
135
+ console.error(`\x1b[31mError: Branch "${branchName}" does not exist.\x1b[0m`);
136
+ process.exit(1);
137
+ }
138
+ const baseBranch = getBaseBranch();
139
+ const worktreesBase = getWorktreesBase();
140
+ const dirName = branchToWorktreeName(branchName);
141
+ const worktreePath = join(worktreesBase, dirName);
142
+ console.log(`Branch: ${branchName}`);
143
+ console.log(`Base branch: ${baseBranch}`);
144
+ if (existsSync(worktreePath)) {
145
+ console.log(`Worktree: ${worktreePath}`);
146
+ }
147
+ console.log();
148
+ // Ask for confirmation
149
+ const confirmed = await promptConfirm(`Merge "${branchName}" into "${baseBranch}"?`, true);
150
+ if (!confirmed) {
151
+ console.log("Merge cancelled.");
152
+ return;
153
+ }
154
+ // Perform the merge from /workspace (which is on the base branch)
155
+ try {
156
+ console.log(`\nMerging "${branchName}" into "${baseBranch}"...`);
157
+ execSync(`git -C /workspace merge "${branchName}" --no-edit`, { stdio: "pipe" });
158
+ console.log(`\x1b[32mSuccessfully merged "${branchName}" into "${baseBranch}".\x1b[0m`);
159
+ }
160
+ catch (err) {
161
+ // Check if this is a merge conflict
162
+ let conflictingFiles = [];
163
+ try {
164
+ const status = execSync("git -C /workspace status --porcelain", { encoding: "utf-8" });
165
+ conflictingFiles = status
166
+ .split("\n")
167
+ .filter((line) => line.startsWith("UU") || line.startsWith("AA") || line.startsWith("DD") || line.startsWith("AU") || line.startsWith("UA") || line.startsWith("DU") || line.startsWith("UD"))
168
+ .map((line) => line.substring(3).trim());
169
+ }
170
+ catch {
171
+ // Ignore status errors
172
+ }
173
+ if (conflictingFiles.length > 0) {
174
+ // Merge conflict detected - abort and report
175
+ console.error(`\n\x1b[31mMerge conflict detected!\x1b[0m`);
176
+ console.error(`\nConflicting files:`);
177
+ for (const file of conflictingFiles) {
178
+ console.error(` \x1b[33m${file}\x1b[0m`);
179
+ }
180
+ // Abort the merge
181
+ try {
182
+ execSync("git -C /workspace merge --abort", { stdio: "pipe" });
183
+ console.error(`\n\x1b[36mMerge aborted.\x1b[0m`);
184
+ }
185
+ catch {
186
+ console.error("\n\x1b[33mWarning: Could not abort merge. You may need to run 'git merge --abort' manually.\x1b[0m");
187
+ }
188
+ console.error(`\nTo resolve:`);
189
+ console.error(` 1. Resolve conflicts manually and merge again`);
190
+ console.error(` 2. Or add a PRD item to resolve the conflicts:`);
191
+ console.error(` ralph prd add # describe the conflict resolution needed`);
192
+ process.exit(1);
193
+ }
194
+ else {
195
+ // Some other merge error
196
+ const message = err instanceof Error ? err.message : String(err);
197
+ console.error(`\x1b[31mMerge failed: ${message}\x1b[0m`);
198
+ // Try to abort in case merge is in progress
199
+ try {
200
+ execSync("git -C /workspace merge --abort", { stdio: "pipe" });
201
+ }
202
+ catch {
203
+ // Ignore if nothing to abort
204
+ }
205
+ process.exit(1);
206
+ }
207
+ }
208
+ // Clean up worktree if it exists
209
+ if (existsSync(worktreePath)) {
210
+ console.log(`\nCleaning up worktree at ${worktreePath}...`);
211
+ try {
212
+ execSync(`git -C /workspace worktree remove "${worktreePath}"`, { stdio: "pipe" });
213
+ console.log(`\x1b[32mWorktree removed.\x1b[0m`);
214
+ }
215
+ catch (err) {
216
+ const message = err instanceof Error ? err.message : String(err);
217
+ console.warn(`\x1b[33mWarning: Could not remove worktree: ${message}\x1b[0m`);
218
+ console.warn("You can remove it manually with: git worktree remove " + worktreePath);
219
+ }
220
+ }
221
+ // Clean up the branch itself (optional - merged branches can be deleted)
222
+ console.log(`\n\x1b[32mDone!\x1b[0m Branch "${branchName}" has been merged into "${baseBranch}".`);
223
+ }
224
+ /**
225
+ * Gets the PRD file path, preferring the primary if it exists.
226
+ */
227
+ function getPrdPath() {
228
+ const prdFiles = getPrdFiles();
229
+ if (prdFiles.primary) {
230
+ return prdFiles.primary;
231
+ }
232
+ return join(getRalphDir(), "prd.json");
233
+ }
234
+ /**
235
+ * Parses a PRD file (YAML or JSON) and returns the entries.
236
+ */
237
+ function parsePrdFile(path) {
238
+ const content = readFileSync(path, "utf-8");
239
+ const ext = extname(path).toLowerCase();
240
+ try {
241
+ let result;
242
+ if (ext === ".yaml" || ext === ".yml") {
243
+ result = YAML.parse(content);
244
+ }
245
+ else {
246
+ result = JSON.parse(content);
247
+ }
248
+ return result ?? [];
249
+ }
250
+ catch {
251
+ console.error(`Error parsing ${path}. Run 'ralph fix-prd' to attempt automatic repair.`);
252
+ process.exit(1);
253
+ }
254
+ }
255
+ /**
256
+ * Saves PRD entries to the PRD file (YAML or JSON based on extension).
257
+ */
258
+ function savePrd(entries) {
259
+ const path = getPrdPath();
260
+ const ext = extname(path).toLowerCase();
261
+ if (ext === ".yaml" || ext === ".yml") {
262
+ writeFileSync(path, YAML.stringify(entries));
263
+ }
264
+ else {
265
+ writeFileSync(path, JSON.stringify(entries, null, 2) + "\n");
266
+ }
267
+ }
268
+ /**
269
+ * Create a PRD item to open a pull request for a branch.
270
+ */
271
+ function branchPr(args) {
272
+ const branchName = args[0];
273
+ if (!branchName) {
274
+ console.error("Usage: ralph branch pr <branch-name>");
275
+ console.error("\nExample: ralph branch pr feat/login");
276
+ process.exit(1);
277
+ }
278
+ // Verify the branch exists
279
+ if (!branchExists(branchName)) {
280
+ console.error(`\x1b[31mError: Branch "${branchName}" does not exist.\x1b[0m`);
281
+ process.exit(1);
282
+ }
283
+ const baseBranch = getBaseBranch();
284
+ const entry = {
285
+ category: "feature",
286
+ description: `Create a pull request from \`${branchName}\` into \`${baseBranch}\``,
287
+ steps: [
288
+ `Ensure all changes on \`${branchName}\` are committed`,
289
+ `Push \`${branchName}\` to the remote if not already pushed`,
290
+ `Create a pull request from \`${branchName}\` into \`${baseBranch}\` using the appropriate tool (e.g. gh pr create)`,
291
+ "Include a descriptive title and summary of the changes in the PR",
292
+ ],
293
+ passes: false,
294
+ branch: branchName,
295
+ };
296
+ const prdPath = getPrdPath();
297
+ const prd = parsePrdFile(prdPath);
298
+ prd.push(entry);
299
+ savePrd(prd);
300
+ console.log(`Added PRD entry #${prd.length}: Create PR for ${branchName} → ${baseBranch}`);
301
+ console.log(`Branch field set to: ${branchName}`);
302
+ console.log("Run 'ralph run' or 'ralph once' to execute.");
303
+ }
304
+ /**
305
+ * Delete a branch: remove worktree, delete git branch, and untag PRD items.
306
+ * Asks for confirmation before proceeding.
307
+ */
308
+ async function branchDelete(args) {
309
+ const branchName = args[0];
310
+ if (!branchName) {
311
+ console.error("Usage: ralph branch delete <branch-name>");
312
+ console.error("\nExample: ralph branch delete feat/old-branch");
313
+ process.exit(1);
314
+ }
315
+ // Verify the branch exists
316
+ if (!branchExists(branchName)) {
317
+ console.error(`\x1b[31mError: Branch "${branchName}" does not exist.\x1b[0m`);
318
+ process.exit(1);
319
+ }
320
+ const worktreesBase = getWorktreesBase();
321
+ const dirName = branchToWorktreeName(branchName);
322
+ const worktreePath = join(worktreesBase, dirName);
323
+ const hasWorktree = existsSync(worktreePath);
324
+ // Load PRD to check for tagged items
325
+ const result = loadPrdEntries();
326
+ const taggedCount = result
327
+ ? result.entries.filter((e) => e.branch === branchName).length
328
+ : 0;
329
+ console.log(`Branch: ${branchName}`);
330
+ if (hasWorktree) {
331
+ console.log(`Worktree: ${worktreePath}`);
332
+ }
333
+ if (taggedCount > 0) {
334
+ console.log(`PRD items tagged: ${taggedCount}`);
335
+ }
336
+ console.log();
337
+ // Ask for confirmation
338
+ const confirmed = await promptConfirm(`Delete branch "${branchName}"${hasWorktree ? " and its worktree" : ""}?`, false);
339
+ if (!confirmed) {
340
+ console.log("Delete cancelled.");
341
+ return;
342
+ }
343
+ // Step 1: Remove worktree if it exists
344
+ if (hasWorktree) {
345
+ console.log(`\nRemoving worktree at ${worktreePath}...`);
346
+ try {
347
+ execSync(`git -C /workspace worktree remove "${worktreePath}" --force`, { stdio: "pipe" });
348
+ console.log(`\x1b[32mWorktree removed.\x1b[0m`);
349
+ }
350
+ catch (err) {
351
+ const message = err instanceof Error ? err.message : String(err);
352
+ console.warn(`\x1b[33mWarning: Could not remove worktree: ${message}\x1b[0m`);
353
+ console.warn("You can remove it manually with: git worktree remove " + worktreePath);
354
+ }
355
+ }
356
+ // Step 2: Delete the git branch
357
+ console.log(`Deleting branch "${branchName}"...`);
358
+ try {
359
+ execSync(`git -C /workspace branch -D "${branchName}"`, { stdio: "pipe" });
360
+ console.log(`\x1b[32mBranch deleted.\x1b[0m`);
361
+ }
362
+ catch (err) {
363
+ const message = err instanceof Error ? err.message : String(err);
364
+ console.error(`\x1b[31mError deleting branch: ${message}\x1b[0m`);
365
+ }
366
+ // Step 3: Remove branch tag from PRD items
367
+ if (result && taggedCount > 0) {
368
+ console.log(`Removing branch tag from ${taggedCount} PRD item(s)...`);
369
+ const updatedEntries = result.entries.map((entry) => {
370
+ if (entry.branch === branchName) {
371
+ const { branch: _, ...rest } = entry;
372
+ return rest;
373
+ }
374
+ return entry;
375
+ });
376
+ writePrdAuto(result.prdPath, updatedEntries);
377
+ console.log(`\x1b[32mPRD items updated.\x1b[0m`);
378
+ }
379
+ console.log(`\n\x1b[32mDone!\x1b[0m Branch "${branchName}" has been deleted.`);
380
+ }
381
+ /**
382
+ * Main branch command dispatcher.
383
+ */
384
+ export async function branch(args) {
385
+ const subcommand = args[0];
386
+ switch (subcommand) {
387
+ case "list":
388
+ branchList();
389
+ break;
390
+ case "merge":
391
+ await branchMerge(args.slice(1));
392
+ break;
393
+ case "delete":
394
+ await branchDelete(args.slice(1));
395
+ break;
396
+ case "pr":
397
+ branchPr(args.slice(1));
398
+ break;
399
+ default:
400
+ console.error("Usage: ralph branch <subcommand>");
401
+ console.error("\nSubcommands:");
402
+ console.error(" list List all branches and their status");
403
+ console.error(" merge <name> Merge a branch worktree into the base branch");
404
+ console.error(" delete <name> Delete a branch and its worktree");
405
+ console.error(" pr <name> Create a PRD item to open a PR for a branch");
406
+ process.exit(1);
407
+ }
408
+ }
@@ -2,7 +2,7 @@
2
2
  * Chat command for managing Telegram, Slack, and other chat integrations.
3
3
  * Allows ralph to receive commands and send notifications via chat services.
4
4
  */
5
- import { existsSync, readFileSync, writeFileSync } from "fs";
5
+ import { existsSync, readFileSync, writeFileSync, watch } from "fs";
6
6
  import { join, basename, extname } from "path";
7
7
  import { spawn } from "child_process";
8
8
  import YAML from "yaml";
@@ -11,7 +11,7 @@ import { createTelegramClient } from "../providers/telegram.js";
11
11
  import { createSlackClient } from "../providers/slack.js";
12
12
  import { createDiscordClient } from "../providers/discord.js";
13
13
  import { generateProjectId, formatStatusMessage, formatStatusForChat, } from "../utils/chat-client.js";
14
- import { getMessagesPath, sendMessage, waitForResponse } from "../utils/message-queue.js";
14
+ import { getMessagesPath, sendMessage, waitForResponse, getPendingMessages, respondToMessage, cleanupOldMessages, } from "../utils/message-queue.js";
15
15
  const CHAT_STATE_FILE = "chat-state.json";
16
16
  /**
17
17
  * Load chat state from .ralph/chat-state.json
@@ -536,6 +536,67 @@ function createChatClient(config, debug) {
536
536
  allowedChatIds: config.chat.telegram.allowedChatIds,
537
537
  };
538
538
  }
539
+ /**
540
+ * Process a message from the sandbox (container).
541
+ * Handles notification actions like slack_notify, telegram_notify, discord_notify.
542
+ */
543
+ async function processSandboxMessage(message, client, allowedChatIds, messagesPath, debug) {
544
+ const { action, args } = message;
545
+ if (debug) {
546
+ console.log(`[chat] Processing sandbox message: ${action} ${args?.join(" ") || ""}`);
547
+ }
548
+ // Handle notification actions
549
+ if (action === "slack_notify" || action === "telegram_notify" || action === "discord_notify") {
550
+ const notifyMessage = args?.join(" ") || "Ralph notification";
551
+ // Check if this notification is for our provider
552
+ const expectedProvider = action === "slack_notify" ? "slack" : action === "telegram_notify" ? "telegram" : "discord";
553
+ if (client.provider !== expectedProvider) {
554
+ if (debug) {
555
+ console.log(`[chat] Ignoring ${action} - current provider is ${client.provider}`);
556
+ }
557
+ respondToMessage(messagesPath, message.id, {
558
+ success: false,
559
+ error: `Chat provider is ${client.provider}, not ${expectedProvider}`,
560
+ });
561
+ return;
562
+ }
563
+ // Send to all allowed chat IDs
564
+ if (!allowedChatIds || allowedChatIds.length === 0) {
565
+ respondToMessage(messagesPath, message.id, {
566
+ success: false,
567
+ error: "No chat IDs configured",
568
+ });
569
+ return;
570
+ }
571
+ try {
572
+ for (const chatId of allowedChatIds) {
573
+ await client.sendMessage(chatId, notifyMessage);
574
+ }
575
+ if (debug) {
576
+ console.log(`[chat] Sent notification to ${allowedChatIds.length} chat(s)`);
577
+ }
578
+ respondToMessage(messagesPath, message.id, {
579
+ success: true,
580
+ output: `Sent to ${client.provider}`,
581
+ });
582
+ }
583
+ catch (err) {
584
+ const errorMsg = err instanceof Error ? err.message : "Unknown error";
585
+ if (debug) {
586
+ console.error(`[chat] Failed to send notification: ${errorMsg}`);
587
+ }
588
+ respondToMessage(messagesPath, message.id, {
589
+ success: false,
590
+ error: errorMsg,
591
+ });
592
+ }
593
+ return;
594
+ }
595
+ // Unknown action - don't respond (let daemon handle it if running)
596
+ if (debug) {
597
+ console.log(`[chat] Ignoring unknown action: ${action}`);
598
+ }
599
+ }
539
600
  /**
540
601
  * Start the chat daemon (listens for messages and handles commands).
541
602
  */
@@ -610,9 +671,61 @@ async function startChat(config, debug) {
610
671
  console.error(`Failed to connect: ${err instanceof Error ? err.message : err}`);
611
672
  process.exit(1);
612
673
  }
674
+ // Watch for sandbox messages (notifications from container)
675
+ const messagesPath = getMessagesPath(false); // host path
676
+ const ralphDir = getRalphDir();
677
+ let sandboxWatcher = null;
678
+ let sandboxPollInterval = null;
679
+ let processingMessages = false;
680
+ const checkSandboxMessages = async () => {
681
+ if (processingMessages)
682
+ return;
683
+ processingMessages = true;
684
+ try {
685
+ const pending = getPendingMessages(messagesPath, "sandbox");
686
+ for (const msg of pending) {
687
+ // Only handle notification actions - let daemon handle others
688
+ if (msg.action === "slack_notify" ||
689
+ msg.action === "telegram_notify" ||
690
+ msg.action === "discord_notify") {
691
+ await processSandboxMessage(msg, client, allowedChatIds, messagesPath, debug);
692
+ }
693
+ }
694
+ // Cleanup old messages periodically
695
+ cleanupOldMessages(messagesPath, 60000);
696
+ }
697
+ catch (err) {
698
+ if (debug) {
699
+ console.error(`[chat] Error processing sandbox messages: ${err}`);
700
+ }
701
+ }
702
+ processingMessages = false;
703
+ };
704
+ // Process any pending sandbox messages on startup
705
+ await checkSandboxMessages();
706
+ // Watch the .ralph directory for changes
707
+ if (existsSync(ralphDir)) {
708
+ sandboxWatcher = watch(ralphDir, { persistent: true }, (eventType, filename) => {
709
+ if (filename === "messages.json") {
710
+ checkSandboxMessages();
711
+ }
712
+ });
713
+ }
714
+ // Also poll periodically as backup
715
+ sandboxPollInterval = setInterval(checkSandboxMessages, 1000);
716
+ if (debug) {
717
+ console.log(`[chat] Watching for sandbox notifications at: ${messagesPath}`);
718
+ }
613
719
  // Handle shutdown
614
720
  const shutdown = async () => {
615
721
  console.log("\nShutting down chat daemon...");
722
+ // Stop sandbox message watching
723
+ if (sandboxWatcher) {
724
+ sandboxWatcher.close();
725
+ }
726
+ if (sandboxPollInterval) {
727
+ clearInterval(sandboxPollInterval);
728
+ }
616
729
  // Send disconnected message to all allowed chats
617
730
  if (allowedChatIds && allowedChatIds.length > 0) {
618
731
  for (const chatId of allowedChatIds) {
@@ -240,6 +240,16 @@ async function processMessage(message, actions, messagesPath, debug) {
240
240
  if (debug) {
241
241
  console.log(`[daemon] Processing: ${message.action} (${message.id})`);
242
242
  }
243
+ // Skip chat notification actions - these are handled by the chat client
244
+ // (which has the connected Socket Mode client)
245
+ const chatNotifyActions = ["slack_notify", "telegram_notify", "discord_notify"];
246
+ if (chatNotifyActions.includes(message.action)) {
247
+ if (debug) {
248
+ console.log(`[daemon] Skipping ${message.action} - handled by chat client`);
249
+ }
250
+ // Don't respond - let the chat client handle it
251
+ return;
252
+ }
243
253
  const action = actions[message.action];
244
254
  if (!action) {
245
255
  respondToMessage(messagesPath, message.id, {
@@ -97,19 +97,24 @@ ${commands}
97
97
  ${commands}
98
98
  `;
99
99
  }
100
- // Build git config section if configured
101
- let gitConfigSection = "";
102
- if (dockerConfig?.git && (dockerConfig.git.name || dockerConfig.git.email)) {
103
- const gitCommands = [];
104
- if (dockerConfig.git.name) {
105
- gitCommands.push(`git config --global user.name "${dockerConfig.git.name}"`);
106
- }
107
- if (dockerConfig.git.email) {
108
- gitCommands.push(`git config --global user.email "${dockerConfig.git.email}"`);
109
- }
110
- gitConfigSection = `
111
- # Configure git identity
100
+ // Build git config section — always set init.defaultBranch, plus identity if configured
101
+ const gitCommands = [`git config --global init.defaultBranch main`];
102
+ if (dockerConfig?.git?.name) {
103
+ gitCommands.push(`git config --global user.name "${dockerConfig.git.name}"`);
104
+ }
105
+ if (dockerConfig?.git?.email) {
106
+ gitCommands.push(`git config --global user.email "${dockerConfig.git.email}"`);
107
+ }
108
+ const gitConfigSection = `
109
+ # Configure git defaults
112
110
  RUN ${gitCommands.join(" \\\n && ")}
111
+ `;
112
+ // Build worktrees directory section if configured
113
+ let worktreesDir = "";
114
+ if (dockerConfig?.worktreesPath) {
115
+ worktreesDir = `
116
+ # Create worktrees directory for git worktree storage
117
+ RUN mkdir -p /worktrees && chown node:node /worktrees
113
118
  `;
114
119
  }
115
120
  // Build asciinema installation section if enabled
@@ -215,7 +220,7 @@ RUN echo "node ALL=(ALL) NOPASSWD: /usr/local/bin/init-firewall.sh" >> /etc/sudo
215
220
  RUN mkdir -p /workspace && chown node:node /workspace
216
221
  RUN mkdir -p /home/node/.claude && chown node:node /home/node/.claude
217
222
  RUN mkdir -p /commandhistory && chown node:node /commandhistory
218
- ${asciinemaDir}
223
+ ${worktreesDir}${asciinemaDir}
219
224
  # Copy firewall script
220
225
  COPY init-firewall.sh /usr/local/bin/init-firewall.sh
221
226
  RUN chmod +x /usr/local/bin/init-firewall.sh
@@ -351,6 +356,11 @@ function generateDockerCompose(imageName, dockerConfig) {
351
356
  " - ${HOME}/.claude:/home/node/.claude",
352
357
  ` - ${imageName}-history:/commandhistory`,
353
358
  ];
359
+ // Mount worktrees path if configured
360
+ if (dockerConfig?.worktreesPath) {
361
+ baseVolumes.push(" # Mount host worktrees directory for git worktree storage");
362
+ baseVolumes.push(` - ${dockerConfig.worktreesPath}:/worktrees`);
363
+ }
354
364
  if (dockerConfig?.volumes && dockerConfig.volumes.length > 0) {
355
365
  const customVolumeLines = dockerConfig.volumes.map((vol) => ` - ${vol}`);
356
366
  baseVolumes.push(...customVolumeLines);
@@ -1075,7 +1085,7 @@ Docker files generated in .ralph/docker/
1075
1085
 
1076
1086
  Next steps:
1077
1087
  1. Build the image: ralph docker build
1078
- 2. Run container: ralph docker run
1088
+ 2. Run container: ralph docker run
1079
1089
 
1080
1090
  Or use docker compose directly:
1081
1091
  cd .ralph/docker && docker compose run --rm ralph
@@ -1186,7 +1196,7 @@ Docker files generated in .ralph/docker/
1186
1196
 
1187
1197
  Next steps:
1188
1198
  1. Build the image: ralph docker build
1189
- 2. Run container: ralph docker run
1199
+ 2. Run container: ralph docker run
1190
1200
 
1191
1201
  Or use docker compose directly:
1192
1202
  cd .ralph/docker && docker compose run --rm ralph