summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPeter Stone <thepeterstone@gmail.com>2026-03-08 20:16:00 +0000
committerPeter Stone <thepeterstone@gmail.com>2026-03-08 20:16:00 +0000
commit1f36e2312d316969db65a601ac7d9793fbc3bc4c (patch)
tree1d91358beaf910df23a5bd18b9dabbc3f59d448a
parent9955a2f10c034dac60bc17cde6b80b432e21d9d3 (diff)
feat: rename working_dir→project_dir; git sandbox execution
- ClaudeConfig.WorkingDir → ProjectDir (json: project_dir) - UnmarshalJSON fallback reads legacy working_dir from DB records - New executions with project_dir clone into a temp sandbox via git clone --local - Non-git project_dirs get git init + initial commit before clone - After success: verify clean working tree, merge --ff-only back to project_dir, remove sandbox - On failure/BLOCKED: sandbox preserved, path included in error message - Resume executions run directly in project_dir (no re-clone) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
-rw-r--r--internal/api/elaborate.go12
-rw-r--r--internal/api/elaborate_test.go2
-rw-r--r--internal/api/validate.go6
-rw-r--r--internal/cli/create.go14
-rw-r--r--internal/executor/claude.go114
-rw-r--r--internal/executor/claude_test.go6
-rw-r--r--internal/task/task.go26
-rw-r--r--internal/task/validator_test.go2
-rw-r--r--web/app.js38
-rw-r--r--web/index.html5
10 files changed, 181 insertions, 44 deletions
diff --git a/internal/api/elaborate.go b/internal/api/elaborate.go
index 00f3297..e480e00 100644
--- a/internal/api/elaborate.go
+++ b/internal/api/elaborate.go
@@ -14,9 +14,9 @@ import (
const elaborateTimeout = 30 * time.Second
func buildElaboratePrompt(workDir string) string {
- workDirLine := ` "working_dir": string — leave empty unless you have a specific reason to set it,`
+ workDirLine := ` "project_dir": string — leave empty unless you have a specific reason to set it,`
if workDir != "" {
- workDirLine = fmt.Sprintf(` "working_dir": string — use %q for tasks that operate on this codebase, empty string otherwise,`, workDir)
+ workDirLine = fmt.Sprintf(` "project_dir": string — use %q for tasks that operate on this codebase, empty string otherwise,`, workDir)
}
return `You are a task configuration assistant for Claudomator, an AI task runner that executes tasks by running Claude as a subprocess.
@@ -53,7 +53,7 @@ type elaboratedTask struct {
type elaboratedClaude struct {
Model string `json:"model"`
Instructions string `json:"instructions"`
- WorkingDir string `json:"working_dir"`
+ ProjectDir string `json:"project_dir"`
MaxBudgetUSD float64 `json:"max_budget_usd"`
AllowedTools []string `json:"allowed_tools"`
}
@@ -87,7 +87,7 @@ func (s *Server) claudeBinaryPath() string {
func (s *Server) handleElaborateTask(w http.ResponseWriter, r *http.Request) {
var input struct {
Prompt string `json:"prompt"`
- WorkingDir string `json:"working_dir"`
+ ProjectDir string `json:"project_dir"`
}
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON: " + err.Error()})
@@ -99,8 +99,8 @@ func (s *Server) handleElaborateTask(w http.ResponseWriter, r *http.Request) {
}
workDir := s.workDir
- if input.WorkingDir != "" {
- workDir = input.WorkingDir
+ if input.ProjectDir != "" {
+ workDir = input.ProjectDir
}
ctx, cancel := context.WithTimeout(r.Context(), elaborateTimeout)
diff --git a/internal/api/elaborate_test.go b/internal/api/elaborate_test.go
index 52f7fdf..09f7fbe 100644
--- a/internal/api/elaborate_test.go
+++ b/internal/api/elaborate_test.go
@@ -56,7 +56,7 @@ func TestElaborateTask_Success(t *testing.T) {
Claude: elaboratedClaude{
Model: "sonnet",
Instructions: "Run go test -race ./... and report results.",
- WorkingDir: "",
+ ProjectDir: "",
MaxBudgetUSD: 0.5,
AllowedTools: []string{"Bash"},
},
diff --git a/internal/api/validate.go b/internal/api/validate.go
index d8ebde9..4b691a9 100644
--- a/internal/api/validate.go
+++ b/internal/api/validate.go
@@ -56,7 +56,7 @@ func (s *Server) handleValidateTask(w http.ResponseWriter, r *http.Request) {
Name string `json:"name"`
Claude struct {
Instructions string `json:"instructions"`
- WorkingDir string `json:"working_dir"`
+ ProjectDir string `json:"project_dir"`
AllowedTools []string `json:"allowed_tools"`
} `json:"claude"`
}
@@ -74,8 +74,8 @@ func (s *Server) handleValidateTask(w http.ResponseWriter, r *http.Request) {
}
userMsg := fmt.Sprintf("Task name: %s\n\nInstructions:\n%s", input.Name, input.Claude.Instructions)
- if input.Claude.WorkingDir != "" {
- userMsg += fmt.Sprintf("\n\nWorking directory: %s", input.Claude.WorkingDir)
+ if input.Claude.ProjectDir != "" {
+ userMsg += fmt.Sprintf("\n\nWorking directory: %s", input.Claude.ProjectDir)
}
if len(input.Claude.AllowedTools) > 0 {
userMsg += fmt.Sprintf("\n\nAllowed tools: %v", input.Claude.AllowedTools)
diff --git a/internal/cli/create.go b/internal/cli/create.go
index fdad932..addd034 100644
--- a/internal/cli/create.go
+++ b/internal/cli/create.go
@@ -4,7 +4,7 @@ import (
"bytes"
"encoding/json"
"fmt"
- "net/http"
+ "io"
"github.com/spf13/cobra"
)
@@ -52,7 +52,7 @@ func createTask(serverURL, name, instructions, workingDir, model, parentID strin
"priority": priority,
"claude": map[string]interface{}{
"instructions": instructions,
- "working_dir": workingDir,
+ "project_dir": workingDir,
"model": model,
"max_budget_usd": budget,
},
@@ -62,20 +62,26 @@ func createTask(serverURL, name, instructions, workingDir, model, parentID strin
}
data, _ := json.Marshal(body)
- resp, err := http.Post(serverURL+"/api/tasks", "application/json", bytes.NewReader(data)) //nolint:noctx
+ resp, err := httpClient.Post(serverURL+"/api/tasks", "application/json", bytes.NewReader(data)) //nolint:noctx
if err != nil {
return fmt.Errorf("POST /api/tasks: %w", err)
}
defer resp.Body.Close()
+ raw, _ := io.ReadAll(resp.Body)
var result map[string]interface{}
- _ = json.NewDecoder(resp.Body).Decode(&result)
+ if err := json.Unmarshal(raw, &result); err != nil {
+ return fmt.Errorf("server returned invalid JSON (status %d): %s", resp.StatusCode, string(raw))
+ }
if resp.StatusCode >= 300 {
return fmt.Errorf("server returned %d: %v", resp.StatusCode, result["error"])
}
id, _ := result["id"].(string)
+ if id == "" {
+ return fmt.Errorf("server returned task without id field")
+ }
fmt.Printf("Created task %s\n", id)
if autoStart {
diff --git a/internal/executor/claude.go b/internal/executor/claude.go
index c04a747..aa715da 100644
--- a/internal/executor/claude.go
+++ b/internal/executor/claude.go
@@ -55,10 +55,18 @@ func (r *ClaudeRunner) binaryPath() string {
// Run executes a claude -p invocation, streaming output to log files.
// It retries up to 3 times on rate-limit errors using exponential backoff.
// If the agent writes a question file and exits, Run returns *BlockedError.
+//
+// When project_dir is set and this is not a resume execution, Run clones the
+// project into a temp sandbox, runs the agent there, then merges committed
+// changes back to project_dir. On failure the sandbox is preserved and its
+// path is included in the error.
func (r *ClaudeRunner) Run(ctx context.Context, t *task.Task, e *storage.Execution) error {
- if t.Claude.WorkingDir != "" {
- if _, err := os.Stat(t.Claude.WorkingDir); err != nil {
- return fmt.Errorf("working_dir %q: %w", t.Claude.WorkingDir, err)
+ projectDir := t.Claude.ProjectDir
+
+ // Validate project_dir exists when set.
+ if projectDir != "" {
+ if _, err := os.Stat(projectDir); err != nil {
+ return fmt.Errorf("project_dir %q: %w", projectDir, err)
}
}
@@ -82,6 +90,20 @@ func (r *ClaudeRunner) Run(ctx context.Context, t *task.Task, e *storage.Executi
e.SessionID = e.ID // reuse execution UUID as session UUID (both are UUIDs)
}
+ // For new (non-resume) executions with a project_dir, clone into a sandbox.
+ // Resume executions run directly in project_dir to pick up the previous session.
+ var sandboxDir string
+ effectiveWorkingDir := projectDir
+ if projectDir != "" && e.ResumeSessionID == "" {
+ var err error
+ sandboxDir, err = setupSandbox(projectDir)
+ if err != nil {
+ return fmt.Errorf("setting up sandbox: %w", err)
+ }
+ effectiveWorkingDir = sandboxDir
+ r.Logger.Info("sandbox created", "sandbox", sandboxDir, "project_dir", projectDir)
+ }
+
questionFile := filepath.Join(logDir, "question.json")
args := r.buildArgs(t, e, questionFile)
@@ -95,9 +117,12 @@ func (r *ClaudeRunner) Run(ctx context.Context, t *task.Task, e *storage.Executi
)
}
attempt++
- return r.execOnce(ctx, args, t.Claude.WorkingDir, e)
+ return r.execOnce(ctx, args, effectiveWorkingDir, e)
})
if err != nil {
+ if sandboxDir != "" {
+ return fmt.Errorf("%w (sandbox preserved at %s)", err, sandboxDir)
+ }
return err
}
@@ -105,8 +130,89 @@ func (r *ClaudeRunner) Run(ctx context.Context, t *task.Task, e *storage.Executi
data, readErr := os.ReadFile(questionFile)
if readErr == nil {
os.Remove(questionFile) // consumed
+ // Preserve sandbox on BLOCKED — agent may have partial work.
return &BlockedError{QuestionJSON: strings.TrimSpace(string(data)), SessionID: e.SessionID}
}
+
+ // Merge sandbox back to project_dir and clean up.
+ if sandboxDir != "" {
+ if mergeErr := teardownSandbox(projectDir, sandboxDir, r.Logger); mergeErr != nil {
+ return fmt.Errorf("sandbox teardown: %w (sandbox preserved at %s)", mergeErr, sandboxDir)
+ }
+ }
+ return nil
+}
+
+// setupSandbox prepares a temporary git clone of projectDir.
+// If projectDir is not a git repo it is initialised with an initial commit first.
+func setupSandbox(projectDir string) (string, error) {
+ // Ensure projectDir is a git repo; initialise if not.
+ check := exec.Command("git", "-C", projectDir, "rev-parse", "--git-dir")
+ if err := check.Run(); err != nil {
+ // Not a git repo — init and commit everything.
+ cmds := [][]string{
+ {"git", "-C", projectDir, "init"},
+ {"git", "-C", projectDir, "add", "-A"},
+ {"git", "-C", projectDir, "commit", "--allow-empty", "-m", "chore: initial commit"},
+ }
+ for _, args := range cmds {
+ if out, err := exec.Command(args[0], args[1:]...).CombinedOutput(); err != nil { //nolint:gosec
+ return "", fmt.Errorf("git init %s: %w\n%s", projectDir, err, out)
+ }
+ }
+ }
+
+ tempDir, err := os.MkdirTemp("", "claudomator-sandbox-*")
+ if err != nil {
+ return "", fmt.Errorf("creating sandbox dir: %w", err)
+ }
+
+ // Clone into the pre-created dir (git clone requires the target to not exist,
+ // so remove it first and let git recreate it).
+ if err := os.Remove(tempDir); err != nil {
+ return "", fmt.Errorf("removing temp dir placeholder: %w", err)
+ }
+ out, err := exec.Command("git", "clone", "--local", projectDir, tempDir).CombinedOutput()
+ if err != nil {
+ return "", fmt.Errorf("git clone: %w\n%s", err, out)
+ }
+ return tempDir, nil
+}
+
+// teardownSandbox verifies the sandbox is clean, merges commits back to
+// projectDir via fast-forward, then removes the sandbox.
+func teardownSandbox(projectDir, sandboxDir string, logger *slog.Logger) error {
+ // Fail if agent left uncommitted changes.
+ out, err := exec.Command("git", "-C", sandboxDir, "status", "--porcelain").Output()
+ if err != nil {
+ return fmt.Errorf("git status: %w", err)
+ }
+ if len(strings.TrimSpace(string(out))) > 0 {
+ return fmt.Errorf("uncommitted changes in sandbox (agent must commit all work):\n%s", out)
+ }
+
+ // Check whether there are any new commits to merge.
+ ahead, err := exec.Command("git", "-C", sandboxDir, "rev-list", "--count", "origin/HEAD..HEAD").Output()
+ if err != nil {
+ // No origin/HEAD (e.g. fresh init with no prior commits) — proceed anyway.
+ logger.Warn("could not determine commits ahead of origin; proceeding with merge", "err", err)
+ }
+ if strings.TrimSpace(string(ahead)) == "0" {
+ // Nothing to merge — clean up and return.
+ os.RemoveAll(sandboxDir)
+ return nil
+ }
+
+ // Fetch new commits from sandbox into project_dir and fast-forward merge.
+ if out, err := exec.Command("git", "-C", projectDir, "fetch", sandboxDir, "HEAD").CombinedOutput(); err != nil {
+ return fmt.Errorf("git fetch from sandbox: %w\n%s", err, out)
+ }
+ if out, err := exec.Command("git", "-C", projectDir, "merge", "--ff-only", "FETCH_HEAD").CombinedOutput(); err != nil {
+ return fmt.Errorf("git merge --ff-only FETCH_HEAD: %w\n%s", err, out)
+ }
+
+ logger.Info("sandbox merged and cleaned up", "sandbox", sandboxDir, "project_dir", projectDir)
+ os.RemoveAll(sandboxDir)
return nil
}
diff --git a/internal/executor/claude_test.go b/internal/executor/claude_test.go
index 056c7e8..31dcf52 100644
--- a/internal/executor/claude_test.go
+++ b/internal/executor/claude_test.go
@@ -224,7 +224,7 @@ func TestClaudeRunner_Run_InaccessibleWorkingDir_ReturnsError(t *testing.T) {
}
tk := &task.Task{
Claude: task.ClaudeConfig{
- WorkingDir: "/nonexistent/path/does/not/exist",
+ ProjectDir: "/nonexistent/path/does/not/exist",
SkipPlanning: true,
},
}
@@ -235,8 +235,8 @@ func TestClaudeRunner_Run_InaccessibleWorkingDir_ReturnsError(t *testing.T) {
if err == nil {
t.Fatal("expected error for inaccessible working_dir, got nil")
}
- if !strings.Contains(err.Error(), "working_dir") {
- t.Errorf("expected 'working_dir' in error, got: %v", err)
+ if !strings.Contains(err.Error(), "project_dir") {
+ t.Errorf("expected 'project_dir' in error, got: %v", err)
}
}
diff --git a/internal/task/task.go b/internal/task/task.go
index f6635cc..498c364 100644
--- a/internal/task/task.go
+++ b/internal/task/task.go
@@ -1,6 +1,9 @@
package task
-import "time"
+import (
+ "encoding/json"
+ "time"
+)
type State string
@@ -29,7 +32,7 @@ type ClaudeConfig struct {
Model string `yaml:"model" json:"model"`
ContextFiles []string `yaml:"context_files" json:"context_files"`
Instructions string `yaml:"instructions" json:"instructions"`
- WorkingDir string `yaml:"working_dir" json:"working_dir"`
+ ProjectDir string `yaml:"project_dir" json:"project_dir"`
MaxBudgetUSD float64 `yaml:"max_budget_usd" json:"max_budget_usd"`
PermissionMode string `yaml:"permission_mode" json:"permission_mode"`
AllowedTools []string `yaml:"allowed_tools" json:"allowed_tools"`
@@ -39,6 +42,25 @@ type ClaudeConfig struct {
SkipPlanning bool `yaml:"skip_planning" json:"skip_planning"`
}
+// UnmarshalJSON reads project_dir with fallback to legacy working_dir.
+func (c *ClaudeConfig) UnmarshalJSON(data []byte) error {
+ type Alias ClaudeConfig
+ aux := &struct {
+ ProjectDir string `json:"project_dir"`
+ WorkingDir string `json:"working_dir"` // legacy
+ *Alias
+ }{Alias: (*Alias)(c)}
+ if err := json.Unmarshal(data, aux); err != nil {
+ return err
+ }
+ if aux.ProjectDir != "" {
+ c.ProjectDir = aux.ProjectDir
+ } else {
+ c.ProjectDir = aux.WorkingDir
+ }
+ return nil
+}
+
type RetryConfig struct {
MaxAttempts int `yaml:"max_attempts" json:"max_attempts"`
Backoff string `yaml:"backoff" json:"backoff"` // "linear", "exponential"
diff --git a/internal/task/validator_test.go b/internal/task/validator_test.go
index 967eed3..02bde45 100644
--- a/internal/task/validator_test.go
+++ b/internal/task/validator_test.go
@@ -11,7 +11,7 @@ func validTask() *Task {
Name: "Valid Task",
Claude: ClaudeConfig{
Instructions: "do something",
- WorkingDir: "/tmp",
+ ProjectDir: "/tmp",
},
Priority: PriorityNormal,
Retry: RetryConfig{MaxAttempts: 1, Backoff: "exponential"},
diff --git a/web/app.js b/web/app.js
index 3b807c4..28d438a 100644
--- a/web/app.js
+++ b/web/app.js
@@ -228,9 +228,10 @@ function sortTasksByDate(tasks) {
// ── Filter ────────────────────────────────────────────────────────────────────
-const HIDE_STATES = new Set(['COMPLETED', 'FAILED']);
-const ACTIVE_STATES = new Set(['PENDING', 'QUEUED', 'RUNNING', 'READY', 'BLOCKED']);
-const DONE_STATES = new Set(['COMPLETED', 'FAILED', 'TIMED_OUT', 'CANCELLED', 'BUDGET_EXCEEDED']);
+const HIDE_STATES = new Set(['COMPLETED', 'FAILED']);
+const ACTIVE_STATES = new Set(['PENDING', 'QUEUED', 'RUNNING', 'READY', 'BLOCKED']);
+const INTERRUPTED_STATES = new Set(['CANCELLED', 'FAILED']);
+const DONE_STATES = new Set(['COMPLETED', 'TIMED_OUT', 'BUDGET_EXCEEDED']);
// filterActiveTasks uses its own set (excludes PENDING — tasks "in-flight" only)
const _PANEL_ACTIVE_STATES = new Set(['RUNNING', 'READY', 'QUEUED', 'BLOCKED']);
@@ -245,8 +246,9 @@ export function filterActiveTasks(tasks) {
}
export function filterTasksByTab(tasks, tab) {
- if (tab === 'active') return tasks.filter(t => ACTIVE_STATES.has(t.state));
- if (tab === 'done') return tasks.filter(t => DONE_STATES.has(t.state));
+ if (tab === 'active') return tasks.filter(t => ACTIVE_STATES.has(t.state));
+ if (tab === 'interrupted') return tasks.filter(t => INTERRUPTED_STATES.has(t.state));
+ if (tab === 'done') return tasks.filter(t => DONE_STATES.has(t.state));
return tasks;
}
@@ -517,7 +519,7 @@ function createEditForm(task) {
form.appendChild(makeField('Description', 'textarea', { name: 'description', rows: '2', value: task.description || '' }));
form.appendChild(makeField('Instructions', 'textarea', { name: 'instructions', rows: '4', value: c.instructions || '' }));
form.appendChild(makeField('Model', 'input', { type: 'text', name: 'model', value: c.model || 'sonnet' }));
- form.appendChild(makeField('Working Directory', 'input', { type: 'text', name: 'working_dir', value: c.working_dir || '', placeholder: '/path/to/repo' }));
+ form.appendChild(makeField('Working Directory', 'input', { type: 'text', name: 'project_dir', value: c.project_dir || '', placeholder: '/path/to/repo' }));
form.appendChild(makeField('Max Budget (USD)', 'input', { type: 'number', name: 'max_budget_usd', step: '0.01', value: c.max_budget_usd != null ? String(c.max_budget_usd) : '1.00' }));
form.appendChild(makeField('Timeout', 'input', { type: 'text', name: 'timeout', value: formatDurationForInput(task.timeout) || '15m', placeholder: '15m' }));
@@ -569,7 +571,7 @@ async function handleEditSave(taskId, form, saveBtn) {
claude: {
model: get('model'),
instructions: get('instructions'),
- working_dir: get('working_dir'),
+ project_dir: get('project_dir'),
max_budget_usd: parseFloat(get('max_budget_usd')),
},
timeout: get('timeout'),
@@ -1018,7 +1020,7 @@ async function elaborateTask(prompt, workingDir) {
const res = await fetch(`${API_BASE}/api/tasks/elaborate`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ prompt, working_dir: workingDir }),
+ body: JSON.stringify({ prompt, project_dir: workingDir }),
});
if (!res.ok) {
let msg = `HTTP ${res.status}`;
@@ -1048,13 +1050,13 @@ function buildValidatePayload() {
const f = document.getElementById('task-form');
const name = f.querySelector('[name="name"]').value;
const instructions = f.querySelector('[name="instructions"]').value;
- const working_dir = f.querySelector('[name="working_dir"]').value;
+ const project_dir = f.querySelector('[name="project_dir"]').value;
const model = f.querySelector('[name="model"]').value;
const allowedToolsEl = f.querySelector('[name="allowed_tools"]');
const allowed_tools = allowedToolsEl
? allowedToolsEl.value.split(',').map(s => s.trim()).filter(Boolean)
: [];
- return { name, claude: { instructions, working_dir, model, allowed_tools } };
+ return { name, claude: { instructions, project_dir, model, allowed_tools } };
}
function renderValidationResult(result) {
@@ -1167,7 +1169,7 @@ function closeTaskModal() {
}
async function createTask(formData) {
- const selectVal = formData.get('working_dir');
+ const selectVal = formData.get('project_dir');
const workingDir = selectVal === '__new__'
? document.getElementById('new-project-input').value.trim()
: selectVal;
@@ -1177,7 +1179,7 @@ async function createTask(formData) {
claude: {
model: formData.get('model'),
instructions: formData.get('instructions'),
- working_dir: workingDir,
+ project_dir: workingDir,
max_budget_usd: parseFloat(formData.get('max_budget_usd')),
},
timeout: formData.get('timeout'),
@@ -1221,7 +1223,7 @@ async function saveTemplate(formData) {
claude: {
model: formData.get('model'),
instructions: formData.get('instructions'),
- working_dir: formData.get('working_dir'),
+ project_dir: formData.get('project_dir'),
max_budget_usd: parseFloat(formData.get('max_budget_usd')),
allowed_tools: splitTrim(formData.get('allowed_tools') || ''),
},
@@ -1401,7 +1403,7 @@ function renderTaskPanel(task, executions) {
claudeGrid.append(
makeMetaItem('Model', c.model),
makeMetaItem('Max Budget', c.max_budget_usd != null ? `$${c.max_budget_usd.toFixed(2)}` : '—'),
- makeMetaItem('Working Dir', c.working_dir),
+ makeMetaItem('Project Dir', c.project_dir),
makeMetaItem('Permission Mode', c.permission_mode || 'default'),
);
if (c.allowed_tools && c.allowed_tools.length > 0) {
@@ -2071,15 +2073,15 @@ if (typeof document !== 'undefined') document.addEventListener('DOMContentLoaded
f.querySelector('[name="name"]').value = result.name;
if (result.claude && result.claude.instructions)
f.querySelector('[name="instructions"]').value = result.claude.instructions;
- if (result.claude && result.claude.working_dir) {
+ if (result.claude && result.claude.project_dir) {
const sel = document.getElementById('project-select');
- const exists = [...sel.options].some(o => o.value === result.claude.working_dir);
+ const exists = [...sel.options].some(o => o.value === result.claude.project_dir);
if (exists) {
- sel.value = result.claude.working_dir;
+ sel.value = result.claude.project_dir;
} else {
sel.value = '__new__';
document.getElementById('new-project-row').hidden = false;
- document.getElementById('new-project-input').value = result.claude.working_dir;
+ document.getElementById('new-project-input').value = result.claude.project_dir;
}
}
if (result.claude && result.claude.model)
diff --git a/web/index.html b/web/index.html
index 3b7901c..842c272 100644
--- a/web/index.html
+++ b/web/index.html
@@ -24,6 +24,7 @@
<div data-panel="tasks" hidden>
<div class="task-list-toolbar">
<button class="filter-tab active" data-filter="active">Active</button>
+ <button class="filter-tab" data-filter="interrupted">Interrupted</button>
<button class="filter-tab" data-filter="done">Done</button>
<button class="filter-tab" data-filter="all">All</button>
</div>
@@ -62,7 +63,7 @@
</div>
<hr class="form-divider">
<label>Project
- <select name="working_dir" id="project-select">
+ <select name="project_dir" id="project-select">
<option value="/workspace/claudomator" selected>/workspace/claudomator</option>
</select>
</label>
@@ -101,7 +102,7 @@
<label>Description <textarea name="description" rows="2"></textarea></label>
<label>Model <input name="model" value="sonnet"></label>
<label>Instructions <textarea name="instructions" rows="6" required></textarea></label>
- <label>Working Directory <input name="working_dir" placeholder="/path/to/repo"></label>
+ <label>Project Directory <input name="project_dir" placeholder="/path/to/repo"></label>
<label>Max Budget (USD) <input name="max_budget_usd" type="number" step="0.01" value="1.00"></label>
<label>Allowed Tools <input name="allowed_tools" placeholder="Bash, Read, Write"></label>
<label>Timeout <input name="timeout" value="15m"></label>