Skip to content

Commit c053920

Browse files
committed
Add user tasks poc
1 parent f6382fd commit c053920

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

41 files changed

+2211
-480
lines changed

agent/agentclaude/agentclaude.go

Lines changed: 317 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,317 @@
1+
package agentclaude
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"fmt"
7+
"io"
8+
"os"
9+
"os/exec"
10+
"path/filepath"
11+
"strings"
12+
"sync"
13+
"time"
14+
15+
"github.com/spf13/afero"
16+
)
17+
18+
func New(ctx context.Context, apiKey, systemPrompt, taskPrompt string) error {
19+
claudePath, err := exec.LookPath("claude")
20+
if err != nil {
21+
return fmt.Errorf("claude not found: %w", err)
22+
}
23+
fs := afero.NewOsFs()
24+
err = injectClaudeMD(fs, `You are an AI agent in a Coder Workspace.
25+
26+
The user is running this task entirely autonomously.
27+
28+
You must use the coder-agent MCP server to periodically report your progress.
29+
If you do not, the user will not be able to see your progress.
30+
`, systemPrompt, "")
31+
if err != nil {
32+
return fmt.Errorf("failed to inject claude md: %w", err)
33+
}
34+
35+
wd, err := os.Getwd()
36+
if err != nil {
37+
return fmt.Errorf("failed to get working directory: %w", err)
38+
}
39+
40+
err = configureClaude(fs, ClaudeConfig{
41+
ConfigPath: "",
42+
ProjectDirectory: wd,
43+
APIKey: apiKey,
44+
AllowedTools: []string{},
45+
MCPServers: map[string]ClaudeConfigMCP{
46+
// "coder-agent": {
47+
// Command: "coder",
48+
// Args: []string{"agent", "mcp"},
49+
// },
50+
},
51+
})
52+
if err != nil {
53+
return fmt.Errorf("failed to configure claude: %w", err)
54+
}
55+
56+
cmd := exec.CommandContext(ctx, claudePath, taskPrompt)
57+
58+
handlePause := func() {
59+
// We need to notify the user that we've paused!
60+
fmt.Println("We would normally notify the user...")
61+
}
62+
63+
// Create a simple wrapper that starts monitoring only after first write
64+
stdoutWriter := &delayedPauseWriter{
65+
writer: os.Stdout,
66+
pauseWindow: 2 * time.Second,
67+
onPause: handlePause,
68+
}
69+
stderrWriter := &delayedPauseWriter{
70+
writer: os.Stderr,
71+
pauseWindow: 2 * time.Second,
72+
onPause: handlePause,
73+
}
74+
75+
cmd.Stdout = stdoutWriter
76+
cmd.Stderr = stderrWriter
77+
cmd.Stdin = os.Stdin
78+
79+
return cmd.Run()
80+
}
81+
82+
// delayedPauseWriter wraps an io.Writer and only starts monitoring for pauses after first write
83+
type delayedPauseWriter struct {
84+
writer io.Writer
85+
pauseWindow time.Duration
86+
onPause func()
87+
lastWrite time.Time
88+
mu sync.Mutex
89+
started bool
90+
pauseNotified bool
91+
}
92+
93+
// Write implements io.Writer and starts monitoring on first write
94+
func (w *delayedPauseWriter) Write(p []byte) (n int, err error) {
95+
w.mu.Lock()
96+
firstWrite := !w.started
97+
w.started = true
98+
w.lastWrite = time.Now()
99+
100+
// Reset pause notification state when new output appears
101+
w.pauseNotified = false
102+
103+
w.mu.Unlock()
104+
105+
// Start monitoring goroutine on first write
106+
if firstWrite {
107+
go w.monitorPauses()
108+
}
109+
110+
return w.writer.Write(p)
111+
}
112+
113+
// monitorPauses checks for pauses in writing and calls onPause when detected
114+
func (w *delayedPauseWriter) monitorPauses() {
115+
ticker := time.NewTicker(500 * time.Millisecond)
116+
defer ticker.Stop()
117+
118+
for range ticker.C {
119+
w.mu.Lock()
120+
elapsed := time.Since(w.lastWrite)
121+
alreadyNotified := w.pauseNotified
122+
123+
// If we detect a pause and haven't notified yet, mark as notified
124+
if elapsed >= w.pauseWindow && !alreadyNotified {
125+
w.pauseNotified = true
126+
}
127+
128+
w.mu.Unlock()
129+
130+
// Only notify once per pause period
131+
if elapsed >= w.pauseWindow && !alreadyNotified {
132+
w.onPause()
133+
}
134+
}
135+
}
136+
137+
func injectClaudeMD(fs afero.Fs, coderPrompt, systemPrompt string, configPath string) error {
138+
if configPath == "" {
139+
configPath = filepath.Join(os.Getenv("HOME"), ".claude", "CLAUDE.md")
140+
}
141+
_, err := fs.Stat(configPath)
142+
if err != nil {
143+
if !os.IsNotExist(err) {
144+
return fmt.Errorf("failed to stat claude config: %w", err)
145+
}
146+
}
147+
content := ""
148+
if err == nil {
149+
contentBytes, err := afero.ReadFile(fs, configPath)
150+
if err != nil {
151+
return fmt.Errorf("failed to read claude config: %w", err)
152+
}
153+
content = string(contentBytes)
154+
}
155+
156+
// Define the guard strings
157+
const coderPromptStartGuard = "<coder-prompt>"
158+
const coderPromptEndGuard = "</coder-prompt>"
159+
const systemPromptStartGuard = "<system-prompt>"
160+
const systemPromptEndGuard = "</system-prompt>"
161+
162+
// Extract the content without the guarded sections
163+
cleanContent := content
164+
165+
// Remove existing coder prompt section if it exists
166+
coderStartIdx := indexOf(cleanContent, coderPromptStartGuard)
167+
coderEndIdx := indexOf(cleanContent, coderPromptEndGuard)
168+
if coderStartIdx != -1 && coderEndIdx != -1 && coderStartIdx < coderEndIdx {
169+
beforeCoderPrompt := cleanContent[:coderStartIdx]
170+
afterCoderPrompt := cleanContent[coderEndIdx+len(coderPromptEndGuard):]
171+
cleanContent = beforeCoderPrompt + afterCoderPrompt
172+
}
173+
174+
// Remove existing system prompt section if it exists
175+
systemStartIdx := indexOf(cleanContent, systemPromptStartGuard)
176+
systemEndIdx := indexOf(cleanContent, systemPromptEndGuard)
177+
if systemStartIdx != -1 && systemEndIdx != -1 && systemStartIdx < systemEndIdx {
178+
beforeSystemPrompt := cleanContent[:systemStartIdx]
179+
afterSystemPrompt := cleanContent[systemEndIdx+len(systemPromptEndGuard):]
180+
cleanContent = beforeSystemPrompt + afterSystemPrompt
181+
}
182+
183+
// Trim any leading whitespace from the clean content
184+
cleanContent = strings.TrimSpace(cleanContent)
185+
186+
// Create the new content with both prompts prepended
187+
var newContent string
188+
189+
// Add coder prompt
190+
newContent = coderPromptStartGuard + "\n" + coderPrompt + "\n" + coderPromptEndGuard + "\n\n"
191+
192+
// Add system prompt
193+
newContent += systemPromptStartGuard + "\n" + systemPrompt + "\n" + systemPromptEndGuard + "\n\n"
194+
195+
// Add the rest of the content
196+
if cleanContent != "" {
197+
newContent += cleanContent
198+
}
199+
200+
// Write the updated content back to the file
201+
err = afero.WriteFile(fs, configPath, []byte(newContent), 0644)
202+
if err != nil {
203+
return fmt.Errorf("failed to write claude config: %w", err)
204+
}
205+
206+
return nil
207+
}
208+
209+
// indexOf returns the index of the first instance of substr in s,
210+
// or -1 if substr is not present in s.
211+
func indexOf(s, substr string) int {
212+
for i := 0; i <= len(s)-len(substr); i++ {
213+
if s[i:i+len(substr)] == substr {
214+
return i
215+
}
216+
}
217+
return -1
218+
}
219+
220+
type ClaudeConfig struct {
221+
ConfigPath string
222+
ProjectDirectory string
223+
APIKey string
224+
AllowedTools []string
225+
MCPServers map[string]ClaudeConfigMCP
226+
}
227+
228+
type ClaudeConfigMCP struct {
229+
Command string
230+
Args []string
231+
Env map[string]string
232+
}
233+
234+
func configureClaude(fs afero.Fs, cfg ClaudeConfig) error {
235+
if cfg.ConfigPath == "" {
236+
cfg.ConfigPath = filepath.Join(os.Getenv("HOME"), ".claude.json")
237+
}
238+
var config map[string]any
239+
_, err := fs.Stat(cfg.ConfigPath)
240+
if err != nil {
241+
if os.IsNotExist(err) {
242+
config = make(map[string]any)
243+
err = nil
244+
} else {
245+
return fmt.Errorf("failed to stat claude config: %w", err)
246+
}
247+
}
248+
if err == nil {
249+
jsonBytes, err := afero.ReadFile(fs, cfg.ConfigPath)
250+
if err != nil {
251+
return fmt.Errorf("failed to read claude config: %w", err)
252+
}
253+
err = json.Unmarshal(jsonBytes, &config)
254+
if err != nil {
255+
return fmt.Errorf("failed to unmarshal claude config: %w", err)
256+
}
257+
}
258+
259+
if cfg.APIKey != "" {
260+
// Stops Claude from requiring the user to generate
261+
// a Claude-specific API key.
262+
config["primaryApiKey"] = cfg.APIKey
263+
}
264+
// Stops Claude from asking for onboarding.
265+
config["hasCompletedOnboarding"] = true
266+
// Stops Claude from asking for permissions.
267+
config["bypassPermissionsModeAccepted"] = true
268+
269+
projects, ok := config["projects"].(map[string]any)
270+
if !ok {
271+
projects = make(map[string]any)
272+
}
273+
274+
project, ok := projects[cfg.ProjectDirectory].(map[string]any)
275+
if !ok {
276+
project = make(map[string]any)
277+
}
278+
279+
allowedTools, ok := project["allowedTools"].([]string)
280+
if !ok {
281+
allowedTools = []string{}
282+
}
283+
284+
// Add cfg.AllowedTools to the list if they're not already present.
285+
for _, tool := range cfg.AllowedTools {
286+
for _, existingTool := range allowedTools {
287+
if tool == existingTool {
288+
continue
289+
}
290+
}
291+
allowedTools = append(allowedTools, tool)
292+
}
293+
project["allowedTools"] = allowedTools
294+
295+
mcpServers, ok := project["mcpServers"].(map[string]any)
296+
if !ok {
297+
mcpServers = make(map[string]any)
298+
}
299+
for name, mcp := range cfg.MCPServers {
300+
mcpServers[name] = mcp
301+
}
302+
project["mcpServers"] = mcpServers
303+
// Prevents Claude from asking the user to complete the project onboarding.
304+
project["hasCompletedProjectOnboarding"] = true
305+
projects[cfg.ProjectDirectory] = project
306+
config["projects"] = projects
307+
308+
jsonBytes, err := json.MarshalIndent(config, "", " ")
309+
if err != nil {
310+
return fmt.Errorf("failed to marshal claude config: %w", err)
311+
}
312+
err = afero.WriteFile(fs, cfg.ConfigPath, jsonBytes, 0644)
313+
if err != nil {
314+
return fmt.Errorf("failed to write claude config: %w", err)
315+
}
316+
return nil
317+
}

agent/agentclaude/agentclaude_test.go

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
package agentclaude
2+
3+
import (
4+
"testing"
5+
6+
"github.com/spf13/afero"
7+
"github.com/stretchr/testify/require"
8+
)
9+
10+
func TestConfigureClaude(t *testing.T) {
11+
t.Run("basic", func(t *testing.T) {
12+
fs := afero.NewMemMapFs()
13+
cfg := ClaudeConfig{
14+
ConfigPath: "/.claude.json",
15+
ProjectDirectory: "/home/coder/projects/coder/coder",
16+
APIKey: "test-api-key",
17+
}
18+
err := configureClaude(fs, cfg)
19+
require.NoError(t, err)
20+
21+
jsonBytes, err := afero.ReadFile(fs, cfg.ConfigPath)
22+
require.NoError(t, err)
23+
24+
require.Equal(t, `{
25+
"bypassPermissionsModeAccepted": true,
26+
"hasCompletedOnboarding": true,
27+
"primaryApiKey": "test-api-key",
28+
"projects": {
29+
"/home/coder/projects/coder/coder": {
30+
"allowedTools": [],
31+
"hasCompletedProjectOnboarding": true,
32+
"mcpServers": {}
33+
}
34+
}
35+
}`, string(jsonBytes))
36+
})
37+
38+
t.Run("override existing config", func(t *testing.T) {
39+
fs := afero.NewMemMapFs()
40+
afero.WriteFile(fs, "/.claude.json", []byte(`{
41+
"bypassPermissionsModeAccepted": false,
42+
"hasCompletedOnboarding": false,
43+
"primaryApiKey": "magic-api-key"
44+
}`), 0644)
45+
cfg := ClaudeConfig{
46+
ConfigPath: "/.claude.json",
47+
ProjectDirectory: "/home/coder/projects/coder/coder",
48+
APIKey: "test-api-key",
49+
}
50+
err := configureClaude(fs, cfg)
51+
require.NoError(t, err)
52+
53+
jsonBytes, err := afero.ReadFile(fs, cfg.ConfigPath)
54+
require.NoError(t, err)
55+
56+
require.Equal(t, `{
57+
"bypassPermissionsModeAccepted": true,
58+
"hasCompletedOnboarding": true,
59+
"primaryApiKey": "test-api-key",
60+
"projects": {
61+
"/home/coder/projects/coder/coder": {
62+
"allowedTools": [],
63+
"hasCompletedProjectOnboarding": true,
64+
"mcpServers": {}
65+
}
66+
}
67+
}`, string(jsonBytes))
68+
})
69+
}

0 commit comments

Comments
 (0)