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)
pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy