diff --git a/dashboard/app.js b/dashboard/app.js index 30985cf..02d3a60 100644 --- a/dashboard/app.js +++ b/dashboard/app.js @@ -262,10 +262,17 @@ function renderPipeline() { card.appendChild(el("span", "state-chip", stateLabel(primary))); card.appendChild(el("div", "agent-runtime", primary.runtime || "\u2014")); - // Click handler for detail drawer - card.addEventListener("click", (function(k) { - return function() { selectAgent(k); }; - })(key)); + // Click + keyboard handler — enter focus mode + card.setAttribute("tabindex", "0"); + card.setAttribute("role", "button"); + card.addEventListener("click", (function(ws, ag) { + return function() { enterFocusMode(ws, ag); }; + })(primary.workspace, primary.agent)); + card.addEventListener("keydown", (function(ws, ag) { + return function(e) { + if (e.key === "Enter" || e.key === " ") { e.preventDefault(); enterFocusMode(ws, ag); } + }; + })(primary.workspace, primary.agent)); } // Render work-item dots for active runs at this stage @@ -604,6 +611,236 @@ function connectSSE() { es.onmessage = handler; } +// ── Agent Focus Mode ── +var $focusView = document.getElementById("focus-view"); +var $focusBack = document.getElementById("focus-back"); +var $focusAgentName = document.getElementById("focus-agent-name"); +var $focusStatus = document.getElementById("focus-status"); +var $focusMeta = document.getElementById("focus-meta"); +var $focusTerminal = document.getElementById("focus-terminal"); +var $focusStats = document.getElementById("focus-stats"); +var $focusDiff = document.getElementById("focus-diff"); +var $focusProgress = document.getElementById("focus-progress"); +var $focusInput = document.getElementById("focus-prompt-input"); +var $focusSend = document.getElementById("focus-send"); +var focusPollId = null; +var focusPolling = false; + +function enterFocusMode(workspace, agent) { + appState.view = "focus"; + appState.focusAgent = { workspace: workspace, agent: agent }; + appState.selectedAgent = null; + + // Hide factory, show focus + document.getElementById("factory").style.display = "none"; + document.getElementById("detail-drawer").style.display = "none"; + document.getElementById("dispatch-panel").style.display = "none"; + document.getElementById("timeline").style.display = "none"; + $focusView.style.display = "flex"; + + $focusAgentName.textContent = agent; + $focusTerminal.textContent = "Connecting\u2026"; + $focusStats.innerHTML = ""; + $focusDiff.innerHTML = '
Loading diff\u2026
'; + $focusProgress.innerHTML = ""; + + // Start polling + pollFocus(); + focusPollId = setInterval(pollFocus, 2000); +} + +function exitFocusMode() { + appState.view = "factory"; + appState.focusAgent = null; + if (focusPollId) { clearInterval(focusPollId); focusPollId = null; } + + $focusView.style.display = "none"; + document.getElementById("factory").style.display = ""; + document.getElementById("dispatch-panel").style.display = ""; + document.getElementById("timeline").style.display = ""; + + renderPipeline(); + renderTimeline(); +} + +if ($focusBack) $focusBack.addEventListener("click", exitFocusMode); + +// Keyboard: Escape exits focus mode +document.addEventListener("keydown", function(e) { + if (e.key === "Escape" && appState.view === "focus") exitFocusMode(); +}); + +function pollFocus() { + var fa = appState.focusAgent; + if (!fa) return; + if (focusPolling) return; // prevent overlapping polls + focusPolling = true; + var url = "/v1/agents/" + encodeURIComponent(fa.workspace) + "/" + encodeURIComponent(fa.agent) + "/focus?lines=200"; + fetch(url).then(function(res) { return res.json(); }).then(function(json) { + focusPolling = false; + // Stale guard: if agent changed mid-flight, discard + if (!appState.focusAgent || appState.focusAgent.workspace !== fa.workspace || appState.focusAgent.agent !== fa.agent) return; + if (!json.data) return; + renderFocusView(json.data); + }).catch(function() { + focusPolling = false; + // Network error — show reconnecting state + if (appState.focusAgent && appState.focusAgent.agent === fa.agent) { + $focusTerminal.textContent = "Connection lost. Reconnecting\u2026"; + } + }); +} + +function renderFocusView(data) { + // Status bar + var stateStr = data.running ? "working" : "stopped"; + $focusStatus.textContent = "\u25CF " + stateStr; + $focusStatus.className = "focus-status " + stateStr; + $focusMeta.textContent = data.session || ""; + + // Terminal + if (!data.running && !data.terminal) { + $focusTerminal.innerHTML = ""; + var emptyDiv = el("div", "term-empty", "Agent is not running."); + var startBtn = document.createElement("button"); + startBtn.textContent = "Start Agent"; + startBtn.addEventListener("click", function() { + var fa = appState.focusAgent; + if (!fa) return; + startBtn.disabled = true; + startBtn.textContent = "Starting\u2026"; + fetch("/v1/actions/up", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ agent: fa.agent, workspace: fa.workspace }) + }).then(function(res) { + if (!res.ok) { startBtn.textContent = "Failed"; startBtn.disabled = false; } + else { startBtn.textContent = "Started"; } + }) + .catch(function() { startBtn.textContent = "Failed"; startBtn.disabled = false; }); + }); + emptyDiv.appendChild(startBtn); + $focusTerminal.appendChild(emptyDiv); + } else { + // Smart auto-scroll: only scroll if user is at the bottom + var termEl = $focusTerminal; + var wasAtBottom = termEl.scrollHeight - termEl.scrollTop - termEl.clientHeight < 30; + termEl.textContent = data.terminal || ""; + if (wasAtBottom) termEl.scrollTop = termEl.scrollHeight; + } + + // Usage stats + var u = data.usage || {}; + var statsHtml = ""; + statsHtml += statRow("input tokens", formatTokens(u.input_tokens || 0)); + statsHtml += statRow("output tokens", formatTokens(u.output_tokens || 0)); + statsHtml += statRow("cache read", formatTokens(u.cache_read || 0), "green"); + statsHtml += statRow("cache write", formatTokens(u.cache_write || 0)); + // Context bar + var ctxPct = data.context_pct; + if (ctxPct != null) { + var ctxColor = ctxPct <= 70 ? "green" : (ctxPct <= 90 ? "amber" : "red"); + statsHtml += statRow("context", ctxPct + "%", ctxColor); + statsHtml += '
'; + } else { + statsHtml += statRow("context", "\u2014"); + } + $focusStats.innerHTML = statsHtml; + + // Diff + var d = data.diff || {}; + if (d.text) { + var diffHtml = ""; + var lines = d.text.split("\n"); + for (var i = 0; i < lines.length && i < 100; i++) { + var line = lines[i]; + var cls = ""; + if (line.indexOf("+") === 0 && line.indexOf("+++") !== 0) cls = "focus-diff-add"; + else if (line.indexOf("-") === 0 && line.indexOf("---") !== 0) cls = "focus-diff-del"; + else if (line.indexOf("@@") === 0) cls = "focus-diff-hunk"; + else if (line.indexOf("diff ") === 0) cls = "focus-diff-file"; + diffHtml += '
' + escapeHtml(line) + '
'; + } + if (lines.length > 100) diffHtml += '
\u2026 ' + (lines.length - 100) + ' more lines
'; + diffHtml += '
' + (d.files_changed || 0) + ' files, +' + (d.insertions || 0) + ' -' + (d.deletions || 0) + '
'; + $focusDiff.innerHTML = diffHtml; + } else { + $focusDiff.innerHTML = '
No changes yet.
'; + } + + // Progress — show active run for this agent if any + var progressHtml = ""; + var runIds = Object.keys(appState.runs); + for (var j = 0; j < runIds.length; j++) { + var run = appState.runs[runIds[j]]; + if (run.status === "running") { + progressHtml += statRow("workflow", run.workflowName); + progressHtml += statRow("step", run.stepIndex + " / " + run.totalSteps); + progressHtml += statRow("started", timeAgo(run.startedAt)); + break; + } + } + if (!progressHtml) { + progressHtml = '
No active run.
'; + } + $focusProgress.innerHTML = progressHtml; +} + +function statRow(label, value, colorClass) { + return '
' + escapeHtml(label) + '' + escapeHtml(String(value)) + '
'; +} + +function formatTokens(n) { + if (!n) return "0"; + if (n >= 1000000) return (n / 1000000).toFixed(1) + "M"; + if (n >= 1000) return (n / 1000).toFixed(1) + "K"; + return String(n); +} + +function escapeHtml(str) { + return str.replace(/&/g, "&").replace(//g, ">"); +} + +// Prompt send from focus view +if ($focusSend) { + $focusSend.addEventListener("click", sendFocusPrompt); +} +if ($focusInput) { + $focusInput.addEventListener("keydown", function(e) { + if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); sendFocusPrompt(); } + }); +} + +function sendFocusPrompt() { + var fa = appState.focusAgent; + if (!fa || !$focusInput.value.trim()) return; + var prompt = $focusInput.value.trim(); + $focusSend.disabled = true; + $focusSend.classList.add("sending"); + + fetch("/v1/actions/send", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ agent: fa.agent, workspace: fa.workspace, prompt: prompt, auto_up: true }) + }).then(function(res) { return res.json(); }).then(function(json) { + $focusSend.disabled = false; + $focusSend.classList.remove("sending"); + if (json.ok) { + $focusInput.value = ""; + $focusInput.style.borderColor = "var(--working)"; + setTimeout(function() { $focusInput.style.borderColor = ""; }, 1000); + } else { + $focusInput.style.borderColor = "var(--blocked)"; + setTimeout(function() { $focusInput.style.borderColor = ""; }, 2000); + } + }).catch(function() { + $focusSend.disabled = false; + $focusSend.classList.remove("sending"); + $focusInput.style.borderColor = "var(--blocked)"; + setTimeout(function() { $focusInput.style.borderColor = ""; }, 2000); + }); +} + // ── Dispatch panel ── var $dispatchToggle = document.getElementById("dispatch-toggle"); var $dispatchForm = document.getElementById("dispatch-form"); @@ -657,7 +894,7 @@ if ($dispatchGo) { body: JSON.stringify(body) }).then(function(res) { return res.json(); }).then(function(json) { $dispatchGo.disabled = false; - if (json.status === "ok") { + if (json.ok) { $dispatchStatus.textContent = "dispatched"; $dispatchStatus.className = "dispatch-status ok"; } else { diff --git a/dashboard/index.html b/dashboard/index.html index b17606a..e419dfe 100644 --- a/dashboard/index.html +++ b/dashboard/index.html @@ -46,6 +46,37 @@ + +
+
+ + — + + +
+
+
+
+
+

usage

+
+
+
+

changes

+
+
+
+

progress

+
+
+
+
+
+ + +
+
+

recent events

    diff --git a/dashboard/style.css b/dashboard/style.css index 81a1d4b..02221c4 100644 --- a/dashboard/style.css +++ b/dashboard/style.css @@ -433,6 +433,215 @@ main#factory { .dispatch-status.ok { color: var(--working); } .dispatch-status.err { color: var(--blocked); } +/* ── agent focus mode ── */ +#focus-view { + position: fixed; + inset: 0; + background: var(--bg); + z-index: 100; + flex-direction: column; + animation: focus-enter 250ms ease-out; +} +@keyframes focus-enter { + from { opacity: 0; } + to { opacity: 1; } +} +.focus-bar { + display: flex; + align-items: center; + gap: 12px; + padding: 8px 16px; + border-bottom: 1px solid var(--border); + background: var(--surface); + font-family: var(--font-mono); + font-size: 0.6875rem; +} +.focus-back { + color: var(--accent); + background: none; + border: none; + cursor: pointer; + font-family: var(--font-mono); + font-size: 0.6875rem; + padding: 4px 8px; +} +.focus-back:hover { text-decoration: underline; } +.focus-agent-name { + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.06em; + font-size: 0.8125rem; +} +.focus-status { + font-size: 0.6875rem; +} +.focus-status.working { color: var(--working); } +.focus-status.stopped { color: var(--dim); } +.focus-status.blocked { color: var(--blocked); } +.focus-meta { + color: var(--dim); + margin-left: auto; +} +.focus-grid { + display: grid; + grid-template-columns: 1fr 280px; + flex: 1; + min-height: 0; + overflow: hidden; +} +.focus-terminal { + background: var(--bg); + border-right: 1px solid var(--border); + padding: 12px; + overflow-y: auto; + font-family: var(--font-mono); + font-size: 12px; + line-height: 1.6; + color: var(--text-secondary); + white-space: pre-wrap; + overflow-wrap: anywhere; +} +.focus-terminal .term-tool { color: var(--working); } +.focus-terminal .term-prompt { color: var(--accent); } +.focus-terminal .term-empty { + color: var(--dim); + text-align: center; + padding-top: 48px; + font-size: 0.8125rem; +} +.focus-terminal .term-empty button { + display: block; + margin: 12px auto 0; + background: var(--working); + color: var(--bg); + border: none; + border-radius: 4px; + padding: 6px 16px; + font-family: var(--font-mono); + font-size: 0.6875rem; + font-weight: 600; + text-transform: uppercase; + cursor: pointer; +} +.focus-sidebar { + background: var(--surface); + overflow-y: auto; + display: flex; + flex-direction: column; +} +.focus-section { + padding: 12px; + border-bottom: 1px solid var(--border); +} +.focus-section h3 { + font-family: var(--font-mono); + font-size: 9px; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--dim); + margin-bottom: 8px; +} +.focus-stat-row { + display: flex; + justify-content: space-between; + font-family: var(--font-mono); + font-size: 0.6875rem; + padding: 2px 0; +} +.focus-stat-label { color: var(--dim); } +.focus-stat-value { color: var(--text); font-weight: 500; } +.focus-stat-value.green { color: var(--working); } +.focus-stat-value.amber { color: var(--auth-fail); } +.focus-stat-value.red { color: var(--blocked); } +/* context fill bar */ +.focus-ctx-bar { + height: 4px; + border-radius: 2px; + background: var(--border); + margin-top: 4px; + overflow: hidden; +} +.focus-ctx-fill { + height: 100%; + border-radius: 2px; + transition: width 0.3s ease-out; +} +/* diff in sidebar */ +.focus-diff { + font-family: var(--font-mono); + font-size: 10px; + line-height: 1.5; + max-height: 200px; + overflow-y: auto; +} +.focus-diff-empty { color: var(--dim); font-size: 0.6875rem; } +.focus-diff-add { color: var(--working); background: rgba(34,197,94,0.08); padding: 0 4px; } +.focus-diff-del { color: var(--blocked); background: rgba(239,68,68,0.08); padding: 0 4px; } +.focus-diff-hunk { color: var(--accent); font-weight: 500; } +.focus-diff-file { color: var(--text-secondary); font-weight: 600; padding: 4px 0 2px; } +/* prompt bar */ +.focus-prompt-bar { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 12px; + border-top: 1px solid var(--border); + background: var(--surface); +} +.focus-prompt-bar input { + flex: 1; + background: var(--bg); + border: 1px solid var(--border); + color: var(--text); + padding: 6px 10px; + border-radius: 6px; + font-family: var(--font-mono); + font-size: 12px; +} +.focus-prompt-bar input:focus { border-color: var(--accent); outline: none; } +.focus-send { + background: var(--working); + color: var(--bg); + border: none; + padding: 6px 14px; + border-radius: 6px; + font-family: var(--font-mono); + font-size: 0.6875rem; + font-weight: 600; + text-transform: uppercase; + cursor: pointer; + flex-shrink: 0; +} +.focus-send:hover { background: #16a34a; } +.focus-send:disabled { opacity: 0.5; cursor: not-allowed; } +.focus-send.sending { animation: dot-pulse 1.2s ease-in-out infinite; } + +/* ── focus mobile ── */ +@media (max-width: 767px) { + .focus-grid { + grid-template-columns: 1fr; + grid-template-rows: 1fr auto; + } + .focus-terminal { + border-right: none; + border-bottom: 1px solid var(--border); + min-height: 50vh; + } + .focus-sidebar { + flex-direction: row; + overflow-x: auto; + scroll-snap-type: x mandatory; + border-bottom: 1px solid var(--border); + } + .focus-section { + min-width: 80vw; + scroll-snap-align: start; + flex-shrink: 0; + } + .focus-prompt-bar { padding: 8px; } + .focus-prompt-bar input { font-size: 16px; /* prevent iOS zoom */ } +} + /* ── event timeline ── */ footer#timeline { border-top: 1px solid var(--border); diff --git a/dashboard/test.html b/dashboard/test.html index 8dc3dfc..a4db320 100644 --- a/dashboard/test.html +++ b/dashboard/test.html @@ -481,6 +481,56 @@

    tutti dashboard tests

    }); assert("failed run excluded from stage", runsAtStage("tester").length, 0); +// ── Focus mode helpers under test ── + +function formatTokens(n) { + if (!n) return "0"; + if (n >= 1000000) return (n / 1000000).toFixed(1) + "M"; + if (n >= 1000) return (n / 1000).toFixed(1) + "K"; + return String(n); +} + +function escapeHtml(str) { + return str.replace(/&/g, "&").replace(//g, ">"); +} + +suite("formatTokens"); +assert("0 tokens", formatTokens(0), "0"); +assert("500 tokens", formatTokens(500), "500"); +assert("1500 tokens = 1.5K", formatTokens(1500), "1.5K"); +assert("142847 tokens = 142.8K", formatTokens(142847), "142.8K"); +assert("1500000 tokens = 1.5M", formatTokens(1500000), "1.5M"); + +suite("escapeHtml"); +assert("escapes angle brackets", escapeHtml("
    "), "<div>"); +assert("escapes ampersand", escapeHtml("a & b"), "a & b"); +assert("plain text unchanged", escapeHtml("hello"), "hello"); + +suite("focus view state"); +// Simulate the view state machine +var testAppState = { view: "factory", focusAgent: null }; + +// Enter focus +testAppState.view = "focus"; +testAppState.focusAgent = { workspace: "tutti", agent: "implementer" }; +assert("focus mode sets view", testAppState.view, "focus"); +assert("focusAgent has workspace", testAppState.focusAgent.workspace, "tutti"); +assert("focusAgent has agent", testAppState.focusAgent.agent, "implementer"); + +// Exit focus +testAppState.view = "factory"; +testAppState.focusAgent = null; +assert("exit restores factory view", testAppState.view, "factory"); +assertNull("exit clears focusAgent", testAppState.focusAgent); + +suite("stale response guard"); +// Simulate: start polling agent A, then switch to agent B +var guardAgent = { workspace: "tutti", agent: "planner" }; +var responseAgent = { workspace: "tutti", agent: "planner" }; +assert("matching agent = accept", guardAgent.agent === responseAgent.agent, true); +responseAgent = { workspace: "tutti", agent: "implementer" }; +assert("different agent = reject", guardAgent.agent === responseAgent.agent, false); + // ── Summary ── var $summary = document.getElementById("summary"); $summary.className = failed > 0 ? "fail" : "pass"; diff --git a/src/cli/serve.rs b/src/cli/serve.rs index ae9779b..c76b84a 100644 --- a/src/cli/serve.rs +++ b/src/cli/serve.rs @@ -583,6 +583,25 @@ fn route_read( )) } } + _ if path.starts_with("/v1/agents/") && path.contains("/focus") => { + // GET /v1/agents/{workspace}/{agent}/focus?lines=200 + let parts: Vec<&str> = path.split('/').collect(); + // Expected: ["", "v1", "agents", "{workspace}", "{agent}", "focus"] + if parts.len() == 6 && parts[5] == "focus" { + let workspace = parts[3]; + let agent_name = parts[4]; + let lines: u32 = query + .get("lines") + .and_then(|v| v.parse().ok()) + .unwrap_or(200) + .clamp(1, 10_000); + agent_focus_data(workspace, agent_name, lines, targets) + } else { + Err(TuttiError::ConfigValidation( + "invalid agent focus path".to_string(), + )) + } + } _ => Err(TuttiError::ConfigValidation("not found".to_string())), } } @@ -1031,6 +1050,146 @@ fn idempotency_save( Ok(()) } +/// Combined focus endpoint — terminal output, usage stats, git diff, and context % +/// in a single response. Polled at 2s by the dashboard focus view. +fn agent_focus_data( + workspace: &str, + agent_name: &str, + lines: u32, + targets: &[WorkspaceTarget], +) -> Result { + let target = targets + .iter() + .find(|t| t.name == workspace) + .ok_or_else(|| TuttiError::AgentNotFound(format!("{workspace}/{agent_name}")))?; + + // Validate agent exists in this workspace's config + let agent_config = target + .config + .agents + .iter() + .find(|a| a.name == agent_name) + .ok_or_else(|| TuttiError::AgentNotFound(format!("{workspace}/{agent_name}")))?; + + let runtime = agent_config.runtime.as_deref().unwrap_or("claude-code"); + + let session = + crate::session::TmuxSession::session_name(&target.config.workspace.name, agent_name); + let running = crate::session::TmuxSession::session_exists(&session); + + // Terminal output + let terminal_output = if running { + crate::session::TmuxSession::capture_pane(&session, lines).unwrap_or_default() + } else { + String::new() + }; + + // Context % — extract from the terminal output we just captured + let context_pct = snapshot::extract_context_pct_for_runtime(runtime, &terminal_output); + + // Usage stats — scan workspace usage and extract per-agent data + let since = chrono::Utc::now() - chrono::Duration::days(7); + let usage_data = crate::usage::scan_workspace_usage( + &target.project_root, + &target.config.workspace.name, + since, + ) + .ok() + .and_then(|wu| wu.by_agent.get(agent_name).cloned()) + .unwrap_or_default(); + + // Git diff — resolve worktree and run git diff + let worktree_path = target + .project_root + .join(".tutti") + .join("worktrees") + .join(agent_name); + let (diff_text, files_changed, insertions, deletions) = if worktree_path.exists() { + let diff = std::process::Command::new("git") + .args(["diff", "HEAD"]) + .current_dir(&worktree_path) + .output() + .ok() + .filter(|o| o.status.success()) + .map(|o| String::from_utf8_lossy(&o.stdout).to_string()) + .unwrap_or_default(); + let stat = std::process::Command::new("git") + .args(["diff", "HEAD", "--stat"]) + .current_dir(&worktree_path) + .output() + .ok() + .filter(|o| o.status.success()) + .map(|o| String::from_utf8_lossy(&o.stdout).to_string()) + .unwrap_or_default(); + // Parse stat summary: " N files changed, M insertions(+), D deletions(-)" + let (fc, ins, del) = parse_diff_stat(&stat); + (diff, fc, ins, del) + } else { + (String::new(), 0, 0, 0) + }; + + Ok(api_ok( + "agent.focus", + json!({ + "workspace": workspace, + "agent": agent_name, + "session": session, + "running": running, + "terminal": terminal_output, + "context_pct": context_pct, + "usage": { + "input_tokens": usage_data.total.input_tokens, + "output_tokens": usage_data.total.output_tokens, + "cache_read": usage_data.total.cache_read_input_tokens, + "cache_write": usage_data.total.cache_creation_input_tokens, + }, + "diff": { + "text": diff_text, + "files_changed": files_changed, + "insertions": insertions, + "deletions": deletions, + } + }), + )) +} + +fn parse_diff_stat(stat: &str) -> (usize, usize, usize) { + // Parse git diff --stat summary line: + // " 3 files changed, 45 insertions(+), 12 deletions(-)" + let mut files = 0; + let mut ins = 0; + let mut del = 0; + for line in stat.lines().rev() { + let l = line.trim(); + if l.contains("changed") { + for part in l.split(',') { + let p = part.trim(); + if p.contains("file") { + files = p + .split_whitespace() + .next() + .and_then(|n| n.parse().ok()) + .unwrap_or(0); + } else if p.contains("insertion") { + ins = p + .split_whitespace() + .next() + .and_then(|n| n.parse().ok()) + .unwrap_or(0); + } else if p.contains("deletion") { + del = p + .split_whitespace() + .next() + .and_then(|n| n.parse().ok()) + .unwrap_or(0); + } + } + break; + } + } + (files, ins, del) +} + /// Gather agent status snapshots across all served workspaces fn status_data(targets: &[WorkspaceTarget]) -> Result { let mut rows = Vec::new(); diff --git a/src/cli/snapshot.rs b/src/cli/snapshot.rs index cf034a7..2558b19 100644 --- a/src/cli/snapshot.rs +++ b/src/cli/snapshot.rs @@ -184,7 +184,7 @@ fn detect_status( } } -fn extract_context_pct_for_runtime(runtime_name: &str, output: &str) -> Option { +pub(crate) fn extract_context_pct_for_runtime(runtime_name: &str, output: &str) -> Option { let runtime = runtime_name.to_ascii_lowercase(); if runtime.contains("claude") { return extract_context_pct_with_hints( diff --git a/tutti.toml b/tutti.toml index c54e98f..5a45b37 100644 --- a/tutti.toml +++ b/tutti.toml @@ -209,6 +209,7 @@ agent = "implementer" inject_files = [".tutti/state/auto/selected_issue.json", ".tutti/state/auto/branch.json"] wait_for_idle = true wait_timeout_secs = 3600 +startup_grace_secs = 120 text = """ Read the full issue body from .tutti/state/auto/selected_issue.json, read the planner handoff JSON from {{output.plan_issue.path}}, and use .tutti/state/auto/branch.json as source of truth for the target branch. Implement only the smallest coherent code slice that advances the issue and aligns with plan_issue.first_slice. Inspect only the files named in the plan plus any directly adjacent test/helper files you need.