From 3ef00bced1b868b14b9bc557f11931f01b5a4faa Mon Sep 17 00:00:00 2001 From: Gros Frumos Date: Sun, 15 Mar 2026 17:11:38 +0200 Subject: [PATCH 01/11] Add SPA static serving and open CORS for Tailscale access Co-Authored-By: Claude Opus 4.6 --- web/api.py | 22 ++++++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/web/api.py b/web/api.py index 6536a77..8ddc61d 100644 --- a/web/api.py +++ b/web/api.py @@ -12,7 +12,8 @@ sys.path.insert(0, str(Path(__file__).parent.parent)) from fastapi import FastAPI, HTTPException, Query from fastapi.middleware.cors import CORSMiddleware -from fastapi.responses import JSONResponse +from fastapi.responses import JSONResponse, FileResponse +from fastapi.staticfiles import StaticFiles from pydantic import BaseModel from core.db import init_db @@ -28,7 +29,7 @@ app = FastAPI(title="Kin API", version="0.1.0") app.add_middleware( CORSMiddleware, - allow_origins=["http://localhost:5173", "http://127.0.0.1:5173"], + allow_origins=["*"], allow_methods=["*"], allow_headers=["*"], ) @@ -414,3 +415,20 @@ def bootstrap(body: BootstrapRequest): "decisions_count": len(decisions) + len((obsidian or {}).get("decisions", [])), "tasks_count": len((obsidian or {}).get("tasks", [])), } + + +# --------------------------------------------------------------------------- +# SPA static files (AFTER all /api/ routes) +# --------------------------------------------------------------------------- + +DIST = Path(__file__).parent / "frontend" / "dist" + +app.mount("/assets", StaticFiles(directory=str(DIST / "assets")), name="assets") + + +@app.get("/{path:path}") +async def serve_spa(path: str): + file = DIST / path + if file.exists() and file.is_file(): + return FileResponse(file) + return FileResponse(DIST / "index.html") From 03961500e69787b6d6dcd386534a21b97604250d Mon Sep 17 00:00:00 2001 From: Gros Frumos Date: Sun, 15 Mar 2026 17:13:37 +0200 Subject: [PATCH 02/11] Use relative API paths for Tailscale access Replaced hardcoded http://localhost:8420/api with /api so the frontend works from any host (Tailscale, LAN, etc). Co-Authored-By: Claude Opus 4.6 --- web/frontend/src/api.ts | 2 +- web/frontend/src/views/ProjectView.vue | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/web/frontend/src/api.ts b/web/frontend/src/api.ts index 89afee3..c912a0f 100644 --- a/web/frontend/src/api.ts +++ b/web/frontend/src/api.ts @@ -1,4 +1,4 @@ -const BASE = 'http://localhost:8420/api' +const BASE = '/api' async function get(path: string): Promise { const res = await fetch(`${BASE}${path}`) diff --git a/web/frontend/src/views/ProjectView.vue b/web/frontend/src/views/ProjectView.vue index 06e608f..d225ca3 100644 --- a/web/frontend/src/views/ProjectView.vue +++ b/web/frontend/src/views/ProjectView.vue @@ -133,7 +133,7 @@ async function addDecision() { category: decForm.value.category || undefined, tags, } - const res = await fetch('http://localhost:8420/api/decisions', { + const res = await fetch('/api/decisions', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), From e755a19633c3b5f9ba71f00b544aa20592dfa0e0 Mon Sep 17 00:00:00 2001 From: Gros Frumos Date: Sun, 15 Mar 2026 17:35:08 +0200 Subject: [PATCH 03/11] Add Auto/Review mode toggle and non-interactive runner - GUI: Auto/Review toggle on TaskDetail and ProjectView persisted per-project in localStorage - Runner: noninteractive param (stdin=DEVNULL, 300s timeout) activated by KIN_NONINTERACTIVE=1 env or param - CLI: --allow-write flag for kin run command - API: POST /run accepts {allow_write: bool}, sets KIN_NONINTERACTIVE=1 and stdin=DEVNULL for subprocess - Fixes pipeline hanging on interactive claude input (VDOL-002) Co-Authored-By: Claude Opus 4.6 --- agents/runner.py | 15 +++++-- cli/main.py | 14 ++++-- tests/test_api.py | 18 ++++++++ tests/test_runner.py | 61 ++++++++++++++++++++++++++ web/api.py | 20 +++++++-- web/frontend/src/api.ts | 4 +- web/frontend/src/views/ProjectView.vue | 34 +++++++++++--- web/frontend/src/views/TaskDetail.vue | 26 ++++++++++- 8 files changed, 174 insertions(+), 18 deletions(-) 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() { - +
+ + +
No tasks.
diff --git a/web/frontend/src/views/TaskDetail.vue b/web/frontend/src/views/TaskDetail.vue index 70b6503..b3a3659 100644 --- a/web/frontend/src/views/TaskDetail.vue +++ b/web/frontend/src/views/TaskDetail.vue @@ -25,10 +25,25 @@ const resolvingAction = ref(false) const showReject = ref(false) const rejectReason = ref('') +// Auto/Review mode (persisted per project) +const autoMode = ref(false) + +function loadMode(projectId: string) { + autoMode.value = localStorage.getItem(`kin-mode-${projectId}`) === 'auto' +} + +function toggleMode() { + autoMode.value = !autoMode.value + if (task.value) { + localStorage.setItem(`kin-mode-${task.value.project_id}`, autoMode.value ? 'auto' : 'review') + } +} + async function load() { try { const prev = task.value task.value = await api.taskFull(props.id) + if (task.value?.project_id) loadMode(task.value.project_id) // Auto-start polling if task is in_progress if (task.value.status === 'in_progress' && !polling.value) { startPolling() @@ -160,7 +175,7 @@ async function reject() { async function runPipeline() { try { - await api.runTask(props.id) + await api.runTask(props.id, autoMode.value) startPolling() await load() } catch (e: any) { @@ -270,6 +285,15 @@ const isRunning = computed(() => task.value?.status === 'in_progress') class="px-4 py-2 text-sm bg-red-900/50 text-red-400 border border-red-800 rounded hover:bg-red-900"> ✗ Reject +