Skip to content

Commit c5375aa

Browse files
feat: rename /bg to /research (read-only) + ralph auto-worktree
Two changes that cleanly separate read vs write background tasks: /research (formerly /bg): Now restricted to read-only tools (Read, Glob, Grep, WebSearch, WebFetch) via --allowedTools. Safe for analysis/investigation without risk of modifying code. /ralph: Now automatically creates a git worktree on first iteration via --worktree flag. All file modifications happen in an isolated branch, protecting the main codebase. Branch persists after completion for user review and merge. - ExecuteOptions.Worktree field added to executor - researchAllowedTools enforced in both Phase 1 and Phase 2 - Ralph progress/result messages show branch info Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent a86d13b commit c5375aa

4 files changed

Lines changed: 55 additions & 32 deletions

File tree

internal/bot/commands.go

Lines changed: 23 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -41,8 +41,8 @@ func (b *Bot) handleCommand(msg *tgbotapi.Message) {
4141
b.cmdRetry()
4242
case "project":
4343
b.cmdProject(msg.CommandArguments())
44-
case "bg":
45-
b.cmdBg(msg)
44+
case "research":
45+
b.cmdResearch(msg)
4646
case "ralph":
4747
b.cmdRalph(msg)
4848
case "plan":
@@ -68,12 +68,12 @@ func (b *Bot) cmdHelp() {
6868
"/project search `<keyword>` — Find git repos to add\n" +
6969
"/project rename `<old>` `<new>` — Rename project\n" +
7070
"/project remove `<name>` — Remove project\n\n" +
71-
"*Background:*\n" +
72-
"/bg `<message>` — Run task in background\n" +
73-
"/bg inject `<id>` — Inject result into session\n" +
74-
"/bg status / cancel `<id>`\n\n" +
75-
"*Ralph (Iterative Loop):*\n" +
76-
"/ralph `<message>` — Auto-loop until done\n" +
71+
"*Research (read-only):*\n" +
72+
"/research `<message>` — Background analysis\n" +
73+
"/research inject `<id>` — Inject result into session\n" +
74+
"/research status / cancel `<id>`\n\n" +
75+
"*Ralph (iterative loop + worktree):*\n" +
76+
"/ralph `<message>` — Auto-loop in isolated branch\n" +
7777
"/ralph `<msg>` --max `<N>` — Set max iterations\n" +
7878
"/ralph status / cancel `<id>`\n\n" +
7979
"*Plan Mode:*\n" +
@@ -506,7 +506,7 @@ func (b *Bot) cmdProjectSearch(keyword string) {
506506
}
507507
}
508508

509-
func (b *Bot) cmdBg(msg *tgbotapi.Message) {
509+
func (b *Bot) cmdResearch(msg *tgbotapi.Message) {
510510
if b.worker == nil {
511511
return
512512
}
@@ -516,13 +516,13 @@ func (b *Bot) cmdBg(msg *tgbotapi.Message) {
516516
// /bg → help
517517
if args == "" {
518518
b.sendMarkdown(
519-
"🔄 *Background Tasks*\n\n" +
520-
"`/bg <message>` — Run in current project\n" +
521-
"`/bg <project> <message>` — Run in specific project\n" +
522-
"`/bg status` — Show running tasks\n" +
523-
"`/bg inject <id>` — Inject result into session\n" +
524-
"`/bg cancel <id>` — Cancel a task\n\n" +
525-
"Background tasks run independently.\n" +
519+
"🔍 *Research (read-only)*\n\n" +
520+
"`/research <message>` — Analyze in background\n" +
521+
"`/research <project> <message>` — In specific project\n" +
522+
"`/research status` — Show running tasks\n" +
523+
"`/research inject <id>` — Inject result into session\n" +
524+
"`/research cancel <id>` — Cancel a task\n\n" +
525+
"Read-only analysis. No file modifications.\n" +
526526
"Use inject to bring results into your conversation.")
527527
return
528528
}
@@ -537,7 +537,7 @@ func (b *Bot) cmdBg(msg *tgbotapi.Message) {
537537
if strings.HasPrefix(args, "inject ") {
538538
taskID := strings.TrimSpace(strings.TrimPrefix(args, "inject"))
539539
if taskID == "" {
540-
b.sendMessage("Usage: /bg inject <task_id>")
540+
b.sendMessage("Usage: /research inject <task_id>")
541541
return
542542
}
543543
resultText, projectName, err := b.worker.GetBackgroundResult(taskID)
@@ -570,19 +570,19 @@ func (b *Bot) cmdBg(msg *tgbotapi.Message) {
570570
if strings.HasPrefix(args, "cancel ") {
571571
taskID := strings.TrimSpace(strings.TrimPrefix(args, "cancel"))
572572
if taskID == "" {
573-
b.sendMessage("Usage: /bg cancel <task_id>")
573+
b.sendMessage("Usage: /research cancel <task_id>")
574574
return
575575
}
576576
if err := b.worker.CancelBackground(taskID); err != nil {
577577
b.sendMessage("Failed: " + err.Error())
578578
} else {
579-
b.sendMessage(fmt.Sprintf("🛑 Cancelled background task: %s", taskID))
579+
b.sendMessage(fmt.Sprintf("🛑 Cancelled research task: %s", taskID))
580580
}
581581
return
582582
}
583583

584584
// /bg <project> <message> or /bg <message>
585-
projectName, message := b.parseBgArgs(args)
585+
projectName, message := b.parseResearchArgs(args)
586586

587587
taskID, err := b.worker.EnqueueBackground(context.Background(), projectName, message)
588588
if err != nil {
@@ -591,16 +591,16 @@ func (b *Bot) cmdBg(msg *tgbotapi.Message) {
591591
}
592592

593593
b.sendMessage(fmt.Sprintf(
594-
"🔄 Background task started\n🆔 %s\n📂 Project: %s\n💬 %s\n\nUse /bg status to check progress.",
594+
"🔍 Research started\n🆔 %s\n📂 Project: %s\n💬 %s\n\nUse /research status to check progress.",
595595
taskID, projectName, worker.Truncate(message, 60)))
596596
}
597597

598-
// parseBgArgs splits args into project name and message.
598+
// parseResearchArgs splits args into project name and message.
599599
// If the first word matches a registered project (and it's different from
600600
// the active project), it's used as the target project. Otherwise the
601601
// entire string is the message for the active project.
602602
// This avoids misrouting when the active project name happens to be the first word.
603-
func (b *Bot) parseBgArgs(args string) (projectName, message string) {
603+
func (b *Bot) parseResearchArgs(args string) (projectName, message string) {
604604
active := b.worker.ActiveProject()
605605
parts := strings.SplitN(args, " ", 2)
606606
if len(parts) == 2 {

internal/claude/executor.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,7 @@ func NewProjectExecutor(cliPath, workDir string, addDirs []string, timeout time.
8787
type ExecuteOptions struct {
8888
SkipPermissions bool
8989
AllowedTools []string // if non-empty, restricts tool access via --allowedTools
90+
Worktree string // if non-empty, passes --worktree <name> to CLI
9091
}
9192

9293
func (e *Executor) Execute(ctx context.Context, userMessage string, skipPermissions bool) (*store.CLIResult, error) {
@@ -120,6 +121,9 @@ func (e *Executor) ExecuteWithOptions(ctx context.Context, userMessage string, o
120121
if len(opts.AllowedTools) > 0 {
121122
args = append(args, "--allowedTools", strings.Join(opts.AllowedTools, ","))
122123
}
124+
if opts.Worktree != "" {
125+
args = append(args, "--worktree", opts.Worktree)
126+
}
123127
if e.systemPrompt != "" {
124128
args = append(args, "--system-prompt", e.systemPrompt)
125129
}

internal/worker/background.go

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,16 @@ import (
1010
"sync/atomic"
1111
"time"
1212

13+
"github.com/GrapeInTheTree/pocket-claude/internal/claude"
1314
"github.com/GrapeInTheTree/pocket-claude/internal/project"
1415
"github.com/GrapeInTheTree/pocket-claude/internal/store"
1516
)
1617

1718
const maxBackgroundSlots = 3
1819

20+
// researchAllowedTools restricts /research tasks to read-only tools.
21+
var researchAllowedTools = []string{"Read", "Glob", "Grep", "WebSearch", "WebFetch"}
22+
1923
// taskCounter provides unique IDs without millisecond collisions.
2024
var taskCounter atomic.Int64
2125

@@ -147,8 +151,10 @@ func (bp *BackgroundPool) run(ctx context.Context, task *BackgroundTask) {
147151
go bp.sendTypingFn(typingCtx)
148152
defer stopTyping()
149153

150-
// Phase 1: Execute with default permissions
151-
result, err := exec.Execute(ctx, task.Message, false)
154+
// Phase 1: Execute with read-only tools (research mode)
155+
result, err := exec.ExecuteWithOptions(ctx, task.Message, claude.ExecuteOptions{
156+
AllowedTools: researchAllowedTools,
157+
})
152158
if err != nil {
153159
if ctx.Err() != nil {
154160
bp.setTaskState(task.ID, "cancelled", "")
@@ -188,7 +194,10 @@ func (bp *BackgroundPool) run(ctx context.Context, task *BackgroundTask) {
188194
defer stopTyping2()
189195

190196
bp.logger.Info("Background task approved, re-executing", "id", task.ID)
191-
result, err = exec.Execute(ctx, task.Message, true)
197+
result, err = exec.ExecuteWithOptions(ctx, task.Message, claude.ExecuteOptions{
198+
SkipPermissions: true,
199+
AllowedTools: researchAllowedTools,
200+
})
192201
if err != nil {
193202
if ctx.Err() != nil {
194203
bp.setTaskState(task.ID, "cancelled", "")

internal/worker/ralph.go

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import (
77
"strings"
88
"time"
99

10+
"github.com/GrapeInTheTree/pocket-claude/internal/claude"
1011
"github.com/GrapeInTheTree/pocket-claude/internal/store"
1112
)
1213

@@ -110,8 +111,12 @@ func (bp *BackgroundPool) runRalph(ctx context.Context, task *BackgroundTask) {
110111
typingCtx, stopTyping := context.WithCancel(ctx)
111112
go bp.sendTypingFn(typingCtx)
112113

113-
// Execute
114-
result, err := exec.Execute(ctx, prompt, false)
114+
// Execute (first iteration creates worktree, subsequent use --resume)
115+
opts := claude.ExecuteOptions{}
116+
if iteration == 1 {
117+
opts.Worktree = task.ID
118+
}
119+
result, err := exec.ExecuteWithOptions(ctx, prompt, opts)
115120
stopTyping()
116121

117122
if err != nil {
@@ -142,7 +147,11 @@ func (bp *BackgroundPool) runRalph(ctx context.Context, task *BackgroundTask) {
142147

143148
typingCtx2, stopTyping2 := context.WithCancel(ctx)
144149
go bp.sendTypingFn(typingCtx2)
145-
result, err = exec.Execute(ctx, prompt, true)
150+
approvedOpts := claude.ExecuteOptions{SkipPermissions: true}
151+
if iteration == 1 {
152+
approvedOpts.Worktree = task.ID
153+
}
154+
result, err = exec.ExecuteWithOptions(ctx, prompt, approvedOpts)
146155
stopTyping2()
147156
if err != nil {
148157
if ctx.Err() != nil {
@@ -209,8 +218,8 @@ func (bp *BackgroundPool) runRalph(ctx context.Context, task *BackgroundTask) {
209218

210219
// Progress update (use local copies)
211220
summary := Truncate(result.Result, 100)
212-
bp.sendFn(fmt.Sprintf("🔁 Ralph [%s] Iteration %d/%d\n📂 %s | 💰 $%.4f\n\n%s",
213-
task.ID, iterNum, task.MaxIterations, task.Project, totalCost, summary))
221+
bp.sendFn(fmt.Sprintf("🔁 Ralph [%s] Iteration %d/%d\n📂 %s | 🌿 %s | 💰 $%.4f\n\n%s",
222+
task.ID, iterNum, task.MaxIterations, task.Project, task.ID, totalCost, summary))
214223
}
215224

216225
// Max iterations reached
@@ -256,7 +265,8 @@ func (bp *BackgroundPool) sendRalphResult(task *BackgroundTask, result *store.CL
256265

257266
var sb strings.Builder
258267
sb.WriteString(fmt.Sprintf("✅ Ralph Done (%d/%d iterations)\n", currentIter, maxIter))
259-
sb.WriteString(fmt.Sprintf("📂 %s | 💰 $%.4f | ⏱ %s\n", task.Project, totalCost, elapsedStr))
268+
sb.WriteString(fmt.Sprintf("📂 %s | 🌿 %s\n", task.Project, task.ID))
269+
sb.WriteString(fmt.Sprintf("💰 $%.4f | ⏱ %s\n", totalCost, elapsedStr))
260270
sb.WriteString(fmt.Sprintf("🏁 %s\n\n", reason))
261271
sb.WriteString(response)
262272

0 commit comments

Comments
 (0)