From a93cd27ead26c553c9c00a7acc07043a42d56dcc Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 13 Aug 2026 16:58:32 +0000 Subject: [PATCH 1/2] Fix untrusted-workspace wedge: re-seed claude trust at every launch Phase 4 (1517e4d) deleted the tmux launch path and with it the only caller of claude_config.ensure_onboarded, so agent working dirs - every fresh worktree is a brand-new path - were never pre-trusted in ~/.claude.json. Headless 'claude -p' runs then wedge or refuse on the workspace-trust dialog with nobody at a TTY to accept it. Spawn and resume now mark onboarding complete and trust the working dir right before launch, next to the settings/claude_gen materialization. Resume matters independently: a cross-worker resume can land in a container whose ~/.claude.json has never seen the dir. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01731mKtVzsfeT4Vi3TvkR48 --- CHANGELOG.md | 10 ++++++++++ src/handler/control/spawn.py | 9 +++++++++ tests/test_control_spawn.py | 35 +++++++++++++++++++++++++++++++++++ 3 files changed, 54 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 72879cf..b39e51e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,16 @@ the image workflows publish (plus `latest` from every push to `main`). ## [Unreleased] +### Fixed + +- **Untrusted-workspace wedge on headless runs.** Phase 4's tmux-path deletion also + removed the only call to `claude_config.ensure_onboarded`, so agent working dirs — + every fresh worktree — were never pre-trusted in `~/.claude.json` and headless + `claude -p` runs wedged or refused on the trust dialog with nobody at a TTY. Spawn + and resume now re-seed onboarding + per-directory trust before every launch (resume + included, so a cross-worker resume landing in a container that has never seen the + working dir is covered). 2 regression tests. + ### Added — mobile app feature parity The iOS app (`app/`) catches up with everything the backend and web dashboard gained diff --git a/src/handler/control/spawn.py b/src/handler/control/spawn.py index eaa50d0..a2e158d 100644 --- a/src/handler/control/spawn.py +++ b/src/handler/control/spawn.py @@ -15,6 +15,7 @@ from ..config import get_settings from ..db import repository as repo from ..db.engine import connection from . import ( + claude_config, claude_gen, credentials, forge, @@ -160,6 +161,11 @@ def spawn( # half also feeds pi-harness agents (their settings.json points at the same dir). # Scoped to the project's owner: shared rows plus theirs, nobody else's. claude_gen.apply(working_dir, visible_to=project.get("owner_user_id")) + # Mark onboarding complete and trust this working dir in ~/.claude.json before + # claude boots: a fresh worktree is a brand-new path, and an untrusted workspace + # wedges/refuses a headless run with nobody at a TTY to accept the dialog. (The + # old tmux launch path did this; it was lost when phase 4 deleted that path.) + claude_config.ensure_onboarded(working_dir) env, harness = _agent_env(project, agent, token, role=role, mise_init=mise_init) # Verify the pinned forge version, if one is configured. Non-fatal: a version drift @@ -286,6 +292,9 @@ def resume(agent: dict, answer: str, worker_id: str | None = None) -> tuple[bool working_dir = agent["working_dir"] settings_path = settings_gen.write_settings(working_dir) claude_gen.apply(working_dir, visible_to=project.get("owner_user_id")) + # Cross-worker resume may land in a container whose ~/.claude.json has never seen + # this working dir — re-seed trust exactly as spawn does. + claude_config.ensure_onboarded(working_dir) try: token = None with connection() as conn: diff --git a/tests/test_control_spawn.py b/tests/test_control_spawn.py index 4500b88..629274a 100644 --- a/tests/test_control_spawn.py +++ b/tests/test_control_spawn.py @@ -181,3 +181,38 @@ def test_resume_refused_while_run_live(env, fake_launch): ok, detail = spawn.resume(agent, "answer") assert ok is False assert "live run" in detail + + +def test_spawn_trusts_working_dir_in_claude_json(env, fake_launch): + """The launch must pre-trust the agent's working dir in ~/.claude.json — an + untrusted workspace wedges a headless run on the trust dialog with nobody at a + TTY (regression: the call was lost when phase 4 deleted the tmux launch path).""" + root = env["tmp"] / "proj" + _write_mise(root, with_test=True) + _register_project(root) + + spawn.spawn("proj", "api", task="build the thing") + + cfg = json.loads((env["tmp"] / ".claude.json").read_text()) + assert cfg["hasCompletedOnboarding"] is True + entry = cfg["projects"][str(root)] + assert entry["hasTrustDialogAccepted"] is True + + +def test_resume_trusts_working_dir_in_claude_json(env, fake_launch): + """Cross-worker resume may run in a container that has never seen this working + dir; resume must re-seed trust exactly as spawn does.""" + root = env["tmp"] / "proj" + _write_mise(root, with_test=True) + _register_project(root) + spawn.spawn("proj", "api", task="do it") + with get_engine().begin() as conn: + agent = repo.get_agent_by_name(conn, "proj", "api") + repo.finish_run(conn, repo.get_latest_run(conn, agent["id"])["id"], "completed") + (env["tmp"] / ".claude.json").unlink() # a "fresh container": no config at all + + ok, _ = spawn.resume(agent, "use Postgres") + + assert ok is True + cfg = json.loads((env["tmp"] / ".claude.json").read_text()) + assert cfg["projects"][str(root)]["hasTrustDialogAccepted"] is True From eaa23611ac983171ce01483a4ecb1f373557e613 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 13 Aug 2026 17:23:50 +0000 Subject: [PATCH 2/2] feat(app): Activity screen - the command queue on mobile MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Settings → Manage → Activity lists every control command with status filter chips, when/where it ran (project, agent, claiming worker or 'unclaimed'), and tap-to-expand result/error text; a Sweep CI button enqueues the global poll. The list auto-refreshes every 5s while open, so a login_start or spawn can be watched to completion — this was the one web dashboard page missing from the phone, and exactly the view needed to diagnose a stuck command. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01731mKtVzsfeT4Vi3TvkR48 --- CHANGELOG.md | 8 ++ app/App.tsx | 2 + app/README.md | 1 + app/src/screens/manage/ActivityScreen.tsx | 160 ++++++++++++++++++++++ app/src/screens/manage/ManageScreen.tsx | 1 + app/src/state/AppState.tsx | 1 + 6 files changed, 173 insertions(+) create mode 100644 app/src/screens/manage/ActivityScreen.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index b39e51e..13d8dd2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,14 @@ the image workflows publish (plus `latest` from every push to `main`). ## [Unreleased] +### Added + +- **Activity screen in the mobile app** (Settings → Manage → Activity): the + control-command queue with status filters, per-row worker attribution + (`on ` / `unclaimed`), expandable result/error text, a Sweep CI action, and + a 5s auto-refresh — the screen that answers "why is my login/spawn/sync stuck" from + the phone. + ### Fixed - **Untrusted-workspace wedge on headless runs.** Phase 4's tmux-path deletion also diff --git a/app/App.tsx b/app/App.tsx index d4e93f4..96f61d7 100644 --- a/app/App.tsx +++ b/app/App.tsx @@ -41,6 +41,7 @@ import { PermissionsScreen } from "./src/screens/manage/PermissionsScreen"; import { RepositoriesScreen } from "./src/screens/manage/RepositoriesScreen"; import { GitServersScreen } from "./src/screens/manage/GitServersScreen"; import { ApprovalsScreen } from "./src/screens/manage/ApprovalsScreen"; +import { ActivityScreen } from "./src/screens/manage/ActivityScreen"; import { SharedContextScreen } from "./src/screens/manage/SharedContextScreen"; import { UsersScreen } from "./src/screens/manage/UsersScreen"; import { AccountScreen } from "./src/screens/manage/AccountScreen"; @@ -78,6 +79,7 @@ function Router() { repositories: RepositoriesScreen, gitServers: GitServersScreen, approvals: ApprovalsScreen, + activity: ActivityScreen, shared: SharedContextScreen, users: UsersScreen, account: AccountScreen, diff --git a/app/README.md b/app/README.md index 1ae33af..2041382 100644 --- a/app/README.md +++ b/app/README.md @@ -46,6 +46,7 @@ The full admin surface — everything the web dashboard can do, under | Plugins | Marketplace plugins pinned to their repo | | Permissions | Default permission mode + allow/deny/ask rules over the read-only env baseline | | Claude login | Drive the worker's `claude /login` (authorize in browser, paste the code back) | +| Activity | The control-command queue: status filters, worker attribution, result/error detail, 5s auto-refresh | | Repositories | Register repos (git-server or manual mode, optional mise-init bootstrap), sync, delete | | Git servers | Forge hosts: encrypted tokens, generated deploy keys (public half copyable) | | Approvals | Record operator approve / reject verdicts per project + branch | diff --git a/app/src/screens/manage/ActivityScreen.tsx b/app/src/screens/manage/ActivityScreen.tsx new file mode 100644 index 0000000..8195be8 --- /dev/null +++ b/app/src/screens/manage/ActivityScreen.tsx @@ -0,0 +1,160 @@ +import React, { useEffect, useState } from "react"; +import { Pressable, ScrollView, StyleSheet, Text, View } from "react-native"; +import { text } from "../../theme/tokens"; +import { useTheme } from "../../theme/useTheme"; +import { Badge } from "../../components/Badge"; +import { Button } from "../../components/Button"; +import { Chip } from "../../components/Chip"; +import { ErrorNotice, ManageShell } from "../../components/ManageShell"; +import { Card, Divider, Mono, SectionLabel } from "../../components/primitives"; +import { useAppState } from "../../state/AppState"; +import { useResource } from "../../state/useResource"; +import { statusLabel, statusTone, timeAgo } from "../../api/format"; +import type { Command } from "../../api/client"; + +/** + * Activity — the control-command queue: every enqueued action and its status + * (queued → running → done/failed), the audit log of what the operator triggered. + * This is the screen that answers "why is my login/spawn/sync stuck": a row stuck + * `queued` means no worker is claiming; a `failed` row carries the worker's error + * verbatim. Auto-refreshes while open so a command can be watched to completion. + */ + +const FILTERS = ["all", "queued", "running", "failed", "done"]; + +export function ActivityScreen() { + const { colors } = useTheme(); + const { client } = useAppState(); + const { data, error, loading, reload } = useResource("/commands?limit=100"); + + const [filter, setFilter] = useState("all"); + const [openId, setOpenId] = useState(null); + const [sweepNote, setSweepNote] = useState(null); + const [sweepError, setSweepError] = useState(null); + + // The whole point of this screen is watching a command land — poll while open. + useEffect(() => { + const id = setInterval(reload, 5000); + return () => clearInterval(id); + }, [reload]); + + const commands = (data ?? []).filter((c) => + filter === "all" ? true : c.status === filter, + ); + + async function sweepCi() { + if (!client) return; + setSweepError(null); + try { + await client.api("/poll-ci", { method: "POST" }); + setSweepNote("CI sweep queued."); + setTimeout(() => setSweepNote(null), 4000); + reload(); + } catch (e) { + setSweepError(e instanceof Error ? e.message : "Couldn't queue the sweep."); + } + } + + return ( + + + + {FILTERS.map((f) => ( + setFilter(f)} + /> + ))} + + + + + {sweepNote ? ( + + {sweepNote} + + ) : null} + + + {loading && data === null ? ( + Loading… + ) : commands.length === 0 ? ( + + {filter === "all" ? "No commands yet." : `No ${filter} commands.`} + + ) : ( + <> + + {`${commands.length} command${commands.length === 1 ? "" : "s"} · refreshes every 5s`} + + + {commands.map((c, i) => { + const open = openId === c.id; + const detail = c.error || (c.result ? JSON.stringify(c.result) : null); + return ( + + {i > 0 && } + setOpenId(open ? null : c.id)} + > + + + {c.type} + + {statusLabel(c.status)} + + {timeAgo(c.created_at)} + + + + {c.project_id ?? "—"} + {c.agent_name ? ` · ${c.agent_name}` : ""} + {c.claimed_by ? ` · on ${c.claimed_by}` : " · unclaimed"} + + {detail ? ( + + {detail} + + ) : null} + + + ); + })} + + + )} + + ); +} + +const styles = StyleSheet.create({ + topRow: { + flexDirection: "row", + alignItems: "center", + gap: 10, + marginBottom: 14, + }, + filters: { flexDirection: "row", gap: 8, paddingRight: 10 }, + row: { paddingVertical: 12, paddingHorizontal: 16 }, + titleRow: { flexDirection: "row", alignItems: "center", gap: 8, flexWrap: "wrap" }, +}); diff --git a/app/src/screens/manage/ManageScreen.tsx b/app/src/screens/manage/ManageScreen.tsx index a43c12b..29e5e2e 100644 --- a/app/src/screens/manage/ManageScreen.tsx +++ b/app/src/screens/manage/ManageScreen.tsx @@ -29,6 +29,7 @@ const CLAUDE_ROWS: Row[] = [ ]; const SERVER_ROWS: Row[] = [ + { screen: "activity", title: "Activity", subtitle: "The command queue — see why an action is stuck" }, { screen: "repositories", title: "Repositories", subtitle: "Register + sync project repos" }, { screen: "gitServers", title: "Git servers", subtitle: "Forge hosts, tokens, deploy keys" }, { screen: "approvals", title: "Approvals", subtitle: "Approve or reject protected branches" }, diff --git a/app/src/state/AppState.tsx b/app/src/state/AppState.tsx index 3153b5e..accf3d2 100644 --- a/app/src/state/AppState.tsx +++ b/app/src/state/AppState.tsx @@ -55,6 +55,7 @@ export type Screen = | "repositories" | "gitServers" | "approvals" + | "activity" | "shared" | "users" | "account"