diff --git a/agents/runner.py b/agents/runner.py index d5c6c1a..90a4d84 100644 --- a/agents/runner.py +++ b/agents/runner.py @@ -4,6 +4,7 @@ Each agent = separate process with isolated context. """ import json +import os import sqlite3 import subprocess import time @@ -24,6 +25,7 @@ def run_agent( brief_override: str | None = None, dry_run: bool = False, allow_write: bool = False, + noninteractive: bool = False, ) -> dict: """Run a single Claude Code agent as a subprocess. @@ -64,7 +66,7 @@ def run_agent( # Run claude subprocess start = time.monotonic() result = _run_claude(prompt, model=model, working_dir=working_dir, - allow_write=allow_write) + allow_write=allow_write, noninteractive=noninteractive) duration = int(time.monotonic() - start) # Parse output — ensure output_text is always a string for DB storage @@ -109,6 +111,7 @@ def _run_claude( model: str = "sonnet", working_dir: str | None = None, allow_write: bool = False, + noninteractive: bool = False, ) -> dict: """Execute claude CLI as subprocess. Returns dict with output, returncode, etc.""" cmd = [ @@ -120,13 +123,17 @@ def _run_claude( if allow_write: cmd.append("--dangerously-skip-permissions") + is_noninteractive = noninteractive or os.environ.get("KIN_NONINTERACTIVE") == "1" + timeout = 300 if is_noninteractive else 600 + try: proc = subprocess.run( cmd, capture_output=True, text=True, - timeout=600, # 10 min max + timeout=timeout, cwd=working_dir, + stdin=subprocess.DEVNULL if is_noninteractive else None, ) except FileNotFoundError: return { @@ -137,7 +144,7 @@ def _run_claude( except subprocess.TimeoutExpired: return { "output": "", - "error": "Agent timed out after 600s", + "error": f"Agent timed out after {timeout}s", "returncode": 124, } @@ -213,6 +220,7 @@ def run_pipeline( steps: list[dict], dry_run: bool = False, allow_write: bool = False, + noninteractive: bool = False, ) -> dict: """Execute a multi-step pipeline of agents. @@ -260,6 +268,7 @@ def run_pipeline( brief_override=brief, dry_run=dry_run, allow_write=allow_write, + noninteractive=noninteractive, ) results.append(result) diff --git a/cli/main.py b/cli/main.py index 8ed3281..8231a8e 100644 --- a/cli/main.py +++ b/cli/main.py @@ -4,6 +4,7 @@ Uses core.models for all data access, never raw SQL. """ import json +import os import sys from pathlib import Path @@ -481,8 +482,9 @@ def approve_task(ctx, task_id, followup, decision_text): @cli.command("run") @click.argument("task_id") @click.option("--dry-run", is_flag=True, help="Show pipeline plan without executing") +@click.option("--allow-write", is_flag=True, help="Allow agents to write files (skip permissions)") @click.pass_context -def run_task(ctx, task_id, dry_run): +def run_task(ctx, task_id, dry_run, allow_write): """Run a task through the agent pipeline. PM decomposes the task into specialist steps, then the pipeline executes. @@ -497,6 +499,7 @@ def run_task(ctx, task_id, dry_run): raise SystemExit(1) project_id = task["project_id"] + is_noninteractive = os.environ.get("KIN_NONINTERACTIVE") == "1" click.echo(f"Task: {task['id']} — {task['title']}") # Step 1: PM decomposes @@ -504,6 +507,7 @@ def run_task(ctx, task_id, dry_run): pm_result = run_agent( conn, "pm", task_id, project_id, model="sonnet", dry_run=dry_run, + allow_write=allow_write, noninteractive=is_noninteractive, ) if dry_run: @@ -537,13 +541,17 @@ def run_task(ctx, task_id, dry_run): for i, step in enumerate(pipeline_steps, 1): click.echo(f" {i}. {step['role']} ({step.get('model', 'sonnet')}): {step.get('brief', '')}") - if not click.confirm("\nExecute pipeline?"): + if is_noninteractive: + click.echo("\n[non-interactive] Auto-executing pipeline...") + elif not click.confirm("\nExecute pipeline?"): click.echo("Aborted.") return # Step 2: Execute pipeline click.echo("\nExecuting pipeline...") - result = run_pipeline(conn, task_id, pipeline_steps) + result = run_pipeline(conn, task_id, pipeline_steps, + allow_write=allow_write, + noninteractive=is_noninteractive) if result["success"]: click.echo(f"\nPipeline completed: {result['steps_completed']} steps") diff --git a/tests/test_api.py b/tests/test_api.py index 8d7ea42..2e57c32 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -173,6 +173,24 @@ def test_run_not_found(client): assert r.status_code == 404 +def test_run_with_allow_write(client): + """POST /run with allow_write=true should be accepted.""" + r = client.post("/api/tasks/P1-001/run", json={"allow_write": True}) + assert r.status_code == 202 + + +def test_run_with_empty_body(client): + """POST /run with empty JSON body should default allow_write=false.""" + r = client.post("/api/tasks/P1-001/run", json={}) + assert r.status_code == 202 + + +def test_run_without_body(client): + """POST /run without body should be backwards-compatible.""" + r = client.post("/api/tasks/P1-001/run") + assert r.status_code == 202 + + def test_project_summary_includes_review(client): from core.db import init_db from core import models diff --git a/tests/test_runner.py b/tests/test_runner.py index f1dd4cd..7b42b38 100644 --- a/tests/test_runner.py +++ b/tests/test_runner.py @@ -1,6 +1,7 @@ """Tests for agents/runner.py — agent execution with mocked claude CLI.""" import json +import subprocess import pytest from unittest.mock import patch, MagicMock from core.db import init_db @@ -274,3 +275,63 @@ class TestTryParseJson: def test_json_array(self): assert _try_parse_json('[1, 2, 3]') == [1, 2, 3] + + +# --------------------------------------------------------------------------- +# Non-interactive mode +# --------------------------------------------------------------------------- + +class TestNonInteractive: + @patch("agents.runner.subprocess.run") + def test_noninteractive_sets_stdin_devnull(self, mock_run, conn): + """When noninteractive=True, subprocess.run should get stdin=subprocess.DEVNULL.""" + mock_run.return_value = _mock_claude_success({"result": "ok"}) + run_agent(conn, "debugger", "VDOL-001", "vdol", noninteractive=True) + call_kwargs = mock_run.call_args[1] + assert call_kwargs.get("stdin") == subprocess.DEVNULL + + @patch("agents.runner.subprocess.run") + def test_noninteractive_uses_300s_timeout(self, mock_run, conn): + mock_run.return_value = _mock_claude_success({"result": "ok"}) + run_agent(conn, "debugger", "VDOL-001", "vdol", noninteractive=True) + call_kwargs = mock_run.call_args[1] + assert call_kwargs.get("timeout") == 300 + + @patch("agents.runner.subprocess.run") + def test_interactive_uses_600s_timeout(self, mock_run, conn): + mock_run.return_value = _mock_claude_success({"result": "ok"}) + run_agent(conn, "debugger", "VDOL-001", "vdol", noninteractive=False) + call_kwargs = mock_run.call_args[1] + assert call_kwargs.get("timeout") == 600 + + @patch("agents.runner.subprocess.run") + def test_interactive_no_stdin_override(self, mock_run, conn): + """In interactive mode, stdin should not be set to DEVNULL.""" + mock_run.return_value = _mock_claude_success({"result": "ok"}) + run_agent(conn, "debugger", "VDOL-001", "vdol", noninteractive=False) + call_kwargs = mock_run.call_args[1] + assert call_kwargs.get("stdin") is None + + @patch.dict("os.environ", {"KIN_NONINTERACTIVE": "1"}) + @patch("agents.runner.subprocess.run") + def test_env_var_activates_noninteractive(self, mock_run, conn): + """KIN_NONINTERACTIVE=1 env var should activate non-interactive mode.""" + mock_run.return_value = _mock_claude_success({"result": "ok"}) + run_agent(conn, "debugger", "VDOL-001", "vdol", noninteractive=False) + call_kwargs = mock_run.call_args[1] + assert call_kwargs.get("stdin") == subprocess.DEVNULL + assert call_kwargs.get("timeout") == 300 + + @patch("agents.runner.subprocess.run") + def test_allow_write_adds_skip_permissions(self, mock_run, conn): + mock_run.return_value = _mock_claude_success({"result": "ok"}) + run_agent(conn, "debugger", "VDOL-001", "vdol", allow_write=True) + cmd = mock_run.call_args[0][0] + assert "--dangerously-skip-permissions" in cmd + + @patch("agents.runner.subprocess.run") + def test_no_allow_write_no_skip_permissions(self, mock_run, conn): + mock_run.return_value = _mock_claude_success({"result": "ok"}) + run_agent(conn, "debugger", "VDOL-001", "vdol", allow_write=False) + cmd = mock_run.call_args[0][0] + assert "--dangerously-skip-permissions" not in cmd diff --git a/web/api.py b/web/api.py index 8ddc61d..3e2771c 100644 --- a/web/api.py +++ b/web/api.py @@ -276,8 +276,12 @@ def is_task_running(task_id: str): return {"running": False} +class TaskRun(BaseModel): + allow_write: bool = False + + @app.post("/api/tasks/{task_id}/run") -def run_task(task_id: str): +def run_task(task_id: str, body: TaskRun | None = None): """Launch pipeline for a task in background. Returns 202.""" conn = get_conn() t = models.get_task(conn, task_id) @@ -289,12 +293,22 @@ def run_task(task_id: str): conn.close() # Launch kin run in background subprocess kin_root = Path(__file__).parent.parent + cmd = [sys.executable, "-m", "cli.main", "--db", str(DB_PATH), + "run", task_id] + if body and body.allow_write: + cmd.append("--allow-write") + + import os + env = os.environ.copy() + env["KIN_NONINTERACTIVE"] = "1" + try: proc = subprocess.Popen( - [sys.executable, "-m", "cli.main", "--db", str(DB_PATH), - "run", task_id], + cmd, cwd=str(kin_root), stdout=subprocess.DEVNULL, + stdin=subprocess.DEVNULL, + env=env, ) import logging logging.getLogger("kin").info(f"Pipeline started for {task_id}, pid={proc.pid}") diff --git a/web/frontend/src/api.ts b/web/frontend/src/api.ts index c912a0f..3ed2d66 100644 --- a/web/frontend/src/api.ts +++ b/web/frontend/src/api.ts @@ -125,8 +125,8 @@ export const api = { post<{ choice: string; result: unknown }>(`/tasks/${id}/resolve`, { action, choice }), rejectTask: (id: string, reason: string) => post<{ status: string }>(`/tasks/${id}/reject`, { reason }), - runTask: (id: string) => - post<{ status: string }>(`/tasks/${id}/run`, {}), + runTask: (id: string, allowWrite = false) => + post<{ status: string }>(`/tasks/${id}/run`, { allow_write: allowWrite }), bootstrap: (data: { path: string; id: string; name: string }) => post<{ project: Project }>('/bootstrap', data), } diff --git a/web/frontend/src/views/ProjectView.vue b/web/frontend/src/views/ProjectView.vue index d225ca3..6fb8c05 100644 --- a/web/frontend/src/views/ProjectView.vue +++ b/web/frontend/src/views/ProjectView.vue @@ -16,6 +16,18 @@ const taskStatusFilter = ref('') const decisionTypeFilter = ref('') const decisionSearch = ref('') +// Auto/Review mode (persisted per project) +const autoMode = ref(false) + +function loadMode() { + autoMode.value = localStorage.getItem(`kin-mode-${props.id}`) === 'auto' +} + +function toggleMode() { + autoMode.value = !autoMode.value + localStorage.setItem(`kin-mode-${props.id}`, autoMode.value ? 'auto' : 'review') +} + // Add task modal const showAddTask = ref(false) const taskForm = ref({ title: '', priority: 5, route_type: '' }) @@ -37,7 +49,7 @@ async function load() { } } -onMounted(load) +onMounted(() => { load(); loadMode() }) const filteredTasks = computed(() => { if (!project.value) return [] @@ -114,7 +126,7 @@ async function runTask(taskId: string, event: Event) { event.stopPropagation() if (!confirm(`Run pipeline for ${taskId}?`)) return try { - await api.runTask(taskId) + await api.runTask(taskId, autoMode.value) await load() } catch (e: any) { error.value = e.message @@ -195,10 +207,20 @@ async function addDecision() { - +