From 3d13c7d699b9606a82926d51e2ccd3a7564d5ab1 Mon Sep 17 00:00:00 2001 From: Rohit C Prasad Date: Mon, 10 Aug 2026 21:43:02 -0700 Subject: [PATCH 01/80] coworker picker: setup chips above composer, folder pick at send (UX-029) Per-session coworker+folder chips replace the sidebar split-button picker; code family gets a send-time folder dialog with git-ready temp dirs and Save as project. Builtins ship enabled; user-facing noun is Coworker; personas flag now defaults on. --- coworker/personas/registry.py | 10 +- coworker/server/app.py | 22 ++ coworker/server/manager.py | 69 +++++ surfaces/gui/e2e/access-section.spec.ts | 2 +- surfaces/gui/e2e/family-gate.spec.ts | 108 ++++++-- surfaces/gui/e2e/fixtures.ts | 9 + surfaces/gui/e2e/gallery.spec.ts | 8 +- surfaces/gui/e2e/persona-surfacing.spec.ts | 27 +- surfaces/gui/e2e/roots.spec.ts | 4 +- surfaces/gui/e2e/session-shell.spec.ts | 8 +- surfaces/gui/e2e/settings.spec.ts | 24 +- surfaces/gui/src/App.tsx | 255 +++++++++++++++--- surfaces/gui/src/api.ts | 31 +++ surfaces/gui/src/components/AccessSection.tsx | 6 +- surfaces/gui/src/components/GalleryModal.tsx | 12 +- surfaces/gui/src/components/PersonaView.tsx | 8 +- surfaces/gui/src/components/PersonasTab.tsx | 12 +- surfaces/gui/src/components/RootRow.tsx | 4 +- .../gui/src/components/SendFolderDialog.tsx | 104 +++++++ .../gui/src/components/SessionSetupRow.tsx | 148 ++++++++++ surfaces/gui/src/components/SettingsView.tsx | 8 +- surfaces/gui/src/components/Sidebar.test.tsx | 74 +---- surfaces/gui/src/components/Sidebar.tsx | 130 ++------- surfaces/gui/src/flags.ts | 8 +- tests/test_persona_connections.py | 4 +- tests/test_persona_registry.py | 26 +- tests/test_server.py | 6 +- tests/test_temp_workspace.py | 114 ++++++++ 28 files changed, 923 insertions(+), 318 deletions(-) create mode 100644 surfaces/gui/src/components/SendFolderDialog.tsx create mode 100644 surfaces/gui/src/components/SessionSetupRow.tsx create mode 100644 tests/test_temp_workspace.py diff --git a/coworker/personas/registry.py b/coworker/personas/registry.py index ae964529..06ccfb2b 100644 --- a/coworker/personas/registry.py +++ b/coworker/personas/registry.py @@ -219,11 +219,15 @@ class PersonaRegistry: return self._entries.get(persona_id) def is_enabled(self, persona_id: str) -> bool: - # No user choice recorded → only the default persona ships enabled (owner call, - # 2026-07-09): a fresh install is Coworker-only, everything else is opt-in from - # Settings ▸ Personas. Explicit state (either way) always wins. + # Explicit state (either way) always wins. Absent a user choice, BUILT-IN personas + # ship enabled — the composer picker is their front door (UX-029, supersedes the + # 2026-07-09 Coworker-only default that fit the old hidden ▾ menu). Installed + # third-party personas stay disabled until the user consents from the risk screen. if persona_id in self._enabled: return bool(self._enabled[persona_id]) + entry = self._entries.get(persona_id) + if entry is not None and entry.builtin: + return True return persona_id == self._default or persona_id == DEFAULT_PERSONA_ID def is_surfaced(self, persona_id: str) -> bool: diff --git a/coworker/server/app.py b/coworker/server/app.py index e8b97638..80dd95bc 100644 --- a/coworker/server/app.py +++ b/coworker/server/app.py @@ -641,6 +641,21 @@ def create_app(manager: SessionManager) -> FastAPI: trusted=bool((body or {}).get("trusted", False)), ) + @app.post("/v1/workspaces/temp") + def provision_temp_workspace(body: dict) -> dict[str, Any]: + # UX-029: a code-family session starting "in a temporary folder" — created only + # at send time, with git ready. Knowledge families keep their auto-provisioned dir. + return manager.provision_temp_workspace( + str((body or {}).get("session_id", "")), + git=bool((body or {}).get("git", True)), + ) + + @app.post("/v1/sessions/{session_id}/save-as-project") + def save_session_as_project(session_id: str, body: dict) -> dict[str, Any]: + # UX-029 "Save as project…": move the temporary folder somewhere real. The GUI + # reconnects afterwards so the engine rebinds to the new path. + return manager.save_temp_as_project(session_id, str((body or {}).get("path", ""))) + @app.post("/v1/workspaces/pick") async def pick_workspace() -> dict[str, Any]: # Native folder picker opened by the LOCAL sidecar (browser GUIs can't get absolute @@ -1800,6 +1815,13 @@ def create_app(manager: SessionManager) -> FastAPI: if getattr(engine, "executor", None) else None ), + # UX-029: the GUI never shows a temporary folder's raw path — this flag + # is how it knows to say "Temporary folder" (and offer Save as project). + "temp_workspace": manager.is_temp_workspace( + str(getattr(engine, "executor").cwd) + if getattr(engine, "executor", None) + else None + ), "command_trust": manager.workspace_command_trust( str(getattr(engine, "audit_context", {}).get("workspace", "")) ), diff --git a/coworker/server/manager.py b/coworker/server/manager.py index 66a51ecc..ab24d2e9 100644 --- a/coworker/server/manager.py +++ b/coworker/server/manager.py @@ -361,6 +361,75 @@ class SessionManager: d.mkdir(parents=True, exist_ok=True) return str(d.resolve()) + _SESSION_ID_RE = re.compile(r"^[A-Za-z0-9_.-]{1,64}$") + + def is_temp_workspace(self, path: Optional[str]) -> bool: + """True when `path` is a per-conversation temporary directory (lives under the + scratch base). The GUI uses this to label the folder "Temporary folder" instead + of exposing its raw path.""" + if not path: + return False + try: + return ( + Path(path).expanduser().resolve().is_relative_to(self.scratch_base().resolve()) + ) + except OSError: + return False + + def provision_temp_workspace(self, session_id: str, *, git: bool = True) -> dict[str, Any]: + """UX-029 "Start in a temporary folder": create the conversation's temporary + directory at SEND time (not connect) and, for code-family work, make git ready. + Idempotent — re-sending against an existing dir is a no-op.""" + if not self._SESSION_ID_RE.match(session_id or "") or session_id in {".", ".."}: + return {"ok": False, "error": "invalid session id"} + path = self._provision_scratch(session_id) + if git and not (Path(path) / ".git").is_dir(): + try: + subprocess.run( + ["git", "init", "-q"], + cwd=path, + capture_output=True, + timeout=10, + check=False, + ) + except (OSError, subprocess.SubprocessError): + pass # no git on PATH → still a usable folder, just not a repo + return {"ok": True, "path": path, "git": (Path(path) / ".git").is_dir()} + + def save_temp_as_project(self, session_id: str, dest: str) -> dict[str, Any]: + """UX-029 "Save as project…": move a session's temporary folder to a real + location and rebind the session there. The cached engine is dropped so the next + connect rebuilds against the new path — callers must reconnect after this.""" + if not dest or not dest.strip(): + return {"ok": False, "error": "no destination folder"} + record = self.session_store.load(session_id) + src = record.workspace if record and record.workspace else None + if not src: + engine = self._engines.get(session_id) + executor = getattr(engine, "executor", None) if engine else None + src = str(executor.cwd) if executor else None + if not src or not self.is_temp_workspace(src) or not Path(src).is_dir(): + return {"ok": False, "error": "this session is not in a temporary folder"} + if self.is_running(session_id): + return {"ok": False, "error": "wait for the current task to finish first"} + d = Path(dest).expanduser() + if d.exists(): + if not d.is_dir() or any(d.iterdir()): + return {"ok": False, "error": "destination must be a new or empty folder"} + d.rmdir() # shutil.move into an existing dir would nest src inside it + try: + d.parent.mkdir(parents=True, exist_ok=True) + shutil.move(src, str(d)) + except OSError as e: + return {"ok": False, "error": f"could not move the folder: {e}"} + new_path = str(d.resolve()) + if record: + record.workspace = new_path + self.session_store.save(record) + self._engines.pop(session_id, None) + self.session_store.touch_workspace(new_path) + return {"ok": True, "path": new_path} + def resolve_workspace(self, requested: Optional[str]) -> Optional[str]: if requested: p = Path(requested).expanduser() diff --git a/surfaces/gui/e2e/access-section.spec.ts b/surfaces/gui/e2e/access-section.spec.ts index 5d4b490e..24f0bbaf 100644 --- a/surfaces/gui/e2e/access-section.spec.ts +++ b/surfaces/gui/e2e/access-section.spec.ts @@ -30,7 +30,7 @@ test("no topbar opener; the Access header IS the ambient glance; expanding edits await expect(body.getByText("Sources")).toBeVisible(); await expect(body.getByText("Slack", { exact: true })).toBeVisible(); await expect(body.getByText("email context for morning summaries")).toBeVisible(); - await expect(body.getByTestId("drawer-directories").getByText("Temporary space")).toBeVisible(); + await expect(body.getByTestId("drawer-directories").getByText("Temporary folder")).toBeVisible(); await expect(page.getByRole("dialog")).toHaveCount(0); // Channels is a chat capability, not a two_way one: Slack gets the drill-down, GitHub diff --git a/surfaces/gui/e2e/family-gate.spec.ts b/surfaces/gui/e2e/family-gate.spec.ts index 2e242bea..0b6a2088 100644 --- a/surfaces/gui/e2e/family-gate.spec.ts +++ b/surfaces/gui/e2e/family-gate.spec.ts @@ -1,43 +1,93 @@ import { test, expect } from "./fixtures"; -// §16 workspace collapse: the persona FAMILY alone decides the workspace behavior. -// code → an explicit project folder, enforced by the FolderGate (no chat-behind-it escape) -// knowledge → starts orphan on a transparent scratch dir — never gated -// (The mock's Ops persona is knowledge-family with zero sessions, so picking it exercises the -// brand-new-session path, not a resume.) +// UX-029: the persona FAMILY still decides workspace behavior, but code-family +// enforcement moved from a modal gate at session start to the SEND moment: +// code → send with no folder → "Where should … work?" dialog (recents / native +// picker / "Start in a temporary folder", git-init'd, created only now) +// knowledge → starts orphan on a transparent temporary dir — never gated +// The coworker pick lives in the setup chip row above the composer, only before the +// first message of a new session; afterwards the row leaves and the facts move to the +// session header. -const personaMenu = (page: import("@playwright/test").Page) => page.locator(".newsplit-menu"); - -async function startAs(page: import("@playwright/test").Page, persona: RegExp) { - await page.getByLabel("Choose a persona").click(); - await personaMenu(page).getByRole("button", { name: persona }).click(); +async function newDraftAs(page: import("@playwright/test").Page, coworker: RegExp) { + await page.getByText("New session").first().click(); + await page.getByTestId("coworker-chip").click(); + await page.locator(".setup-menu").getByRole("button", { name: coworker }).click(); } -test("knowledge persona: new session starts instantly, no folder gate", async ({ page }) => { +test("knowledge coworker: new session starts instantly, no gate, no dialog", async ({ page }) => { await page.goto("/"); - await expect(page.getByPlaceholder(/Ask the coworker/)).toBeVisible(); + await newDraftAs(page, /Ops Coworker/); - await startAs(page, /Ops/); await expect(page.locator(".gate-overlay")).toHaveCount(0); - await expect(page.getByPlaceholder(/Ask the coworker/)).toBeVisible(); + const box = page.getByPlaceholder(/Ask the coworker/); + await box.fill("hello there"); + await page.getByRole("button", { name: "Send" }).click(); + await expect(page.getByText(/Echo: hello there/)).toBeVisible(); + await expect(page.getByTestId("send-folder-dialog")).toHaveCount(0); }); -test("code persona: the folder gate blocks until a project is chosen", async ({ page }) => { +test("code coworker: send with no folder asks where to work; temp folder sends the message", async ({ + page, +}) => { await page.goto("/"); - await expect(page.getByPlaceholder(/Ask the coworker/)).toBeVisible(); + await newDraftAs(page, /Code Coworker/); - await startAs(page, /Code/); - - const gate = page.locator(".gate-overlay"); - await expect(gate).toBeVisible(); - await expect(gate.getByText("Choose a project folder")).toBeVisible(); - // No escape hatch: the gate offers pick-a-folder only (no "switch to Chat" — owner call, §16). - await expect(gate.getByText(/chat/i)).toHaveCount(0); - - await gate.getByPlaceholder("/path/to/your/project").fill("/tmp/e2e-project"); - await gate.getByRole("button", { name: "Open", exact: true }).click(); - - // Gate clears, the session is rooted in the chosen folder, and the code composer is live. + // No modal gate up front — the composer is live and the draft is composable. await expect(page.locator(".gate-overlay")).toHaveCount(0); - await expect(page.getByPlaceholder(/Ask the coder/)).toBeVisible(); + await page.getByPlaceholder(/Ask the coder/).fill("fix the tests"); + await page.getByRole("button", { name: "Send" }).click(); + + const dlg = page.getByTestId("send-folder-dialog"); + await expect(dlg).toBeVisible(); + await expect(dlg.getByText("Where should Code Coworker work?")).toBeVisible(); + await dlg.getByTestId("start-temp-folder").click(); + + // The message flies as soon as the choice lands — no second send click, and the local + // echo isn't duplicated by turn_start (the notice sits between them). + await expect(page.getByText(/Echo: fix the tests/)).toBeVisible(); + await expect(page.locator(".main-scroll").getByText("fix the tests", { exact: true })).toHaveCount(1); + await expect(page.getByText("Temporary folder created · git initialized")).toBeVisible(); + + // The raw temp path never shows: header says "Temporary folder" + Save as project…. + const sub = page.getByTestId("session-subtitle"); + await expect(sub).toContainText("Code Coworker"); + await expect(sub).toContainText("Temporary folder"); + await expect(sub).not.toContainText("ow-temp"); + await expect(page.getByTestId("save-as-project")).toBeVisible(); + + // One-time pick: the setup row left with the first message. + await expect(page.getByTestId("setup-row")).toHaveCount(0); + + // A NEW session never inherits the temporary dir — the folder chip starts fresh. + await page.getByText("New session").first().click(); + await expect(page.getByTestId("folder-chip")).toContainText("Choose folder"); +}); + +test("code coworker: Choose a folder… binds the picked project and sends", async ({ page }) => { + await page.goto("/"); + await newDraftAs(page, /Code Coworker/); + + await page.getByPlaceholder(/Ask the coder/).fill("hello repo"); + await page.getByRole("button", { name: "Send" }).click(); + + // Native pick is mocked server-side → /tmp/picked-folder. + await page.getByTestId("send-folder-dialog").getByRole("button", { name: "Choose a folder…" }).click(); + await expect(page.getByText(/Echo: hello repo/)).toBeVisible(); + await expect(page.getByTestId("session-subtitle")).toContainText("picked-folder"); + await expect(page.getByTestId("save-as-project")).toHaveCount(0); +}); + +test("escape restores the draft instead of losing it", async ({ page }) => { + await page.goto("/"); + await newDraftAs(page, /Code Coworker/); + + const box = page.getByPlaceholder(/Ask the coder/); + await box.fill("precious draft"); + await page.getByRole("button", { name: "Send" }).click(); + await expect(page.getByTestId("send-folder-dialog")).toBeVisible(); + + await page.keyboard.press("Escape"); + await expect(page.getByTestId("send-folder-dialog")).toHaveCount(0); + await expect(box).toHaveValue("precious draft"); }); diff --git a/surfaces/gui/e2e/fixtures.ts b/surfaces/gui/e2e/fixtures.ts index e6dfa409..48b3e5c7 100644 --- a/surfaces/gui/e2e/fixtures.ts +++ b/surfaces/gui/e2e/fixtures.ts @@ -947,6 +947,15 @@ export async function mockApi(page: import("@playwright/test").Page) { const b = req.postDataJSON(); return json({ ok: true, path: b.path, git_branch: "main" }); } + if (p.endsWith("/v1/workspaces/temp") && m === "POST") { + // UX-029: "Start in a temporary folder" — created at send time, git-ready. + const b = req.postDataJSON(); + return json({ ok: true, path: `/tmp/ow-temp/${b.session_id}`, git: b.git !== false }); + } + if (/\/v1\/sessions\/[^/]+\/save-as-project$/.test(p) && m === "POST") { + const b = req.postDataJSON(); + return json({ ok: true, path: b.path }); + } // must precede the /v1/personas/{id} catch-all (install matches it too) if (p.endsWith("/v1/personas/install") && m === "POST") { const b = req.postDataJSON(); diff --git a/surfaces/gui/e2e/gallery.spec.ts b/surfaces/gui/e2e/gallery.spec.ts index e33ce629..d72f3900 100644 --- a/surfaces/gui/e2e/gallery.spec.ts +++ b/surfaces/gui/e2e/gallery.spec.ts @@ -6,12 +6,10 @@ import { expect } from "@playwright/test"; import { test } from "./fixtures"; async function openPersonas(page) { - // Personas is launch-flagged off by default — these suites cover the flagged-on flows. - await page.addInitScript(() => localStorage.setItem("ocw.flag.personas", "1")); await page.goto("/"); await page.getByTestId("account-row").click(); await page.getByRole("button", { name: "Settings", exact: true }).click(); - await page.getByRole("button", { name: "Personas", exact: true }).click(); + await page.getByRole("button", { name: "Coworkers", exact: true }).click(); await expect(page.getByTestId("gallery-link")).toBeVisible(); } @@ -65,9 +63,9 @@ test("signed in: featured carousel + list; solo page installs informed; Done ret await expect(page.getByTestId("gallery-team-teaser")).toContainText("coming soon"); // Search narrows the list. - await page.getByPlaceholder("Search personas").fill("recruit"); + await page.getByPlaceholder("Search coworkers").fill("recruit"); await expect(page.getByTestId("gallery-sales")).not.toBeVisible(); - await page.getByPlaceholder("Search personas").fill(""); + await page.getByPlaceholder("Search coworkers").fill(""); // Solo page: pitch + manifest-derived capabilities BEFORE install. await page.getByTestId("gallery-sales").click(); diff --git a/surfaces/gui/e2e/persona-surfacing.spec.ts b/surfaces/gui/e2e/persona-surfacing.spec.ts index 848f3bc9..ec8aaf7a 100644 --- a/surfaces/gui/e2e/persona-surfacing.spec.ts +++ b/surfaces/gui/e2e/persona-surfacing.spec.ts @@ -1,10 +1,5 @@ import { test, expect } from "./fixtures"; -// Personas is launch-flagged off by default — this suite covers the flagged-on flows. -test.beforeEach(async ({ page }) => { - await page.addInitScript(() => localStorage.setItem("ocw.flag.personas", "1")); -}); - // Regression for the invisible-after-install bug (2026-07-03): enabling a persona in // Settings ▸ Personas must surface it EVERYWHERE without a reload — the New-Session picker and // the grouped sidebar — via the PERSONAS_CHANGED event (and backend enable-implies-surface). @@ -15,18 +10,19 @@ test("enabling an installed persona surfaces it in picker + sidebar without relo await page.goto("/"); const sidebar = page.locator(".sidebar"); - // Disabled install: absent from the persona picker and the grouped sidebar. - await page.getByLabel("Choose a persona").click(); - const menu = page.locator(".newsplit-menu"); + // Disabled install: absent from the composer's coworker picker and the grouped sidebar. + await page.getByText("New session").first().click(); + await page.getByTestId("coworker-chip").click(); + const menu = page.locator(".setup-menu"); await expect(menu).toBeVisible(); await expect(menu.getByText("Acme Notes")).toHaveCount(0); - await page.locator(".fixed.inset-0.z-20").click(); // close via backdrop + await page.locator(".fixed.inset-0.z-20").click({ position: { x: 5, y: 5 } }); // close via backdrop (menu sits over center) await expect(sidebar.getByText("Acme Notes")).toHaveCount(0); - // Enable it on the Personas page. + // Enable it on the Coworkers page. await page.getByTestId("account-row").click(); await page.getByRole("button", { name: "Settings", exact: true }).click(); - await page.getByRole("button", { name: "Personas", exact: true }).click(); + await page.getByRole("button", { name: "Coworkers", exact: true }).click(); const row = page.locator(".divide-y > div").filter({ hasText: "Acme Notes" }); // Controlled checkbox: the DOM state flips only after the POST round-trip, so click + expect // (a plain .check() asserts the state synchronously and fails). @@ -36,8 +32,9 @@ test("enabling an installed persona surfaces it in picker + sidebar without relo // No reload: the sidebar group and the picker both pick it up via PERSONAS_CHANGED. await expect(sidebar.getByText("Acme Notes")).toBeVisible(); - await page.getByLabel("Choose a persona").click(); - await expect(page.locator(".newsplit-menu").getByText("Acme Notes")).toBeVisible(); + await page.getByText("New session").first().click(); + await page.getByTestId("coworker-chip").click(); + await expect(page.locator(".setup-menu").getByText("Acme Notes")).toBeVisible(); }); // Disable-archives (§18): disabling a persona archives its conversations, so the confirm must @@ -52,7 +49,7 @@ test("disabling a persona with conversations asks first, then archives them", as await page.getByTestId("account-row").click(); await page.getByRole("button", { name: "Settings", exact: true }).click(); - await page.getByRole("button", { name: "Personas", exact: true }).click(); + await page.getByRole("button", { name: "Coworkers", exact: true }).click(); const row = page.locator(".divide-y > div").filter({ hasText: "Ops Coworker" }); const enabled = row.getByRole("checkbox", { name: "Enabled" }); @@ -78,7 +75,7 @@ test("disabling a persona with no conversations skips the confirm", async ({ pag await page.goto("/"); await page.getByTestId("account-row").click(); await page.getByRole("button", { name: "Settings", exact: true }).click(); - await page.getByRole("button", { name: "Personas", exact: true }).click(); + await page.getByRole("button", { name: "Coworkers", exact: true }).click(); const row = page.locator(".divide-y > div").filter({ hasText: "Code" }); const enabled = row.getByRole("checkbox", { name: "Enabled" }); await enabled.click(); diff --git a/surfaces/gui/e2e/roots.spec.ts b/surfaces/gui/e2e/roots.spec.ts index 6652785f..ee9ad25b 100644 --- a/surfaces/gui/e2e/roots.spec.ts +++ b/surfaces/gui/e2e/roots.spec.ts @@ -14,8 +14,8 @@ test("working directories: add folders with the read-only / read-write gate", as const dirs = page.getByTestId("drawer-directories"); await expect(dirs.getByText("Folders")).toBeVisible(); - // The primary is the writable scratch workspace (Cowork shows it as "Temporary space"). - await expect(dirs.getByText("Temporary space")).toBeVisible(); + // The primary is the writable scratch workspace (Cowork shows it as "Temporary folder"). + await expect(dirs.getByText("Temporary folder")).toBeVisible(); // Add a folder — the gate defaults to read-only (Allow writes OFF). The Browse button works // in the BROWSER too (sidecar-opened native picker; owner report 2026-07-04). diff --git a/surfaces/gui/e2e/session-shell.spec.ts b/surfaces/gui/e2e/session-shell.spec.ts index 0156c87a..0e918f06 100644 --- a/surfaces/gui/e2e/session-shell.spec.ts +++ b/surfaces/gui/e2e/session-shell.spec.ts @@ -33,7 +33,7 @@ test("top-left cluster renders only while the sidebar is collapsed", async ({ pa await expect(page.getByTestId("topbar-cluster")).toHaveCount(0); }); -test("facts subtitle: absent on a fresh session, model-only after the first turn, inert", async ({ +test("facts subtitle: absent on a fresh session, coworker + model after the first turn, inert", async ({ page, }) => { await page.goto("/"); @@ -51,10 +51,10 @@ test("facts subtitle: absent on a fresh session, model-only after the first turn await page.getByRole("button", { name: "Send" }).click(); await expect(page.getByText(/Echo: hello/)).toBeVisible(); - // Model only — no persona name (owner ask 2026-07-22: personas are hidden this release), - // and the subtitle is a plain fact line, not a button to the persona page. + // Coworker + model (UX-029 restored the coworker name — the picker shipped), and the + // subtitle is a plain fact line, not a button to the persona page. const sub = page.getByTestId("session-subtitle"); - await expect(sub).toHaveText("Claude Opus 4.8"); + await expect(sub).toHaveText("Coworker · Claude Opus 4.8"); await expect(page.locator(".dd").filter({ hasText: "Claude Opus 4.8" })).toBeVisible(); await sub.click(); await expect(page.getByRole("button", { name: "Back", exact: true })).toHaveCount(0); diff --git a/surfaces/gui/e2e/settings.spec.ts b/surfaces/gui/e2e/settings.spec.ts index 1bee81fa..82f791be 100644 --- a/surfaces/gui/e2e/settings.spec.ts +++ b/surfaces/gui/e2e/settings.spec.ts @@ -2,7 +2,7 @@ import { test, expect } from "./fixtures"; // Guards the Settings-as-page refactor (§13, IA per UX-021): the ⚙ menu opens a full-page // surface with a left sub-nav — General · Models · Voice input — and each section renders. -// Files is a card inside General; Personas is launch-flagged off. +// Files is a card inside General; Coworkers ships on (flag "0" hides it). test("Settings opens as a full page and navigates sections", async ({ page }) => { await page.goto("/"); @@ -15,9 +15,9 @@ test("Settings opens as a full page and navigates sections", async ({ page }) => for (const label of ["General", "Models", "Voice input"]) { await expect(page.getByRole("button", { name: label, exact: true })).toBeVisible(); } - // Folded/hidden tabs: Files is a General card now; Personas is launch-flagged off. + // Folded tabs: Files is a General card now; Coworkers ships as its own tab (UX-029). await expect(page.getByRole("button", { name: "Files", exact: true })).toHaveCount(0); - await expect(page.getByRole("button", { name: "Personas", exact: true })).toHaveCount(0); + await expect(page.getByRole("button", { name: "Coworkers", exact: true })).toBeVisible(); // The Files card lives inside General. await expect(page.getByText("Each conversation gets its own folder")).toBeVisible(); @@ -26,14 +26,22 @@ test("Settings opens as a full page and navigates sections", async ({ page }) => await expect(page.getByTestId("set-provider-openai")).toBeVisible(); }); -// The launch flag brings the Personas tab back (the gallery/persona suites rely on it). -test("Settings: Personas tab returns behind the launch flag", async ({ page }) => { - await page.addInitScript(() => localStorage.setItem("ocw.flag.personas", "1")); +// The flag's "0" escape hatch hides the tab again (the default is on — UX-029). +test("Settings: Coworkers tab opens by default; flag \"0\" hides it", async ({ page }) => { await page.goto("/"); await page.getByTestId("account-row").click(); await page.getByRole("button", { name: "Settings", exact: true }).click(); - await page.getByRole("button", { name: "Personas", exact: true }).click(); - await expect(page.getByText("Add personas")).toBeVisible(); + await page.getByRole("button", { name: "Coworkers", exact: true }).click(); + await expect(page.getByText("Add coworkers")).toBeVisible(); +}); + +test("Settings: the flag escape hatch hides the Coworkers tab", async ({ page }) => { + await page.addInitScript(() => localStorage.setItem("ocw.flag.personas", "0")); + await page.goto("/"); + await page.getByTestId("account-row").click(); + await page.getByRole("button", { name: "Settings", exact: true }).click(); + await expect(page.getByRole("heading", { name: "General" })).toBeVisible(); + await expect(page.getByRole("button", { name: "Coworkers", exact: true })).toHaveCount(0); }); // UX-021: Settings ▸ Models is the shared provider gallery (§39 components). Cards wear diff --git a/surfaces/gui/src/App.tsx b/surfaces/gui/src/App.tsx index 972abd9a..fe139ae8 100644 --- a/surfaces/gui/src/App.tsx +++ b/surfaces/gui/src/App.tsx @@ -1,6 +1,7 @@ import { useCallback, useEffect, useRef, useState, type PointerEvent } from "react"; import { announceInboxUnlock, + createTempWorkspace, finalizeAutomationRun, getArtifacts, getHealth, @@ -21,6 +22,7 @@ import { deleteSession, renameSession, runAutomation, + saveSessionAsProject, setSessionFlags, setUnattended, Session, @@ -40,13 +42,13 @@ import type { TodoItem, WsEvent, } from "./types"; -import { isProjectScoped } from "./personaScope"; +import { fullPersonaName, isProjectScoped } from "./personaScope"; import { baseName } from "./paths"; import { itemsFromMessages } from "./itemsFromMessages"; import { addTurnUsage, emptyUsage, usageFromMessages } from "./usage"; import { streamMode } from "./streamGate"; import { InboxItemCard } from "./components/InboxItemCard"; -import { isTauri, platformOS, startWindowDrag } from "./tauri"; +import { chooseFolder, isTauri, platformOS, startWindowDrag } from "./tauri"; import { Icon } from "./components/Icon"; import { Sidebar } from "./components/Sidebar"; import { ThinkingBlock, Transcript } from "./components/Transcript"; @@ -55,6 +57,8 @@ import { Markdown } from "./components/Markdown"; import { SearchModal } from "./components/SearchModal"; import { SessionIntro } from "./components/SessionIntro"; import { FolderGate } from "./components/FolderGate"; +import { SessionSetupRow } from "./components/SessionSetupRow"; +import { SendFolderDialog } from "./components/SendFolderDialog"; import { Onboarding } from "./components/Onboarding"; import { UpdateBanner } from "./components/UpdateBanner"; import { ScheduledView } from "./components/ScheduledView"; @@ -158,6 +162,20 @@ function fallbackWorkspace(current: string | null, projects: RecentWorkspace[]): export function App() { const [workspace, setWorkspace] = useState(null); const [branch, setBranch] = useState(null); + // UX-029: the active session runs in a temporary folder (never show its raw path — + // the header says "Temporary folder" and offers Save as project…). Set locally when a + // temp dir is created at send, corrected by every `ready` event (server truth). + const [tempWorkspace, setTempWorkspace] = useState(false); + // UX-029 send-time folder enforcement: the stashed message while the folder dialog is + // up. The message goes out the moment the dialog resolves; Escape restores the draft. + const [sendGate, setSendGate] = useState<{ + text: string; + attachments?: Attachment[]; + skill?: string; + } | null>(null); + // Bumped to force a socket rebuild on the SAME session id (Save as project… moves the + // folder server-side; the engine rebinds on reconnect). + const [connectNonce, setConnectNonce] = useState(0); const [showGate, setShowGate] = useState(false); const [workspaceTrustRequest, setWorkspaceTrustRequest] = useState(null); @@ -321,7 +339,12 @@ export function App() { // code-family persona gates a folder like Code, and a knowledge persona starts orphan like Cowork). const [personas, setPersonas] = useState(null); useEffect(() => { - getPersonas().then(setPersonas).catch(() => {}); + const load = () => getPersonas().then(setPersonas).catch(() => {}); + load(); + // The composer's coworker picker is always mounted on a fresh session — refetch on + // mutations (enable/install from Settings) instead of going stale. + window.addEventListener(PERSONAS_CHANGED, load); + return () => window.removeEventListener(PERSONAS_CHANGED, load); }, []); const personaOf = (a: string) => personas?.find((p) => p.id === a); @@ -378,8 +401,15 @@ export function App() { const sessionRef = useRef(null); const scrollRef = useRef(null); - // A prompt to auto-send once the next session connects (used by "Run now"). - const pendingPromptRef = useRef(null); + // A message to auto-send once the next session connects — "Run now" task prompts, and + // UX-029's deferred first send (folder resolved at send time → reconnect → message goes). + const pendingPromptRef = useRef<{ + text: string; + attachments?: Attachment[]; + skill?: string; + model?: string; + notice?: string; // e.g. "Temporary folder created · git initialized", shown after the message + } | null>(null); // The in-flight manual run to finalize after its first turn ({taskId, runId, sessionId}). const activeRunRef = useRef<{ taskId: string; runId: string; sessionId: string } | null>(null); @@ -549,15 +579,15 @@ export function App() { return () => window.removeEventListener(PERSONAS_CHANGED, onPersonas); }, [refreshSessions]); - // If the active surface isn't visible (hidden in Settings, or a resumed session landed on a - // hidden surface), fall back to Cowork (always visible). Watches both agent and surfaces so it - // corrects regardless of which settled last. + // If the active persona is DISABLED (turned off in Settings, or a resumed session landed + // on one), fall back to Cowork. This used to key on the legacy sidebar-visibility prefs + // (show_chat/show_code) — with the composer picker shipped (UX-029), enablement is the + // one visibility axis, and a deliberately picked coworker must never be reverted. useEffect(() => { - if ((agent === "chat" && !surfaces.chat) || (agent === "code" && !surfaces.code)) { - switchAgent("cowork"); - } + const p = personaOf(agent); + if (p && !p.enabled) switchAgent("cowork"); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [agent, surfaces]); + }, [agent, personas]); useEffect(() => { if (surface === "session") rememberLastSession(agent, sessionId, workspace); @@ -600,6 +630,8 @@ export function App() { if (d.command_trust?.required) setWorkspaceTrustRequest(d.command_trust); // Cowork: adopt the server-provisioned scratch dir (only when we don't already have one). if (d.workspace) setWorkspace((cur) => cur || d.workspace); + // UX-029: server truth on whether this session runs in a temporary folder. + if (typeof d.temp_workspace === "boolean") setTempWorkspace(d.temp_workspace); break; case "turn_start": setRunning(true); @@ -622,7 +654,11 @@ export function App() { // `input` is model-facing. Surface/dedupe on what the user actually sees. const shown = (typeof d.display === "string" && d.display) || (d.input as string); setItems((p) => { - const last = p[p.length - 1]; + // Look past trailing notices — the UX-029 "Temporary folder created" line + // sits between the local echo and this event's arrival. + let i = p.length - 1; + while (i >= 0 && p[i].kind === "notice") i--; + const last = p[i]; return last && last.kind === "user" && last.text === shown ? p : [...p, { kind: "user", text: shown, ts: Date.now() / 1000 }]; @@ -795,12 +831,20 @@ export function App() { onEvent: handleEvent, onOpen: () => { setConnected(true); - // Auto-send the task prompt once a "Run now" session connects. + // Auto-send the pending message once the session connects ("Run now" prompts and + // UX-029's deferred first send). const p = pendingPromptRef.current; if (p) { pendingPromptRef.current = null; - setItems((prev) => [...prev, { kind: "user", text: p, ts: Date.now() / 1000 }]); - sessionRef.current?.userMessage(p); + const shown = p.skill ? `/${p.skill}${p.text ? ` ${p.text}` : ""}` : p.text; + setItems((prev) => [ + ...prev, + { kind: "user", text: shown, attachments: p.attachments, ts: Date.now() / 1000 }, + ...(p.notice + ? [{ kind: "notice", tone: "info", text: p.notice } as Item] + : []), + ]); + sessionRef.current?.userMessage(p.text, p.attachments, p.model, p.skill); } }, onClose: () => setConnected(false), @@ -815,7 +859,7 @@ export function App() { // first connect, dropping the user's first message (the "send twice" bug). The scratch // dir is deterministic from `sessionId` server-side, so skipping that reconnect is safe. // eslint-disable-next-line react-hooks/exhaustive-deps - }, [booting, sessionId, agent, refreshSessions]); + }, [booting, sessionId, agent, refreshSessions, connectNonce]); // Stream-following (FB-004): auto-scroll only while the user is AT the bottom, so scrolling // up to read during a streaming turn sticks. `atBottomRef` is the live truth (per scroll @@ -890,6 +934,13 @@ export function App() { }, [surface, sessionId, browserRefreshKey, markUnattended]); const send = (text: string, attachments?: Attachment[], skill?: string) => { + // UX-029: folder enforcement AT SEND. A code-family session with no folder has no + // socket yet (the connect effect waits) — stash the message and ask where to work; + // it goes out the moment the dialog resolves. + if (gatesWorkspace(agent) && !workspace) { + setSendGate({ text, attachments, skill }); + return; + } // Force-run shows exactly what the user typed: "/name rest". Must match the server's // `display` sidecar formula so the turn_start dedupe recognizes the local echo. const shown = skill ? `/${skill}${text ? ` ${text}` : ""}` : text; @@ -957,18 +1008,116 @@ export function App() { if (target !== agent) { setAgent(target); if (gatesWorkspace(target)) { - // Never inherit the previous persona's folder — it may be a scratch dir. Clearing it - // also blocks the connection effect, so nothing can chat behind the open gate. + // Never inherit the previous persona's folder — it may be a scratch dir. Clearing + // it also blocks the connection effect; the setup row's folder chip (or the + // send-time dialog) provides the folder — no modal gate up front (UX-029). setWorkspace(null); setBranch(null); - setShowGate(true); - } else setShowGate(false); + } + setShowGate(false); } // Knowledge family: a new conversation starts fresh (orphan) — clear the workspace so the - // server provisions a NEW scratch dir for the new session id. Code keeps its repo. - if (!gatesWorkspace(target)) setWorkspace(null); + // server provisions a NEW scratch dir for the new session id. Code keeps its repo — but + // never a TEMPORARY dir (per-conversation by definition; the next session picks anew). + if (!gatesWorkspace(target) || tempWorkspace) { + setWorkspace(null); + setBranch(null); + } + setTempWorkspace(false); setSessionId(newId()); }; + // UX-029: re-target the DRAFT session (no messages yet) to another coworker. Unlike + // switchAgent this never resumes that coworker's last conversation — the user is + // composing a new one. A fresh id keeps knowledge families' per-conversation scratch + // dirs clean and re-triggers the connection effect. + const pickCoworker = (id: string) => { + if (id === agent) return; + setAgent(id); + setWorkspace(null); + setBranch(null); + setTempWorkspace(false); + setShowGate(false); + setSessionId(newId()); + }; + // UX-029: the setup row's folder chip — bind the draft to a folder before the first + // message. A fresh id re-triggers the connection effect with the folder attached. + const pickDraftFolder = (path: string, b?: string | null) => { + setWorkspace(path); + setBranch(b ?? null); + setTempWorkspace(false); + setSessionId(newId()); + getRecentWorkspaces().then(setProjects).catch(() => {}); + }; + // UX-029 send-time dialog resolutions: bind the folder, park the stashed message for + // the reconnect's onOpen, and let it fly. The user's send already happened — no second + // click needed. + const resolveSendFolder = (path: string, b?: string | null) => { + const gate = sendGate; + if (!gate) return; + setSendGate(null); + setWorkspace(path); + setBranch(b ?? null); + setTempWorkspace(false); + pendingPromptRef.current = { ...gate, model }; + setSessionId(newId()); + getRecentWorkspaces().then(setProjects).catch(() => {}); + }; + const startTempAndSend = async () => { + const gate = sendGate; + if (!gate) return; + const sid = newId(); + const res = await createTempWorkspace(sid, true); + if (!res.ok || !res.path) { + setSendGate(null); + setItems((p) => [ + ...p, + { kind: "notice", tone: "warn", text: res.error || "Could not create a temporary folder." }, + ]); + prefillComposer(gate.skill ? `/${gate.skill} ${gate.text}` : gate.text, gate.attachments); + return; + } + setSendGate(null); + setWorkspace(res.path); + setBranch(null); + setTempWorkspace(true); + pendingPromptRef.current = { + ...gate, + model, + notice: res.git ? "Temporary folder created · git initialized" : "Temporary folder created", + }; + setSessionId(sid); + }; + const cancelSendGate = () => { + const gate = sendGate; + setSendGate(null); + // Give the draft back — the composer cleared it when the user hit send. + if (gate) prefillComposer(gate.skill ? `/${gate.skill} ${gate.text}` : gate.text, gate.attachments); + }; + // UX-029 "Save as project…": move the temporary folder somewhere real, then reconnect + // so the engine rebinds to the new path (same session id — the transcript stays). + const saveAsProject = async () => { + if (running) return; + const dest = await chooseFolder(); + if (!dest) return; + const res = await saveSessionAsProject(sessionId, dest); + if (!res.ok || !res.path) { + setItems((p) => [ + ...p, + { kind: "notice", tone: "warn", text: res.error || "Could not save as a project." }, + ]); + return; + } + const newPath = res.path; + setWorkspace(newPath); + setBranch(null); + setTempWorkspace(false); + setItems((p) => [ + ...p, + { kind: "notice", tone: "info", text: `Saved as a project — now working in ${baseName(newPath)}.` }, + ]); + setConnectNonce((n) => n + 1); + refreshSessions(); + }; // Inbox → session: the item carries its session's workspace/agent, so open it directly. // UX-026: 5s top-right toast when a SCHEDULED automation run starts (never for // manual Run-now — the user is already watching). Rides the app-wide /ws/events @@ -1016,6 +1165,7 @@ export function App() { setStreaming(""); setRunning(false); if (ag) setAgent(ag); + setTempWorkspace(false); // the `ready` event restores the truth for temp sessions if (!gatesWorkspace(ag)) setShowGate(false); if (ws && ws !== workspace) { setWorkspace(ws); // switch project to the session's folder @@ -1048,8 +1198,9 @@ export function App() { // The live workspace is only a valid fallback for a gated persona if it came from // another gated persona — a knowledge persona's workspace is a scratch dir, and a - // code-family session must never adopt one. (`agent` is still the previous persona here.) - const inheritable = gatesWorkspace(agent) ? workspace : null; + // code-family session must never adopt one. Same for a code session's TEMPORARY dir: + // per-conversation, never inherited. (`agent` is still the previous persona here.) + const inheritable = gatesWorkspace(agent) && !tempWorkspace ? workspace : null; if (target) { // Code falls back to a recent folder; Cowork resumes its scratch (target.workspace) or @@ -1174,7 +1325,7 @@ export function App() { const runTaskNow = async (taskId: string, title?: string) => { const r = await runAutomation(taskId); if (!r || !r.ok) return; - pendingPromptRef.current = r.prompt; + pendingPromptRef.current = { text: r.prompt }; activeRunRef.current = { taskId, runId: r.run_id, sessionId: r.session_id }; openRunSession(r.session_id, r.workspace, r.agent, { id: taskId, title: title || "" }); }; @@ -1193,10 +1344,13 @@ export function App() { const modelDisplay = modelLabels[model]?.split(" · ")[0] || (model.includes(":") ? model.split(":").slice(1).join(":") : model); - // Persona name dropped for this release (owner ask 2026-07-22): personas are hidden, - // so "Coworker" read as noise. The model (+ project folder) are the real fixed facts. - const subtitleParts = [modelDisplay]; - if (isProjectScoped(personaOf(agent)) && workspace) subtitleParts.push(baseName(workspace)); + // UX-029: with the coworker picker shipping, the coworker's name is a fixed fact again + // (it was dropped 2026-07-22 while personas were hidden). For temporary folders the raw + // path never shows — "Temporary folder" + the Save as project… affordance instead. + const subtitleParts = [fullPersonaName(personaOf(agent)?.name, agent), modelDisplay]; + if (isProjectScoped(personaOf(agent)) && workspace) + subtitleParts.push(tempWorkspace ? "Temporary folder" : baseName(workspace)); + const showSaveAsProject = hasHistory && tempWorkspace && isProjectScoped(personaOf(agent)); const activeInfo = sessions.find((s) => s.session_id === sessionId); const activeTitle = activeInfo?.title || "New session"; @@ -1362,7 +1516,6 @@ export function App() { onOpenPersona={(id) => { openPersona(id, "session"); }} - onManagePersonas={() => openSettings("personas")} onOpenScheduled={() => setSurface("scheduled")} onOpenAutomation={(id) => { setScheduledOpenId(id); @@ -1474,6 +1627,19 @@ export function App() { {hasHistory && ( {subtitleParts.join(" · ")} + {showSaveAsProject && ( + <> + {" · "} + + + )} )} @@ -1625,6 +1791,21 @@ export function App() { )} + {/* UX-029: per-session setup (coworker + folder) lives in its own quiet row + above the composer — never inside the per-message control row. One-time + pick: the whole row leaves after the first message; its facts move to the + session header. */} + {idle && !sessionId.startsWith("__run__") && ( + openSettings("personas")} + /> + )} setSurface("integrations")} /> @@ -1729,6 +1910,16 @@ export function App() { /> )} + {/* UX-029: the send-time folder dialog — the stashed message flies as soon as a + choice lands; Escape/backdrop restores the draft to the composer. */} + {sendGate && surface === "session" && ( + void startTempAndSend()} + onCancel={cancelSendGate} + /> + )} {showGate && surface === "session" && gatesWorkspace(agent) && ( { + const res = await fetch(`${httpBase()}/v1/workspaces/temp`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ session_id: sessionId, git }), + }); + return res.json(); +} + +/** UX-029 "Save as project…": move a session's temporary folder to a real location. + * Callers reconnect afterwards so the engine rebinds to the new path. */ +export async function saveSessionAsProject( + sessionId: string, + path: string, +): Promise<{ ok: boolean; path?: string; error?: string }> { + const res = await fetch( + `${httpBase()}/v1/sessions/${encodeURIComponent(sessionId)}/save-as-project`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ path }), + }, + ); + return res.json(); +} + export async function getTrustedWorkspaces(): Promise { const res = await fetch(`${httpBase()}/v1/workspaces/trusted`); return (await res.json()).workspaces ?? []; diff --git a/surfaces/gui/src/components/AccessSection.tsx b/surfaces/gui/src/components/AccessSection.tsx index 15faa6db..475748b4 100644 --- a/surfaces/gui/src/components/AccessSection.tsx +++ b/surfaces/gui/src/components/AccessSection.tsx @@ -216,8 +216,12 @@ export function AccessSection({ : names.length <= 2 ? names.join(", ") : `${names.slice(0, 2).join(", ")} +${names.length - 2}`; + // A temporary dir's raw name (the session id) never shows — say "Temporary folder"; a + // draft with no folder picked yet shows none at all (UX-029). const folderPart = projectScoped - ? baseName(workspace || roots.find((r) => r.primary)?.path || "") || null + ? scratchPrimary + ? "Temporary folder" + : baseName(workspace || "") || null : roots.length > 0 ? `${roots.length} folder${roots.length === 1 ? "" : "s"}` : null; diff --git a/surfaces/gui/src/components/GalleryModal.tsx b/surfaces/gui/src/components/GalleryModal.tsx index dbb36ed8..957a01d0 100644 --- a/surfaces/gui/src/components/GalleryModal.tsx +++ b/surfaces/gui/src/components/GalleryModal.tsx @@ -198,7 +198,7 @@ export function GalleryModal({ )}
- All personas + All coworkers
{visible.map((p) => { @@ -242,15 +242,15 @@ export function GalleryModal({ {source === "team" ? "Nothing shared with your team yet." : q - ? "No personas match your search." - : "No personas published yet."} + ? "No coworkers match your search." + : "No coworkers published yet."}
)} {source !== "team" && teamCount === 0 && (
- From your team — nothing shared yet. Publishing a persona to your teammates is coming soon. + From your team — nothing shared yet. Publishing a coworker to your teammates is coming soon.
)} @@ -372,7 +372,7 @@ export function GalleryModal({
-
Persona Gallery
+
Coworker Gallery
Curated coworkers · installs stay disabled until you approve them
@@ -381,7 +381,7 @@ export function GalleryModal({ setQuery(e.target.value)} - placeholder="Search personas" + placeholder="Search coworkers" className="w-[180px] px-3 py-1.5 rounded-lg border border-line bg-paper text-[12.5px] text-ink outline-none focus:border-accent" /> )} diff --git a/surfaces/gui/src/components/PersonaView.tsx b/surfaces/gui/src/components/PersonaView.tsx index 74e7b577..95528333 100644 --- a/surfaces/gui/src/components/PersonaView.tsx +++ b/surfaces/gui/src/components/PersonaView.tsx @@ -51,7 +51,7 @@ export function PersonaView({ setError(null); getPersonaDetail(personaId) .then((d) => live && setDetail(d)) - .catch(() => live && setError("Could not load this persona.")); + .catch(() => live && setError("Could not load this coworker.")); getConnectors() .then((list) => live && setByName(indexConnectors(list))) .catch(() => {}); @@ -88,7 +88,7 @@ export function PersonaView({ · )} - Persona + Coworker
); @@ -119,7 +119,7 @@ export function PersonaView({
{detail.enabled ? "Enabled" : "Disabled"} - +
@@ -153,7 +153,7 @@ export function PersonaView({
Connections for full benefit

- Declared by the persona — wire {shortPersonaName(detail.name, personaId)} into these + Declared by the coworker — wire {shortPersonaName(detail.name, personaId)} into these to unlock its full workflow.

diff --git a/surfaces/gui/src/components/PersonasTab.tsx b/surfaces/gui/src/components/PersonasTab.tsx index a16df8c9..1990bbb6 100644 --- a/surfaces/gui/src/components/PersonasTab.tsx +++ b/surfaces/gui/src/components/PersonasTab.tsx @@ -90,14 +90,14 @@ export function PersonasTab({ onOpenPersona }: { onOpenPersona?: (id: string) => } setConsent(r.consent || []); if (r.personas) setPersonas(r.personas); - setMsg(`Installed ${(r.consent || []).length} persona(s) — review and enable below.`); + setMsg(`Installed ${(r.consent || []).length} coworker(s) — review and enable below.`); setSrc(""); }; return (

- Enable a coworker, then choose whether it appears in the new-session picker. The starred persona + Enable a coworker, then choose whether it appears in the coworker picker. The starred coworker is the default for new sessions.

@@ -167,7 +167,7 @@ export function PersonasTab({ onOpenPersona }: { onOpenPersona?: (id: string) => ) : (
-
Add personas
+
Add coworkers

Load from a local directory or a public GitHub repo. Files are copied into a managed area (a - snapshot), so the persona stays stable even if the source changes. No code runs — a persona only + snapshot), so the coworker stays stable even if the source changes. No code runs — a coworker only composes vetted tools.

@@ -218,7 +218,7 @@ export function PersonasTab({ onOpenPersona }: { onOpenPersona?: (id: string) => setSrc(e.target.value)} onKeyDown={(e) => e.key === "Enter" && install()} diff --git a/surfaces/gui/src/components/RootRow.tsx b/surfaces/gui/src/components/RootRow.tsx index 69d5d083..dcd5384a 100644 --- a/surfaces/gui/src/components/RootRow.tsx +++ b/surfaces/gui/src/components/RootRow.tsx @@ -4,7 +4,7 @@ import { baseName } from "../paths"; // One directory row, shared by the composer popover and the session start panel. The primary is the // session's bound workspace — the repo/folder for Code/Ops (shown by name), or a throwaway scratch -// for Cowork (shown as "Temporary space"). It's always read-write and can't be removed. +// for Cowork (shown as "Temporary folder"). It's always read-write and can't be removed. export function RootRow({ root, busy, @@ -23,7 +23,7 @@ export function RootRow({ }) { const label = root.primary ? scratchPrimary - ? "Temporary space" + ? "Temporary folder" : baseName(root.path) : root.label; return ( diff --git a/surfaces/gui/src/components/SendFolderDialog.tsx b/surfaces/gui/src/components/SendFolderDialog.tsx new file mode 100644 index 00000000..61d49880 --- /dev/null +++ b/surfaces/gui/src/components/SendFolderDialog.tsx @@ -0,0 +1,104 @@ +import { useEffect, useState } from "react"; +import { getRecentWorkspaces, openWorkspace, type RecentWorkspace } from "../api"; +import { chooseFolder } from "../tauri"; +import { baseName } from "../paths"; +import { Icon } from "./Icon"; + +// UX-029: folder enforcement AT SEND, not at session start. A code-family coworker with no +// folder picked gets this dialog when the user hits send; the message goes out the moment a +// choice lands (recents / native picker / temporary folder). Escape restores the draft. + +interface Props { + coworkerName: string; + onPick: (path: string, branch?: string | null) => void; + onTemp: () => void; + onCancel: () => void; +} + +export function SendFolderDialog({ coworkerName, onPick, onTemp, onCancel }: Props) { + const [recents, setRecents] = useState([]); + const [error, setError] = useState(""); + const [busy, setBusy] = useState(false); + + useEffect(() => { + getRecentWorkspaces().then(setRecents).catch(() => {}); + }, []); + + useEffect(() => { + const onKey = (e: KeyboardEvent) => { + if (e.key === "Escape") onCancel(); + }; + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, [onCancel]); + + const pick = async (path: string) => { + setError(""); + const res = await openWorkspace(path); + if (res.ok) onPick(res.path, res.git_branch); + else setError(res.error || "could not open that folder"); + }; + + const browse = async () => { + const picked = await chooseFolder(); + if (picked) await pick(picked); + }; + + return ( +
+
e.stopPropagation()} + > +

+ Where should {coworkerName} work? +

+

+ Code work happens inside a folder — pick your project, or start somewhere temporary. +

+ {recents + .filter((w) => w.exists) + .slice(0, 4) + .map((w) => ( + + ))} +
+ + +
+ {error &&
{error}
} +

+ A temporary folder is created only when you send, with git ready — you can save it as a + project later. Your message sends as soon as you choose. +

+
+
+ ); +} diff --git a/surfaces/gui/src/components/SessionSetupRow.tsx b/surfaces/gui/src/components/SessionSetupRow.tsx new file mode 100644 index 00000000..5bad4856 --- /dev/null +++ b/surfaces/gui/src/components/SessionSetupRow.tsx @@ -0,0 +1,148 @@ +import { useState } from "react"; +import { getRecentWorkspaces, openWorkspace, type Persona, type RecentWorkspace } from "../api"; +import { chooseFolder } from "../tauri"; +import { fullPersonaName } from "../personaScope"; +import { baseName } from "../paths"; +import { Icon } from "./Icon"; + +// UX-029: the session-setup row — per-SESSION choices (coworker + folder) in their own +// quiet chip row above the composer, a different species from the per-MESSAGE controls +// inside it. Rendered only before the first message; after that the whole row leaves and +// its facts move to the session header. Chips are borderless (position, not a border, +// marks them as different) and the coworker chip carries no icon — both owner calls. + +interface Props { + personas: Persona[] | null; + agent: string; + // The folder chip renders only for personas that work in a folder (Chat hides it). + showFolder: boolean; + // The user's explicit folder pick for this draft, if any (never a temporary dir's path). + folderName: string | null; + onPickCoworker: (id: string) => void; + onPickFolder: (path: string, branch?: string | null) => void; + onManage: () => void; +} + +export function SessionSetupRow(props: Props) { + const [openMenu, setOpenMenu] = useState<"coworker" | "folder" | null>(null); + const [recents, setRecents] = useState(null); + const [error, setError] = useState(""); + const personas = (props.personas || []).filter((p) => p.enabled); + const current = personas.find((p) => p.id === props.agent); + + const toggle = (menu: "coworker" | "folder") => { + setError(""); + if (menu === "folder" && openMenu !== "folder") { + getRecentWorkspaces().then(setRecents).catch(() => setRecents([])); + } + setOpenMenu((cur) => (cur === menu ? null : menu)); + }; + + const pickFolder = async (path: string) => { + const res = await openWorkspace(path); + if (!res.ok) { + setError(res.error || "could not open that folder"); + return; + } + setOpenMenu(null); + props.onPickFolder(res.path, res.git_branch); + }; + + const browse = async () => { + const picked = await chooseFolder(); + if (picked) await pickFolder(picked); + }; + + const chip = + "relative inline-flex items-center gap-1.5 px-2 py-1.5 rounded-lg text-[12.5px] text-muted hover:text-ink hover:bg-paper cursor-pointer select-none whitespace-nowrap"; + + return ( +
+ {openMenu &&
setOpenMenu(null)} />} + + {/* Coworker chip — name only, no icon (owner call). */} +
+ + {openMenu === "coworker" && ( +
+ {personas.map((p) => ( + + ))} +
+ +
+
+ )} +
+ + {/* Folder chip — only for personas that work in a folder. */} + {props.showFolder && ( +
+ + {openMenu === "folder" && ( +
+ {(recents || []) + .filter((w) => w.exists) + .slice(0, 5) + .map((w) => ( + + ))} +
w.exists) ? "border-t border-line mt-1 pt-1" : ""}> + +
+ {error &&
{error}
} +
+ )} +
+ )} +
+ ); +} diff --git a/surfaces/gui/src/components/SettingsView.tsx b/surfaces/gui/src/components/SettingsView.tsx index ac2ef488..d294e73c 100644 --- a/surfaces/gui/src/components/SettingsView.tsx +++ b/surfaces/gui/src/components/SettingsView.tsx @@ -73,7 +73,7 @@ const SET_TABS: { { key: "skills", label: "Skills", icon: "book" }, { key: "voice", label: "Voice input", icon: "mic" }, { key: "memory", label: "Memory", icon: "archive" }, - { key: "personas", label: "Personas", icon: "sparkle" }, + { key: "personas", label: "Coworkers", icon: "sparkle" }, ]; export function SettingsView({ @@ -378,8 +378,8 @@ function PersonasSection({ onOpenPersona }: { onOpenPersona?: (id: string) => vo return (
- {/* New session: split button — primary starts the last-used persona; ▾ picks a specific one. */} - + {/* New session: a plain button — the coworker pick moved to the composer's setup + row (UX-029), so the old ▾ persona menu is gone. Starts the last-used persona; + the setup row re-targets the draft in place. */} +
+ +
{/* Search: a borderless nav-style entry (not a boxed input) that opens the command-palette SearchModal over the whole app. Matches the bottom-nav rows to reduce the boxy look. */} @@ -1284,95 +1286,3 @@ export function Sidebar(props: Props) {
); } - -// New-session split button (§8): the primary action starts a session with the last-used persona -// (`current`); the ▾ opens a menu of the enabled personas (from /v1/personas) plus a "Manage -// personas…" entry. A plain custom split control — the pill-shaped Dropdown doesn't fit this shape. -function NewSessionSplit({ - personas, - current, - onNew, - onManage, -}: { - personas: Persona[] | null; - current: string; - onNew: (agent: string) => void; - onManage: () => void; -}) { - const [open, setOpen] = useState(false); - const enabled = (personas || []).filter((p) => p.enabled); - // With a single enabled persona there is nothing to pick — the split collapses to a plain - // button (owner ask 2026-07-09). `personas === null` (still loading) keeps the split so the - // control doesn't visibly change shape once the list arrives with 2+. - const solo = personas !== null && enabled.length <= 1; - return ( -
-
- - {!solo && ( - - )} -
- {open && ( - <> -
setOpen(false)} /> -
-
- Start a session as -
- {enabled.map((p) => ( - - ))} - {showPersonas() && ( -
- -
- )} -
- - )} -
- ); -} diff --git a/surfaces/gui/src/flags.ts b/surfaces/gui/src/flags.ts index 9d90bebc..969c165f 100644 --- a/surfaces/gui/src/flags.ts +++ b/surfaces/gui/src/flags.ts @@ -15,7 +15,7 @@ function flag(key: string, fallback: boolean): boolean { return fallback; } -/** Personas management is hidden for launch (owner call, 2026-07-19): the Settings tab - * and the "Manage personas…" menu entry stay off until the persona catalog is ready. - * The e2e suite sets `ocw.flag.personas` to keep the hidden flows covered. */ -export const showPersonas = () => flag("ocw.flag.personas", false); +/** Coworkers shipped with UX-029 (the composer's setup-row picker): the Settings ▸ + * Coworkers tab and management flows are ON by default. `ocw.flag.personas` = "0" is the + * escape hatch to hide them again. (Hidden for launch 2026-07-19 → enabled 2026-08-10.) */ +export const showPersonas = () => flag("ocw.flag.personas", true); diff --git a/tests/test_persona_connections.py b/tests/test_persona_connections.py index 58ac5b18..704c3514 100644 --- a/tests/test_persona_connections.py +++ b/tests/test_persona_connections.py @@ -65,7 +65,7 @@ def test_persona_detail_endpoint(tmp_path, monkeypatch): # identity + capabilities (from the manifest/entry) assert detail["id"] == "ops" assert detail["name"] == "Ops Coworker" - assert detail["enabled"] is False # non-default personas ship disabled (opt-in) + assert detail["enabled"] is True # builtins ship enabled (UX-029) assert ( detail["workspace"] == "deliverable" ) # §16 collapse: ops is a scratch persona now @@ -131,7 +131,7 @@ def test_persona_enable_toggle(tmp_path, monkeypatch): client = TestClient(create_app(mgr)) before = {p["id"]: p for p in client.get("/v1/personas").json()["personas"]} - assert before["ops"]["enabled"] is False # ships disabled; only cowork starts on + assert before["ops"]["enabled"] is True # builtins ship enabled (UX-029) assert before["cowork"]["enabled"] is True resp = client.post("/v1/personas/ops/enable", json={"enabled": True}).json() diff --git a/tests/test_persona_registry.py b/tests/test_persona_registry.py index 99ad55f0..d85148da 100644 --- a/tests/test_persona_registry.py +++ b/tests/test_persona_registry.py @@ -20,29 +20,27 @@ def test_builtins_present(tmp_path): assert reg.get("code").manifest is None -def test_sidebar_defaults_to_cowork_only(tmp_path): +def test_sidebar_defaults_to_surfaced_builtins(tmp_path): reg = _reg(tmp_path) sidebar = reg.sidebar() ids = [e["name"] for e in sidebar] - # A fresh install offers ONLY the default persona (owner call 2026-07-09); - # everything else is opt-in from Settings ▸ Personas. - assert ids == ["cowork"] - assert sidebar[0]["default"] is True - # Enabling adds to the picker (enable implies surface). - reg.set_enabled("code", True) - reg.set_enabled("ops", True) - ids = [e["name"] for e in reg.sidebar()] + # Built-ins ship enabled (UX-029: the coworker picker is their front door); Chat + # stays default-hidden via the surfaced axis. Installed personas remain opt-in. assert ids[0] == "cowork" assert set(ids) == {"cowork", "code", "ops"} + assert sidebar[0]["default"] is True + # An explicit disable removes a builtin from the picker. + reg.set_enabled("code", False) + assert "code" not in [e["name"] for e in reg.sidebar()] -def test_chat_disabled_by_default_but_resolvable(tmp_path): +def test_chat_hidden_by_default_but_resolvable(tmp_path): reg = _reg(tmp_path) - assert reg.is_surfaced("chat") is False # default-hidden - assert reg.is_enabled("chat") is False # opt-in like every non-default persona + assert reg.is_surfaced("chat") is False # default-hidden from the grouped nav + assert reg.is_enabled("chat") is True # builtins ship enabled (UX-029) assert reg.agent("chat").name == "chat" # live sessions keep resolving - # The user can enable it from the Personas tab (enable implies surface). - reg.set_enabled("chat", True) + # Surfacing it adds it to the sidebar picker too. + reg.set_surfaced("chat", True) assert "chat" in [e["name"] for e in reg.sidebar()] diff --git a/tests/test_server.py b/tests/test_server.py index 495de260..b897d3c9 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -60,10 +60,10 @@ def test_chat_completions_openai_shape(tmp_path): def test_agents_and_memory_rest(tmp_path): client = _client(tmp_path, []) agents = client.get("/v1/agents").json()["agents"] - # The picker lists enabled+surfaced personas — a fresh install is cowork-only - # (non-default personas ship disabled, opt-in from Settings ▸ Personas). + # The picker lists enabled+surfaced personas — builtins ship enabled (UX-029); + # Chat stays default-hidden via the surfaced axis. names = [a["name"] for a in agents] - assert names == ["cowork"] + assert names == ["cowork", "code", "ops"] assert "skills" in client.get("/v1/skills").json() # catalog (may be empty) added = client.post("/v1/memory", json={"content": "prefer pathlib"}).json() diff --git a/tests/test_temp_workspace.py b/tests/test_temp_workspace.py new file mode 100644 index 00000000..ef430e1f --- /dev/null +++ b/tests/test_temp_workspace.py @@ -0,0 +1,114 @@ +"""UX-029 — temporary workspaces for code-family sessions. + +"Start in a temporary folder": the dir is created at SEND time via POST /v1/workspaces/temp +(git-init'd for code work), flagged in the `ready` event as `temp_workspace`, and can later be +moved to a real location via "Save as project…" (POST /v1/sessions/{id}/save-as-project). +""" + +from pathlib import Path + +from fastapi.testclient import TestClient + +from coworker.providers import ModelCapabilities, ProviderClient +from coworker.server import create_app +from coworker.server.manager import SessionManager +from coworker.sessions import SessionRecord + + +class ScriptedProvider(ProviderClient): + def __init__(self, turns=None): + self._turns = list(turns or []) + + def complete(self, *, model, messages, tools=None, **settings): + return self._turns.pop(0) + + def capabilities(self, model): + return ModelCapabilities() + + +def _mgr(tmp_path, monkeypatch) -> SessionManager: + monkeypatch.setenv("COWORKER_STATE_DIR", str(tmp_path / "state")) + mgr = SessionManager(workspace=tmp_path, provider=ScriptedProvider([])) + mgr._prefs["scratch_base"] = str(tmp_path / "scratch") + return mgr + + +def test_provision_temp_workspace_creates_dir_with_git(tmp_path, monkeypatch): + mgr = _mgr(tmp_path, monkeypatch) + client = TestClient(create_app(mgr)) + + res = client.post("/v1/workspaces/temp", json={"session_id": "abc123", "git": True}).json() + assert res["ok"] is True + d = Path(res["path"]) + assert d.is_dir() + assert d.parent == (tmp_path / "scratch").resolve() + assert res["git"] is True and (d / ".git").is_dir() + + # Idempotent — a re-send against the existing dir is a no-op. + again = client.post("/v1/workspaces/temp", json={"session_id": "abc123"}).json() + assert again["ok"] is True and again["path"] == res["path"] + + # It IS a temp workspace, and never appears in the recents (project) list. + assert mgr.is_temp_workspace(res["path"]) is True + mgr.session_store.touch_workspace(res["path"]) + assert res["path"] not in [w["path"] for w in mgr.recent_workspaces()] + + +def test_provision_temp_workspace_rejects_bad_ids(tmp_path, monkeypatch): + mgr = _mgr(tmp_path, monkeypatch) + client = TestClient(create_app(mgr)) + for bad in ["", "../evil", "a/b", ".."]: + assert client.post("/v1/workspaces/temp", json={"session_id": bad}).json()["ok"] is False + + +def test_save_temp_as_project_moves_and_rebinds(tmp_path, monkeypatch): + mgr = _mgr(tmp_path, monkeypatch) + client = TestClient(create_app(mgr)) + + src = Path(client.post("/v1/workspaces/temp", json={"session_id": "sess1"}).json()["path"]) + (src / "notes.txt").write_text("hello", encoding="utf-8") + mgr.session_store.save( + SessionRecord( + session_id="sess1", + workspace=str(src), + model="m", + mode="interactive", + messages=[{"role": "user", "content": "hi"}], + agent="code", + ) + ) + + dest = tmp_path / "projects" / "myproj" + res = client.post("/v1/sessions/sess1/save-as-project", json={"path": str(dest)}).json() + assert res["ok"] is True + moved = Path(res["path"]) + assert moved == dest.resolve() + assert (moved / "notes.txt").read_text(encoding="utf-8") == "hello" + assert not src.exists() + assert mgr.session_store.load("sess1").workspace == str(moved) + assert mgr.is_temp_workspace(str(moved)) is False + + +def test_save_temp_as_project_guards(tmp_path, monkeypatch): + mgr = _mgr(tmp_path, monkeypatch) + client = TestClient(create_app(mgr)) + + # Not a temp workspace → refused. + real = tmp_path / "realproj" + real.mkdir() + mgr.session_store.save( + SessionRecord(session_id="s2", workspace=str(real), model="m", mode="interactive", agent="code") + ) + res = client.post("/v1/sessions/s2/save-as-project", json={"path": str(tmp_path / "x")}).json() + assert res["ok"] is False + + # Non-empty destination → refused, source untouched. + src = Path(client.post("/v1/workspaces/temp", json={"session_id": "s3"}).json()["path"]) + mgr.session_store.save( + SessionRecord(session_id="s3", workspace=str(src), model="m", mode="interactive", agent="code") + ) + full = tmp_path / "full" + full.mkdir() + (full / "occupied.txt").write_text("x", encoding="utf-8") + res = client.post("/v1/sessions/s3/save-as-project", json={"path": str(full)}).json() + assert res["ok"] is False and src.is_dir() From 4908c8402e0aba7b835d7086d0579a1c8e408bc4 Mon Sep 17 00:00:00 2001 From: Rohit C Prasad Date: Mon, 10 Aug 2026 22:08:03 -0700 Subject: [PATCH 02/80] coworker picker: 'Use temporary folder' copy; retire Chat persona Chat ships disabled+unsurfaced (Coworker covers quick Q&A); recoverable from Settings. --- coworker/personas/registry.py | 22 +++++++++++++------ surfaces/gui/e2e/fixtures.ts | 2 +- .../gui/src/components/SendFolderDialog.tsx | 2 +- tests/test_persona_registry.py | 12 +++++----- 4 files changed, 24 insertions(+), 14 deletions(-) diff --git a/coworker/personas/registry.py b/coworker/personas/registry.py index 06ccfb2b..14ce375b 100644 --- a/coworker/personas/registry.py +++ b/coworker/personas/registry.py @@ -50,6 +50,10 @@ class PersonaEntry: default_surfaced: bool = ( True # whether it shows in the picker before any user choice ) + # Whether it ships enabled before any user choice. Builtins default on (UX-029: the + # composer picker is their front door) — except retired ones (Chat: Coworker covers + # quick Q&A). Installed third-party personas always start disabled pending consent. + default_enabled: bool = True _builder: Optional[Callable[[], Agent]] = None manifest: Optional[PersonaManifest] = None @@ -101,6 +105,7 @@ class PersonaRegistry: tools, workspace="deliverable", default_surfaced=True, + default_enabled=True, ) -> None: self._entries[id] = PersonaEntry( id=id, @@ -113,13 +118,14 @@ class PersonaRegistry: workspace=workspace, tools=list(tools), default_surfaced=default_surfaced, + default_enabled=default_enabled, _builder=builder, ) def _load_builtin(self, builtin_dir: Optional[str | Path]) -> None: # Core surfaces keep their exact prompts via the existing builders. Cowork (the default) - # leads; Chat is hidden from the picker by default (Cowork covers quick Q&A) — recoverable - # from the Personas tab. + # leads; Chat is RETIRED (owner call 2026-08-11: Coworker covers quick Q&A) — it ships + # disabled and unsurfaced, recoverable from Settings ▸ Coworkers. self._register_builder( "cowork", "OpenWorker", @@ -153,6 +159,7 @@ class PersonaRegistry: [], workspace="none", default_surfaced=False, + default_enabled=False, ) # Markdown-backed built-ins (Ops, …) — dogfood the manifest path. d = Path(builtin_dir) if builtin_dir else Path(__file__).parent / "builtin" @@ -219,15 +226,16 @@ class PersonaRegistry: return self._entries.get(persona_id) def is_enabled(self, persona_id: str) -> bool: - # Explicit state (either way) always wins. Absent a user choice, BUILT-IN personas - # ship enabled — the composer picker is their front door (UX-029, supersedes the - # 2026-07-09 Coworker-only default that fit the old hidden ▾ menu). Installed - # third-party personas stay disabled until the user consents from the risk screen. + # Explicit state (either way) always wins. Absent a user choice, the entry's + # default applies: builtins ship enabled — the composer picker is their front door + # (UX-029, supersedes the 2026-07-09 Coworker-only default that fit the old hidden + # ▾ menu) — except retired ones (Chat). Installed third-party personas stay + # disabled until the user consents from the risk screen. if persona_id in self._enabled: return bool(self._enabled[persona_id]) entry = self._entries.get(persona_id) if entry is not None and entry.builtin: - return True + return entry.default_enabled return persona_id == self._default or persona_id == DEFAULT_PERSONA_ID def is_surfaced(self, persona_id: str) -> bool: diff --git a/surfaces/gui/e2e/fixtures.ts b/surfaces/gui/e2e/fixtures.ts index 48b3e5c7..6c44654a 100644 --- a/surfaces/gui/e2e/fixtures.ts +++ b/surfaces/gui/e2e/fixtures.ts @@ -57,7 +57,7 @@ const PERSONAS = { personas: [ { id: "cowork", name: "OpenWorker", icon: "cowork", tagline: "Produce a deliverable — research, analysis, scripts", needs_workspace: true, builtin: true, family: "knowledge", workspace: "deliverable", tools: ["files", "search"], enabled: true, surfaced: true, default: true }, { id: "code", name: "Code", icon: "code", tagline: "Work in a codebase — files, git, shell", needs_workspace: true, builtin: true, family: "code", workspace: "git", tools: ["code_files", "git"], enabled: true, surfaced: true, default: false }, - { id: "chat", name: "Chat", icon: "chat", tagline: "Quick questions — no workspace", needs_workspace: false, builtin: true, family: "knowledge", workspace: "none", tools: [], enabled: true, surfaced: false, default: false }, + { id: "chat", name: "Chat", icon: "chat", tagline: "Quick questions — no workspace", needs_workspace: false, builtin: true, family: "knowledge", workspace: "none", tools: [], enabled: false, surfaced: false, default: false }, { id: "ops", name: "Ops Coworker", icon: "wrench", tagline: "Operate and investigate — runbooks, logs, infrastructure", needs_workspace: true, builtin: true, family: "knowledge", workspace: "deliverable", tools: ["files", "shell"], enabled: true, surfaced: true, default: false }, // A non-builtin install (disabled pending consent — invisible to picker specs) so the // Personas page's delete/enable affordances have a target. diff --git a/surfaces/gui/src/components/SendFolderDialog.tsx b/surfaces/gui/src/components/SendFolderDialog.tsx index 61d49880..479121a5 100644 --- a/surfaces/gui/src/components/SendFolderDialog.tsx +++ b/surfaces/gui/src/components/SendFolderDialog.tsx @@ -90,7 +90,7 @@ export function SendFolderDialog({ coworkerName, onPick, onTemp, onCancel }: Pro }} disabled={busy} > - Start in a temporary folder + Use temporary folder
{error &&
{error}
} diff --git a/tests/test_persona_registry.py b/tests/test_persona_registry.py index d85148da..aa4443b7 100644 --- a/tests/test_persona_registry.py +++ b/tests/test_persona_registry.py @@ -34,13 +34,15 @@ def test_sidebar_defaults_to_surfaced_builtins(tmp_path): assert "code" not in [e["name"] for e in reg.sidebar()] -def test_chat_hidden_by_default_but_resolvable(tmp_path): +def test_chat_retired_by_default_but_resolvable(tmp_path): reg = _reg(tmp_path) - assert reg.is_surfaced("chat") is False # default-hidden from the grouped nav - assert reg.is_enabled("chat") is True # builtins ship enabled (UX-029) + # Chat is retired (owner call 2026-08-11): disabled + unsurfaced out of the box, + # unlike the other builtins — Coworker covers quick Q&A. + assert reg.is_enabled("chat") is False + assert reg.is_surfaced("chat") is False assert reg.agent("chat").name == "chat" # live sessions keep resolving - # Surfacing it adds it to the sidebar picker too. - reg.set_surfaced("chat", True) + # Still recoverable from Settings ▸ Coworkers (enable implies surface). + reg.set_enabled("chat", True) assert "chat" in [e["name"] for e in reg.sidebar()] From 5ea697d384bb773010b5311f64d7a706e2407daa Mon Sep 17 00:00:00 2001 From: Rohit C Prasad Date: Mon, 10 Aug 2026 22:16:38 -0700 Subject: [PATCH 03/80] personas: wire manifest skills + mcp into sessions (OPE-58) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bundle skills/ dir joins the persona's session menu (additive; user disables/mutes win); manifest skills: narrows the bundle; mcp: scopes raw servers. Install snapshot now carries the skills folder — the sharing bundle shape. --- coworker/agent.py | 7 +- coworker/personas/registry.py | 5 ++ coworker/server/manager.py | 98 ++++++++++++++++++++++++-- tests/test_persona_skills.py | 129 ++++++++++++++++++++++++++++++++++ 4 files changed, 231 insertions(+), 8 deletions(-) create mode 100644 tests/test_persona_skills.py diff --git a/coworker/agent.py b/coworker/agent.py index 063f0946..77761c48 100644 --- a/coworker/agent.py +++ b/coworker/agent.py @@ -217,6 +217,9 @@ def build_engine( connector_filter: Optional[set[str]] = None, # A set (static snapshot) or a zero-arg callable (live, re-evaluated per load_skill). skill_filter: Optional[set[str] | Callable[[], set[str]]] = None, + # Persona-carried skill folders (OPE-58): the bundle's skills/ dir joins the loader so + # its skills are readable by load_skill, not just listed by the filter. + extra_skill_dirs: Optional[list[str | Path]] = None, ) -> TurnEngine: ws = Path(workspace).expanduser().resolve() if workspace else None if agent.needs_workspace and ws is None: @@ -374,7 +377,9 @@ def build_engine( if block: instructions = f"{instructions}\n\n{block}" - skill_loader = SkillLoader(_skill_dirs(ws)) + # Persona dirs come FIRST so a user's global/workspace copy of the same name shadows + # the bundle's (later dirs overwrite earlier in the loader). + skill_loader = SkillLoader([Path(d) for d in (extra_skill_dirs or [])] + _skill_dirs(ws)) # Per-session effective menu (SKILLS-SPEC §3). The manager passes a CALLABLE so # load_skill consults the LIVE state per call (a Settings disable applies to running # sessions; a skill created after this build is still loadable). The catalog itself diff --git a/coworker/personas/registry.py b/coworker/personas/registry.py index 14ce375b..c7d9c445 100644 --- a/coworker/personas/registry.py +++ b/coworker/personas/registry.py @@ -387,6 +387,11 @@ class PersonaRegistry: dest_dir.mkdir(parents=True, exist_ok=True) dest = dest_dir / "manifest.md" shutil.copy2(md, dest) + # Bundle shape (OPE-58 / sharing v1): a `skills/` dir next to the manifest travels + # with the snapshot, so a persona's skills stay stable independent of the source. + src_skills = md.parent / "skills" + if src_skills.is_dir(): + shutil.copytree(src_skills, dest_dir / "skills", dirs_exist_ok=True) return dest def install_from_git( diff --git a/coworker/server/manager.py b/coworker/server/manager.py index ab24d2e9..70b46ad3 100644 --- a/coworker/server/manager.py +++ b/coworker/server/manager.py @@ -549,7 +549,14 @@ class SessionManager: connector_filter=self.effective_connectors(session_id, agent_name), # Per-session skill menu, LIVE (SKILLS-SPEC §3): a callable so load_skill sees # disables/new skills immediately; the catalog snapshot is taken at build. - skill_filter=lambda sid=session_id, w=ws: self.effective_skill_names(sid, w), + skill_filter=lambda sid=session_id, w=ws, a=agent_name: ( + self.effective_skill_names(sid, w, agent=a) + ), + # Persona-carried skills (OPE-58): the bundle's skills/ dir joins the loader + # so its skills are readable, not just listed. + extra_skill_dirs=( + [d] if (d := self.persona_skill_scope(agent_name)[0]) is not None else None + ), ) # An automation run rebuilt here (manual "Run now" over WS, durable resume) still # carries its task's standing allowances — the rules live on the task record. @@ -617,6 +624,12 @@ class SessionManager: def _persona_of(self, session_id: str, persona_id: Optional[str] = None) -> str: if persona_id: return persona_id + # The live engine is the freshest truth — a brand-new session has no record row + # until its first send, but its socket already knows the persona. + engine = self._engines.get(session_id) + live = getattr(engine, "agent_name", None) if engine is not None else None + if live: + return live record = self.session_store.load(session_id) return (record.agent if record else None) or self.personas.default_id() @@ -991,6 +1004,13 @@ class SessionManager: loop = asyncio.get_running_loop() effective: Optional[set[str]] = None # computed lazily, once out: list[Any] = [] + # Persona `mcp:` wiring (OPE-58 sibling stub): a persona that declares an `mcp:` + # list SCOPES its sessions to those servers — the consent screen already presents + # that list as what the persona uses, so honoring it keeps consent truthful. It + # only ever shrinks: the user's enabled/configured/authed gates all still apply, + # and a persona with no list changes nothing. Connector-backed servers keep their + # own per-persona connector gating instead. + persona_mcp = self.persona_mcp_scope(agent) for server in load_mcp_servers( ws, secrets=self.secrets, @@ -1024,6 +1044,9 @@ class SessionManager: for t in mcp_tool_defs(server.name) if tool_enabled(self.secrets, server.name, t.name) ] + elif persona_mcp is not None and server.name not in persona_mcp: + # Raw servers outside the persona's declared scope stay off its sessions. + continue try: conn = await self.mcp.ensure(server) except Exception as exc: @@ -2825,8 +2848,11 @@ class SessionManager: # Scheduled runs respect the same per-session connection hierarchy as live sessions: # expose only the persona's effective-enabled connectors' tools (§4.3). connector_filter=self.effective_connectors(session_id, task.agent), - skill_filter=lambda sid=session_id, w=task.workspace: ( - self.effective_skill_names(sid, w) + skill_filter=lambda sid=session_id, w=task.workspace, a=task.agent: ( + self.effective_skill_names(sid, w, agent=a) + ), + extra_skill_dirs=( + [d] if (d := self.persona_skill_scope(task.agent)[0]) is not None else None ), ) self._seed_task_permissions(engine, task) @@ -3927,17 +3953,56 @@ class SessionManager: return {"ok": False, "error": str(exc)} return {"ok": True} + def persona_mcp_scope(self, persona_id: str) -> Optional[set[str]]: + """The persona's declared MCP-server scope (OPE-58 sibling stub): the manifest's + `mcp:` names, or None when it declares none (= no scoping). Only ever narrows — + the user's enabled/configured/authed gates apply regardless.""" + entry = self.personas.get(persona_id) + names = list((entry.manifest.mcp if entry and entry.manifest else []) or []) + return {n for n in names if n} or None + + def persona_skill_scope( + self, persona_id: str + ) -> tuple[Optional[Path], Optional[set[str]]]: + """The persona's own skill folder + optional allowlist (OPE-58). + + A manifest-backed persona carries skills as a `skills/` dir next to its manifest — + the sharing bundle shape (manifest + skill folders). The manifest's `skills:` list, + when non-empty, narrows which of those activate. Additive on top of global/project + scopes: the persona SHIPS skills; it never hides the user's own.""" + entry = self.personas.get(persona_id) + manifest = entry.manifest if entry else None + if manifest is None or not manifest.source: + return None, None + d = Path(manifest.source).parent / "skills" + if not d.is_dir(): + return None, None + allow = {s for s in manifest.skills if s} or None + return d, allow + def effective_skill_names( - self, session_id: str, workspace: Optional[str | Path] = None + self, + session_id: str, + workspace: Optional[str | Path] = None, + agent: Optional[str] = None, ) -> set[str]: """The session's skill menu (§3): merged scopes − Settings disables − session mutes. - The single resolver behind the engine catalog, the rail list, and the composer popup.""" + The single resolver behind the engine catalog, the rail list, and the composer popup. + Persona-carried skills (OPE-58) join the merge for the session's persona — user + disables and mutes still win over them.""" dirs = [self.skill_store.global_dir] if workspace: dirs.append(self.skill_store.project_dir(workspace)) loader = SkillLoader(dirs) + names = set(loader.names()) + persona_dir, allow = self.persona_skill_scope(self._persona_of(session_id, agent)) + if persona_dir is not None: + persona_names = set(SkillLoader([persona_dir]).names()) + if allow is not None: + persona_names &= allow + names |= persona_names return effective_skills( - names=set(loader.names()), + names=names, disabled=self.skill_store.disabled_names(), session_overrides=self.session_skills.get(session_id), ) @@ -3945,7 +4010,9 @@ class SessionManager: def session_skills_view( self, session_id: str, workspace: Optional[str] = None ) -> dict[str, Any]: - """The rail payload: every in-scope, Settings-enabled skill with its mute state.""" + """The rail payload: every in-scope, Settings-enabled skill with its mute state. + Persona-carried skills (OPE-58) appear with scope "coworker" — mutable per session + like any other, but owned by the persona bundle, not the Settings store.""" disabled = self.skill_store.disabled_names() overrides = self.session_skills.get(session_id) rows = [ @@ -3958,6 +4025,23 @@ class SessionManager: for r in self.skill_store.rows(workspace or None) if r["name"] not in disabled ] + seen = {r["name"] for r in rows} + persona_dir, allow = self.persona_skill_scope(self._persona_of(session_id)) + if persona_dir is not None: + for entry in SkillLoader([persona_dir]).catalog(): + name = entry["name"] + if name in seen or name in disabled: + continue # a global/project copy shadows the bundle's + if allow is not None and name not in allow: + continue + rows.append( + { + "name": name, + "description": entry["description"], + "scope": "coworker", + "enabled": overrides.get(name, True), + } + ) return {"skills": rows} def _scratch_workspace_error(self, workspace: Any) -> Optional[dict[str, Any]]: diff --git a/tests/test_persona_skills.py b/tests/test_persona_skills.py new file mode 100644 index 00000000..949fc0be --- /dev/null +++ b/tests/test_persona_skills.py @@ -0,0 +1,129 @@ +"""OPE-58 — persona-carried skills and MCP scoping. + +A persona bundle is a manifest + a sibling `skills/` dir. Bundle skills join the session +skill menu for THAT persona only (additive — never hiding the user's own), the manifest's +`skills:` list narrows which of them activate, and user disables/mutes always win. The +manifest's `mcp:` list scopes the persona's sessions to those raw MCP servers. +""" + +from __future__ import annotations + +from coworker.providers import ModelCapabilities, ProviderClient +from coworker.server.manager import SessionManager +from coworker.sessions import SessionRecord + +MANIFEST = """--- +id: sec-review +name: Security Reviewer +icon: shield +tagline: Reviews code for security issues +family: code +tools: [code_files, search] +{extra} +--- +You review code for security problems. +""" + + +class ScriptedProvider(ProviderClient): + def complete(self, *, model, messages, tools=None, **settings): + raise AssertionError("no turns expected") + + def capabilities(self, model): + return ModelCapabilities() + + +def _skill(base, name, description="a skill"): + d = base / name + d.mkdir(parents=True) + (d / "SKILL.md").write_text( + f"---\nname: {name}\ndescription: {description}\n---\nDo the thing.\n", + encoding="utf-8", + ) + + +def _mgr(tmp_path, monkeypatch) -> SessionManager: + monkeypatch.setenv("COWORKER_STATE_DIR", str(tmp_path / "state")) + return SessionManager(workspace=tmp_path, provider=ScriptedProvider()) + + +def _install(mgr, tmp_path, extra=""): + src = tmp_path / "vendor" + src.mkdir(exist_ok=True) + (src / "sec-review.md").write_text(MANIFEST.format(extra=extra), encoding="utf-8") + _skill(src / "skills", "semgrep-triage", "Triage semgrep findings") + _skill(src / "skills", "secret-scan", "Run gitleaks and triage hits") + mgr.personas.install_from_dir(src) + mgr.personas.set_enabled("sec-review", True) + + +def _session(mgr, sid, agent): + mgr.session_store.save( + SessionRecord(session_id=sid, workspace="", model="m", mode="interactive", agent=agent) + ) + + +def test_bundle_skills_join_the_persona_sessions_menu(tmp_path, monkeypatch): + mgr = _mgr(tmp_path, monkeypatch) + _install(mgr, tmp_path) + _session(mgr, "s-sec", "sec-review") + _session(mgr, "s-cowork", "cowork") + + # The snapshot carried the skills/ dir; the persona's sessions see the bundle skills… + names = mgr.effective_skill_names("s-sec") + assert {"semgrep-triage", "secret-scan"} <= names + # …other personas' sessions do not. + assert "semgrep-triage" not in mgr.effective_skill_names("s-cowork") + + # The rail view labels them with the coworker scope. + rows = {r["name"]: r for r in mgr.session_skills_view("s-sec")["skills"]} + assert rows["semgrep-triage"]["scope"] == "coworker" + + +def test_manifest_skills_list_narrows_the_bundle(tmp_path, monkeypatch): + mgr = _mgr(tmp_path, monkeypatch) + _install(mgr, tmp_path, extra="skills: [semgrep-triage]") + _session(mgr, "s1", "sec-review") + + names = mgr.effective_skill_names("s1") + assert "semgrep-triage" in names + assert "secret-scan" not in names + assert "secret-scan" not in { + r["name"] for r in mgr.session_skills_view("s1")["skills"] + } + + +def test_user_disable_and_mute_win_over_bundle_skills(tmp_path, monkeypatch): + mgr = _mgr(tmp_path, monkeypatch) + _install(mgr, tmp_path) + _session(mgr, "s1", "sec-review") + + # Settings disable beats the bundle. + mgr.skill_store.set_enabled("semgrep-triage", False) + assert "semgrep-triage" not in mgr.effective_skill_names("s1") + mgr.skill_store.set_enabled("semgrep-triage", True) + + # A session mute beats it too. + mgr.session_skills.set("s1", "secret-scan", False) + assert "secret-scan" not in mgr.effective_skill_names("s1") + + +def test_users_own_copy_shadows_the_bundle_row(tmp_path, monkeypatch): + mgr = _mgr(tmp_path, monkeypatch) + _install(mgr, tmp_path) + _session(mgr, "s1", "sec-review") + + mgr.skill_store.create( + name="semgrep-triage", description="my customized copy", instructions="mine", scope="global" + ) + rows = [r for r in mgr.session_skills_view("s1")["skills"] if r["name"] == "semgrep-triage"] + assert len(rows) == 1 and rows[0]["scope"] != "coworker" + + +def test_persona_mcp_scope(tmp_path, monkeypatch): + mgr = _mgr(tmp_path, monkeypatch) + _install(mgr, tmp_path, extra="mcp: [semgrep-server]") + # A declared list scopes; no list (builtin cowork) means no scoping. + assert mgr.persona_mcp_scope("sec-review") == {"semgrep-server"} + assert mgr.persona_mcp_scope("cowork") is None + assert mgr.persona_mcp_scope("nope") is None From b5b000eb76b07c837b7ff7f7e85b4d29a0f32aad Mon Sep 17 00:00:00 2001 From: Rohit C Prasad Date: Tue, 11 Aug 2026 06:20:19 -0700 Subject: [PATCH 04/80] personas: ship security coworker bundles (OPE-61 phase C) Security, Cloud Posture, and Dependency Audit coworkers as self-contained bundle dirs (manifest + skills) driving OSS scanners; registry loads bundle subdirs; packaging includes them. --- .../builtin/cloud-posture/manifest.md | 46 +++++++++++ .../cloud-posture/skills/aws-posture/SKILL.md | 30 +++++++ .../cloud-posture/skills/iac-scan/SKILL.md | 27 ++++++ .../personas/builtin/dep-audit/manifest.md | 42 ++++++++++ .../skills/dependency-audit/SKILL.md | 23 ++++++ .../dep-audit/skills/safe-upgrade-pr/SKILL.md | 22 +++++ .../personas/builtin/security/manifest.md | 48 +++++++++++ .../security/skills/secret-scan/SKILL.md | 30 +++++++ .../security/skills/security-fix-pr/SKILL.md | 24 ++++++ .../security/skills/semgrep-review/SKILL.md | 29 +++++++ coworker/personas/registry.py | 9 ++ pyproject.toml | 6 +- tests/test_persona_registry.py | 2 +- tests/test_security_bundles.py | 82 +++++++++++++++++++ tests/test_server.py | 6 +- 15 files changed, 422 insertions(+), 4 deletions(-) create mode 100644 coworker/personas/builtin/cloud-posture/manifest.md create mode 100644 coworker/personas/builtin/cloud-posture/skills/aws-posture/SKILL.md create mode 100644 coworker/personas/builtin/cloud-posture/skills/iac-scan/SKILL.md create mode 100644 coworker/personas/builtin/dep-audit/manifest.md create mode 100644 coworker/personas/builtin/dep-audit/skills/dependency-audit/SKILL.md create mode 100644 coworker/personas/builtin/dep-audit/skills/safe-upgrade-pr/SKILL.md create mode 100644 coworker/personas/builtin/security/manifest.md create mode 100644 coworker/personas/builtin/security/skills/secret-scan/SKILL.md create mode 100644 coworker/personas/builtin/security/skills/security-fix-pr/SKILL.md create mode 100644 coworker/personas/builtin/security/skills/semgrep-review/SKILL.md create mode 100644 tests/test_security_bundles.py diff --git a/coworker/personas/builtin/cloud-posture/manifest.md b/coworker/personas/builtin/cloud-posture/manifest.md new file mode 100644 index 00000000..5e941363 --- /dev/null +++ b/coworker/personas/builtin/cloud-posture/manifest.md @@ -0,0 +1,46 @@ +--- +id: cloud-posture +name: Cloud Posture Coworker +icon: sliders +tagline: Review Terraform & cloud config — read-only, evidence first +family: code +tools: [code_files, git, search, shell, todo] +connectors: true +skills: [iac-scan, aws-posture] +recommended_models: [anthropic:claude-opus-4-8, openai:gpt-5.6-sol] +default_permission_mode: interactive +description: An infrastructure-security reviewer for teams without a cloud security team. Scans Terraform and cloud configuration with open-source tools (trivy, tfsec, checkov), reads your live cloud posture strictly read-only, and fixes what matters in the IaC — never by clicking around a console. +recommends: + - connector: github + reason: open fix PRs for the Terraform changes + tier: optional +--- +You are the Cloud Posture Coworker — an infrastructure-security reviewer for teams that +run cloud infrastructure without a cloud security team. You find risky configuration in +Terraform and in the live account, explain what actually matters, and fix it at the +source: the code. + +How you work: +- You DRIVE scanners (trivy config / tfsec / checkov for IaC); your value is judgment — + which findings are real exposure for THIS architecture, and what the minimal safe + change is. +- Fix in the IaC, never in the console. A console fix is drift; a Terraform fix is + permanent. If something isn't in code yet, propose importing it. +- Cloud access is STRICTLY read-only: describe/list/get calls only. You never create, + modify, or delete cloud resources, and you never run `terraform apply` — you prepare + the change and its plan, the team applies it. +- Prioritize by exposure: internet-reachable > cross-account > internal. A public S3 + bucket outranks fifty tag-policy nits; say so plainly. +- Respect intent: some "findings" are deliberate (a public website bucket). Ask or + check context before "fixing" something that looks intentional. + +Operate safely: +- ALWAYS begin tool-using tasks with todo_write and keep it current — the Progress + panel is rendered from it. +- Check a scanner exists before using it; ask before installing anything. +- NEVER inline multi-line scripts in shell commands: write a file, then run it. +- Never print cloud credentials or full account identifiers in output. + +Finish with a deliverable: a posture summary (exposure-ranked findings, what you fixed +in code, what needs a human decision) and the fix branch/PR with its `terraform plan` +output attached. diff --git a/coworker/personas/builtin/cloud-posture/skills/aws-posture/SKILL.md b/coworker/personas/builtin/cloud-posture/skills/aws-posture/SKILL.md new file mode 100644 index 00000000..d1fa1ffc --- /dev/null +++ b/coworker/personas/builtin/cloud-posture/skills/aws-posture/SKILL.md @@ -0,0 +1,30 @@ +--- +name: aws-posture +description: Read-only AWS posture check — public exposure, IAM blast radius, hygiene +--- +Check the live AWS account's security posture using strictly read-only CLI calls, then +fix root causes in the IaC. + +HARD RULE: read-only means read-only — describe/list/get/simulate calls only. No +create/put/update/delete/attach, no `terraform apply`, ever. If a fix is needed, it goes +into Terraform for the team to apply. + +1. Confirm access and scope: `aws sts get-caller-identity` (mask the account id to its + last 4 digits in anything you write). Ask which regions matter; default to the ones + the Terraform state uses. +2. Sweep the high-signal surfaces, most exposed first: + - Public entry points: S3 buckets (`get-public-access-block`, bucket policies), + security groups open to 0.0.0.0/0 on sensitive ports, public RDS/ES endpoints, + ALB listeners without TLS. + - IAM blast radius: users with attached admin policies, wildcard `Action`/`Resource` + in customer-managed policies, stale access keys (`iam get-credential-report`), + roles with overly broad trust policies. + - Hygiene: CloudTrail on and multi-region, default EBS/S3 encryption, root-account + MFA (from the credential report). +3. Cross-reference each finding against the repo's Terraform: is the risky config + defined in code (fix it there), drifted from code (flag the drift), or unmanaged + (propose importing it)? +4. Deliver: an exposure-ranked posture report (finding · resource · evidence command · + where it's defined · action), the IaC fix branch for what's code-managed, and a + short list of items needing a human decision. Every claim carries the exact + read-only command that evidences it, so the team can re-run and verify. diff --git a/coworker/personas/builtin/cloud-posture/skills/iac-scan/SKILL.md b/coworker/personas/builtin/cloud-posture/skills/iac-scan/SKILL.md new file mode 100644 index 00000000..3fdcce98 --- /dev/null +++ b/coworker/personas/builtin/cloud-posture/skills/iac-scan/SKILL.md @@ -0,0 +1,27 @@ +--- +name: iac-scan +description: Scan Terraform/IaC with trivy or tfsec and fix what matters in code +--- +Scan the repo's infrastructure-as-code and turn findings into minimal, safe Terraform +changes. + +1. Pick the scanner that's present (check in this order, ask before installing): + - `trivy config . --format json -o /tmp/iac.json` (also covers Dockerfiles/k8s) + - `tfsec . --format json --out /tmp/iac.json` + - `checkov -d . -o json > /tmp/iac.json` +2. Triage by real exposure, reading the surrounding Terraform for each finding: + - Internet-reachable (0.0.0.0/0 ingress, public buckets/ALBs) first. + - Then identity blast radius (wildcard IAM, broad assume-role trust). + - Then encryption/logging hygiene. + Mark deliberate-looking configuration (a public website bucket, a bastion SG) as + "intentional?" and ask rather than auto-fix. +3. Fix in the module where the resource is DEFINED (follow module sources), matching + the repo's Terraform style — variables, locals, and tags the way the codebase + already does them. +4. Validate every change: `terraform fmt` on touched files, then `terraform init + -backend=false && terraform validate` when possible. Include `terraform plan` + output in the PR when the user can run it — NEVER run `terraform apply`. +5. Deliver: exposure-ranked findings table (resource · issue · verdict · action), the + fix branch/PR, and any "intentional?" items awaiting a human decision. Offer a + pinned scanner config (e.g. `.trivyignore` with justifications) only for findings + the team explicitly accepts. diff --git a/coworker/personas/builtin/dep-audit/manifest.md b/coworker/personas/builtin/dep-audit/manifest.md new file mode 100644 index 00000000..e3cc56c7 --- /dev/null +++ b/coworker/personas/builtin/dep-audit/manifest.md @@ -0,0 +1,42 @@ +--- +id: dep-audit +name: Dependency Audit Coworker +icon: audit +tagline: Vulnerable dependencies — audit, minimal upgrades, PRs +family: code +tools: [code_files, git, search, shell, todo] +connectors: true +skills: [dependency-audit, safe-upgrade-pr] +recommended_models: [anthropic:claude-opus-4-8, openai:gpt-5.6-sol] +default_permission_mode: interactive +description: A dependency auditor for teams without a security team. Runs open-source vulnerability scanners (osv-scanner, npm audit, pip-audit, trivy) across your lockfiles, separates exploitable from theoretical, and ships minimal, test-verified upgrade PRs. +recommends: + - connector: github + reason: open upgrade PRs and reference the advisories they close + tier: core +--- +You are the Dependency Audit Coworker — you keep a project's third-party dependencies +from becoming its breach story, without drowning the team in upgrade churn. + +How you work: +- You DRIVE scanners (osv-scanner, npm audit, pip-audit, trivy fs); your value is + judgment: is the vulnerable function actually reachable from this codebase, and + what's the SMALLEST upgrade that closes it? +- Severity ≠ priority. A medium in a hot path beats a critical in an unused transitive + dev dependency — read the code paths before ranking. +- Minimal upgrades first: prefer the patch/minor that fixes the advisory over a major + bump. Majors come with a migration note and only when there's no smaller path. +- Every upgrade is verified: install, build, and run the project's own test suite + before calling it done. A red suite means investigate or revert — never hand over a + broken upgrade. +- Respect the lockfile discipline the repo already uses (npm/pnpm/yarn, pip-tools/uv/ + poetry) — regenerate locks with the repo's own toolchain, never by hand. + +Operate safely: +- ALWAYS begin tool-using tasks with todo_write and keep it current — the Progress + panel is rendered from it. +- Check a scanner exists before using it; ask before installing anything. +- NEVER inline multi-line scripts in shell commands: write a file, then run it. + +Finish with a deliverable: an audit summary (advisory · package · reachability verdict · +action) and one focused upgrade branch/PR per ecosystem, tests green. diff --git a/coworker/personas/builtin/dep-audit/skills/dependency-audit/SKILL.md b/coworker/personas/builtin/dep-audit/skills/dependency-audit/SKILL.md new file mode 100644 index 00000000..4b508802 --- /dev/null +++ b/coworker/personas/builtin/dep-audit/skills/dependency-audit/SKILL.md @@ -0,0 +1,23 @@ +--- +name: dependency-audit +description: Scan lockfiles for vulnerable dependencies and triage by real reachability +--- +Audit the project's dependencies and separate what's exploitable from what's noise. + +1. Identify the ecosystems present (package-lock.json / pnpm-lock.yaml / yarn.lock, + requirements*.txt / uv.lock / poetry.lock, go.sum, Cargo.lock, pyproject). +2. Pick scanners that are present (check first; ask before installing): + - `osv-scanner --lockfile --format json` (best cross-ecosystem) + - `npm audit --json` / `pip-audit -f json` / `trivy fs --scanners vuln . -f json` +3. Deduplicate advisories across scanners (key on advisory id + package), then triage + each one by reading the code: + - Direct or transitive? (`npm ls `, `pipdeptree -r -p ` or grep imports) + - Is the vulnerable functionality actually used here? Grep for the affected API; + an unreachable advisory in a dev-only tool is LOW no matter its CVSS. + - Verdict per advisory: fix-now / fix-soon / accept-with-note, one line of why. +4. Map each fix-now to its smallest closing upgrade (advisory metadata's fixed-in + version); note when only a major closes it and what the migration entails. +5. Deliver: an audit table (advisory · package · direct? · reachable? · verdict · + smallest fix) ordered by real priority — then hand off to `safe-upgrade-pr` for the + actual upgrades. Offer a CI guard (e.g. an osv-scanner step) so new advisories + surface on PRs instead of in the next audit. diff --git a/coworker/personas/builtin/dep-audit/skills/safe-upgrade-pr/SKILL.md b/coworker/personas/builtin/dep-audit/skills/safe-upgrade-pr/SKILL.md new file mode 100644 index 00000000..cafa5d61 --- /dev/null +++ b/coworker/personas/builtin/dep-audit/skills/safe-upgrade-pr/SKILL.md @@ -0,0 +1,22 @@ +--- +name: safe-upgrade-pr +description: Ship minimal, test-verified dependency upgrades as focused PRs +--- +Turn triaged advisories into upgrade PRs a reviewer can merge without fear. + +1. One branch per ecosystem (`security/deps-npm`, `security/deps-python`), smallest + viable bumps: the fixed-in patch/minor, not "latest". Majors get their own branch + and a migration note. +2. Regenerate lockfiles with the repo's OWN toolchain (`npm install pkg@ver`, + `uv lock`, `poetry update pkg` …) — never hand-edit a lockfile. +3. Verify before proposing: clean install, build, and the project's test suite. Red + suite → investigate; if the bump itself breaks the build, document what's entangled + and propose the next-smallest path instead of forcing it. +4. PR body per upgrade: advisory id(s) closed, package old→new version, reachability + verdict from the audit (one line), and the verification commands run. Skip CVE + boilerplate walls — link the advisory instead. +5. Leave `accept-with-note` advisories OUT of the PR; record them in the PR body's + "consciously not fixed" list with their justification, so the decision is visible + and revisitable. +6. Never merge your own upgrade PR — deliver it with what a reviewer should check + (typically: lockfile diff sanity and the test run). diff --git a/coworker/personas/builtin/security/manifest.md b/coworker/personas/builtin/security/manifest.md new file mode 100644 index 00000000..55c5c906 --- /dev/null +++ b/coworker/personas/builtin/security/manifest.md @@ -0,0 +1,48 @@ +--- +id: security +name: Security Coworker +icon: shield +tagline: Find and fix security issues — scan, triage, PR +family: code +tools: [code_files, git, search, shell, todo] +connectors: true +skills: [semgrep-review, secret-scan, security-fix-pr] +recommended_models: [anthropic:claude-opus-4-8, openai:gpt-5.6-sol] +default_permission_mode: interactive +description: A code-security reviewer for teams without a security team. Drives open-source scanners (semgrep, gitleaks), triages findings in the context of YOUR codebase, and owns the fix through to a reviewable pull request. +recommends: + - connector: github + reason: open focused fix PRs and reference the findings they close + tier: core +--- +You are the Security Coworker — a pragmatic application-security engineer for teams that +don't have one. You help everyday developers find and fix security problems in their own +code instead of shipping them. + +How you work: +- You DRIVE scanners; you don't replace them. Detection comes from proven open-source + tools (semgrep, gitleaks); your value is everything a scanner can't do — understanding + a finding in the context of this codebase, separating real risk from noise, and fixing + it properly. +- Triage before you touch anything. For each finding: is it reachable? is the input + attacker-controlled? what's the blast radius? Rate it (critical/high/medium/low/noise) + and say why in one or two sentences a developer will actually read. +- Fix with context. A good fix matches the codebase's own patterns — its existing + validation helpers, its escaping conventions, its test style. Never paste generic + boilerplate that fights the surrounding code. +- Own the remediation end to end: fix, add or update a test that would have caught it, + and prepare a focused branch/PR per theme — never a giant mixed diff. +- Never weaken security to silence a warning (no disabling checks, no broad ignores) + without saying so explicitly and getting agreement first. + +Operate safely: +- ALWAYS begin tool-using tasks with todo_write (even a short 2-4 item plan) and keep it + current — the Progress panel is rendered from it. +- Scanners run read-only; installing one is a visible, approved step — check availability + first and tell the user what's missing rather than failing silently. +- NEVER inline multi-line scripts in shell commands: write a file, then run it. +- Secrets are radioactive: never print a discovered secret's value anywhere — not in + output, notes, commits, or PRs. Refer to it by location and kind only. + +Finish with a deliverable: a findings summary (what was found, what matters, what you +fixed, what you recommend next) and the branch/PR that carries the fixes. diff --git a/coworker/personas/builtin/security/skills/secret-scan/SKILL.md b/coworker/personas/builtin/security/skills/secret-scan/SKILL.md new file mode 100644 index 00000000..9630c815 --- /dev/null +++ b/coworker/personas/builtin/security/skills/secret-scan/SKILL.md @@ -0,0 +1,30 @@ +--- +name: secret-scan +description: Hunt committed secrets with gitleaks and drive safe rotation +--- +Find committed credentials and get them rotated and removed — without ever exposing them +further yourself. + +ABSOLUTE RULE: never print a secret's value — not in output, notes, todo items, commits, +or PRs. Refer to every hit as " in : (commit )". + +1. Check the tool: `gitleaks version`. If missing, tell the user how to install it + (`brew install gitleaks`) and STOP — ask before installing anything. +2. Scan working tree AND history: + `gitleaks detect --source . --report-format json --report-path /tmp/gitleaks.json` + (history matters: a secret deleted in HEAD is still live in every clone). +3. Triage each hit by reading its context: + - Real credential, test fixture, or example placeholder? Say which and why. + - For real ones: what does it grant access to, and is it plausibly still valid? +4. For every real secret, in this order: + a. ROTATE first — tell the user exactly where to revoke/rotate it (the provider's + console page or CLI command). Rotation beats removal: history rewrite without + rotation is false comfort. + b. Remove it from the code: move to env vars or the project's secret store, matching + how this codebase already handles configuration. + c. Prevent recurrence: add/extend `.gitignore` for local secret files and offer a + `.gitleaks.toml` baseline plus a pre-commit hook. + d. History purge (git filter-repo/BFG) is DESTRUCTIVE and rewrites shared history — + describe the trade-off and only proceed if the user explicitly asks. +5. Deliver: a hit list (kind · location · verdict · rotation status), the cleanup + branch/PR, and the prevention setup you added or recommend. diff --git a/coworker/personas/builtin/security/skills/security-fix-pr/SKILL.md b/coworker/personas/builtin/security/skills/security-fix-pr/SKILL.md new file mode 100644 index 00000000..503291b5 --- /dev/null +++ b/coworker/personas/builtin/security/skills/security-fix-pr/SKILL.md @@ -0,0 +1,24 @@ +--- +name: security-fix-pr +description: Turn triaged security findings into focused, reviewable fix PRs +--- +Package security fixes so a busy reviewer can approve them with confidence. + +1. One PR per theme (e.g. "parameterize SQL in the reports module"), never a mixed + security dump. Small diffs get reviewed; big ones get postponed. +2. Branch naming: `security/` from the repo's default branch. Follow the repo's + existing commit-message style. +3. Every fix commit carries its test: add or extend one that fails without the fix, + in the repo's existing test layout and idiom. If testing a fix isn't practical, + say so in the PR body instead of skipping silently. +4. PR body structure (keep it tight): + - What was wrong, in plain language, with severity and why it matters HERE (one or + two sentences of reachability/impact, not scanner boilerplate). + - What the fix does, and what it deliberately does not change. + - How it was verified (test names, commands run). + - NEVER include secret values, exploit payloads, or step-by-step attack recipes in + a public PR — describe the class of issue instead. +5. If the GitHub connector is available, open the PR with it; otherwise prepare the + branch and hand the user the exact push/PR commands. +6. Fixing is yours; MERGING is the team's. Never merge your own security PR — deliver + it and summarize what a reviewer should scrutinize. diff --git a/coworker/personas/builtin/security/skills/semgrep-review/SKILL.md b/coworker/personas/builtin/security/skills/semgrep-review/SKILL.md new file mode 100644 index 00000000..7205f7cc --- /dev/null +++ b/coworker/personas/builtin/security/skills/semgrep-review/SKILL.md @@ -0,0 +1,29 @@ +--- +name: semgrep-review +description: Run a semgrep scan and turn findings into triaged, contextual fixes +--- +Run a static-analysis pass with semgrep and own the findings end to end. + +1. Check the tool: `semgrep --version`. If missing, tell the user how to install it + (`pip install semgrep` or `brew install semgrep`) and STOP — ask before installing + anything yourself. +2. Scan the repo (from its root): + `semgrep scan --config auto --json --quiet -o /tmp/semgrep.json` + Use `--config auto` unless the repo carries its own rules (`.semgrep.yml`, + `semgrep.yml`) — prefer the repo's own configuration when present. +3. Parse the JSON and triage EVERY finding — do not echo the raw report: + - Read the flagged code and enough surrounding context to judge reachability. + - Is the tainted input attacker-controlled or internal? Is there an upstream guard? + - Rate: critical / high / medium / low / noise, with a one-line justification each. +4. Fix what's real, highest severity first: + - Match the codebase's own conventions (its validation helpers, escaping utilities, + parameterized-query style) — read neighboring code before writing the fix. + - Add or extend a test that fails without the fix where the test harness makes that + reasonable. + - Group fixes by theme (one branch per theme), never one giant mixed diff. +5. For findings you judge noise, say WHY (e.g. constant input, dead code, framework + already escapes) — never silently drop them, and never add ignore rules to make the + scanner quiet without agreement. +6. Deliver: a short findings table (severity · location · verdict · action) and the + fix branches/PRs. If the repo has no semgrep config, offer to commit a starter + `.semgrep.yml` pinned to the rulesets that mattered here. diff --git a/coworker/personas/registry.py b/coworker/personas/registry.py index c7d9c445..617ceb9b 100644 --- a/coworker/personas/registry.py +++ b/coworker/personas/registry.py @@ -173,6 +173,15 @@ class PersonaRegistry: self._register_manifest( load_manifest_file(md, builtin=builtin), builtin=builtin ) + # Bundle subdirs (OPE-58): //manifest.md with an optional sibling + # skills/ folder — the same self-contained shape an install snapshot uses, so a + # persona's skills live with it instead of leaking into a shared flat dir. + for sub in sorted(p for p in d.iterdir() if p.is_dir()): + md = sub / "manifest.md" + if md.is_file(): + self._register_manifest( + load_manifest_file(md, builtin=builtin), builtin=builtin + ) def _register_manifest(self, m, *, builtin: bool) -> None: self._entries[m.id] = PersonaEntry( diff --git a/pyproject.toml b/pyproject.toml index 3c1f10f4..9561bd91 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -59,7 +59,11 @@ where = ["."] include = ["coworker*"] [tool.setuptools.package-data] -coworker = ["personas/builtin/*.md"] +coworker = [ + "personas/builtin/*.md", + "personas/builtin/*/manifest.md", + "personas/builtin/*/skills/*/SKILL.md", +] [tool.pytest.ini_options] testpaths = ["tests"] diff --git a/tests/test_persona_registry.py b/tests/test_persona_registry.py index aa4443b7..abc39ecd 100644 --- a/tests/test_persona_registry.py +++ b/tests/test_persona_registry.py @@ -27,7 +27,7 @@ def test_sidebar_defaults_to_surfaced_builtins(tmp_path): # Built-ins ship enabled (UX-029: the coworker picker is their front door); Chat # stays default-hidden via the surfaced axis. Installed personas remain opt-in. assert ids[0] == "cowork" - assert set(ids) == {"cowork", "code", "ops"} + assert set(ids) == {"cowork", "code", "ops", "security", "cloud-posture", "dep-audit"} assert sidebar[0]["default"] is True # An explicit disable removes a builtin from the picker. reg.set_enabled("code", False) diff --git a/tests/test_security_bundles.py b/tests/test_security_bundles.py new file mode 100644 index 00000000..b03277e8 --- /dev/null +++ b/tests/test_security_bundles.py @@ -0,0 +1,82 @@ +"""Phase C (OPE-61) — the shipped security coworker bundles. + +Three builtin bundles (security, cloud-posture, dep-audit) live as self-contained dirs +(manifest.md + skills/) under personas/builtin/. Each is code-family, drives OSS +scanners via the vetted catalog only, and its skills reach only its own sessions. +""" + +from __future__ import annotations + +from pathlib import Path + +from coworker.personas.registry import PersonaRegistry +from coworker.providers import ModelCapabilities, ProviderClient +from coworker.server.manager import SessionManager +from coworker.sessions import SessionRecord + +BUNDLES = { + "security": {"semgrep-review", "secret-scan", "security-fix-pr"}, + "cloud-posture": {"iac-scan", "aws-posture"}, + "dep-audit": {"dependency-audit", "safe-upgrade-pr"}, +} + + +class ScriptedProvider(ProviderClient): + def complete(self, *, model, messages, tools=None, **settings): + raise AssertionError("no turns expected") + + def capabilities(self, model): + return ModelCapabilities() + + +def _reg(tmp_path) -> PersonaRegistry: + return PersonaRegistry(state_path=tmp_path / "personas.json") + + +def test_bundles_register_as_enabled_code_builtins(tmp_path): + reg = _reg(tmp_path) + for pid in BUNDLES: + entry = reg.get(pid) + assert entry is not None and entry.builtin + assert entry.family == "code" # folder pick at send, like Code + assert reg.is_enabled(pid) is True # in the picker out of the box + agent = reg.agent(pid) # catalog-expanded tools materialize + assert agent.family == "code" and agent.needs_workspace + + +def test_bundle_skill_folders_match_their_manifests(tmp_path): + reg = _reg(tmp_path) + for pid, expected in BUNDLES.items(): + m = reg.get(pid).manifest + assert set(m.skills) == expected + skills_dir = Path(m.source).parent / "skills" + on_disk = {p.name for p in skills_dir.iterdir() if (p / "SKILL.md").is_file()} + # Every listed skill exists; nothing ships unlisted. + assert on_disk == expected + + +def test_bundle_skills_stay_with_their_persona(tmp_path, monkeypatch): + monkeypatch.setenv("COWORKER_STATE_DIR", str(tmp_path / "state")) + mgr = SessionManager(workspace=tmp_path, provider=ScriptedProvider()) + for i, (pid, expected) in enumerate(BUNDLES.items()): + sid = f"s{i}" + mgr.session_store.save( + SessionRecord(session_id=sid, workspace="", model="m", mode="interactive", agent=pid) + ) + names = mgr.effective_skill_names(sid) + assert expected <= names + # No leakage from the sibling bundles. + others = set().union(*(v for k, v in BUNDLES.items() if k != pid)) + assert not (others & names) + + +def test_prompts_carry_the_positioning_guardrails(tmp_path): + # "Drive scanners, never replace them" + safe-ops language is the product stance — + # a reworded manifest that drops it should fail loudly, not ship quietly. + reg = _reg(tmp_path) + for pid in BUNDLES: + prompt = reg.get(pid).manifest.system_prompt.lower() + assert "todo_write" in prompt + assert "drive" in prompt # drives scanners; value is judgment/remediation + assert "read-only" in reg.get("cloud-posture").manifest.system_prompt.lower() + assert "never print a discovered secret" in reg.get("security").manifest.system_prompt.lower() diff --git a/tests/test_server.py b/tests/test_server.py index b897d3c9..d1e18a27 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -61,9 +61,11 @@ def test_agents_and_memory_rest(tmp_path): client = _client(tmp_path, []) agents = client.get("/v1/agents").json()["agents"] # The picker lists enabled+surfaced personas — builtins ship enabled (UX-029); - # Chat stays default-hidden via the surfaced axis. + # Chat stays default-hidden via the surfaced axis. The security bundles (Phase C) + # ship in the picker out of the box. names = [a["name"] for a in agents] - assert names == ["cowork", "code", "ops"] + assert names[0] == "cowork" + assert set(names) == {"cowork", "code", "ops", "security", "cloud-posture", "dep-audit"} assert "skills" in client.get("/v1/skills").json() # catalog (may be empty) added = client.post("/v1/memory", json={"content": "prefer pathlib"}).json() From 110a8ae8ce365cf0c013a2a13f66d071b7a30123 Mon Sep 17 00:00:00 2001 From: Rohit C Prasad Date: Tue, 11 Aug 2026 12:12:24 -0700 Subject: [PATCH 05/80] =?UTF-8?q?personas:=20sharing=20v1=20=E2=80=94=20ex?= =?UTF-8?q?port/import=20bundles,=20version=20+=20consent=20(OPE-7)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bundle zip export + import (zip-slip guarded) through the picker's Import door; version+provenance with a replaces-note, re-consent only when capabilities grow. Consent screen: trust warning first, capability summary with collapsed tool list, recommended connectors. --- .../builtin/cloud-posture/manifest.md | 1 + .../personas/builtin/dep-audit/manifest.md | 1 + .../personas/builtin/security/manifest.md | 1 + coworker/personas/loading.py | 20 ++ coworker/personas/manifest.py | 5 + coworker/personas/registry.py | 107 +++++++++- coworker/server/app.py | 20 +- surfaces/gui/e2e/fixtures.ts | 32 +++ surfaces/gui/e2e/sharing.spec.ts | 68 ++++++ surfaces/gui/src/App.tsx | 8 + surfaces/gui/src/api.ts | 20 +- surfaces/gui/src/components/PersonasTab.tsx | 196 +++++++++++++++--- .../gui/src/components/SessionSetupRow.tsx | 13 ++ tests/test_sharing_v1.py | 142 +++++++++++++ 14 files changed, 600 insertions(+), 34 deletions(-) create mode 100644 surfaces/gui/e2e/sharing.spec.ts create mode 100644 tests/test_sharing_v1.py diff --git a/coworker/personas/builtin/cloud-posture/manifest.md b/coworker/personas/builtin/cloud-posture/manifest.md index 5e941363..552b363e 100644 --- a/coworker/personas/builtin/cloud-posture/manifest.md +++ b/coworker/personas/builtin/cloud-posture/manifest.md @@ -4,6 +4,7 @@ name: Cloud Posture Coworker icon: sliders tagline: Review Terraform & cloud config — read-only, evidence first family: code +version: "1" tools: [code_files, git, search, shell, todo] connectors: true skills: [iac-scan, aws-posture] diff --git a/coworker/personas/builtin/dep-audit/manifest.md b/coworker/personas/builtin/dep-audit/manifest.md index e3cc56c7..13c51520 100644 --- a/coworker/personas/builtin/dep-audit/manifest.md +++ b/coworker/personas/builtin/dep-audit/manifest.md @@ -4,6 +4,7 @@ name: Dependency Audit Coworker icon: audit tagline: Vulnerable dependencies — audit, minimal upgrades, PRs family: code +version: "1" tools: [code_files, git, search, shell, todo] connectors: true skills: [dependency-audit, safe-upgrade-pr] diff --git a/coworker/personas/builtin/security/manifest.md b/coworker/personas/builtin/security/manifest.md index 55c5c906..907670d2 100644 --- a/coworker/personas/builtin/security/manifest.md +++ b/coworker/personas/builtin/security/manifest.md @@ -4,6 +4,7 @@ name: Security Coworker icon: shield tagline: Find and fix security issues — scan, triage, PR family: code +version: "1" tools: [code_files, git, search, shell, todo] connectors: true skills: [semgrep-review, secret-scan, security-fix-pr] diff --git a/coworker/personas/loading.py b/coworker/personas/loading.py index 8c55fc9d..25412d4e 100644 --- a/coworker/personas/loading.py +++ b/coworker/personas/loading.py @@ -31,11 +31,31 @@ def consent_summary(m: PersonaManifest) -> dict: "messaging": m.messaging, "recommended_mode": m.default_permission_mode, "recommended_models": list(m.recommended_models), + # Recommended connectors/MCP with reasons + tiers — the consent screen shows + # these so the user knows what the coworker hopes to use (sharing v1). + "recommends": [ + {"kind": r.kind, "ref": r.ref, "reason": r.reason, "tier": r.tier} + for r in m.recommends + ], + "version": m.version, "source": m.source, "builtin": m.builtin, } +def capability_set(m: PersonaManifest) -> set[str]: + """The persona's capability surface as a flat comparable set — used to decide + whether an update GREW capabilities (which requires re-consent; a same-or-smaller + update keeps the user's enabled state).""" + caps = {f"tool:{t}" for t in m.tools} + caps |= {f"mcp:{s}" for s in m.mcp} + if m.connectors: + caps.add("connectors") + if m.messaging: + caps.add("messaging") + return caps + + def git_clone( url: str, dest: Path ) -> None: # pragma: no cover - exercised via injection diff --git a/coworker/personas/manifest.py b/coworker/personas/manifest.py index 58730591..4715fee9 100644 --- a/coworker/personas/manifest.py +++ b/coworker/personas/manifest.py @@ -63,6 +63,10 @@ class PersonaManifest: recommended_models: list[str] = field(default_factory=list) skills: list[str] = field(default_factory=list) mcp: list[str] = field(default_factory=list) + # Sharing v1 (OPE-7): the author's version string ("1", "1.2", "2026-08"…). Purely + # informational provenance — with folder/git distribution there is no authoritative + # update channel, so this drives the "replaces vN" note on re-install, nothing more. + version: str = "" recommends: list[Recommendation] = field(default_factory=list) builtin: bool = False source: Optional[str] = ( @@ -237,6 +241,7 @@ def parse_manifest( recommended_models=_strlist(meta, "recommended_models"), skills=_strlist(meta, "skills"), mcp=_strlist(meta, "mcp"), + version=str(meta.get("version", "") or "").strip(), recommends=_recommends(persona_id, meta), builtin=builtin, source=source, diff --git a/coworker/personas/registry.py b/coworker/personas/registry.py index 617ceb9b..1c19dc8a 100644 --- a/coworker/personas/registry.py +++ b/coworker/personas/registry.py @@ -85,6 +85,9 @@ class PersonaRegistry: self._entries: dict[str, PersonaEntry] = {} self._enabled: dict[str, bool] = {} self._surfaced: dict[str, bool] = {} + # Sharing v1 (OPE-7): install provenance per installed persona — + # {version, source, installed_at} — drives the "replaces vN" note on re-install. + self._installed_meta: dict[str, dict] = {} self._default = DEFAULT_PERSONA_ID self._load_builtin(builtin_dir) for d in extra_dirs or []: @@ -209,6 +212,7 @@ class PersonaRegistry: data = json.loads(self.state_path.read_text(encoding="utf-8")) self._enabled = dict(data.get("enabled", {})) self._surfaced = dict(data.get("surfaced", {})) + self._installed_meta = dict(data.get("installed_meta", {})) self._default = data.get("default", DEFAULT_PERSONA_ID) def save(self) -> None: @@ -220,6 +224,7 @@ class PersonaRegistry: { "enabled": self._enabled, "surfaced": self._surfaced, + "installed_meta": self._installed_meta, "default": self._default, }, indent=2, @@ -308,6 +313,8 @@ class PersonaRegistry: "enabled": self.is_enabled(e.id), "surfaced": self.is_surfaced(e.id), "default": e.id == self.default_id(), + "version": e.manifest.version if e.manifest else "", + "installed_at": self._installed_meta.get(e.id, {}).get("installed_at", ""), } for e in self._entries.values() ] @@ -378,15 +385,109 @@ class PersonaRegistry: summaries: list[dict] = [] for md in mds: m = load_manifest_file(md, builtin=False) # validate before snapshotting + replaces = self._replaces_of(m) snapshot = self._snapshot(md, m.id) installed = load_manifest_file(snapshot, builtin=False) if snapshot else m self._register_manifest(installed, builtin=False) - self._enabled[m.id] = False # pending consent — never auto-enabled - self._surfaced[m.id] = False - summaries.append(consent_summary(installed)) + # Consent rules (sharing v1): a fresh install always lands disabled pending + # consent. An UPDATE keeps the user's enabled state — unless its capability + # set GREW, which is a new decision, never a silent upgrade. + if replaces is None or replaces.get("capabilities_grew"): + self._enabled[m.id] = False + self._surfaced[m.id] = False + self._installed_meta[m.id] = { + "version": installed.version, + "source": str(md), + "installed_at": self._now_stamp(), + } + summary = consent_summary(installed) + summary["replaces"] = replaces + summaries.append(summary) self.save() return summaries + @staticmethod + def _now_stamp() -> str: + from datetime import date + + return date.today().isoformat() + + def _replaces_of(self, incoming) -> Optional[dict]: + """When re-installing an already-installed persona id: what the new copy + replaces ({version, installed_at, capabilities_grew}), else None.""" + from .loading import capability_set + + existing = self._entries.get(incoming.id) + if existing is None or existing.builtin or existing.manifest is None: + return None + meta = self._installed_meta.get(incoming.id, {}) + grew = bool(capability_set(incoming) - capability_set(existing.manifest)) + return { + "version": meta.get("version") or existing.manifest.version or "", + "installed_at": meta.get("installed_at", ""), + "capabilities_grew": grew, + } + + def export_persona(self, persona_id: str, dest_dir: str | Path) -> dict: + """Sharing v1 export: zip the persona's bundle (manifest + skills/) into + ``dest_dir``. The zip's contents ARE the import format — extract or point the + installer at it and the round trip is lossless.""" + import zipfile + + entry = self._entries.get(persona_id) + if entry is None or entry.manifest is None or not entry.manifest.source: + return {"ok": False, "error": "this coworker has no shareable bundle"} + src_md = Path(entry.manifest.source) + if not src_md.is_file(): + return {"ok": False, "error": "the coworker's bundle files are missing"} + dest = Path(dest_dir).expanduser() + if not dest.is_dir(): + return {"ok": False, "error": "destination folder does not exist"} + version = entry.manifest.version + zip_name = f"{persona_id}-coworker{('-v' + version) if version else ''}.zip" + zip_path = dest / zip_name + skills_dir = src_md.parent / "skills" + try: + with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as zf: + zf.write(src_md, "manifest.md") + if skills_dir.is_dir(): + for p in sorted(skills_dir.rglob("*")): + if p.is_file(): + zf.write(p, str(Path("skills") / p.relative_to(skills_dir))) + except OSError as e: + return {"ok": False, "error": f"could not write the archive: {e}"} + return {"ok": True, "path": str(zip_path)} + + def install_from_zip(self, data: bytes, filename: str = "") -> list[dict]: + """Install persona(s) from a shared bundle zip (the export format). The archive + is extracted to a temp dir with a zip-slip guard, then installed like a local + directory — landing disabled pending consent like every install.""" + import io + import tempfile + import zipfile + + with tempfile.TemporaryDirectory(prefix="ocw-persona-zip-") as tmp: + root = Path(tmp) + try: + with zipfile.ZipFile(io.BytesIO(data)) as zf: + for info in zf.infolist(): + name = info.filename + target = (root / name).resolve() + if not str(target).startswith(str(root.resolve())): + raise FileNotFoundError(f"unsafe path in archive: {name}") + zf.extractall(root) + except zipfile.BadZipFile as e: + raise FileNotFoundError(f"not a valid bundle archive: {e}") from e + # Accept both layouts: files at the root, or a single wrapping folder + # (how macOS zips a directory). + candidates = [root, *[p for p in root.iterdir() if p.is_dir()]] + for d in candidates: + if list(d.glob("*.md")) or (d / "manifest.md").is_file(): + return self.install_from_dir(d) + raise FileNotFoundError( + f"no persona manifest found in {filename or 'the archive'}" + ) + def _snapshot(self, md: Path, persona_id: str) -> Optional[Path]: """Copy a manifest into the managed install area; return the snapshot path (or None if no managed area is configured, e.g. an ephemeral in-memory registry).""" diff --git a/coworker/server/app.py b/coworker/server/app.py index 80dd95bc..1eca5ef6 100644 --- a/coworker/server/app.py +++ b/coworker/server/app.py @@ -450,6 +450,16 @@ def create_app(manager: SessionManager) -> FastAPI: summaries = reg.install_from_git(str(body["git_url"])) elif body.get("dir"): summaries = reg.install_from_dir(str(body["dir"])) + elif body.get("zip_b64"): + # Sharing v1 (OPE-7): a bundle zip — the export format — round-trips + # through the same dir installer + consent path. + try: + data = base64.b64decode(str(body["zip_b64"]), validate=True) + except (ValueError, binascii.Error): + return {"ok": False, "error": "Invalid archive encoding."} + summaries = reg.install_from_zip( + data, str(body.get("filename", "")) + ) elif body.get("gallery_slug"): # Gallery install = fetch the manifest markdown from the cloud # (sign-in required), verify its hash, then reuse the exact @@ -483,12 +493,20 @@ def create_app(manager: SessionManager) -> FastAPI: else: return { "ok": False, - "error": "provide a `dir`, `git_url`, or `gallery_slug`", + "error": "provide a `dir`, `git_url`, `zip_b64`, or `gallery_slug`", } except Exception as e: # surface manifest/clone errors to the caller return {"ok": False, "error": str(e)} return {"ok": True, "consent": summaries, "personas": reg.list_all()} + @app.post("/v1/personas/{persona_id}/export") + def export_persona(persona_id: str, body: dict) -> dict[str, Any]: + # Sharing v1 (OPE-7): zip the persona's bundle into the chosen folder. The zip + # is the import format — send it to a teammate, they import it from the picker. + return manager.personas.export_persona( + persona_id, str((body or {}).get("dir", "")) + ) + @app.get("/v1/cloud/gallery/{slug}") def cloud_gallery_detail(slug: str) -> dict[str, Any]: """Solo page for one gallery coworker: publisher pitch + capabilities diff --git a/surfaces/gui/e2e/fixtures.ts b/surfaces/gui/e2e/fixtures.ts index 6c44654a..2651f40b 100644 --- a/surfaces/gui/e2e/fixtures.ts +++ b/surfaces/gui/e2e/fixtures.ts @@ -956,9 +956,41 @@ export async function mockApi(page: import("@playwright/test").Page) { const b = req.postDataJSON(); return json({ ok: true, path: b.path }); } + if (/\/v1\/personas\/[^/]+\/export$/.test(p) && m === "POST") { + // Sharing v1: export the bundle zip into the chosen folder. + const id = p.split("/").slice(-2)[0]; + const b = req.postDataJSON(); + return json({ ok: true, path: `${b.dir}/${id}-coworker-v1.zip` }); + } // must precede the /v1/personas/{id} catch-all (install matches it too) if (p.endsWith("/v1/personas/install") && m === "POST") { const b = req.postDataJSON(); + if (b.zip_b64) { + // Sharing v1: a bundle zip import — consent with version + replaces + recommends. + const imported = { + id: "team-sec", name: "Team Security Coworker", icon: "shield", + tagline: "Our security playbook", needs_workspace: true, builtin: false, + family: "code", workspace: "git", tools: ["code_files", "search", "shell"], + enabled: false, surfaced: false, default: false, version: "2", + }; + if (!personas.some((x) => x.id === "team-sec")) personas.push(imported); + return json({ + ok: true, + personas, + consent: [{ + id: "team-sec", name: "Team Security Coworker", + description: "Reviews code the way our team does.", + tools: ["code_files", "search", "shell"], + risk: ["read", "write_local", "exec"], + connectors: true, mcp: [], messaging: false, + recommended_mode: "interactive", recommended_models: [], + recommends: [{ kind: "connector", ref: "github", reason: "open fix PRs", tier: "core" }], + version: "2", + replaces: { version: "1", installed_at: "2026-08-01", capabilities_grew: true }, + source: "/tmp/team-sec.zip", builtin: false, + }], + }); + } if (b.gallery_slug) { return json( CLOUD_STATE.signed_in diff --git a/surfaces/gui/e2e/sharing.spec.ts b/surfaces/gui/e2e/sharing.spec.ts new file mode 100644 index 00000000..85d07d8f --- /dev/null +++ b/surfaces/gui/e2e/sharing.spec.ts @@ -0,0 +1,68 @@ +import { test, expect } from "./fixtures"; + +// Sharing v1 (OPE-7): the picker's "Import coworker…" door, the zip-import consent flow +// (trust warning first, capabilities behind a chevron, replaces-note), and per-coworker +// export from Settings ▸ Coworkers. + +test("picker's Import door lands on Settings ▸ Coworkers at the Add section", async ({ page }) => { + await page.goto("/"); + await page.getByText("New session").first().click(); + await page.getByTestId("coworker-chip").click(); + await page.getByTestId("import-coworker").click(); + + // Settings ▸ Coworkers opened, with the Add section (the import surface) present. + await expect(page.getByText("Add coworkers")).toBeVisible(); + await expect(page.getByRole("combobox")).toBeVisible(); +}); + +test("zip import: trust warning leads, tools collapse behind a chevron, replaces-note shows", async ({ + page, +}) => { + await page.goto("/"); + await page.getByTestId("account-row").click(); + await page.getByRole("button", { name: "Settings", exact: true }).click(); + await page.getByRole("button", { name: "Coworkers", exact: true }).click(); + + // Pick the Bundle zip mode and feed a file through the hidden input. + await page.getByRole("combobox").selectOption("zip"); + await page.getByTestId("persona-zip-input").setInputFiles({ + name: "team-sec.zip", + mimeType: "application/zip", + buffer: Buffer.from("fake-zip-bytes"), + }); + + const review = page.getByTestId("consent-review"); + await expect(review).toBeVisible(); + // The trust warning comes FIRST (owner design). + await expect(review.getByText(/Only enable coworkers from someone you trust/)).toBeVisible(); + + const card = page.getByTestId("consent-team-sec"); + await expect(card.getByText("Team Security Coworker").first()).toBeVisible(); + await expect(card.getByText(/Can read files, create & edit files and run shell commands/)).toBeVisible(); + + // Exact tools hidden until the chevron is clicked. + await expect(card.getByText("code_files · search · shell")).toHaveCount(0); + await card.getByTestId("consent-tools-toggle").click(); + await expect(card.getByText("code_files · search · shell")).toBeVisible(); + + // Version + replaces + grew-capabilities re-consent note; recommended connector shown. + await expect(card.getByTestId("replaces-note")).toContainText("Replaces Team Security Coworker v1"); + await expect(card.getByTestId("replaces-note")).toContainText("MORE capabilities"); + await expect(card.getByText(/github.*(recommended).*open fix PRs/)).toBeVisible(); + + // Imported coworker landed disabled in the list above, pending consent. + const row = page.locator(".divide-y > div").filter({ hasText: "Team Security Coworker" }); + await expect(row.getByRole("checkbox", { name: "Enabled" })).not.toBeChecked(); +}); + +test("Export… zips an installed coworker's bundle to a chosen folder", async ({ page }) => { + await page.goto("/"); + await page.getByTestId("account-row").click(); + await page.getByRole("button", { name: "Settings", exact: true }).click(); + await page.getByRole("button", { name: "Coworkers", exact: true }).click(); + + // The installed (non-builtin) fixture persona carries the Export affordance; + // the native folder pick is server-mocked → /tmp/picked-folder. + await page.getByTestId("persona-export-acme-notes").click(); + await expect(page.getByText("Exported to /tmp/picked-folder/acme-notes-coworker-v1.zip")).toBeVisible(); +}); diff --git a/surfaces/gui/src/App.tsx b/surfaces/gui/src/App.tsx index fe139ae8..40662266 100644 --- a/surfaces/gui/src/App.tsx +++ b/surfaces/gui/src/App.tsx @@ -1804,6 +1804,14 @@ export function App() { onPickCoworker={pickCoworker} onPickFolder={pickDraftFolder} onManage={() => openSettings("personas")} + onImport={() => { + openSettings("personas"); + // Give the Settings page a beat to mount, then spotlight the Add section. + window.setTimeout( + () => window.dispatchEvent(new CustomEvent("ocw-focus-import")), + 250, + ); + }} /> )} { + const res = await fetch(`${httpBase()}/v1/personas/${encodeURIComponent(id)}/export`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ dir }), + }); + return res.json(); +} + export async function installPersona( - body: { dir?: string; git_url?: string; gallery_slug?: string }, + body: { dir?: string; git_url?: string; gallery_slug?: string; zip_b64?: string; filename?: string }, ): Promise<{ ok: boolean; consent?: PersonaConsent[]; personas?: Persona[]; error?: string }> { const res = await fetch(`${httpBase()}/v1/personas/install`, { method: "POST", diff --git a/surfaces/gui/src/components/PersonasTab.tsx b/surfaces/gui/src/components/PersonasTab.tsx index 1990bbb6..5d7bd917 100644 --- a/surfaces/gui/src/components/PersonasTab.tsx +++ b/surfaces/gui/src/components/PersonasTab.tsx @@ -1,6 +1,7 @@ -import { useEffect, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { deletePersona, + exportPersona, getPersonas, getSessions, installPersona, @@ -8,6 +9,7 @@ import { type Persona, type PersonaConsent, } from "../api"; +import { chooseFolder } from "../tauri"; import type { SessionInfo } from "../types"; import { Icon } from "./Icon"; @@ -27,7 +29,7 @@ const BTN_BORDERED = export function PersonasTab({ onOpenPersona }: { onOpenPersona?: (id: string) => void }) { const [personas, setPersonas] = useState([]); - const [mode, setMode] = useState<"git" | "dir">("git"); + const [mode, setMode] = useState<"git" | "dir" | "zip">("git"); const [src, setSrc] = useState(""); const [busy, setBusy] = useState(false); const [msg, setMsg] = useState(null); @@ -37,6 +39,14 @@ export function PersonasTab({ onOpenPersona }: { onOpenPersona?: (id: string) => // arm an inline confirm (same two-step idiom as delete) instead of flipping immediately. const [confirmOff, setConfirmOff] = useState(null); const [sessions, setSessions] = useState([]); + // The picker's "Import coworker…" door lands here and asks us to put the Add section + // front and center (sharing v1). + const addRef = useRef(null); + useEffect(() => { + const focus = () => addRef.current?.scrollIntoView({ behavior: "smooth", block: "center" }); + window.addEventListener("ocw-focus-import", focus); + return () => window.removeEventListener("ocw-focus-import", focus); + }, []); const reload = () => getPersonas().then(setPersonas).catch(() => {}); const reloadSessions = () => getSessions().then(setSessions).catch(() => {}); @@ -75,6 +85,36 @@ export function PersonasTab({ onOpenPersona }: { onOpenPersona?: (id: string) => else reload(); }; + const finishInstall = (r: Awaited>) => { + setBusy(false); + if (!r.ok) { + setMsg(r.error || "install failed"); + return; + } + setConsent(r.consent || []); + if (r.personas) setPersonas(r.personas); + setMsg(`Installed ${(r.consent || []).length} coworker(s) — review and enable below.`); + setSrc(""); + }; + + const installZip = async (file: File) => { + setBusy(true); + setMsg(null); + setConsent(null); + const buf = new Uint8Array(await file.arrayBuffer()); + let bin = ""; + for (let i = 0; i < buf.length; i += 0x8000) + bin += String.fromCharCode(...buf.subarray(i, i + 0x8000)); + finishInstall(await installPersona({ zip_b64: btoa(bin), filename: file.name })); + }; + + const exportOne = async (p: Persona) => { + const dir = await chooseFolder(); + if (!dir) return; + const r = await exportPersona(p.id, dir); + setMsg(r.ok ? `Exported to ${r.path}` : r.error || "export failed"); + }; + const install = async () => { if (!src.trim()) return; setBusy(true); @@ -150,6 +190,16 @@ export function PersonasTab({ onOpenPersona }: { onOpenPersona?: (id: string) => )} + {!p.builtin && ( + + )} {!p.builtin && (confirmDel === p.id ? ( @@ -205,50 +255,138 @@ export function PersonasTab({ onOpenPersona }: { onOpenPersona?: (id: string) => ))}
-
Add coworkers
+
Add coworkers

Load from a local directory or a public GitHub repo. Files are copied into a managed area (a snapshot), so the coworker stays stable even if the source changes. No code runs — a coworker only composes vetted tools.

- setMode(e.target.value as "git" | "dir" | "zip")} + > + - setSrc(e.target.value)} - onKeyDown={(e) => e.key === "Enter" && install()} - /> - + {mode === "zip" ? ( + + ) : ( + <> + setSrc(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && install()} + /> + + + )}
{msg &&
{msg}
} {consent && consent.length > 0 && ( -
+
+ {/* Trust first (owner design, 2026-08-11): the source warning leads; capabilities + are a one-line summary with the exact tools under a collapsed chevron. A + coworker runs no third-party code, so this list is complete — but a prompt + still steers an agent, so who it came from genuinely matters. */} +
+ + + Only enable coworkers from someone you trust. Nothing here runs third-party + code — but its instructions will guide the coworker's behavior. + +
{consent.map((c) => ( -
-
{c.name}
-
{c.description}
-
Tools: {c.tools.join(", ") || "—"}
-
- Risk: {c.risk.join(", ") || "read"} - {c.connectors ? " · connectors" : ""} - {c.messaging ? " · messaging" : ""} - {c.mcp.length ? ` · mcp: ${c.mcp.join(", ")}` : ""} -
-
- Recommended mode: {c.recommended_mode}. Enable it above to use it. -
-
+ ))}
)}
); } + +// One phrase per risk class — the plain-language capability summary the consent card leads +// with; unknown classes fall back to their raw id so nothing is silently omitted. +const RISK_PHRASE: Record = { + read: "read files", + write_local: "create & edit files", + exec: "run shell commands", + network: "access the network", + write_remote: "act on connected services", +}; + +function ConsentCard({ c }: { c: PersonaConsent }) { + const [showTools, setShowTools] = useState(false); + const phrases = (c.risk.length ? c.risk : ["read"]).map((r) => RISK_PHRASE[r] || r); + const summary = phrases.join(", ").replace(/, ([^,]*)$/, " and $1"); + const recommends = c.recommends || []; + return ( +
+
+ {c.name} + {c.version && v{c.version}} +
+ {c.description &&
{c.description}
} + {c.replaces && ( +
+ Replaces {c.name} + {c.replaces.version ? ` v${c.replaces.version}` : ""} + {c.replaces.installed_at ? ` (installed ${c.replaces.installed_at})` : ""}. + {c.replaces.capabilities_grew + ? " This update asks for MORE capabilities than the copy it replaces — review below before re-enabling." + : " Same capabilities as before — it stays enabled."} +
+ )} +
+ Can {summary} + {c.connectors ? " · use your connected services" : ""} + {c.messaging ? " · send messages" : ""} + {c.mcp.length ? ` · use MCP: ${c.mcp.join(", ")}` : ""} + +
+ {showTools && ( +
{c.tools.join(" · ") || "—"}
+ )} + {recommends.length > 0 && ( +
+ {recommends.map((r) => ( +
+ {r.ref} + {r.tier === "core" ? " (recommended)" : " (optional)"} — {r.reason} +
+ ))} +
+ )} +
+ Recommended mode: {c.recommended_mode}. Enable it above to use it. +
+
+ ); +} diff --git a/surfaces/gui/src/components/SessionSetupRow.tsx b/surfaces/gui/src/components/SessionSetupRow.tsx index 5bad4856..bb1e37f4 100644 --- a/surfaces/gui/src/components/SessionSetupRow.tsx +++ b/surfaces/gui/src/components/SessionSetupRow.tsx @@ -21,6 +21,9 @@ interface Props { onPickCoworker: (id: string) => void; onPickFolder: (path: string, branch?: string | null) => void; onManage: () => void; + // Sharing v1 (OPE-7): the quick door to the import/browse screen — one row, so the + // picker itself never grows beyond the user's own coworkers. + onImport: () => void; } export function SessionSetupRow(props: Props) { @@ -89,6 +92,16 @@ export function SessionSetupRow(props: Props) { ))}
+ )} + {/* Session-wide read-only grant (owner ask 2026-08-11): offered only when the + server's conservative classifier accepted THIS command — one click, then every + local-read command in the session runs without a card. Network, writes, and + anything doubtful keep asking. */} + {item.name === "run_shell" && item.readonlyOk && !item.resolved && ( + + )}
{consent.map((c) => ( - + p.id === c.id)?.enabled ?? false} + onEnable={async () => { + await toggle(c.id, { enabled: true, surfaced: true }); + }} + /> ))} )} @@ -336,8 +343,17 @@ const RISK_PHRASE: Record = { write_remote: "act on connected services", }; -function ConsentCard({ c }: { c: PersonaConsent }) { +function ConsentCard({ + c, + enabled, + onEnable, +}: { + c: PersonaConsent; + enabled: boolean; + onEnable: () => Promise; +}) { const [showTools, setShowTools] = useState(false); + const [busy, setBusy] = useState(false); const phrases = (c.risk.length ? c.risk : ["read"]).map((r) => RISK_PHRASE[r] || r); const summary = phrases.join(", ").replace(/, ([^,]*)$/, " and $1"); const recommends = c.recommends || []; @@ -384,8 +400,27 @@ function ConsentCard({ c }: { c: PersonaConsent }) { ))} )} -
- Recommended mode: {c.recommended_mode}. Enable it above to use it. +
+ {/* Enable right here (owner ask 2026-08-11) — the old "enable it above" copy + sent the user hunting back up the list. */} + {enabled ? ( + + ✓ Enabled — it's in your coworker picker. + + ) : ( + + )} + Recommended mode: {c.recommended_mode}.
); diff --git a/surfaces/gui/src/types.ts b/surfaces/gui/src/types.ts index 02f81276..dd3ac4da 100644 --- a/surfaces/gui/src/types.ts +++ b/surfaces/gui/src/types.ts @@ -34,7 +34,7 @@ import type { MessageSource } from "./api"; // "always_task" persists to the owning automation's task record (standing scoped // approval, UX-DECISIONS §25) — offered only on automation-run approval cards, in-app. -export type ApprovalDecision = "once" | "deny" | "always_tool" | "always_command" | "always_task"; +export type ApprovalDecision = "once" | "deny" | "always_tool" | "always_command" | "always_task" | "readonly_session"; export interface TodoItem { content: string; @@ -116,6 +116,9 @@ export type Item = // The exact target a standing rule could pin (server-computed) — with a run // context, the card offers "Allow every time" (§25). standingTarget?: string; + // Server-classified: this shell command only reads locally, so the card may offer + // the session-wide "Allow read-only commands" grant. + readonlyOk?: boolean; resolved?: ApprovalDecision; } | { diff --git a/tests/test_readonly_grant.py b/tests/test_readonly_grant.py new file mode 100644 index 00000000..67bad7e7 --- /dev/null +++ b/tests/test_readonly_grant.py @@ -0,0 +1,119 @@ +"""The session-scoped read-only command grant (owner ask 2026-08-11). + +The classifier is deliberately fail-closed: local reads and pure pipelines only. A false +negative costs one manual approval; a false positive costs an unreviewed side effect. +""" + +from __future__ import annotations + +import pytest + +from coworker.permissions import Mode, PermissionEngine +from coworker.readonly import is_readonly_command + +ACCEPT = [ + "ls -la", + "cat README.md", + "grep -rn 'pattern' src", + "rg --json TODO", + "nl -ba tests/test_x.py | sed -n '320,345p'", + "nl -ba a.py | sed -E \"s/'[^']*'/'[REDACTED]'/g\"", + "git log --oneline -5", + "git diff main...HEAD", + "git status", + "git -C /tmp/repo log -1", + "git branch --show-current", + "git stash list", + "git config --get user.name", + "git remote -v", + "jq '.results | length' /tmp/report.json", + "find . -name '*.py'", + "LC_ALL=C grep -c uses .github/workflows/ci.yml", + "command -v semgrep", + "wc -l file.txt | sort", + "printf 'a: '", + "awk '{print $1}' data.txt", + "head -20 x | tail -5 | uniq -c", +] + +REJECT = [ + "", + "rm -rf /", + "cat a > b", # redirection + "cat a >> b", + "grep x f 2>/dev/null", # even stderr redirects + "ls; rm -rf /", # chaining + "ls && touch x", + "cat `whoami`", # substitution + "cat $(secret)", + "grep '$(x)' file", # can't tell quoted-safe apart — fail closed + "curl https://api.github.com/repos/x", # network = exfil channel, excluded + "wget http://x", + "ssh host ls", + "python3 -c 'print(1)'", # interpreters + "bash -c ls", + "sed -i 's/a/b/' f", # in-place write + "sed -n 'w /tmp/x' f", # sed write command + "sed -f script.sed f", # script file could carry w + "awk '{print > \"f\"}' x", # awk redirection + "awk 'BEGIN{system(\"rm x\")}'", + "find . -delete", + "find . -exec rm {} ;", + "git push origin main", + "git branch new-branch", # creates + "git tag v1", # creates + "git stash", # writes + "git config user.name evil", # writes + "git -c core.pager='touch x' log", # exec hook via -c + "git log --output=/tmp/f", # write via flag + "/tmp/evil/cat file", # path-invoked binary + "env FOO=1 rm x", + "tee /tmp/x", + "xargs rm", + "ls | tee /tmp/x", # every pipeline stage must classify + "ls |", # dangling pipe + "sudo cat /etc/shadow", +] + + +@pytest.mark.parametrize("cmd", ACCEPT) +def test_classifier_accepts(cmd): + assert is_readonly_command(cmd) is True, cmd + + +@pytest.mark.parametrize("cmd", REJECT) +def test_classifier_rejects(cmd): + assert is_readonly_command(cmd) is False, cmd + + +def test_engine_grant_gates_on_classifier(tmp_path): + eng = PermissionEngine(workspace_root=tmp_path, mode=Mode.INTERACTIVE) + + class Meta: + category = "shell" + risk_level = "high" + capabilities = ["exec"] + + # Before the grant: a read-only command still asks. + d = eng.evaluate("run_shell", {"command": "ls -la"}, Meta()) + assert d.needs_user + + eng.allow_readonly_for_session() + assert eng.evaluate("run_shell", {"command": "ls -la"}, Meta()).allowed + assert eng.evaluate("run_shell", {"command": "git log -1"}, Meta()).allowed + # The grant never covers writes/network — those keep asking. + assert eng.evaluate("run_shell", {"command": "rm -rf x"}, Meta()).needs_user + assert eng.evaluate("run_shell", {"command": "curl https://x"}, Meta()).needs_user + + +def test_grant_persists_via_session_grants(tmp_path): + from coworker.server.manager import _grants_of + + class FakeEngine: + class permissions: + session_allow_tools = set() + session_allow_commands = set() + session_readonly = True + + grants = _grants_of(FakeEngine) + assert grants == {"tools": [], "commands": [], "readonly": True} From 5f3ffe385dbe0b11b850fcabb51efe582db9469b Mon Sep 17 00:00:00 2001 From: Rohit C Prasad Date: Thu, 13 Aug 2026 15:46:13 -0700 Subject: [PATCH 07/80] packaging: ship builtin persona bundles in the sidecar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit collect_submodules only takes .py, so packaged builds had no builtin coworkers. Caught by inspecting the DMG — dev installs read them from the source tree. --- packaging/openworker-server.spec | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/packaging/openworker-server.spec b/packaging/openworker-server.spec index fb113ad3..0f77857d 100644 --- a/packaging/openworker-server.spec +++ b/packaging/openworker-server.spec @@ -23,7 +23,7 @@ hides the window while keeping stdio intact. import os import sys -from PyInstaller.utils.hooks import collect_all, collect_submodules +from PyInstaller.utils.hooks import collect_all, collect_data_files, collect_submodules # SPECPATH is injected by PyInstaller and points at this file's directory # (/packaging). Derive everything else from it — no hardcoded paths. @@ -44,6 +44,14 @@ binaries = [] for pkg in ("coworker", "aisuite", "mcp", "ddgs", "croniter", "docstring_parser"): hiddenimports += collect_submodules(pkg) +# Builtin personas ship as DATA, not code: personas/builtin//manifest.md plus their +# skills//SKILL.md. collect_submodules only takes .py files, so without this the +# packaged sidecar starts with NO builtin coworkers — the picker comes up empty and every +# persona-scoped skill silently disappears. (pyproject's package-data covers pip installs; +# PyInstaller needs its own instruction.) Keep this even if the persona set changes — it +# collects whatever non-.py files the package carries. +datas += collect_data_files("coworker") + if not INCLUDE_EXPERIMENTAL: hiddenimports = [ m for m in hiddenimports if not m.startswith("coworker.connectors.experimental") From 49c16af076827d67b35f56eef4a55a6b34eff4d7 Mon Sep 17 00:00:00 2001 From: Rohit C Prasad Date: Thu, 13 Aug 2026 16:47:11 -0700 Subject: [PATCH 08/80] gui: reload coworkers after health, not only at mount The mount-time persona fetch loses the race to the sidecar boot, leaving the composer picker empty all session while Settings looked fine. --- surfaces/gui/e2e/boot.spec.ts | 31 +++++++++++++++++++++++++++++++ surfaces/gui/src/App.tsx | 17 ++++++++++++----- 2 files changed, 43 insertions(+), 5 deletions(-) diff --git a/surfaces/gui/e2e/boot.spec.ts b/surfaces/gui/e2e/boot.spec.ts index c5000875..53743b95 100644 --- a/surfaces/gui/e2e/boot.spec.ts +++ b/surfaces/gui/e2e/boot.spec.ts @@ -42,3 +42,34 @@ test("model picker recovers when settings fetches die during sidecar boot", asyn }); await expect(page.getByTestId("models-loading")).toHaveCount(0); }); + +test("coworker picker recovers when the persona fetch dies during sidecar boot", async ({ + page, +}) => { + // Same cold-start shape as above, for /v1/personas (owner-hit 2026-08-13, packaged app): + // the mount-time fetch loses to the sidecar boot and its only other trigger is + // PERSONAS_CHANGED, so the composer's picker stayed empty for the WHOLE session — while + // Settings ▸ Coworkers (mounted later) listed everything and looked healthy. + let sidecarUp = false; + await page.route("**/v1/health", async (route) => { + await new Promise((r) => setTimeout(r, 700)); + sidecarUp = true; + await route.fallback(); + }); + await page.route("**/v1/personas", async (route) => { + if (route.request().method() === "GET" && !sidecarUp) { + await route.abort(); + return; + } + await route.fallback(); + }); + + await page.goto("/"); + await page.getByText("New session").first().click(); + await page.getByTestId("coworker-chip").click(); + + // The menu must list real coworkers, not just its Import/Manage footer. + const menu = page.locator(".setup-menu"); + await expect(menu.getByText("Code Coworker")).toBeVisible({ timeout: 10_000 }); + await expect(menu.getByTestId("import-coworker")).toBeVisible(); +}); diff --git a/surfaces/gui/src/App.tsx b/surfaces/gui/src/App.tsx index bf11dc57..006b3d07 100644 --- a/surfaces/gui/src/App.tsx +++ b/surfaces/gui/src/App.tsx @@ -338,14 +338,16 @@ export function App() { // Persona metadata drives workspace behavior by FAMILY, not by hardcoded id (so a DevOps/SecOps // code-family persona gates a folder like Code, and a knowledge persona starts orphan like Cowork). const [personas, setPersonas] = useState(null); + const loadPersonas = useCallback(() => { + getPersonas().then(setPersonas).catch(() => {}); + }, []); useEffect(() => { - const load = () => getPersonas().then(setPersonas).catch(() => {}); - load(); + loadPersonas(); // The composer's coworker picker is always mounted on a fresh session — refetch on // mutations (enable/install from Settings) instead of going stale. - window.addEventListener(PERSONAS_CHANGED, load); - return () => window.removeEventListener(PERSONAS_CHANGED, load); - }, []); + window.addEventListener(PERSONAS_CHANGED, loadPersonas); + return () => window.removeEventListener(PERSONAS_CHANGED, loadPersonas); + }, [loadPersonas]); const personaOf = (a: string) => personas?.find((p) => p.id === a); // Pending Inbox items for the ACTIVE session — surfaced inline above the composer so an @@ -505,6 +507,11 @@ export function App() { // on a cold start that left "Loading models…" stuck until the user visited // Settings (owner-hit 2026-07-23). Health just answered, so this one lands. loadSettings(); + // Same race, same fix: the mount-time persona fetch loses to the sidecar boot in + // the packaged app, and its only other trigger is PERSONAS_CHANGED — so the + // composer's coworker picker stayed empty for the whole session while Settings + // (mounted later) looked fine (owner-hit 2026-08-13). + loadPersonas(); if (!cancelled) setBooting(false); }) .catch(() => { From 62ad9dbdca3513bedd5dd568a67d73c8f753589a Mon Sep 17 00:00:00 2001 From: Rohit C Prasad Date: Fri, 14 Aug 2026 15:28:57 -0700 Subject: [PATCH 09/80] tools: give coworkers the user's real toolchain, and stop silent skips Sidecar inherits the login shell's env; toolchain resolves absolute paths with pinned installs; request_tool replaces the 'tool missing -> STOP' instruction that hid a check. --- coworker/agent.py | 7 + coworker/engine.py | 63 +++++ coworker/events.py | 1 + coworker/inbox.py | 23 ++ .../personas/builtin/security/manifest.md | 11 + .../security/skills/secret-scan/SKILL.md | 20 +- .../security/skills/semgrep-review/SKILL.md | 11 +- coworker/server/app.py | 52 ++++ coworker/server/manager.py | 4 + coworker/toolchain.py | 242 ++++++++++++++++++ coworker/tools/toolreq.py | 44 ++++ surfaces/gui/e2e/fixtures.ts | 24 ++ surfaces/gui/e2e/toolreq.spec.ts | 36 +++ surfaces/gui/src-tauri/src/lib.rs | 124 +++++++++ surfaces/gui/src/App.tsx | 35 +++ surfaces/gui/src/api.ts | 5 + .../gui/src/components/ToolRequestCard.tsx | 54 ++++ surfaces/gui/src/types.ts | 10 + tests/test_security_bundles.py | 32 +++ tests/test_tool_request.py | 96 +++++++ tests/test_toolchain.py | 147 +++++++++++ 21 files changed, 1033 insertions(+), 8 deletions(-) create mode 100644 coworker/toolchain.py create mode 100644 coworker/tools/toolreq.py create mode 100644 surfaces/gui/e2e/toolreq.spec.ts create mode 100644 surfaces/gui/src/components/ToolRequestCard.tsx create mode 100644 tests/test_tool_request.py create mode 100644 tests/test_toolchain.py diff --git a/coworker/agent.py b/coworker/agent.py index 77761c48..86eb1e44 100644 --- a/coworker/agent.py +++ b/coworker/agent.py @@ -41,6 +41,7 @@ from .tools import ToolRegistry from .tools.ask import ask_user_tool from .tools.directories import request_directory_tool from .tools.plan import propose_plan_tool +from .tools.toolreq import request_tool_tool from .tools.subagent import explorer_tools from .web import make_web_fetch_tool, make_web_search_tool from .workspace_trust import WorkspaceTrustStore @@ -211,6 +212,7 @@ def build_engine( directory_requester: Optional[Any] = None, plan_approver: Optional[Any] = None, question_asker: Optional[Any] = None, + tool_requester: Optional[Any] = None, subscription_store: Optional[Any] = None, channel_buffer: Optional[Any] = None, routing_targets: Optional[list[str]] = None, @@ -274,6 +276,10 @@ def build_engine( # Knowledge surfaces with a multi-root workspace can ask the user mid-task for another folder. if agent.family == "knowledge" and root_list: registry.register(request_directory_tool()) + # Anything with a shell can hit a missing CLI (a scanner, aws, kubectl). Give it a way to + # ask instead of silently dropping the check that needed it (OPE-85). + if executor is not None: + registry.register(request_tool_tool()) if agent.connectors: enabled_connectors, enabled_tools = _enabled_connector_tools(secrets) # Per-session connection hierarchy (UI-REFRESH §4.3): when the caller supplies the session's @@ -489,6 +495,7 @@ def build_engine( directory_requester=directory_requester, plan_approver=plan_approver, question_asker=question_asker, + tool_requester=tool_requester, ) engine.executor = executor # type: ignore[attr-defined] engine.todo = todo # type: ignore[attr-defined] diff --git a/coworker/engine.py b/coworker/engine.py index 3d144571..561eee11 100644 --- a/coworker/engine.py +++ b/coworker/engine.py @@ -85,6 +85,9 @@ class TurnEngine: question_asker: Optional[ Callable[[dict[str, Any]], "Awaitable[dict[str, Any]]"] ] = None, + tool_requester: Optional[ + Callable[[dict[str, Any]], "Awaitable[dict[str, Any]]"] + ] = None, # Called (thread-safe, best-effort) when the user stops the turn — e.g. the # executor's kill for a running shell command. interrupt_hooks: Optional[list[Callable[[], None]]] = None, @@ -107,6 +110,10 @@ class TurnEngine: # user to grant/decline a folder out-of-band, applies the grant to this live session, and # returns the outcome. None on surfaces that can't prompt (the tool then no-ops). self.directory_requester = directory_requester + # Handles the `request_tool` tool: emits TOOL_REQUESTED, waits for the user to install + # the pinned build or decline. None on surfaces that can't prompt (the tool then + # no-ops, and the agent is told so it can fall back openly rather than skip silently). + self.tool_requester = tool_requester # Handles the `propose_plan` tool: emits PLAN_PROPOSED, waits for the user's decision. # An approving result flips the live PermissionEngine out of plan mode (same session, # context kept). None on surfaces that can't prompt (the tool then no-ops). @@ -616,6 +623,10 @@ class TurnEngine: async for event in self._handle_directory_request(tool_call): yield event continue + if tool_call.name == "request_tool": + async for event in self._handle_tool_request(tool_call): + yield event + continue if tool_call.name == "propose_plan": async for event in self._handle_plan_proposal(tool_call): yield event @@ -931,6 +942,58 @@ class TurnEngine: }, ) + async def _handle_tool_request(self, tool_call: ToolCall) -> AsyncIterator[Event]: + """Emit the install prompt, await the user's decision, hand the outcome back. + + Declining is a normal outcome, not an error: the result tells the agent to fall back + and disclose the gap, because a security report that quietly loses a check is worse + than one that says which checks it couldn't run. + """ + args = tool_call.arguments or {} + name = str(args.get("name", "")).strip() + reason = str(args.get("reason", "")) + + if self.tool_requester is None or not name: + result: dict[str, Any] = { + "installed": False, + "error": "tool requests aren't available here", + "guidance": ( + "Continue without it: use a fallback check if you have one, and say in " + "your report which checks were degraded." + ), + } + else: + yield Event(EventType.TOOL_REQUESTED, {"name": name, "reason": reason}) + self._audit(tool_call, stage="tool_requested", reason=reason) + result = await self._interruptible( + self.tool_requester(dict(args), tool_call.id), + interrupted={"installed": False, "error": "interrupted by user"}, + ) or {"installed": False, "error": "no response"} + if not result.get("installed"): + result.setdefault( + "guidance", + "Continue without it: use a fallback check if you have one, and say in " + "your report which checks were degraded.", + ) + + status = "ok" if result.get("installed") else "denied" + self.messages.append(_tool_result_message(tool_call, result)) + self._audit( + tool_call, + stage="finished", + status=status, + result=result, + result_preview=_preview(result), + ) + yield Event( + EventType.TOOL_FINISHED, + { + "name": tool_call.name, + "status": status, + "result_preview": _preview(result), + }, + ) + async def _handle_directory_request( self, tool_call: ToolCall ) -> AsyncIterator[Event]: diff --git a/coworker/events.py b/coworker/events.py index cdb9fe00..457e1da4 100644 --- a/coworker/events.py +++ b/coworker/events.py @@ -19,6 +19,7 @@ class EventType(str, Enum): TOOL_PROPOSED = "tool_proposed" PERMISSION_REQUIRED = "permission_required" DIRECTORY_REQUESTED = "directory_requested" # agent asks the user to grant a folder + TOOL_REQUESTED = "tool_requested" # agent asks for a missing CLI tool (scanner, etc.) QUESTION_REQUESTED = ( "question_requested" # agent asks the user a free-text/multiple-choice question ) diff --git a/coworker/inbox.py b/coworker/inbox.py index 2354db47..4491227e 100644 --- a/coworker/inbox.py +++ b/coworker/inbox.py @@ -27,6 +27,7 @@ KIND_QUESTION = "question" KIND_NOTIFICATION = "notification" KIND_DIRECTORY = "directory" # agent asks to be granted a folder KIND_PLAN = "plan" # agent presents a plan for approval +KIND_TOOL = "tool" # agent asks for a missing CLI tool to be installed STATE_PENDING = "pending" STATE_RESOLVED = "resolved" @@ -269,6 +270,28 @@ class InboxStore: tool_call_id=tool_call_id, ) + def add_tool_request( + self, + session_id, + title, + *, + body="", + inbox="default", + visibility=VIS_INBOX, + data=None, + tool_call_id=None, + ) -> InboxItem: + return self.add( + session_id, + KIND_TOOL, + title, + body=body, + inbox=inbox, + visibility=visibility, + data=data, + tool_call_id=tool_call_id, + ) + def add_notification( self, session_id, title, *, body="", inbox="default", visibility=VIS_INBOX ) -> InboxItem: diff --git a/coworker/personas/builtin/security/manifest.md b/coworker/personas/builtin/security/manifest.md index 907670d2..196102b6 100644 --- a/coworker/personas/builtin/security/manifest.md +++ b/coworker/personas/builtin/security/manifest.md @@ -41,6 +41,17 @@ Operate safely: current — the Progress panel is rendered from it. - Scanners run read-only; installing one is a visible, approved step — check availability first and tell the user what's missing rather than failing silently. +- NEVER silently skip a check because its tool is missing. A check either RUNS, or it is + REPORTED as not run, with the reason. Three options when a tool is absent, in order: + ask for it with `request_tool`; fall back to a manual equivalent and say you did; or + state plainly that the check was skipped and what that leaves uncovered. Dropping a + check quietly turns "we couldn't look" into "nothing there" — the worst outcome a + security report can produce. +- Every review ends with a short **Coverage** note: which checks ran, which tool ran + them, and which were degraded or skipped. Specifically: if gitleaks is unavailable, do + the secret sweep yourself over the working tree AND the history (`git log -p`, and the + contents of any deleted env/config files) — a secret removed from HEAD but alive in + history is exactly what this check exists to catch. - NEVER inline multi-line scripts in shell commands: write a file, then run it. - Secrets are radioactive: never print a discovered secret's value anywhere — not in output, notes, commits, or PRs. Refer to it by location and kind only. diff --git a/coworker/personas/builtin/security/skills/secret-scan/SKILL.md b/coworker/personas/builtin/security/skills/secret-scan/SKILL.md index 9630c815..27dfee0d 100644 --- a/coworker/personas/builtin/security/skills/secret-scan/SKILL.md +++ b/coworker/personas/builtin/security/skills/secret-scan/SKILL.md @@ -8,11 +8,21 @@ further yourself. ABSOLUTE RULE: never print a secret's value — not in output, notes, todo items, commits, or PRs. Refer to every hit as " in : (commit )". -1. Check the tool: `gitleaks version`. If missing, tell the user how to install it - (`brew install gitleaks`) and STOP — ask before installing anything. -2. Scan working tree AND history: - `gitleaks detect --source . --report-format json --report-path /tmp/gitleaks.json` - (history matters: a secret deleted in HEAD is still live in every clone). +1. Check the tool: `gitleaks version`. If it's missing, do NOT skip this scan and do not + stop the review — ask for it with `request_tool("gitleaks", …)`. If the user declines, + or no pinned build exists for their platform, fall back to step 2b and say in your + report that the sweep was manual. +2. Scan working tree AND history — history matters most: a secret deleted in HEAD is still + live in every clone, and it is the hit users are most surprised by. + a. With gitleaks: + `gitleaks detect --source . --report-format json --report-path /tmp/gitleaks.json` + b. Without it, do the same job by hand, and say so: + - working tree: `git grep -nIE '(api[_-]?key|secret|token|password|BEGIN [A-Z ]*PRIVATE KEY|AKIA[0-9A-Z]{16}|sk_(live|test)_[0-9a-zA-Z]{16,}|xox[baprs]-)'` + - history, including files deleted since: `git log -p --all -S 'AKIA' --pickaxe-all` + and `git log --diff-filter=D --name-only --pretty=format:%h -- '*.env*' '*credential*' '*secret*'`, + then read the removed contents with `git show ^:`. + - Pipe anything you read through a redactor rather than into your transcript, e.g. + `sed -E "s/[A-Za-z0-9_\\-]{16,}/[REDACTED]/g"` — the no-printing rule still applies. 3. Triage each hit by reading its context: - Real credential, test fixture, or example placeholder? Say which and why. - For real ones: what does it grant access to, and is it plausibly still valid? diff --git a/coworker/personas/builtin/security/skills/semgrep-review/SKILL.md b/coworker/personas/builtin/security/skills/semgrep-review/SKILL.md index 7205f7cc..025161ef 100644 --- a/coworker/personas/builtin/security/skills/semgrep-review/SKILL.md +++ b/coworker/personas/builtin/security/skills/semgrep-review/SKILL.md @@ -4,9 +4,14 @@ description: Run a semgrep scan and turn findings into triaged, contextual fixes --- Run a static-analysis pass with semgrep and own the findings end to end. -1. Check the tool: `semgrep --version`. If missing, tell the user how to install it - (`pip install semgrep` or `brew install semgrep`) and STOP — ask before installing - anything yourself. +1. Check the tool: `semgrep --version`. If it's missing, ask for it with + `request_tool("semgrep", …)` rather than skipping the pass. If the user declines, + continue with a targeted manual review — read the routes/handlers, the auth and + session code, every query built by string concatenation, deserialization, and + outbound requests built from user input — and say in your report that the static + pass was manual, so the user knows the coverage is narrower than a full scan. + Note that community semgrep rules miss whole classes (e.g. SQL built through a + project's own DB wrapper), so reading the code is worth doing even when it runs. 2. Scan the repo (from its root): `semgrep scan --config auto --json --quiet -o /tmp/semgrep.json` Use `--config auto` unless the repo carries its own rules (`.semgrep.yml`, diff --git a/coworker/server/app.py b/coworker/server/app.py index 1eca5ef6..460a0d2c 100644 --- a/coworker/server/app.py +++ b/coworker/server/app.py @@ -161,6 +161,7 @@ from ..engine import ApprovalOutcome from ..inbox import VIS_INBOX, VIS_INLINE, args_preview from ..permissions import Mode from ..providers import AssistantTurn +from .. import toolchain from .manager import SessionManager @@ -1694,6 +1695,52 @@ def create_app(manager: SessionManager) -> FastAPI: ) return answer_result(item.questions, await manager.inbox.wait(item.id)) + async def tool_requester(args: dict, tool_call_id=None) -> dict: + """Park a TOOL_REQUESTED prompt, then install the PINNED build if approved. + + Declining is a first-class outcome: the agent is told to fall back and disclose + the gap rather than drop the check (OPE-85). Installs only ever come from the + pinned registry with its digest verified — an approval is consent to install + THAT artifact, not licence to fetch whatever a prompt asked for. + """ + name = str(args.get("name", "")).strip() + info = toolchain.describe(name) + item = manager.inbox.add_tool_request( + session_id, + f"Install {name}?" if name else "Install a tool?", + body=str(args.get("reason", "")), + inbox=_route(), + visibility=_visibility(), + data={ + "tool": name, + "installable": bool(info), + "version": (info or {}).get("version", ""), + "summary": (info or {}).get("summary", ""), + "url": (info or {}).get("url", ""), + }, + tool_call_id=tool_call_id, + ) + if item.state == "pending": + manager.persist_session(session_id) + if item.visibility == VIS_INBOX: + await _mirror(item) + resp = _parse_json(await manager.inbox.wait(item.id)) # {approved} + if not resp.get("approved"): + return { + "installed": False, + "reason": "the user declined to install it", + } + if not info: + return { + "installed": False, + "error": f"no pinned build of {name} is available for this platform", + } + try: + path = await asyncio.to_thread(toolchain.install, name) + except Exception as exc: # noqa: BLE001 - surfaced to the agent verbatim + return {"installed": False, "error": str(exc)} + return {"installed": True, "path": path, "version": info["version"]} + async def directory_requester(args: dict, tool_call_id=None) -> dict: # The engine has already emitted DIRECTORY_REQUESTED. Park, await, then apply the grant. item = manager.inbox.add_directory( @@ -1805,6 +1852,7 @@ def create_app(manager: SessionManager) -> FastAPI: directory_requester=directory_requester, plan_approver=plan_approver, question_asker=question_asker, + tool_requester=tool_requester, ) if engine is None: await ws.send_json( @@ -1941,6 +1989,10 @@ def create_app(manager: SessionManager) -> FastAPI: } ) ) + elif kind == "tool_response": + _resolve_pending( + json.dumps({"approved": bool(message.get("approved"))}) + ) elif kind == "plan_response": _resolve_pending( json.dumps( diff --git a/coworker/server/manager.py b/coworker/server/manager.py index d1a30cd8..450629fb 100644 --- a/coworker/server/manager.py +++ b/coworker/server/manager.py @@ -466,6 +466,7 @@ class SessionManager: directory_requester: Optional[Any] = None, plan_approver: Optional[Any] = None, question_asker: Optional[Any] = None, + tool_requester: Optional[Any] = None, ) -> Optional[TurnEngine]: engine = self._engines.get(session_id) if engine is not None: @@ -477,6 +478,8 @@ class SessionManager: engine.plan_approver = plan_approver if question_asker is not None: engine.question_asker = question_asker + if tool_requester is not None: + engine.tool_requester = tool_requester return engine record = self.session_store.load(session_id) @@ -548,6 +551,7 @@ class SessionManager: plan_approver=plan_approver or self.inbox_plan_approver(session_id, agent), question_asker=question_asker or self.inbox_question_asker(session_id, agent), + tool_requester=tool_requester, subscription_store=self.subscriptions, channel_buffer=self.channel_buffer, routing_targets=self._routing_targets(session_id, agent), diff --git a/coworker/toolchain.py b/coworker/toolchain.py new file mode 100644 index 00000000..c7f46136 --- /dev/null +++ b/coworker/toolchain.py @@ -0,0 +1,242 @@ +"""Finding (and optionally installing) the CLI tools a coworker's skills drive. + +Two problems, deliberately kept apart (OPE-82): + +* **The user's own toolchain** — aws, kubectl, terraform, gh, node. The whole point is + *their* installed, configured, credentialed copy, so we only ever LOCATE these. The + desktop shell hands us the login shell's PATH at spawn (OPE-83); `resolve()` is the + belt-and-braces for every other launch path (headless, systemd, a double-clicked + binary) — it also searches the dirs launchd's PATH never covers. +* **Tools a skill fundamentally IS** — the scanners behind the security bundles. Those + we can install and PIN, so a security review is reproducible instead of depending on + whatever version the user's package manager happened to ship. + +Everything returns an ABSOLUTE path: once resolved, invocation never depends on PATH +again, so a tool found here works even if the caller's environment is bare. + +Nothing here downloads anything on its own. `install()` runs only when the user has +approved it (via `request_tool`, OPE-85) — fetching an executable is a supply-chain +decision, so it is pinned by version, verified by SHA-256, and never implicit. +""" + +from __future__ import annotations + +import hashlib +import os +import platform +import shutil +import stat +import sys +import tarfile +import tempfile +import urllib.request +from dataclasses import dataclass +from pathlib import Path +from typing import Iterable, Optional + +from .secrets import state_dir + +# Dirs that hold user-installed CLIs but never appear in launchd's PATH. Mirrors +# KNOWN_TOOL_DIRS in the desktop shell (src-tauri/src/lib.rs) — keep the two in step. +_KNOWN_DIRS: tuple[str, ...] = ( + "/opt/homebrew/bin", + "/opt/homebrew/sbin", + "/usr/local/bin", + "/usr/local/sbin", + "/opt/local/bin", + "~/.local/bin", + "~/.cargo/bin", + "~/go/bin", +) + + +def managed_dir() -> Path: + """Where we keep tools we installed ourselves (never the user's own copies).""" + return state_dir() / "tools" + + +def _platform_key() -> str: + """`_` using the naming the upstream release assets use.""" + system = {"darwin": "darwin", "linux": "linux", "win32": "windows"}.get( + sys.platform, sys.platform + ) + machine = platform.machine().lower() + arch = "arm64" if machine in ("arm64", "aarch64") else "amd64" + return f"{system}_{arch}" + + +@dataclass(frozen=True) +class Download: + url: str + sha256: str + # Path of the binary inside the archive; None when the asset IS the binary. + member: Optional[str] = None + + +@dataclass(frozen=True) +class ManagedTool: + name: str + version: str + # platform key -> download + downloads: dict[str, Download] + summary: str + + +# Pinned scanner registry. Versions and digests are copied from the upstream release's +# own checksum manifest; bumping a tool means bumping the digest in the same commit. +# +# Not every scanner belongs here: semgrep is distributed as a Python package (pip/brew), +# and trivy/tfsec ship per-distro packaging — for those we resolve the user's install +# rather than half-managing a copy. That's a deliberate line, not an omission. +MANAGED: dict[str, ManagedTool] = { + "gitleaks": ManagedTool( + name="gitleaks", + version="8.30.1", + summary="scans git history and the working tree for committed secrets", + downloads={ + "darwin_arm64": Download( + url="https://github.com/gitleaks/gitleaks/releases/download/v8.30.1/gitleaks_8.30.1_darwin_arm64.tar.gz", + sha256="b40ab0ae55c505963e365f271a8d3846efbc170aa17f2607f13df610a9aeb6a5", + member="gitleaks", + ), + "darwin_amd64": Download( + url="https://github.com/gitleaks/gitleaks/releases/download/v8.30.1/gitleaks_8.30.1_darwin_x64.tar.gz", + sha256="dfe101a4db2255fc85120ac7f3d25e4342c3c20cf749f2c20a18081af1952709", + member="gitleaks", + ), + "linux_amd64": Download( + url="https://github.com/gitleaks/gitleaks/releases/download/v8.30.1/gitleaks_8.30.1_linux_x64.tar.gz", + sha256="551f6fc83ea457d62a0d98237cbad105af8d557003051f41f3e7ca7b3f2470eb", + member="gitleaks", + ), + }, + ), + "osv-scanner": ManagedTool( + name="osv-scanner", + version="2.5.0", + summary="checks dependency lockfiles against the OSV vulnerability database", + downloads={ + "darwin_arm64": Download( + url="https://github.com/google/osv-scanner/releases/download/v2.5.0/osv-scanner_darwin_arm64", + sha256="fff5a2e351b7f0a60001e87cbf862e82fb82e2792d368b533fec7a5865a73da2", + ), + "darwin_amd64": Download( + url="https://github.com/google/osv-scanner/releases/download/v2.5.0/osv-scanner_darwin_amd64", + sha256="baef4f4a4ce2924a9241869c36d4bd9d6c04b632cae6637a0f6347ab9272eb16", + ), + "linux_amd64": Download( + url="https://github.com/google/osv-scanner/releases/download/v2.5.0/osv-scanner_linux_amd64", + sha256="edcfc41d257db36148f065055655fe3fcfc434b0b423ea67468a84c207524e0c", + ), + }, + ), +} + + +def _managed_path(tool: ManagedTool) -> Path: + exe = tool.name + (".exe" if sys.platform == "win32" else "") + return managed_dir() / tool.name / tool.version / exe + + +def resolve(name: str) -> Optional[str]: + """Absolute path to `name`, or None. PATH first (the user's choice wins), then the + dirs a GUI launch can't see, then anything we installed ourselves.""" + found = shutil.which(name) + if found: + return str(Path(found).resolve()) + + for raw in _KNOWN_DIRS: + candidate = Path(raw).expanduser() / name + if candidate.is_file() and os.access(candidate, os.X_OK): + return str(candidate.resolve()) + + tool = MANAGED.get(name) + if tool: + managed = _managed_path(tool) + if managed.is_file() and os.access(managed, os.X_OK): + return str(managed) + return None + + +def have(name: str) -> bool: + return resolve(name) is not None + + +def missing(names: Iterable[str]) -> list[str]: + """Which of `names` we can't find — what a skill checks before promising a scan.""" + return [n for n in names if not have(n)] + + +def installable(name: str) -> bool: + """Whether we could install this ourselves (i.e. it's pinned for this platform).""" + tool = MANAGED.get(name) + return bool(tool and _platform_key() in tool.downloads) + + +def describe(name: str) -> Optional[dict[str, str]]: + """What to show the user when asking permission to install (OPE-85).""" + tool = MANAGED.get(name) + if not tool: + return None + dl = tool.downloads.get(_platform_key()) + if not dl: + return None + return { + "name": tool.name, + "version": tool.version, + "summary": tool.summary, + "url": dl.url, + "sha256": dl.sha256, + } + + +def _verify(blob: bytes, expected: str) -> None: + actual = hashlib.sha256(blob).hexdigest() + if actual != expected: + raise ValueError( + f"checksum mismatch: expected {expected}, got {actual} — refusing to install" + ) + + +def install(name: str, *, timeout: int = 120) -> str: + """Install a pinned tool and return its absolute path. + + Only ever called after the user approves the request. The download is verified + against the pinned digest BEFORE anything is written to its final location, so a + tampered or truncated artifact never becomes an executable on disk. + """ + tool = MANAGED.get(name) + if not tool: + raise KeyError(f"{name} is not a managed tool") + dl = tool.downloads.get(_platform_key()) + if not dl: + raise KeyError(f"{name} has no pinned build for {_platform_key()}") + + target = _managed_path(tool) + if target.is_file() and os.access(target, os.X_OK): + return str(target) + + with urllib.request.urlopen(dl.url, timeout=timeout) as resp: # noqa: S310 - pinned URL + blob = resp.read() + _verify(blob, dl.sha256) + + target.parent.mkdir(parents=True, exist_ok=True) + with tempfile.TemporaryDirectory() as tmp: + tmp_path = Path(tmp) + if dl.member: + archive = tmp_path / "asset.tar.gz" + archive.write_bytes(blob) + with tarfile.open(archive) as tf: + extracted = tf.extractfile(dl.member) + if extracted is None: + raise ValueError(f"{dl.member} missing from {name} archive") + payload = extracted.read() + else: + payload = blob + + staged = tmp_path / "binary" + staged.write_bytes(payload) + staged.chmod(staged.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH) + shutil.move(str(staged), str(target)) + + return str(target) diff --git a/coworker/tools/toolreq.py b/coworker/tools/toolreq.py new file mode 100644 index 00000000..e611b838 --- /dev/null +++ b/coworker/tools/toolreq.py @@ -0,0 +1,44 @@ +"""The `request_tool` tool — the agent asks the user for a CLI it needs but can't find. + +Sibling of `request_directory`: the TurnEngine intercepts it, emits TOOL_REQUESTED, and the +user decides out-of-band (install the pinned build, or skip and let the run continue +degraded). The callable here is only a schema carrier + the fallback for surfaces with no +requester wired. + +This exists because of a specific failure mode (OPE-85): with gitleaks absent, a security +review silently dropped its git-history secret scan — the check didn't fail, it vanished +from the report. A missing tool must become a visible decision, never an invisible gap. +""" + +from __future__ import annotations + +from aisuite.agents import ToolMetadata, tool + + +def request_tool_tool() -> object: + def request_tool(name: str, reason: str) -> dict: + """Ask the user to install a command-line tool you need but can't find on this + machine (e.g. `gitleaks`, `osv-scanner`, `semgrep`). Say in `reason` what check it + unlocks, so the user can judge whether it's worth installing. + + Use this INSTEAD of quietly skipping a check. If the user declines, carry on with a + fallback (e.g. reading git history yourself instead of running gitleaks) and state + plainly in your report which checks were degraded and why. + """ + return { + "installed": False, + "error": "tool requests aren't available in this surface", + } + + return tool( + request_tool, + metadata=ToolMetadata( + category="system", + risk_level="low", + capabilities=["request_tool"], + description=( + "Ask the user to install a missing command-line tool, rather than silently " + "skipping the check that needs it." + ), + ), + ) diff --git a/surfaces/gui/e2e/fixtures.ts b/surfaces/gui/e2e/fixtures.ts index b6dda034..9e7527f1 100644 --- a/surfaces/gui/e2e/fixtures.ts +++ b/surfaces/gui/e2e/fixtures.ts @@ -610,6 +610,17 @@ export async function mockApi(page: import("@playwright/test").Page) { }); return; // suspended on the approval } + // OPE-85: the agent hits a missing scanner and asks instead of skipping the check. + if (/scan for secrets/i.test(msg.text)) { + send("tool_requested", { + name: "gitleaks", + reason: "scan the git history for committed secrets", + installable: true, + version: "8.30.1", + summary: "scans git history and the working tree for committed secrets", + }); + return; // suspended on the tool request + } // §35 compact row: a routine workspace write (content rides in the args). if (/write a file/i.test(msg.text)) { pendingTool = "write_file"; @@ -767,6 +778,19 @@ export async function mockApi(page: import("@playwright/test").Page) { send("assistant_message", { text: `Done via ${pendingTool} [decision=${msg.decision}]` }); } send("turn_done"); + } else if (msg.type === "tool_response") { + // Either way the turn continues — the point of the contract is that declining + // degrades the report openly instead of dropping the check. + if (msg.approved) { + send("assistant_message", { + text: "Installed gitleaks 8.30.1 — scanned history, no secrets found.", + }); + } else { + send("assistant_message", { + text: "Skipped gitleaks. Coverage: history secret sweep done by hand instead.", + }); + } + send("turn_done"); } else if (msg.type === "interrupt") { // Stop mid-stream: like the real engine, end the turn with `interrupted` and // NO assistant_message — the client owns promoting the partial into the transcript. diff --git a/surfaces/gui/e2e/toolreq.spec.ts b/surfaces/gui/e2e/toolreq.spec.ts new file mode 100644 index 00000000..67f47def --- /dev/null +++ b/surfaces/gui/e2e/toolreq.spec.ts @@ -0,0 +1,36 @@ +// OPE-85: a missing CLI becomes a visible decision, never a silently dropped check. +// The bug this guards (owner-hit 2026-08-13): with gitleaks absent, a security review +// quietly omitted its git-history secret scan — "we couldn't look" rendered as "clean". +import { expect } from "@playwright/test"; +import { test } from "./fixtures"; + +async function ask(page: import("@playwright/test").Page) { + await page.goto("/"); + await page.getByPlaceholder(/Ask the coworker/).fill("scan for secrets"); + await page.getByRole("button", { name: "Send" }).click(); +} + +test("request_tool surfaces a card naming the tool, the reason and the pinned version", async ({ + page, +}) => { + await ask(page); + const card = page.locator(".dirreq-card"); + await expect(card).toContainText("gitleaks"); + await expect(card).toContainText("scan the git history for committed secrets"); + await expect(card).toContainText("8.30.1"); + await expect(card).toContainText(/checksum-verified/i); + // Declining must read as a normal choice, not a failure. + await expect(card.getByTestId("toolreq-skip")).toBeVisible(); +}); + +test("installing runs the check; skipping still reports coverage", async ({ page }) => { + await ask(page); + await page.getByTestId("toolreq-install").click(); + await expect(page.locator(".main-scroll")).toContainText("Installed gitleaks"); + + await page.getByPlaceholder(/Ask the coworker/).fill("scan for secrets"); + await page.getByRole("button", { name: "Send" }).click(); + await page.getByTestId("toolreq-skip").click(); + // The whole point: the skipped check is disclosed, not invisible. + await expect(page.locator(".main-scroll")).toContainText(/Coverage:/); +}); diff --git a/surfaces/gui/src-tauri/src/lib.rs b/surfaces/gui/src-tauri/src/lib.rs index 0a1df47f..d96c3c6f 100644 --- a/surfaces/gui/src-tauri/src/lib.rs +++ b/surfaces/gui/src-tauri/src/lib.rs @@ -47,6 +47,126 @@ fn launch_token() -> String { format!("{}{}", Uuid::new_v4().simple(), Uuid::new_v4().simple()) } +/// Directories where user-installed CLIs live but launchd's PATH never looks. Used to +/// repair PATH when the login-shell probe can't run (broken profile, exotic shell). +#[cfg(not(target_os = "windows"))] +const KNOWN_TOOL_DIRS: &[&str] = &[ + "/opt/homebrew/bin", // Apple Silicon Homebrew + "/opt/homebrew/sbin", + "/usr/local/bin", // Intel Homebrew, most installers + "/usr/local/sbin", + "/opt/local/bin", // MacPorts +]; + +/// The environment the sidecar should run with (OPE-83). +/// +/// A Finder/Dock-launched app inherits launchd's minimal PATH — `/usr/bin:/bin:/usr/sbin:/sbin` +/// — so every tool the user installed via Homebrew/nvm/pyenv/asdf is invisible to the agent: +/// semgrep, gitleaks, gh, node, aws, kubectl, terraform. That silently guts the security +/// coworkers (they drive those scanners) and every ops workflow. Fix, same as VS Code and +/// friends: ask the user's login shell for its environment once at spawn and merge it in, so +/// the coworker gets the user's REAL toolchain. Credentials follow for free — aws/kubectl read +/// ~/.aws and ~/.kube via HOME, which a Finder launch already has. +/// +/// Guards: `-i` (not just `-l`) because brew/nvm/pyenv init usually lives in .zshrc; markers so +/// a chatty profile's own output can't be parsed as variables; a 5s timeout with the child +/// killed, so a hanging profile can never block app launch; and a well-known-dirs PATH repair as +/// the fallback. Skipped entirely when we were launched FROM a shell (SHLVL set) — we already +/// inherit the real thing, and `npm run tauri dev` should behave exactly as before. +#[cfg(not(target_os = "windows"))] +fn sidecar_env() -> std::collections::HashMap { + use std::collections::HashMap; + use std::io::Read; + use std::sync::mpsc; + use std::time::Duration; + + const START: &str = "__OCW_ENV_START__"; + const END: &str = "__OCW_ENV_END__"; + + let mut out: HashMap = HashMap::new(); + + // Launched from a shell (dev run, `open` from a terminal): the env is already real. + if std::env::var_os("SHLVL").is_some() { + return out; + } + + let shell = std::env::var("SHELL").unwrap_or_else(|_| "/bin/zsh".to_string()); + let script = format!("echo {START}; env; echo {END}"); + let spawned = Command::new(&shell) + .args(["-ilc", &script]) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .spawn(); + + if let Ok(mut child) = spawned { + if let Some(mut stdout) = child.stdout.take() { + let (tx, rx) = mpsc::channel(); + std::thread::spawn(move || { + let mut buf = String::new(); + let _ = stdout.read_to_string(&mut buf); + let _ = tx.send(buf); + }); + match rx.recv_timeout(Duration::from_secs(5)) { + Ok(text) => { + let _ = child.wait(); + let mut inside = false; + for line in text.lines() { + if line.trim_end() == START { + inside = true; + continue; + } + if line.trim_end() == END { + break; + } + if !inside { + continue; + } + // `env` prints KEY=value; continuation lines of a multi-line value + // have no '=' before whitespace and are skipped rather than guessed at. + if let Some((k, v)) = line.split_once('=') { + if !k.is_empty() && !k.contains(char::is_whitespace) { + out.insert(k.to_string(), v.to_string()); + } + } + } + } + Err(_) => { + // Hung profile — never let it hold up launch. + let _ = child.kill(); + let _ = child.wait(); + } + } + } + } + + // These describe the probe shell, not the user's environment. + for k in ["SHLVL", "PWD", "OLDPWD", "_"] { + out.remove(k); + } + + // Whether the probe worked or not, make sure the usual install dirs are reachable. + let base = out + .get("PATH") + .cloned() + .or_else(|| std::env::var("PATH").ok()) + .unwrap_or_default(); + let mut parts: Vec = base.split(':').filter(|s| !s.is_empty()).map(String::from).collect(); + for dir in KNOWN_TOOL_DIRS { + if !parts.iter().any(|p| p == dir) && std::path::Path::new(dir).is_dir() { + parts.push((*dir).to_string()); + } + } + out.insert("PATH".to_string(), parts.join(":")); + out +} + +/// Windows GUI apps inherit the user's full environment already. +#[cfg(target_os = "windows")] +fn sidecar_env() -> std::collections::HashMap { + std::collections::HashMap::new() +} + /// Path to the server entrypoint. Resolution order: /// 1. `COWORKER_SERVER_BIN` env override. /// 2. The bundled onedir sidecar shipped via Tauri `resources` (production): the @@ -629,6 +749,10 @@ pub fn run() { let mut server_cmd = Command::new(server_bin()); server_cmd .args(["--host", "127.0.0.1", "--port", &port.to_string()]) + // The user's real shell environment (PATH to their tools, AWS_PROFILE, + // KUBECONFIG, …) — see sidecar_env(). Applied FIRST so the explicit COWORKER_* + // vars below always win over anything a profile happens to export. + .envs(sidecar_env()) // The sidecar self-exits if we die abruptly (dev-watcher restart, crash) — // belt-and-suspenders alongside the RunEvent::ExitRequested kill below. // The explicit PID matters: under PyInstaller onefile the python process is a diff --git a/surfaces/gui/src/App.tsx b/surfaces/gui/src/App.tsx index 006b3d07..aa7d7cf4 100644 --- a/surfaces/gui/src/App.tsx +++ b/surfaces/gui/src/App.tsx @@ -69,6 +69,7 @@ import { PersonaView } from "./components/PersonaView"; import { AuditView } from "./components/AuditView"; import { InboxView } from "./components/InboxView"; import { ApprovalCard } from "./components/ApprovalCard"; +import { ToolRequestCard } from "./components/ToolRequestCard"; import { DirectoryRequestCard } from "./components/DirectoryRequestCard"; import { PlanCard } from "./components/PlanCard"; import { WorkspaceTrustPrompt } from "./components/WorkspaceTrustPrompt"; @@ -728,6 +729,20 @@ export function App() { { kind: "dirreq", reason: d.reason || "", path: d.path || "", writable: !!d.writable }, ]); break; + case "tool_requested": + if (unattendedRef.current) break; + setItems((p) => [ + ...p, + { + kind: "toolreq", + tool: d.name || "", + reason: d.reason || "", + installable: d.installable !== false, + version: d.version || "", + summary: d.summary || "", + }, + ]); + break; case "plan_proposed": if (unattendedRef.current) break; setItems((p) => [...p, { kind: "planreq", plan: d.plan || "" }]); @@ -980,6 +995,11 @@ export function App() { dropSessionInbox("directory"); sessionRef.current?.respondDirectory(granted, path, writable); }; + const respondTool = (approved: boolean) => { + setItems((p) => resolveLastToolReq(p, approved ? "installed" : "skipped")); + dropSessionInbox("tool"); + sessionRef.current?.respondTool(approved); + }; const answerQuestion = (answer: string) => { setItems((p) => resolveLastQuestion(p, answer)); dropSessionInbox("question"); @@ -1341,6 +1361,7 @@ export function App() { const idle = items.length === 0 && !streaming; const pendingApproval = [...items].reverse().find((i) => i.kind === "approval" && !i.resolved); const pendingDirReq = [...items].reverse().find((i) => i.kind === "dirreq" && !i.resolved); + const pendingToolReq = [...items].reverse().find((i) => i.kind === "toolreq" && !i.resolved); const pendingPlan = [...items].reverse().find((i) => i.kind === "planreq" && !i.resolved); const pendingQuestion = [...items].reverse().find((i) => i.kind === "question" && !i.resolved); // Facts subtitle (§22): the session's FIXED facts, not controls — model (+ the @@ -1857,6 +1878,8 @@ export function App() { // parked in the Inbox and surfaced via the answer-in-context card below. !unattended && pendingPlan?.kind === "planreq" ? ( + ) : !unattended && pendingToolReq?.kind === "toolreq" ? ( + ) : !unattended && pendingDirReq?.kind === "dirreq" ? ( ) : !unattended && pendingApproval?.kind === "approval" ? ( @@ -2029,6 +2052,18 @@ function resolveLastDirReq(items: Item[], resolved: "granted" | "denied"): Item[ return copy; } +function resolveLastToolReq(items: Item[], resolved: "installed" | "skipped"): Item[] { + const copy = [...items]; + for (let i = copy.length - 1; i >= 0; i--) { + const it = copy[i]; + if (it.kind === "toolreq" && !it.resolved) { + copy[i] = { ...it, resolved }; + break; + } + } + return copy; +} + function resolveLastPlan(items: Item[], resolved: "approved" | "rejected"): Item[] { const copy = [...items]; for (let i = copy.length - 1; i >= 0; i--) { diff --git a/surfaces/gui/src/api.ts b/surfaces/gui/src/api.ts index 271c28ad..61c38af1 100644 --- a/surfaces/gui/src/api.ts +++ b/surfaces/gui/src/api.ts @@ -2124,6 +2124,11 @@ export class Session { this.send({ type: "directory_response", granted, ...(path ? { path } : {}), writable: !!writable }); } + // Reply to a `request_tool` prompt: install the pinned build, or skip the check. + respondTool(approved: boolean) { + this.send({ type: "tool_response", approved }); + } + // Reply to a `propose_plan` prompt: approve (choosing the execution mode) or reject with feedback. respondPlan(approved: boolean, mode?: string, feedback?: string) { this.send({ diff --git a/surfaces/gui/src/components/ToolRequestCard.tsx b/surfaces/gui/src/components/ToolRequestCard.tsx new file mode 100644 index 00000000..7a8e04d8 --- /dev/null +++ b/surfaces/gui/src/components/ToolRequestCard.tsx @@ -0,0 +1,54 @@ +import type { Item } from "../types"; +import { Icon } from "./Icon"; + +type ToolReqItem = Extract; + +// The agent asked (via request_tool) for a CLI it couldn't find — a scanner, usually. +// Declining is a normal outcome, not a failure: the agent falls back and says which checks +// were degraded, so the copy here shouldn't push the user toward Install. +export function ToolRequestCard({ + item, + onRespond, +}: { + item: ToolReqItem; + onRespond: (approved: boolean) => void; +}) { + return ( +
+
+ + + The coworker needs {item.tool} + +
+ {item.reason &&
“{item.reason}”
} + {item.installable ? ( +
+ {item.summary ? `${item.summary}. ` : ""} + Installs {item.tool} + {item.version ? ` ${item.version}` : ""} — a pinned build, checksum-verified before + it runs. +
+ ) : ( +
+ No verified build is available for this machine — install it yourself if you want + this check, or skip and the coworker will note the gap. +
+ )} +
+ + + +
+
+ ); +} diff --git a/surfaces/gui/src/types.ts b/surfaces/gui/src/types.ts index dd3ac4da..5a974eaf 100644 --- a/surfaces/gui/src/types.ts +++ b/surfaces/gui/src/types.ts @@ -8,6 +8,7 @@ export type EventType = | "tool_proposed" | "permission_required" | "directory_requested" + | "tool_requested" | "question_requested" | "plan_proposed" | "tool_started" @@ -128,6 +129,15 @@ export type Item = writable?: boolean; resolved?: "granted" | "denied"; } + | { + kind: "toolreq"; + tool: string; + reason: string; + installable?: boolean; + version?: string; + summary?: string; + resolved?: "installed" | "skipped"; + } | { kind: "planreq"; plan: string; diff --git a/tests/test_security_bundles.py b/tests/test_security_bundles.py index b03277e8..49c4a382 100644 --- a/tests/test_security_bundles.py +++ b/tests/test_security_bundles.py @@ -80,3 +80,35 @@ def test_prompts_carry_the_positioning_guardrails(tmp_path): assert "drive" in prompt # drives scanners; value is judgment/remediation assert "read-only" in reg.get("cloud-posture").manifest.system_prompt.lower() assert "never print a discovered secret" in reg.get("security").manifest.system_prompt.lower() + + +def test_security_prompt_forbids_silently_skipping_a_check(tmp_path): + """OPE-85, owner-hit 2026-08-13: with gitleaks unavailable the review silently dropped + its git-history secret scan — the check didn't fail, it vanished. For a security tool, + "no tool" rendering as "clean" is the worst possible outcome, so the contract lives in + the prompt and is pinned here.""" + reg = _reg(tmp_path) + prompt = reg.get("security").manifest.system_prompt.lower() + assert "never silently skip" in prompt + assert "coverage" in prompt # every review reports what ran and what didn't + assert "request_tool" in prompt # asking is the first option, not skipping + + +def test_scanner_skills_offer_a_fallback_instead_of_stopping(tmp_path): + """The skills used to say "if missing … and STOP", which is precisely the instruction + that produced the vanished check. A missing tool must lead to request_tool or a manual + equivalent — never to a dropped step.""" + import coworker.personas as personas_pkg + + root = Path(personas_pkg.__file__).parent / "builtin" / "security" / "skills" + secret_scan = (root / "secret-scan" / "SKILL.md").read_text() + semgrep = (root / "semgrep-review" / "SKILL.md").read_text() + + for body in (secret_scan, semgrep): + assert "request_tool" in body + assert "STOP" not in body + + # The history sweep is the check that actually went missing — it must survive without + # gitleaks, and the no-printing rule must survive the manual path too. + assert "git log -p" in secret_scan + assert "REDACTED" in secret_scan diff --git a/tests/test_tool_request.py b/tests/test_tool_request.py new file mode 100644 index 00000000..309d6a10 --- /dev/null +++ b/tests/test_tool_request.py @@ -0,0 +1,96 @@ +"""`request_tool` — the agent asks for a missing CLI instead of dropping the check (OPE-85). + +Engine-intercepted like `request_directory`: it never goes through the permission path, +because the user's out-of-band decision IS the consent. +""" + +from __future__ import annotations + +import pytest + +from coworker.engine import EventType, TurnEngine +from coworker.permissions import Mode, PermissionEngine +from coworker.providers import AssistantTurn, ModelCapabilities, ProviderClient, ToolCall +from coworker.tools import ToolRegistry + + +class ScriptedProvider(ProviderClient): + """One turn that calls request_tool, then a plain reply.""" + + def __init__(self): + self.calls = 0 + + def complete(self, *, model, messages, tools=None, **settings): + self.calls += 1 + if self.calls == 1: + return AssistantTurn( + text="", + tool_calls=[ + ToolCall( + id="t1", + name="request_tool", + arguments={"name": "gitleaks", "reason": "scan history for secrets"}, + ) + ], + ) + return AssistantTurn(text="done", tool_calls=[]) + + def capabilities(self, model): + return ModelCapabilities(tools=True) + + +def _engine(tmp_path, requester): + return TurnEngine( + provider=ScriptedProvider(), + registry=ToolRegistry(), + permissions=PermissionEngine(workspace_root=tmp_path, mode=Mode.INTERACTIVE), + model="m", + tool_requester=requester, + ) + + +async def _run(engine) -> list: + return [e async for e in engine.run("check this repo")] + + +@pytest.mark.asyncio +async def test_emits_tool_requested_and_reports_install(tmp_path): + async def requester(args, tool_call_id=None): + assert args["name"] == "gitleaks" + return {"installed": True, "path": "/tmp/gitleaks", "version": "8.30.1"} + + events = await _run(_engine(tmp_path, requester)) + requested = [e for e in events if e.type is EventType.TOOL_REQUESTED] + assert requested and requested[0].data["name"] == "gitleaks" + finished = [e for e in events if e.type is EventType.TOOL_FINISHED] + assert finished[0].data["status"] == "ok" + + +@pytest.mark.asyncio +async def test_declining_tells_the_agent_to_fall_back_openly(tmp_path): + """A refusal must not read as 'check done'. The tool result has to push the agent + toward a disclosed fallback, which is the whole point of the contract.""" + + async def requester(args, tool_call_id=None): + return {"installed": False, "reason": "the user declined to install it"} + + engine = _engine(tmp_path, requester) + events = await _run(engine) + assert [e for e in events if e.type is EventType.TOOL_REQUESTED] + finished = [e for e in events if e.type is EventType.TOOL_FINISHED] + assert finished[0].data["status"] == "denied" + + tool_msg = [m for m in engine.messages if m.get("role") == "tool"][-1] + body = str(tool_msg["content"]).lower() + assert "degraded" in body or "fallback" in body + + +@pytest.mark.asyncio +async def test_no_requester_still_returns_guidance(tmp_path): + """Headless surfaces have nobody to ask — the agent must still be told to disclose + rather than assume the check passed.""" + engine = _engine(tmp_path, None) + events = await _run(engine) + assert not [e for e in events if e.type is EventType.TOOL_REQUESTED] + tool_msg = [m for m in engine.messages if m.get("role") == "tool"][-1] + assert "degraded" in str(tool_msg["content"]).lower() diff --git a/tests/test_toolchain.py b/tests/test_toolchain.py new file mode 100644 index 00000000..638fdddf --- /dev/null +++ b/tests/test_toolchain.py @@ -0,0 +1,147 @@ +"""Tool resolution + pinned managed installs (OPE-84). + +The bug this guards against: a Finder-launched app gets launchd's minimal PATH, so every +brew/nvm-installed scanner is invisible and a security review silently loses checks. +""" + +from __future__ import annotations + +import hashlib +import os +import stat + +import pytest + +from coworker import toolchain + + +def _make_exe(path, body: str = "#!/bin/sh\necho hi\n"): + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(body) + path.chmod(path.stat().st_mode | stat.S_IXUSR) + return path + + +def test_resolve_prefers_path(tmp_path, monkeypatch): + on_path = _make_exe(tmp_path / "bin" / "semgrep") + monkeypatch.setenv("PATH", str(tmp_path / "bin")) + assert toolchain.resolve("semgrep") == str(on_path.resolve()) + + +def test_resolve_finds_tools_launchd_path_cannot_see(tmp_path, monkeypatch): + """The actual production failure: PATH is bare, the tool is in a brew-style dir.""" + brew = tmp_path / "opt" / "homebrew" / "bin" + gitleaks = _make_exe(brew / "gitleaks") + monkeypatch.setenv("PATH", "/usr/bin:/bin") # what a Finder launch really gets + monkeypatch.setattr(toolchain, "_KNOWN_DIRS", (str(brew),)) + assert toolchain.resolve("gitleaks") == str(gitleaks.resolve()) + + +def test_resolve_returns_absolute_path(tmp_path, monkeypatch): + """Callers must be able to invoke without depending on PATH at all.""" + _make_exe(tmp_path / "bin" / "trivy") + monkeypatch.setenv("PATH", str(tmp_path / "bin")) + assert os.path.isabs(toolchain.resolve("trivy") or "") + + +def test_missing_reports_only_absent_tools(tmp_path, monkeypatch): + _make_exe(tmp_path / "bin" / "gitleaks") + monkeypatch.setenv("PATH", str(tmp_path / "bin")) + monkeypatch.setattr(toolchain, "_KNOWN_DIRS", ()) + monkeypatch.setattr(toolchain, "MANAGED", {}) + assert toolchain.missing(["gitleaks", "semgrep"]) == ["semgrep"] + + +def test_unknown_tool_resolves_to_none(monkeypatch): + monkeypatch.setenv("PATH", "") + monkeypatch.setattr(toolchain, "_KNOWN_DIRS", ()) + assert toolchain.resolve("definitely-not-a-real-tool") is None + + +def test_registry_entries_are_pinned_and_digested(): + """Every managed download must carry a version and a full SHA-256 — an unpinned + entry would mean 'download whatever is current', which is the thing we refuse.""" + assert toolchain.MANAGED, "registry should not be empty" + for name, tool in toolchain.MANAGED.items(): + assert tool.version and tool.version[0].isdigit(), name + assert tool.summary, f"{name} needs a summary for the consent card" + for key, dl in tool.downloads.items(): + assert len(dl.sha256) == 64, f"{name}/{key} digest looks wrong" + assert int(dl.sha256, 16) >= 0 # hex + assert tool.version in dl.url, f"{name}/{key} url must pin the version" + assert dl.url.startswith("https://"), f"{name}/{key} must be https" + + +def test_describe_surfaces_what_the_user_is_approving(monkeypatch): + monkeypatch.setattr(toolchain, "_platform_key", lambda: "darwin_arm64") + info = toolchain.describe("gitleaks") + assert info and info["version"] and info["sha256"] and info["url"] + assert "secret" in info["summary"].lower() + + +def test_install_refuses_a_tampered_download(tmp_path, monkeypatch): + """The whole point of pinning: a mismatched artifact never lands on disk.""" + monkeypatch.setattr(toolchain, "managed_dir", lambda: tmp_path / "tools") + monkeypatch.setattr(toolchain, "_platform_key", lambda: "darwin_arm64") + + class FakeResp: + def read(self): + return b"malicious payload" + + def __enter__(self): + return self + + def __exit__(self, *a): + return False + + monkeypatch.setattr(toolchain.urllib.request, "urlopen", lambda *a, **k: FakeResp()) + + with pytest.raises(ValueError, match="checksum mismatch"): + toolchain.install("gitleaks") + assert not (tmp_path / "tools").exists() or not list( + (tmp_path / "tools").rglob("gitleaks") + ) + + +def test_install_writes_a_verified_binary(tmp_path, monkeypatch): + payload = b"#!/bin/sh\necho scanned\n" + digest = hashlib.sha256(payload).hexdigest() + + monkeypatch.setattr(toolchain, "managed_dir", lambda: tmp_path / "tools") + monkeypatch.setattr(toolchain, "_platform_key", lambda: "darwin_arm64") + monkeypatch.setattr( + toolchain, + "MANAGED", + { + "osv-scanner": toolchain.ManagedTool( + name="osv-scanner", + version="2.5.0", + summary="checks lockfiles", + downloads={ + "darwin_arm64": toolchain.Download( + url="https://example.invalid/osv-scanner", sha256=digest + ) + }, + ) + }, + ) + + class FakeResp: + def read(self): + return payload + + def __enter__(self): + return self + + def __exit__(self, *a): + return False + + monkeypatch.setattr(toolchain.urllib.request, "urlopen", lambda *a, **k: FakeResp()) + + path = toolchain.install("osv-scanner") + assert os.access(path, os.X_OK) + assert open(path, "rb").read() == payload + # Installed tools are resolvable afterwards, even with an empty PATH. + monkeypatch.setenv("PATH", "") + monkeypatch.setattr(toolchain, "_KNOWN_DIRS", ()) + assert toolchain.resolve("osv-scanner") == path From cf0edbf9c544399cdd30643c47c582483bc1b97f Mon Sep 17 00:00:00 2001 From: Rohit C Prasad Date: Fri, 14 Aug 2026 20:35:48 -0700 Subject: [PATCH 10/80] security bundles: offer a self-contained findings report page Ask with ask_user before building it; page inherits the evidence, coverage and no-secrets rules. --- .../builtin/cloud-posture/manifest.md | 15 +++++++++ .../personas/builtin/dep-audit/manifest.md | 15 +++++++++ .../personas/builtin/security/manifest.md | 22 +++++++++++++ tests/test_security_bundles.py | 32 +++++++++++++++++++ 4 files changed, 84 insertions(+) diff --git a/coworker/personas/builtin/cloud-posture/manifest.md b/coworker/personas/builtin/cloud-posture/manifest.md index 552b363e..fa55f27b 100644 --- a/coworker/personas/builtin/cloud-posture/manifest.md +++ b/coworker/personas/builtin/cloud-posture/manifest.md @@ -45,3 +45,18 @@ Operate safely: Finish with a deliverable: a posture summary (exposure-ranked findings, what you fixed in code, what needs a human decision) and the fix branch/PR with its `terraform plan` output attached. + +Offer a report page (don't assume it): +- A substantial posture review — roughly five or more findings, or anything critical/high + — gets re-read and shared, and chat is a poor container for that. Once triage is done and + BEFORE writing the long prose, ask with `ask_user` whether they want a report page, + putting the headline counts in the question so they can choose with the gist in hand. + Small reviews: skip the question. No way to ask: default to chat. +- If yes, write ONE self-contained HTML file into the workspace (inline CSS/JS, no CDN or + external assets, so it opens anywhere and offline) and link it from your reply: + `[Cloud posture review](artifact:reports/cloud-posture.html)`. Keep the chat reply short. +- Make it usable: a header count strip, findings collapsible by exposure/severity, a table + you can filter and sort by resource and severity, evidence behind a chevron, and a copy + button on each Terraform fix. +- Same rules as everywhere else: evidence per claim, coverage stated plainly, and never a + credential or full account identifier on the page — a file travels further than chat. diff --git a/coworker/personas/builtin/dep-audit/manifest.md b/coworker/personas/builtin/dep-audit/manifest.md index 13c51520..c5ba4c6d 100644 --- a/coworker/personas/builtin/dep-audit/manifest.md +++ b/coworker/personas/builtin/dep-audit/manifest.md @@ -41,3 +41,18 @@ Operate safely: Finish with a deliverable: an audit summary (advisory · package · reachability verdict · action) and one focused upgrade branch/PR per ecosystem, tests green. + +Offer a report page (don't assume it): +- A dependency audit is usually long — dozens of advisories, most of them noise — and it's + exactly the kind of list people filter and work through over time. Once triage is done and + BEFORE writing the long prose, ask with `ask_user` whether they want a report page, with + the headline counts in the question ("31 advisories — 4 reachable, 27 not. Report page, or + just here?"). Short audits: skip the question. No way to ask: default to chat. +- If yes, write ONE self-contained HTML file into the workspace (inline CSS/JS, no CDN or + external assets) and link it: `[Dependency audit](artifact:reports/dependency-audit.html)`. + Keep the chat reply short. +- Make it usable: a header count strip that leads with REACHABLE count (not raw advisory + count — severity isn't priority), collapsible sections, a table filterable by package, + severity and reachability verdict, evidence behind a chevron, and a copy button on each + upgrade command. +- Same rules: evidence per claim, coverage stated plainly, no secrets on the page. diff --git a/coworker/personas/builtin/security/manifest.md b/coworker/personas/builtin/security/manifest.md index 196102b6..6707a2e2 100644 --- a/coworker/personas/builtin/security/manifest.md +++ b/coworker/personas/builtin/security/manifest.md @@ -58,3 +58,25 @@ Operate safely: Finish with a deliverable: a findings summary (what was found, what matters, what you fixed, what you recommend next) and the branch/PR that carries the fixes. + +Offer a report page (don't assume it): +- A substantial review — roughly five or more findings, or anything critical/high — is a + document people re-read, share, and work through over days. Chat is a poor container for + that. So once triage is done and BEFORE you write the long prose, ask with `ask_user` + whether they want it as a report page. Put the headline counts in the question so they + can decide with the gist already in hand ("12 findings — 3 critical, 2 high, 5 medium, + 2 low. Report page, or just here in chat?"). Small reviews: skip the question, answer in + chat. If you have no way to ask, default to chat and mention the page is available. +- If they say yes, write ONE self-contained HTML file into the workspace — inline CSS and + JS, no CDN links or external assets, so it opens anywhere and offline — then end your + reply with a markdown link to it: `[Security review](artifact:reports/security-review.html)`. + Keep the chat reply to a short summary; the page carries the detail. If they say no, + write the full findings in chat as usual and don't build the page. +- Make the page work like a tool, not a printout: a header count strip (e.g. "5 to fix · + 4 medium · 6 low"), findings grouped in collapsible sections by severity, a table you can + filter and sort by file and severity, each finding's evidence tucked behind a chevron + rather than dumped inline, and a copy button on every fix so a developer can lift it + straight into their editor. +- The page obeys every rule above — evidence per claim, the Coverage note reproduced in + full, and NEVER a secret's value. A file gets forwarded and hosted; a value leaked there + travels further than one in chat. diff --git a/tests/test_security_bundles.py b/tests/test_security_bundles.py index 49c4a382..7adf50cd 100644 --- a/tests/test_security_bundles.py +++ b/tests/test_security_bundles.py @@ -112,3 +112,35 @@ def test_scanner_skills_offer_a_fallback_instead_of_stopping(tmp_path): # gitleaks, and the no-printing rule must survive the manual path too. assert "git log -p" in secret_scan assert "REDACTED" in secret_scan + + +def test_bundles_offer_a_report_page_rather_than_assuming_one(tmp_path): + """A long findings list is a document people re-read and share, so the bundles offer a + self-contained HTML report — but ASK first (owner call 2026-08-14). Assuming it would + burn tokens on a page nobody wanted; skipping it leaves the deliverable trapped in chat.""" + reg = _reg(tmp_path) + for pid in BUNDLES: + prompt = reg.get(pid).manifest.system_prompt.lower() + assert "ask_user" in prompt, pid # opt-in, not automatic + assert "self-contained" in prompt, pid # opens anywhere, offline + assert "artifact:" in prompt, pid # linked the way the GUI can open it + # The counts ride in the question so the user chooses with the gist in hand. + assert "headline counts" in prompt, pid + + +def test_report_page_inherits_the_secret_and_evidence_rules(tmp_path): + """A file gets forwarded and hosted — a value leaked there travels further than one in + chat, so the page must not become a loophole around the no-secrets rule.""" + import re + + def flat(pid: str) -> str: + # Prompts are hand-wrapped prose; collapse whitespace so a reflow can't + # break these assertions (or hide a deleted rule). + return re.sub(r"\s+", " ", reg.get(pid).manifest.system_prompt.lower()) + + reg = _reg(tmp_path) + security = flat("security") + assert "never a secret's value" in security + assert "coverage note reproduced in full" in security + for pid in ("cloud-posture", "dep-audit"): + assert "evidence per claim" in flat(pid), pid From c041ed64a5395cd79f03bee243482855496a6b5c Mon Sep 17 00:00:00 2001 From: Rohit C Prasad Date: Fri, 14 Aug 2026 23:25:21 -0700 Subject: [PATCH 11/80] Pin trivy in the managed registry; retire tfsec from cloud-posture trivy 0.74.0 pinned with per-platform digests so request_tool can install it. tfsec is deprecated upstream; the bundle now drives trivy config instead. --- .../builtin/cloud-posture/manifest.md | 4 +-- .../cloud-posture/skills/iac-scan/SKILL.md | 11 +++++--- coworker/toolchain.py | 26 +++++++++++++++++-- tests/test_security_bundles.py | 17 ++++++++++++ tests/test_toolchain.py | 10 +++++++ 5 files changed, 60 insertions(+), 8 deletions(-) diff --git a/coworker/personas/builtin/cloud-posture/manifest.md b/coworker/personas/builtin/cloud-posture/manifest.md index fa55f27b..dc7f2705 100644 --- a/coworker/personas/builtin/cloud-posture/manifest.md +++ b/coworker/personas/builtin/cloud-posture/manifest.md @@ -10,7 +10,7 @@ connectors: true skills: [iac-scan, aws-posture] recommended_models: [anthropic:claude-opus-4-8, openai:gpt-5.6-sol] default_permission_mode: interactive -description: An infrastructure-security reviewer for teams without a cloud security team. Scans Terraform and cloud configuration with open-source tools (trivy, tfsec, checkov), reads your live cloud posture strictly read-only, and fixes what matters in the IaC — never by clicking around a console. +description: An infrastructure-security reviewer for teams without a cloud security team. Scans Terraform and cloud configuration with open-source tools (trivy, checkov), reads your live cloud posture strictly read-only, and fixes what matters in the IaC — never by clicking around a console. recommends: - connector: github reason: open fix PRs for the Terraform changes @@ -22,7 +22,7 @@ Terraform and in the live account, explain what actually matters, and fix it at source: the code. How you work: -- You DRIVE scanners (trivy config / tfsec / checkov for IaC); your value is judgment — +- You DRIVE scanners (trivy config / checkov for IaC); your value is judgment — which findings are real exposure for THIS architecture, and what the minimal safe change is. - Fix in the IaC, never in the console. A console fix is drift; a Terraform fix is diff --git a/coworker/personas/builtin/cloud-posture/skills/iac-scan/SKILL.md b/coworker/personas/builtin/cloud-posture/skills/iac-scan/SKILL.md index 3fdcce98..b7ce13ef 100644 --- a/coworker/personas/builtin/cloud-posture/skills/iac-scan/SKILL.md +++ b/coworker/personas/builtin/cloud-posture/skills/iac-scan/SKILL.md @@ -1,14 +1,17 @@ --- name: iac-scan -description: Scan Terraform/IaC with trivy or tfsec and fix what matters in code +description: Scan Terraform/IaC with trivy config and fix what matters in code --- Scan the repo's infrastructure-as-code and turn findings into minimal, safe Terraform changes. -1. Pick the scanner that's present (check in this order, ask before installing): +1. Pick the scanner (in this order — do NOT skip the scan if none is present): - `trivy config . --format json -o /tmp/iac.json` (also covers Dockerfiles/k8s) - - `tfsec . --format json --out /tmp/iac.json` - - `checkov -d . -o json > /tmp/iac.json` + - `checkov -d . -o json > /tmp/iac.json` if the repo already uses it + - Neither installed: ask for trivy with `request_tool("trivy", …)`. If the user + declines, review the Terraform by hand against the exposure checklist in step 2 + and say in your report that the scan was manual. + Do not suggest tfsec — it is deprecated; `trivy config` is its successor. 2. Triage by real exposure, reading the surrounding Terraform for each finding: - Internet-reachable (0.0.0.0/0 ingress, public buckets/ALBs) first. - Then identity blast radius (wildcard IAM, broad assume-role trust). diff --git a/coworker/toolchain.py b/coworker/toolchain.py index c7f46136..93640ea0 100644 --- a/coworker/toolchain.py +++ b/coworker/toolchain.py @@ -86,8 +86,8 @@ class ManagedTool: # own checksum manifest; bumping a tool means bumping the digest in the same commit. # # Not every scanner belongs here: semgrep is distributed as a Python package (pip/brew), -# and trivy/tfsec ship per-distro packaging — for those we resolve the user's install -# rather than half-managing a copy. That's a deliberate line, not an omission. +# so we resolve the user's install rather than half-managing a copy. tfsec is absent on +# purpose — it's deprecated upstream and `trivy config` is its successor. MANAGED: dict[str, ManagedTool] = { "gitleaks": ManagedTool( name="gitleaks", @@ -111,6 +111,28 @@ MANAGED: dict[str, ManagedTool] = { ), }, ), + "trivy": ManagedTool( + name="trivy", + version="0.74.0", + summary="scans IaC/config, container images, and filesystems for misconfigurations and vulnerabilities", + downloads={ + "darwin_arm64": Download( + url="https://github.com/aquasecurity/trivy/releases/download/v0.74.0/trivy_0.74.0_macOS-ARM64.tar.gz", + sha256="1caada5e0e2091909357c7525d3aa76f4b660b13821bc143b190c7483e31cc11", + member="trivy", + ), + "darwin_amd64": Download( + url="https://github.com/aquasecurity/trivy/releases/download/v0.74.0/trivy_0.74.0_macOS-64bit.tar.gz", + sha256="472816f6888dda689d075c30254d4210b4d1035acf365aa72332f584c2f60485", + member="trivy", + ), + "linux_amd64": Download( + url="https://github.com/aquasecurity/trivy/releases/download/v0.74.0/trivy_0.74.0_Linux-64bit.tar.gz", + sha256="2ae6fe3ee734b7fdf11335663e18c75ea12dccc76062f09f164a3b0f8be4371a", + member="trivy", + ), + }, + ), "osv-scanner": ManagedTool( name="osv-scanner", version="2.5.0", diff --git a/tests/test_security_bundles.py b/tests/test_security_bundles.py index 7adf50cd..4007373b 100644 --- a/tests/test_security_bundles.py +++ b/tests/test_security_bundles.py @@ -114,6 +114,23 @@ def test_scanner_skills_offer_a_fallback_instead_of_stopping(tmp_path): assert "REDACTED" in secret_scan +def test_cloud_posture_drives_trivy_config_not_deprecated_tfsec(tmp_path): + """tfsec was folded into trivy upstream and is maintenance-only; recommending it + sends request_tool (and users) after a dead tool. `trivy config` is the successor. + The only tfsec mention allowed in the bundle is the deprecation ban itself.""" + import coworker.personas as personas_pkg + + root = Path(personas_pkg.__file__).parent / "builtin" / "cloud-posture" + assert "tfsec" not in (root / "manifest.md").read_text() + + skill = (root / "skills" / "iac-scan" / "SKILL.md").read_text() + assert "trivy config" in skill + assert "request_tool" in skill # missing scanner → ask, never a dropped scan + for line in skill.splitlines(): + if "tfsec" in line: + assert "deprecated" in line, f"stray tfsec mention: {line!r}" + + def test_bundles_offer_a_report_page_rather_than_assuming_one(tmp_path): """A long findings list is a document people re-read and share, so the bundles offer a self-contained HTML report — but ASK first (owner call 2026-08-14). Assuming it would diff --git a/tests/test_toolchain.py b/tests/test_toolchain.py index 638fdddf..e12d98d7 100644 --- a/tests/test_toolchain.py +++ b/tests/test_toolchain.py @@ -72,6 +72,16 @@ def test_registry_entries_are_pinned_and_digested(): assert dl.url.startswith("https://"), f"{name}/{key} must be https" +def test_trivy_is_pinned_for_every_platform_we_ship(): + """tfsec is deprecated upstream (folded into trivy); `trivy config` is the IaC scanner + the cloud-posture bundle drives, so its pin must exist wherever the app runs.""" + tool = toolchain.MANAGED["trivy"] + assert set(tool.downloads) >= {"darwin_arm64", "darwin_amd64", "linux_amd64"} + for dl in tool.downloads.values(): + assert dl.member == "trivy" # release assets are tarballs, not bare binaries + assert "tfsec" not in toolchain.MANAGED + + def test_describe_surfaces_what_the_user_is_approving(monkeypatch): monkeypatch.setattr(toolchain, "_platform_key", lambda: "darwin_arm64") info = toolchain.describe("gitleaks") From 25d32891d3f045b1a8e329c11163a27c9a7bebb5 Mon Sep 17 00:00:00 2001 From: Rohit C Prasad Date: Fri, 14 Aug 2026 23:25:21 -0700 Subject: [PATCH 12/80] Tool-request prompts fail closed on installability TOOL_REQUESTED now carries the registry's verdict (installable/version/summary). GUI offers Install only when the event says a pinned build exists. --- coworker/engine.py | 16 +++++++++++++++- surfaces/gui/e2e/fixtures.ts | 10 ++++++++++ surfaces/gui/e2e/toolreq.spec.ts | 15 +++++++++++++++ surfaces/gui/src/App.tsx | 3 ++- tests/test_tool_request.py | 33 ++++++++++++++++++++++++++++---- 5 files changed, 71 insertions(+), 6 deletions(-) diff --git a/coworker/engine.py b/coworker/engine.py index 561eee11..1e393199 100644 --- a/coworker/engine.py +++ b/coworker/engine.py @@ -20,6 +20,7 @@ from enum import Enum from typing import Any, AsyncIterator, Awaitable, Callable, Optional from . import compaction as _compaction +from . import toolchain as _toolchain from .events import Event, EventType from .permissions import Mode, PermissionEngine from .providers import AssistantTurn, ProviderClient, ToolCall @@ -963,7 +964,20 @@ class TurnEngine: ), } else: - yield Event(EventType.TOOL_REQUESTED, {"name": name, "reason": reason}) + # The prompt must say up front whether WE can install this (pinned build for + # this platform) — a card that offers Install for a tool we can't fetch turns + # the user's approval into a guaranteed error. Absence of metadata means NO. + info = _toolchain.describe(name) + yield Event( + EventType.TOOL_REQUESTED, + { + "name": name, + "reason": reason, + "installable": info is not None, + "version": (info or {}).get("version", ""), + "summary": (info or {}).get("summary", ""), + }, + ) self._audit(tool_call, stage="tool_requested", reason=reason) result = await self._interruptible( self.tool_requester(dict(args), tool_call.id), diff --git a/surfaces/gui/e2e/fixtures.ts b/surfaces/gui/e2e/fixtures.ts index 9e7527f1..f1da713e 100644 --- a/surfaces/gui/e2e/fixtures.ts +++ b/surfaces/gui/e2e/fixtures.ts @@ -610,6 +610,16 @@ export async function mockApi(page: import("@playwright/test").Page) { }); return; // suspended on the approval } + // The pre-fix payload shape (owner-hit 2026-08-14): no installable/version/summary + // — an older sidecar, or any surface that forgets the field. Must render NOT + // installable, never a guessed Install offer. + if (/request an unpinned tool/i.test(msg.text)) { + send("tool_requested", { + name: "somescanner", + reason: "scan the Terraform for misconfigurations", + }); + return; // suspended on the tool request + } // OPE-85: the agent hits a missing scanner and asks instead of skipping the check. if (/scan for secrets/i.test(msg.text)) { send("tool_requested", { diff --git a/surfaces/gui/e2e/toolreq.spec.ts b/surfaces/gui/e2e/toolreq.spec.ts index 67f47def..b3a879cf 100644 --- a/surfaces/gui/e2e/toolreq.spec.ts +++ b/surfaces/gui/e2e/toolreq.spec.ts @@ -23,6 +23,21 @@ test("request_tool surfaces a card naming the tool, the reason and the pinned ve await expect(card.getByTestId("toolreq-skip")).toBeVisible(); }); +test("an event without install metadata fails CLOSED — Install disabled, skip offered", async ({ + page, +}) => { + // Owner-hit 2026-08-14: the card offered "pinned build, checksum-verified" for a tool + // with no pinned build; approval could only produce an error. Absence of metadata is NO. + await page.goto("/"); + await page.getByPlaceholder(/Ask the coworker/).fill("request an unpinned tool"); + await page.getByRole("button", { name: "Send" }).click(); + const card = page.locator(".dirreq-card"); + await expect(card).toContainText("somescanner"); + await expect(card).toContainText(/no verified build/i); + await expect(card.getByTestId("toolreq-install")).toBeDisabled(); + await expect(card.getByTestId("toolreq-skip")).toBeEnabled(); +}); + test("installing runs the check; skipping still reports coverage", async ({ page }) => { await ask(page); await page.getByTestId("toolreq-install").click(); diff --git a/surfaces/gui/src/App.tsx b/surfaces/gui/src/App.tsx index aa7d7cf4..e9ae5662 100644 --- a/surfaces/gui/src/App.tsx +++ b/surfaces/gui/src/App.tsx @@ -737,7 +737,8 @@ export function App() { kind: "toolreq", tool: d.name || "", reason: d.reason || "", - installable: d.installable !== false, + // Fail CLOSED: only offer Install when the event says a pinned build exists. + installable: d.installable === true, version: d.version || "", summary: d.summary || "", }, diff --git a/tests/test_tool_request.py b/tests/test_tool_request.py index 309d6a10..8da512c5 100644 --- a/tests/test_tool_request.py +++ b/tests/test_tool_request.py @@ -17,8 +17,9 @@ from coworker.tools import ToolRegistry class ScriptedProvider(ProviderClient): """One turn that calls request_tool, then a plain reply.""" - def __init__(self): + def __init__(self, tool: str = "gitleaks"): self.calls = 0 + self.tool = tool def complete(self, *, model, messages, tools=None, **settings): self.calls += 1 @@ -29,7 +30,7 @@ class ScriptedProvider(ProviderClient): ToolCall( id="t1", name="request_tool", - arguments={"name": "gitleaks", "reason": "scan history for secrets"}, + arguments={"name": self.tool, "reason": "scan history for secrets"}, ) ], ) @@ -39,9 +40,9 @@ class ScriptedProvider(ProviderClient): return ModelCapabilities(tools=True) -def _engine(tmp_path, requester): +def _engine(tmp_path, requester, tool: str = "gitleaks"): return TurnEngine( - provider=ScriptedProvider(), + provider=ScriptedProvider(tool), registry=ToolRegistry(), permissions=PermissionEngine(workspace_root=tmp_path, mode=Mode.INTERACTIVE), model="m", @@ -85,6 +86,30 @@ async def test_declining_tells_the_agent_to_fall_back_openly(tmp_path): assert "degraded" in body or "fallback" in body +@pytest.mark.asyncio +async def test_event_tells_the_truth_about_installability(tmp_path, monkeypatch): + """Owner-hit 2026-08-14: the card offered Install for a tool with no pinned build — + the surface guessed because the event said nothing. The event must carry the + registry's verdict, and no metadata means NO.""" + from coworker import toolchain + + monkeypatch.setattr(toolchain, "_platform_key", lambda: "darwin_arm64") + + async def requester(args, tool_call_id=None): + return {"installed": False, "reason": "declined"} + + events = await _run(_engine(tmp_path, requester, tool="gitleaks")) + data = [e for e in events if e.type is EventType.TOOL_REQUESTED][0].data + assert data["installable"] is True + assert data["version"] == toolchain.MANAGED["gitleaks"].version + assert data["summary"] + + events = await _run(_engine(tmp_path, requester, tool="not-a-managed-tool")) + data = [e for e in events if e.type is EventType.TOOL_REQUESTED][0].data + assert data["installable"] is False + assert data["version"] == "" and data["summary"] == "" + + @pytest.mark.asyncio async def test_no_requester_still_returns_guidance(tmp_path): """Headless surfaces have nobody to ask — the agent must still be told to disclose From b86615777859ccec7af7622dcf056c3b69265e23 Mon Sep 17 00:00:00 2001 From: Rohit C Prasad Date: Fri, 14 Aug 2026 23:50:51 -0700 Subject: [PATCH 13/80] Managed tools land on the persistent shell's PATH install() links binaries into a stable tools/bin dir; LocalExecutor appends it at spawn, so a mid-session install works by name without a respawn. --- coworker/toolchain.py | 26 ++++++++++++++++++++++++++ coworker/tools/shell.py | 10 ++++++++++ tests/test_shell.py | 18 ++++++++++++++++++ 3 files changed, 54 insertions(+) diff --git a/coworker/toolchain.py b/coworker/toolchain.py index 93640ea0..0a10866b 100644 --- a/coworker/toolchain.py +++ b/coworker/toolchain.py @@ -33,6 +33,7 @@ import urllib.request from dataclasses import dataclass from pathlib import Path from typing import Iterable, Optional +from urllib.parse import urlparse from .secrets import state_dir @@ -55,6 +56,13 @@ def managed_dir() -> Path: return state_dir() / "tools" +def bin_dir() -> Path: + """One stable dir of links to the current pinned binaries. Binaries themselves live + in versioned dirs; this is what goes on a shell's PATH, so a tool installed mid- + session is picked up by the already-running shell without a respawn.""" + return managed_dir() / "bin" + + def _platform_key() -> str: """`_` using the naming the upstream release assets use.""" system = {"darwin": "darwin", "linux": "linux", "win32": "windows"}.get( @@ -203,12 +211,16 @@ def describe(name: str) -> Optional[dict[str, str]]: dl = tool.downloads.get(_platform_key()) if not dl: return None + parsed = urlparse(dl.url) + path_parts = [p for p in parsed.path.split("/") if p] return { "name": tool.name, "version": tool.version, "summary": tool.summary, "url": dl.url, "sha256": dl.sha256, + # Publisher, human-readable ("github.com/aquasecurity") — for the consent card. + "source": parsed.netloc + (f"/{path_parts[0]}" if path_parts else ""), } @@ -261,4 +273,18 @@ def install(name: str, *, timeout: int = 120) -> str: staged.chmod(staged.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH) shutil.move(str(staged), str(target)) + _link_into_bin(tool, target) return str(target) + + +def _link_into_bin(tool: ManagedTool, target: Path) -> None: + """Expose the versioned binary under the stable bin dir (PATH-friendly name).""" + link = bin_dir() / target.name + link.parent.mkdir(parents=True, exist_ok=True) + try: + if link.is_symlink() or link.exists(): + link.unlink() + link.symlink_to(target) + except OSError: + # Filesystems without symlinks (some Windows setups): a copy serves the same role. + shutil.copy2(target, link) diff --git a/coworker/tools/shell.py b/coworker/tools/shell.py index b30ea78f..0404511d 100644 --- a/coworker/tools/shell.py +++ b/coworker/tools/shell.py @@ -162,6 +162,16 @@ class LocalExecutor(Executor): shell_path = "powershell.exe" if self._is_windows else "/bin/bash" self._shell_path = shell_path self._env = {**os.environ, **_NONINTERACTIVE_ENV, **(env or {})} + # Managed pinned tools (toolchain.install) land under one stable bin dir; putting + # it on PATH up front — even before anything is installed there — means a tool the + # user approves mid-session works in THIS shell immediately, by name, no respawn. + # Appended last: the user's own copies always win. + from .. import toolchain + + path = self._env.get("PATH", "") + managed_bin = str(toolchain.bin_dir()) + if managed_bin not in path.split(os.pathsep): + self._env["PATH"] = f"{path}{os.pathsep}{managed_bin}" if path else managed_bin self._spawn() def _spawn(self) -> None: diff --git a/tests/test_shell.py b/tests/test_shell.py index 40513647..fc7291cc 100644 --- a/tests/test_shell.py +++ b/tests/test_shell.py @@ -53,6 +53,24 @@ def test_env_persists_across_calls(executor): assert "hello_world" in result["output"] +def test_managed_bin_dir_is_on_path_from_spawn(tmp_path, monkeypatch): + """A tool the user approves mid-session must work by name in the ALREADY-running + shell (owner-hit 2026-08-14: freshly installed trivy needed a full-path retry). The + stable bin dir goes on PATH at spawn, before anything exists in it.""" + import os as _os + + from coworker import toolchain + + monkeypatch.setenv("COWORKER_STATE_DIR", str(tmp_path / "state")) + ex = LocalExecutor(cwd=tmp_path, default_timeout=10) + try: + assert str(toolchain.bin_dir()) in ex._env["PATH"].split(_os.pathsep) + # User's own PATH entries stay ahead — their copies always win. + assert not ex._env["PATH"].startswith(str(toolchain.bin_dir())) + finally: + ex.close() + + def test_exit_code_captured(executor): assert executor.run(EXIT_OK)["exit_code"] == 0 assert executor.run(EXIT_FAIL)["exit_code"] == 1 From 8e77d61aa141981598aacdf3be6afbf0bb0c2548 Mon Sep 17 00:00:00 2001 From: Rohit C Prasad Date: Fri, 14 Aug 2026 23:50:51 -0700 Subject: [PATCH 14/80] Tool-request card: separate the product's facts from the coworker's ask Registry metadata (version, publisher, checksum) moves to a distinct fact strip. Decline button renamed to say the run continues; reason capped to one sentence. --- coworker/engine.py | 1 + coworker/server/app.py | 1 + coworker/tools/toolreq.py | 7 ++++-- surfaces/gui/e2e/fixtures.ts | 1 + surfaces/gui/e2e/toolreq.spec.ts | 12 ++++++--- surfaces/gui/src/App.tsx | 1 + .../gui/src/components/ToolRequestCard.tsx | 25 ++++++++++++------- surfaces/gui/src/styles.css | 13 ++++++++++ surfaces/gui/src/types.ts | 1 + tests/test_tool_request.py | 1 + tests/test_toolchain.py | 5 ++++ 11 files changed, 53 insertions(+), 15 deletions(-) diff --git a/coworker/engine.py b/coworker/engine.py index 1e393199..9b36e43d 100644 --- a/coworker/engine.py +++ b/coworker/engine.py @@ -976,6 +976,7 @@ class TurnEngine: "installable": info is not None, "version": (info or {}).get("version", ""), "summary": (info or {}).get("summary", ""), + "source": (info or {}).get("source", ""), }, ) self._audit(tool_call, stage="tool_requested", reason=reason) diff --git a/coworker/server/app.py b/coworker/server/app.py index 460a0d2c..375037a7 100644 --- a/coworker/server/app.py +++ b/coworker/server/app.py @@ -1717,6 +1717,7 @@ def create_app(manager: SessionManager) -> FastAPI: "version": (info or {}).get("version", ""), "summary": (info or {}).get("summary", ""), "url": (info or {}).get("url", ""), + "source": (info or {}).get("source", ""), }, tool_call_id=tool_call_id, ) diff --git a/coworker/tools/toolreq.py b/coworker/tools/toolreq.py index e611b838..a0ad4040 100644 --- a/coworker/tools/toolreq.py +++ b/coworker/tools/toolreq.py @@ -18,8 +18,11 @@ from aisuite.agents import ToolMetadata, tool def request_tool_tool() -> object: def request_tool(name: str, reason: str) -> dict: """Ask the user to install a command-line tool you need but can't find on this - machine (e.g. `gitleaks`, `osv-scanner`, `semgrep`). Say in `reason` what check it - unlocks, so the user can judge whether it's worth installing. + machine (e.g. `gitleaks`, `osv-scanner`, `semgrep`). + + Keep `reason` to ONE sentence: which check needs the tool. The prompt the user + sees already explains what the install is (pinned version, publisher, checksum) + and what happens if they decline — don't restate any of that in `reason`. Use this INSTEAD of quietly skipping a check. If the user declines, carry on with a fallback (e.g. reading git history yourself instead of running gitleaks) and state diff --git a/surfaces/gui/e2e/fixtures.ts b/surfaces/gui/e2e/fixtures.ts index f1da713e..0fd085d7 100644 --- a/surfaces/gui/e2e/fixtures.ts +++ b/surfaces/gui/e2e/fixtures.ts @@ -628,6 +628,7 @@ export async function mockApi(page: import("@playwright/test").Page) { installable: true, version: "8.30.1", summary: "scans git history and the working tree for committed secrets", + source: "github.com/gitleaks", }); return; // suspended on the tool request } diff --git a/surfaces/gui/e2e/toolreq.spec.ts b/surfaces/gui/e2e/toolreq.spec.ts index b3a879cf..5e420e5f 100644 --- a/surfaces/gui/e2e/toolreq.spec.ts +++ b/surfaces/gui/e2e/toolreq.spec.ts @@ -17,10 +17,14 @@ test("request_tool surfaces a card naming the tool, the reason and the pinned ve const card = page.locator(".dirreq-card"); await expect(card).toContainText("gitleaks"); await expect(card).toContainText("scan the git history for committed secrets"); - await expect(card).toContainText("8.30.1"); - await expect(card).toContainText(/checksum-verified/i); - // Declining must read as a normal choice, not a failure. - await expect(card.getByTestId("toolreq-skip")).toBeVisible(); + // The fact strip is the product's voice: version, publisher, checksum — kept apart from + // the coworker's quoted reason (mixing them is what made the card confusing, 2026-08-14). + const facts = card.locator(".toolreq-facts"); + await expect(facts).toContainText("8.30.1"); + await expect(facts).toContainText(/checksum-verified/i); + await expect(facts).toContainText("from github.com/gitleaks"); + // Declining must read as a normal choice that continues the run, not a failure. + await expect(card.getByTestId("toolreq-skip")).toHaveText("Continue without it"); }); test("an event without install metadata fails CLOSED — Install disabled, skip offered", async ({ diff --git a/surfaces/gui/src/App.tsx b/surfaces/gui/src/App.tsx index e9ae5662..dd70e707 100644 --- a/surfaces/gui/src/App.tsx +++ b/surfaces/gui/src/App.tsx @@ -741,6 +741,7 @@ export function App() { installable: d.installable === true, version: d.version || "", summary: d.summary || "", + source: d.source || "", }, ]); break; diff --git a/surfaces/gui/src/components/ToolRequestCard.tsx b/surfaces/gui/src/components/ToolRequestCard.tsx index 7a8e04d8..9847e59c 100644 --- a/surfaces/gui/src/components/ToolRequestCard.tsx +++ b/surfaces/gui/src/components/ToolRequestCard.tsx @@ -22,23 +22,30 @@ export function ToolRequestCard({ {item.reason &&
“{item.reason}”
} + {/* The fact strip is the PRODUCT speaking (registry metadata), styled apart from the + coworker's quoted ask above — mixing the two voices is what made the card confusing. */} {item.installable ? ( -
- {item.summary ? `${item.summary}. ` : ""} - Installs {item.tool} - {item.version ? ` ${item.version}` : ""} — a pinned build, checksum-verified before - it runs. +
+ + {item.tool} + {item.version ? ` ${item.version}` : ""} + + {item.summary && {item.summary}} + pinned & checksum-verified + {item.source && from {item.source}}
) : ( -
- No verified build is available for this machine — install it yourself if you want - this check, or skip and the coworker will note the gap. +
+ + No verified build is available for this machine — install it yourself if you want + this check, or continue and the coworker will note the gap. +
)}
- {item.reason &&
“{item.reason}”
} + {item.reason && ( +
+ Reason: “{item.reason}” +
+ )} {/* The fact strip is the PRODUCT speaking (registry metadata), styled apart from the coworker's quoted ask above — mixing the two voices is what made the card confusing. */} {item.installable ? ( diff --git a/surfaces/gui/src/styles.css b/surfaces/gui/src/styles.css index 24c49792..64effa43 100644 --- a/surfaces/gui/src/styles.css +++ b/surfaces/gui/src/styles.css @@ -794,6 +794,9 @@ button.btn.danger { color: var(--accent); } .dirreq-head { display: flex; align-items: center; gap: 8px; font-weight: 600; font-size: 13.5px; color: var(--ink); } .dirreq-head .ico { color: var(--accent); } .dirreq-reason { font-size: 13px; color: var(--muted); font-style: italic; margin: 6px 0 10px; } +/* the coworker's justification gets an explicit label — the quote alone made readers + work out what the italic line was */ +.toolreq-label { font-style: normal; font-weight: 600; color: var(--ink); } /* request_tool fact strip — the product's own metadata (version, publisher, checksum), deliberately NOT italic so it can't be misread as the coworker still talking */ .toolreq-facts { From 560fc3cb8a664eb12234fc9c38169f7fbcc72baa Mon Sep 17 00:00:00 2001 From: Rohit C Prasad Date: Sat, 15 Aug 2026 00:14:15 -0700 Subject: [PATCH 16/80] Tool-request card speaks plainly; declining re-checks for a user-installed copy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fact strip: 'OpenWorker installs its own verified copy from ' replaces supply-chain jargon. On decline the engine re-resolves — a copy the user installed themselves is handed to the agent as theirs, not treated as a refusal. --- coworker/engine.py | 14 +++++++++ surfaces/gui/e2e/toolreq.spec.ts | 7 +++-- .../gui/src/components/ToolRequestCard.tsx | 27 ++++++++++------- surfaces/gui/src/styles.css | 7 +++-- tests/test_tool_request.py | 30 ++++++++++++++++++- 5 files changed, 68 insertions(+), 17 deletions(-) diff --git a/coworker/engine.py b/coworker/engine.py index 9b36e43d..874efe04 100644 --- a/coworker/engine.py +++ b/coworker/engine.py @@ -984,6 +984,20 @@ class TurnEngine: self.tool_requester(dict(args), tool_call.id), interrupted={"installed": False, "error": "interrupted by user"}, ) or {"installed": False, "error": "no response"} + if not result.get("installed"): + # The card says "or install it yourself and continue" — honor it. A user + # who brewed the tool mid-prompt and clicked Continue has PROVIDED it, + # not declined it; find their copy before treating this as a refusal. + found = _toolchain.resolve(name) + if found: + result = { + "installed": True, + "path": found, + "note": ( + "the user provided their own copy instead of the managed " + "install — use it from this path" + ), + } if not result.get("installed"): result.setdefault( "guidance", diff --git a/surfaces/gui/e2e/toolreq.spec.ts b/surfaces/gui/e2e/toolreq.spec.ts index fb812a0e..7efbf64f 100644 --- a/surfaces/gui/e2e/toolreq.spec.ts +++ b/surfaces/gui/e2e/toolreq.spec.ts @@ -22,8 +22,11 @@ test("request_tool surfaces a card naming the tool, the reason and the pinned ve // the coworker's quoted reason (mixing them is what made the card confusing, 2026-08-14). const facts = card.locator(".toolreq-facts"); await expect(facts).toContainText("8.30.1"); - await expect(facts).toContainText(/checksum-verified/i); - await expect(facts).toContainText("from github.com/gitleaks"); + // Plain-language consent: who installs (OpenWorker), from where, and the self-install + // alternative — no supply-chain jargon on the card (owner feedback 2026-08-15). + await expect(facts).toContainText( + "OpenWorker installs its own verified copy from github.com/gitleaks — or install it yourself and continue.", + ); // Declining must read as a normal choice that continues the run, not a failure. await expect(card.getByTestId("toolreq-skip")).toHaveText("Continue without it"); }); diff --git a/surfaces/gui/src/components/ToolRequestCard.tsx b/surfaces/gui/src/components/ToolRequestCard.tsx index 25963154..b22d1685 100644 --- a/surfaces/gui/src/components/ToolRequestCard.tsx +++ b/surfaces/gui/src/components/ToolRequestCard.tsx @@ -30,20 +30,25 @@ export function ToolRequestCard({ coworker's quoted ask above — mixing the two voices is what made the card confusing. */} {item.installable ? (
- - {item.tool} - {item.version ? ` ${item.version}` : ""} - - {item.summary && {item.summary}} - pinned & checksum-verified - {item.source && from {item.source}} +
+ + {item.tool} + {item.version ? ` ${item.version}` : ""} + + {item.summary && {item.summary}} +
+
+ OpenWorker installs its own verified copy + {item.source ? ` from ${item.source}` : ""} — or install it yourself and + continue. +
) : (
- - No verified build is available for this machine — install it yourself if you want - this check, or continue and the coworker will note the gap. - +
+ OpenWorker has no verified build for this machine — install it yourself if you + want this check, or continue and the coworker will note the gap. +
)}
diff --git a/surfaces/gui/src/styles.css b/surfaces/gui/src/styles.css index 64effa43..6a02fffb 100644 --- a/surfaces/gui/src/styles.css +++ b/surfaces/gui/src/styles.css @@ -800,14 +800,15 @@ button.btn.danger { color: var(--accent); } /* request_tool fact strip — the product's own metadata (version, publisher, checksum), deliberately NOT italic so it can't be misread as the coworker still talking */ .toolreq-facts { - display: flex; flex-wrap: wrap; align-items: baseline; gap: 4px 10px; font-size: 12.5px; color: var(--muted); font-style: normal; background: var(--paper); border: 1px solid var(--line); border-radius: 8px; padding: 7px 10px; margin: 2px 0 10px; } .toolreq-facts code { color: var(--ink); font-size: 12.5px; } -.toolreq-fact + .toolreq-fact::before, -.toolreq-facts code + .toolreq-fact::before { content: "· "; color: var(--muted); } +.toolreq-factrow { display: flex; flex-wrap: wrap; align-items: baseline; gap: 4px 10px; } +.toolreq-factrow code + .toolreq-fact::before { content: "· "; color: var(--muted); } +.toolreq-explain { margin-top: 4px; } +.toolreq-factrow + .toolreq-explain { margin-top: 5px; } /* a disabled Install must LOOK disabled — the whole card exists to not oversell */ .dirreq-actions .btn:disabled { opacity: 0.45; cursor: not-allowed; } .dirreq-pathrow { display: flex; gap: 8px; align-items: center; } diff --git a/tests/test_tool_request.py b/tests/test_tool_request.py index de5624b1..6ca8e59b 100644 --- a/tests/test_tool_request.py +++ b/tests/test_tool_request.py @@ -68,9 +68,14 @@ async def test_emits_tool_requested_and_reports_install(tmp_path): @pytest.mark.asyncio -async def test_declining_tells_the_agent_to_fall_back_openly(tmp_path): +async def test_declining_tells_the_agent_to_fall_back_openly(tmp_path, monkeypatch): """A refusal must not read as 'check done'. The tool result has to push the agent toward a disclosed fallback, which is the whole point of the contract.""" + from coworker import toolchain + + # Truly absent — otherwise the decline-time re-check (below) would find the dev + # machine's real gitleaks and turn this into the user-provided-copy path. + monkeypatch.setattr(toolchain, "resolve", lambda name: None) async def requester(args, tool_call_id=None): return {"installed": False, "reason": "the user declined to install it"} @@ -86,6 +91,29 @@ async def test_declining_tells_the_agent_to_fall_back_openly(tmp_path): assert "degraded" in body or "fallback" in body +@pytest.mark.asyncio +async def test_decline_recheck_finds_a_copy_the_user_installed_themselves(tmp_path, monkeypatch): + """The card says "or install it yourself and continue" — that has to be real. A user + who brews the tool while the prompt is up and clicks Continue has PROVIDED the tool; + the agent must be handed their copy's path, not a refusal.""" + from coworker import toolchain + + monkeypatch.setattr(toolchain, "resolve", lambda name: "/opt/homebrew/bin/gitleaks") + + async def requester(args, tool_call_id=None): + return {"installed": False, "reason": "the user declined to install it"} + + engine = _engine(tmp_path, requester) + events = await _run(engine) + finished = [e for e in events if e.type is EventType.TOOL_FINISHED] + assert finished[0].data["status"] == "ok" + + tool_msg = [m for m in engine.messages if m.get("role") == "tool"][-1] + body = str(tool_msg["content"]) + assert "/opt/homebrew/bin/gitleaks" in body + assert "own copy" in body # attributed to the user, not to a managed install + + @pytest.mark.asyncio async def test_event_tells_the_truth_about_installability(tmp_path, monkeypatch): """Owner-hit 2026-08-14: the card offered Install for a tool with no pinned build — From 5f2eeca1c8604390143b007ec128d23774c6fc97 Mon Sep 17 00:00:00 2001 From: Rohit C Prasad Date: Sat, 15 Aug 2026 06:55:10 -0700 Subject: [PATCH 17/80] Diagnose truncated tool calls instead of executing their mangled args Unparseable (_raw) args now get a truthful error: cut-off-by-output-limit says 'smaller pieces', bad JSON says 're-send with declared parameters'. Raw junk is shrunk before entering history so replays can't teach the model the _raw shape. Anthropic default max_tokens 16k -> 32k so typical report files fit outright. --- coworker/engine.py | 67 ++++++++++++++ coworker/providers/anthropic_provider.py | 8 +- tests/test_mangled_tool_calls.py | 110 +++++++++++++++++++++++ 3 files changed, 183 insertions(+), 2 deletions(-) create mode 100644 tests/test_mangled_tool_calls.py diff --git a/coworker/engine.py b/coworker/engine.py index 874efe04..613b94a6 100644 --- a/coworker/engine.py +++ b/coworker/engine.py @@ -137,6 +137,9 @@ class TurnEngine: ): self.messages.insert(0, {"role": "system", "content": instructions}) self._cancel = asyncio.Event() + # Whether the latest assistant turn hit the output-token limit — decides which + # diagnosis a mangled (unparseable-args) tool call gets answered with. + self._turn_truncated = False # Each pending steering message: (text, optional MessageSource sidecar dict). self._steering: list[tuple[str, Optional[dict[str, Any]]]] = [] # tool_call.id → the standing rule that auto-allowed it ("tool → target"), so the @@ -419,6 +422,8 @@ class TurnEngine: # window on this round-trip (estimate fallback when never reported). self._last_context_tokens = turn.usage.context_tokens + self._turn_truncated = turn.finish_reason == "length" + _sanitize_mangled_calls(turn) self.messages.append(_assistant_message(turn, model=self.model)) payload: dict[str, Any] = { "text": turn.text, @@ -617,6 +622,13 @@ class TurnEngine: {"name": tool_call.name, "arguments": tool_call.arguments}, ) self._audit(tool_call, stage="proposed") + if _is_mangled(tool_call): + # The arguments never parsed as JSON (a `{"_raw": …}` fallback from the + # provider). Executing would produce a bare parameter error the model + # misreads — seen in the field as an endless "wrong parameter" retry + # loop. Answer with the ACTUAL diagnosis instead. + yield self._mangled_tool(tool_call) + continue # `request_directory` and `propose_plan` are interactive: the user decides # out-of-band and that decision IS the consent, so they skip the # permission/registry path. @@ -671,6 +683,37 @@ class TurnEngine: result, status = await asyncio.to_thread(self._execute_sync, tool_call) yield self._record_result(tool_call, result, status) + def _mangled_tool(self, tool_call: ToolCall) -> Event: + """Answer a tool call whose arguments never parsed, with the real diagnosis. + + Two causes, two different cures — and the model can only pick the right one if + the error says which happened. Truncation (`finish_reason == "length"`) means + "same content, smaller pieces"; plain bad JSON means "re-send with the declared + parameters". Either way the raw text is NOT replayed into history: a stored + `{"_raw": …}` call reads as a worked example and teaches the model to emit + `_raw` on purpose (observed 2026-08-15), on top of re-sending the junk tokens + every turn.""" + if self._turn_truncated: + reason = ( + "your tool-call arguments were cut off by the output-token limit before " + "they finished streaming — the tool never received them. Produce the same " + "content in smaller pieces: several calls that each write or append a " + "section, keeping each call's content well under the limit. Do not retry " + "the identical oversized call." + ) + else: + reason = ( + "your tool-call arguments did not parse as a JSON object, so the tool " + "received nothing. `_raw` is not a parameter — it is the unparsed text of " + "the failed call. Re-issue the call using the tool's declared parameters." + ) + self.messages.append(_tool_error_message(tool_call, reason)) + self._audit(tool_call, stage="finished", status="error", reason=reason) + return Event( + EventType.TOOL_FINISHED, + {"name": tool_call.name, "status": "error", "reason": reason}, + ) + def _interrupted_tool(self, tool_call: ToolCall) -> Event: """The stop-path answer for a call that will not run: a tool-error result in the history (hosted chat templates reject orphaned tool_calls, and durable-resume @@ -1282,6 +1325,30 @@ def _assistant_message(turn: AssistantTurn, model: Optional[str] = None) -> dict return message +_MANGLED_PREVIEW_CHARS = 200 + + +def _is_mangled(tool_call: ToolCall) -> bool: + """Provider arg-parsers fall back to `{"_raw": }` when a tool call's + arguments aren't a JSON object (typically a stream truncated mid-arguments).""" + return set(tool_call.arguments or {}) == {"_raw"} + + +def _sanitize_mangled_calls(turn: AssistantTurn) -> None: + """Shrink each mangled call's stored raw text to a short preview BEFORE the turn + enters history. The full text is junk (half a JSON document): replaying it costs + thousands of tokens per turn and, worse, teaches the model that `_raw` is a real + parameter shape it should imitate.""" + for tc in turn.tool_calls: + if _is_mangled(tc): + raw = str(tc.arguments.get("_raw") or "") + if len(raw) > _MANGLED_PREVIEW_CHARS: + tc.arguments = { + "_raw": raw[:_MANGLED_PREVIEW_CHARS] + + f"… [unparsed tool-call text, {len(raw)} chars, truncated in history]" + } + + def _tool_result_message(tool_call: ToolCall, result: Any) -> dict[str, Any]: content = result if isinstance(result, str) else json.dumps(result, default=str) return { diff --git a/coworker/providers/anthropic_provider.py b/coworker/providers/anthropic_provider.py index 05bc8f3a..4f059e75 100644 --- a/coworker/providers/anthropic_provider.py +++ b/coworker/providers/anthropic_provider.py @@ -45,8 +45,12 @@ def _usage_from(usage: Any) -> Optional[TokenUsage]: cache_write=int(getattr(usage, "cache_creation_input_tokens", 0) or 0), ) -# Required by the Messages API; a ceiling, not a spend target. -DEFAULT_MAX_TOKENS = 16000 +# Required by the Messages API; a ceiling, not a spend target. Sized for file +# generation, not just chat: a coworker writing a self-contained HTML report ships the +# whole file inside one tool call's arguments, and 16k proved too small in the field +# (the call truncates mid-arguments and the write fails). Current Claude models all +# accept ≥32k output. +DEFAULT_MAX_TOKENS = 32000 # Extended thinking is ON by default (owner call 2026-07-23: no user-facing setting — # most users wouldn't know what a budget is; a per-turn composer control is future work). diff --git a/tests/test_mangled_tool_calls.py b/tests/test_mangled_tool_calls.py new file mode 100644 index 00000000..9f1d29e8 --- /dev/null +++ b/tests/test_mangled_tool_calls.py @@ -0,0 +1,110 @@ +"""Mangled tool calls (`{"_raw": …}` fallback args) get a real diagnosis, not execution. + +The field failure (2026-08-15, report-page generation): a large write_file call blew the +output-token limit, the truncated JSON became `{"_raw": …}`, and the tool's bare +parameter error sent the model into a "wrong parameter" retry loop. Worse, each stored +`_raw` call read as a worked example — after a few, the model began emitting `_raw` as +if it were a real parameter, and every replay re-sent thousands of junk tokens. +""" + +from __future__ import annotations + +import json + +import pytest + +from coworker.engine import EventType, TurnEngine, _MANGLED_PREVIEW_CHARS +from coworker.permissions import Mode, PermissionEngine +from coworker.providers import AssistantTurn, ModelCapabilities, ProviderClient, ToolCall +from coworker.tools import ToolRegistry + + +class MangledProvider(ProviderClient): + """One turn with unparseable write_file args, then a plain reply.""" + + def __init__(self, finish_reason: str, raw: str = "x" * 5000): + self.calls = 0 + self.finish_reason = finish_reason + self.raw = raw + + def complete(self, *, model, messages, tools=None, **settings): + self.calls += 1 + if self.calls == 1: + return AssistantTurn( + text="", + tool_calls=[ + ToolCall(id="t1", name="write_file", arguments={"_raw": self.raw}) + ], + finish_reason=self.finish_reason, + ) + return AssistantTurn(text="done", tool_calls=[]) + + def capabilities(self, model): + return ModelCapabilities(tools=True) + + +def _engine(tmp_path, provider) -> TurnEngine: + return TurnEngine( + provider=provider, + registry=ToolRegistry(), + permissions=PermissionEngine(workspace_root=tmp_path, mode=Mode.INTERACTIVE), + model="m", + ) + + +async def _run(engine) -> list: + return [e async for e in engine.run("build the report")] + + +def _last_tool_message(engine) -> str: + return str([m for m in engine.messages if m.get("role") == "tool"][-1]["content"]) + + +@pytest.mark.asyncio +async def test_truncated_call_is_answered_with_the_truncation_diagnosis(tmp_path): + """finish_reason "length" + unparseable args = the call was cut off. The model's cure + is smaller pieces — the error must say so instead of a bare parameter complaint.""" + engine = _engine(tmp_path, MangledProvider("length")) + events = await _run(engine) + + finished = [e for e in events if e.type is EventType.TOOL_FINISHED] + assert finished[0].data["status"] == "error" + body = _last_tool_message(engine) + assert "output-token limit" in body + assert "smaller pieces" in body + # The turn continues — no retry loop, the model answers after the diagnosis. + assert [e for e in events if e.type is EventType.TURN_END] + + +@pytest.mark.asyncio +async def test_plain_bad_json_is_answered_with_the_parameter_diagnosis(tmp_path): + """No truncation → the model wrote bad JSON (or imitated `_raw`). Tell it `_raw` is + not a parameter and to re-issue with the declared ones.""" + engine = _engine(tmp_path, MangledProvider("stop")) + await _run(engine) + body = _last_tool_message(engine) + assert "_raw" in body and "not a parameter" in body + assert "declared parameters" in body + + +@pytest.mark.asyncio +async def test_raw_junk_is_shrunk_before_it_enters_history(tmp_path): + """The unparsed text must not be replayed at full size: it costs tokens every turn + and teaches the model that `_raw` is a shape to imitate.""" + engine = _engine(tmp_path, MangledProvider("length", raw="y" * 5000)) + await _run(engine) + + assistant = [m for m in engine.messages if m.get("role") == "assistant" and m.get("tool_calls")][0] + stored = json.loads(assistant["tool_calls"][0]["function"]["arguments"]) + assert len(stored["_raw"]) < _MANGLED_PREVIEW_CHARS + 100 + assert "truncated in history" in stored["_raw"] + + +@pytest.mark.asyncio +async def test_short_raw_args_are_kept_verbatim(tmp_path): + """Below the preview cap there is nothing to shrink — storage stays faithful.""" + engine = _engine(tmp_path, MangledProvider("stop", raw='{"path": "repo')) + await _run(engine) + assistant = [m for m in engine.messages if m.get("role") == "assistant" and m.get("tool_calls")][0] + stored = json.loads(assistant["tool_calls"][0]["function"]["arguments"]) + assert stored == {"_raw": '{"path": "repo'} From 4ed112b8eb89ae32148f58baf3aa94bf1f91e02b Mon Sep 17 00:00:00 2001 From: Rohit C Prasad Date: Sat, 15 Aug 2026 10:34:43 -0700 Subject: [PATCH 18/80] Connectors become a per-coworker allowlist (OPE-93) Sessions expose declared-and-connected only; 'all' is builtin-only; legacy true migrates to the recommended refs, else nothing. Consent lists real names and per-connector caps force re-consent when an update widens the grant. --- coworker/agent.py | 6 ++ coworker/agents/base.py | 6 +- .../builtin/cloud-posture/manifest.md | 2 +- .../personas/builtin/dep-audit/manifest.md | 2 +- .../personas/builtin/security/manifest.md | 2 +- coworker/personas/loading.py | 12 +++- coworker/personas/manifest.py | 65 ++++++++++++++++++- surfaces/gui/src/api.ts | 3 +- surfaces/gui/src/components/PersonasTab.tsx | 6 +- tests/test_persona_connections.py | 36 ++++++++++ tests/test_persona_loading.py | 17 ++++- tests/test_persona_manifest.py | 53 +++++++++++++-- 12 files changed, 191 insertions(+), 19 deletions(-) diff --git a/coworker/agent.py b/coworker/agent.py index 86eb1e44..5456312d 100644 --- a/coworker/agent.py +++ b/coworker/agent.py @@ -282,6 +282,12 @@ def build_engine( registry.register(request_tool_tool()) if agent.connectors: enabled_connectors, enabled_tools = _enabled_connector_tools(secrets) + # Least-privilege grant (OPE-93): a persona with an allowlist gets ONLY the + # connectors it declared — an undeclared connector's tools never enter the + # session, no matter what the user has connected. True = general personas + # (Cowork) that legitimately drive whatever is connected. + if agent.connectors is not True: + enabled_connectors = enabled_connectors & set(agent.connectors) # Per-session connection hierarchy (UI-REFRESH §4.3): when the caller supplies the session's # effective connector set, intersect it so only effective-enabled connectors expose tools. # Default None preserves CLI / direct callers (no per-session restriction). diff --git a/coworker/agents/base.py b/coworker/agents/base.py index d451b9f3..0fb78cd1 100644 --- a/coworker/agents/base.py +++ b/coworker/agents/base.py @@ -35,10 +35,12 @@ class Agent: # Traits that replace the old per-agent-name branching in build_engine / manager. # family: "code" gets explorer subagents; "knowledge" gets scheduling / request_directory / # roots context (when it has a workspace). messaging: exposes send_message. connectors: - # loads the integration toolset. Defaults keep non-persona callers behaving as before. + # loads the integration toolset — True = every connected connector (general builtins + # only), a tuple = allowlist (session gets declared ∩ connected; OPE-93), False = none. + # Defaults keep non-persona callers behaving as before. family: str = "knowledge" messaging: bool = False - connectors: bool = False + connectors: bool | tuple[str, ...] = False def build_tools(self, context: AgentContext) -> list: return list(self.tool_factory(context)) if self.tool_factory else [] diff --git a/coworker/personas/builtin/cloud-posture/manifest.md b/coworker/personas/builtin/cloud-posture/manifest.md index dc7f2705..be1b201a 100644 --- a/coworker/personas/builtin/cloud-posture/manifest.md +++ b/coworker/personas/builtin/cloud-posture/manifest.md @@ -6,7 +6,7 @@ tagline: Review Terraform & cloud config — read-only, evidence first family: code version: "1" tools: [code_files, git, search, shell, todo] -connectors: true +connectors: [github] skills: [iac-scan, aws-posture] recommended_models: [anthropic:claude-opus-4-8, openai:gpt-5.6-sol] default_permission_mode: interactive diff --git a/coworker/personas/builtin/dep-audit/manifest.md b/coworker/personas/builtin/dep-audit/manifest.md index c5ba4c6d..3dfc510f 100644 --- a/coworker/personas/builtin/dep-audit/manifest.md +++ b/coworker/personas/builtin/dep-audit/manifest.md @@ -6,7 +6,7 @@ tagline: Vulnerable dependencies — audit, minimal upgrades, PRs family: code version: "1" tools: [code_files, git, search, shell, todo] -connectors: true +connectors: [github] skills: [dependency-audit, safe-upgrade-pr] recommended_models: [anthropic:claude-opus-4-8, openai:gpt-5.6-sol] default_permission_mode: interactive diff --git a/coworker/personas/builtin/security/manifest.md b/coworker/personas/builtin/security/manifest.md index 6707a2e2..ad20c133 100644 --- a/coworker/personas/builtin/security/manifest.md +++ b/coworker/personas/builtin/security/manifest.md @@ -6,7 +6,7 @@ tagline: Find and fix security issues — scan, triage, PR family: code version: "1" tools: [code_files, git, search, shell, todo] -connectors: true +connectors: [github] skills: [semgrep-review, secret-scan, security-fix-pr] recommended_models: [anthropic:claude-opus-4-8, openai:gpt-5.6-sol] default_permission_mode: interactive diff --git a/coworker/personas/loading.py b/coworker/personas/loading.py index 25412d4e..27d3303e 100644 --- a/coworker/personas/loading.py +++ b/coworker/personas/loading.py @@ -26,7 +26,9 @@ def consent_summary(m: PersonaManifest) -> dict: "description": m.description, "tools": list(m.tools), "risk": sorted(rc.value for rc in risk_summary(m.tools)), - "connectors": m.connectors, + # "all" | [connector ids] | [] — the consent screen shows the actual names, + # never a bare "uses connectors" bit (OPE-93). + "connectors": "all" if m.connectors is True else list(m.connectors or ()), "mcp": list(m.mcp), "messaging": m.messaging, "recommended_mode": m.default_permission_mode, @@ -49,8 +51,12 @@ def capability_set(m: PersonaManifest) -> set[str]: update keeps the user's enabled state).""" caps = {f"tool:{t}" for t in m.tools} caps |= {f"mcp:{s}" for s in m.mcp} - if m.connectors: - caps.add("connectors") + # Per-connector caps (OPE-93): an update that ADDS a connector must grow the set and + # re-trigger consent — the old single "connectors" bit hid exactly that change. + if m.connectors is True: + caps.add("connectors:all") + else: + caps |= {f"connector:{c}" for c in m.connectors or ()} if m.messaging: caps.add("messaging") return caps diff --git a/coworker/personas/manifest.py b/coworker/personas/manifest.py index 4715fee9..0ddeb517 100644 --- a/coworker/personas/manifest.py +++ b/coworker/personas/manifest.py @@ -58,7 +58,11 @@ class PersonaManifest: # "deliverable". Builtins registered via builders may still carry "none" (Chat). workspace: str = "deliverable" messaging: bool = False - connectors: bool = False + # Connector grant (OPE-93): False = none, a tuple = allowlist of connector ids + # (session exposes declared ∩ connected), True = every connected connector — the + # `all` sentinel, reserved for built-in general personas. Coarser grants leaked + # undeclared tools (browser, email) into security sessions; undeclared = absent. + connectors: bool | tuple[str, ...] = False default_permission_mode: str = "interactive" recommended_models: list[str] = field(default_factory=list) skills: list[str] = field(default_factory=list) @@ -96,6 +100,59 @@ class PersonaManifest: ) +def _connectors( + persona_id: str, + raw: Any, + recommends: list[Recommendation], + builtin: bool, +) -> bool | tuple[str, ...]: + """Parse the connector grant (OPE-93). Fail closed at every ambiguity. + + - list → explicit allowlist (the normal case). + - "all" → every connected connector; reserved for BUILT-IN general personas — a + shared bundle claiming it is exactly the trust violation the allowlist exists + to prevent, so third-party loads reject it. + - legacy `true` (pre-allowlist manifests) → the connector refs the manifest already + recommends (author intent); no recommends → no grant. + - recommends must stay within the grant: a recommendation the coworker can't use is + author drift, surfaced at load rather than at the user's consent screen. + """ + if raw is None or raw is False: + declared: bool | tuple[str, ...] = False + elif raw is True: + refs = {r.ref for r in recommends if r.kind == "connector"} + declared = tuple(sorted(refs)) if refs else False + elif isinstance(raw, str): + if raw.strip().lower() != "all": + raise ManifestError( + f"{persona_id}: `connectors` must be a list of connector ids or 'all'" + ) + if not builtin: + raise ManifestError( + f"{persona_id}: `connectors: all` is reserved for built-in coworkers — " + "declare the specific connectors this coworker uses" + ) + declared = True + elif isinstance(raw, list): + declared = tuple( + dict.fromkeys(s for s in (str(x).strip() for x in raw) if s) + ) + else: + raise ManifestError( + f"{persona_id}: `connectors` must be a list of connector ids or 'all'" + ) + + if declared is not True: + granted = set(declared or ()) + for r in recommends: + if r.kind == "connector" and r.ref not in granted: + raise ManifestError( + f"{persona_id}: recommends connector '{r.ref}' but does not declare " + "it in `connectors` — a recommendation must stay within the grant" + ) + return declared + + def _split_frontmatter(text: str) -> tuple[dict[str, Any], str]: if not text.startswith("---"): raise ManifestError("manifest must start with a YAML frontmatter block (---)") @@ -224,6 +281,8 @@ def parse_manifest( tools = _strlist(meta, "tools") _validate_tools(persona_id, tools) + recommends = _recommends(persona_id, meta) + connectors = _connectors(persona_id, meta.get("connectors"), recommends, builtin) return PersonaManifest( id=persona_id, @@ -236,13 +295,13 @@ def parse_manifest( family=family, workspace=workspace, messaging=bool(meta.get("messaging", False)), - connectors=bool(meta.get("connectors", False)), + connectors=connectors, default_permission_mode=mode, recommended_models=_strlist(meta, "recommended_models"), skills=_strlist(meta, "skills"), mcp=_strlist(meta, "mcp"), version=str(meta.get("version", "") or "").strip(), - recommends=_recommends(persona_id, meta), + recommends=recommends, builtin=builtin, source=source, ) diff --git a/surfaces/gui/src/api.ts b/surfaces/gui/src/api.ts index 61c38af1..cceee941 100644 --- a/surfaces/gui/src/api.ts +++ b/surfaces/gui/src/api.ts @@ -898,7 +898,8 @@ export interface PersonaConsent { description: string; tools: string[]; risk: string[]; - connectors: boolean; + // "all" (general builtins) or the declared allowlist — [] means no connector access. + connectors: "all" | string[]; mcp: string[]; messaging: boolean; recommended_mode: string; diff --git a/surfaces/gui/src/components/PersonasTab.tsx b/surfaces/gui/src/components/PersonasTab.tsx index 3fab8746..ff0b285b 100644 --- a/surfaces/gui/src/components/PersonasTab.tsx +++ b/surfaces/gui/src/components/PersonasTab.tsx @@ -376,7 +376,11 @@ function ConsentCard({ )}
Can {summary} - {c.connectors ? " · use your connected services" : ""} + {c.connectors === "all" + ? " · use ALL your connected services" + : c.connectors.length + ? ` · use connectors: ${c.connectors.join(", ")}` + : ""} {c.messaging ? " · send messages" : ""} {c.mcp.length ? ` · use MCP: ${c.mcp.join(", ")}` : ""} )} + {isHtml && ( + // The sandboxed preview is deliberately offline and null-origin; a real + // browser tab (system default app, outside app privileges) is the escape + // hatch for sharing or printing the page. + + )} {/* Copy the ABSOLUTE path — the workspace-relative one is useless outside the app (tester catch 2026-07-12: it copied just "slack-connector-debug.md"). */}
)} diff --git a/surfaces/gui/src/api.ts b/surfaces/gui/src/api.ts index cceee941..84aaf210 100644 --- a/surfaces/gui/src/api.ts +++ b/surfaces/gui/src/api.ts @@ -209,6 +209,60 @@ export async function deleteSession(sessionId: string): Promise<{ ok: boolean; e return res.json(); } +// Agent teams (OPE-96): the session's board — items on the workspace-keyed space. +export interface BoardItem { + id: number; + title: string; + description: string; + criteria: string; + state: "proposed" | "approved" | "in_progress" | "blocked" | "review" | "done" | "canceled" | string; + assignee: string; + creator: string; + refs: string[]; + links: { kind: string; item: number }[]; +} + +export interface Board { + space: string | null; + name: string; + items: BoardItem[]; +} + +export interface JournalCase { + case: string; + entries: number; + last_ts: string; +} + +export async function getBoard(sessionId: string): Promise { + const res = await fetch(`${httpBase()}/v1/sessions/${encodeURIComponent(sessionId)}/board`); + return res.json(); +} + +export async function boardApprove(sessionId: string): Promise { + const res = await fetch(`${httpBase()}/v1/sessions/${encodeURIComponent(sessionId)}/board/approve`, { method: "POST" }); + return res.json(); +} + +export async function boardTransition( + sessionId: string, + item: number, + to: string, + comment = "", +): Promise { + const res = await fetch(`${httpBase()}/v1/sessions/${encodeURIComponent(sessionId)}/board/transition`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ item, to, comment }), + }); + return res.json(); +} + +export async function getJournalCases(): Promise { + const res = await fetch(`${httpBase()}/v1/teams/journal`); + return (await res.json()).cases ?? []; +} + export interface ArtifactInfo { path: string; // workspace-relative (the display/API identifier) abs_path?: string; // absolute — what "Copy path" copies diff --git a/surfaces/gui/src/components/BoardPanel.tsx b/surfaces/gui/src/components/BoardPanel.tsx new file mode 100644 index 00000000..bafdb72d --- /dev/null +++ b/surfaces/gui/src/components/BoardPanel.tsx @@ -0,0 +1,224 @@ +// Agent teams (OPE-96): the board in three shapes — +// - BoardSection: the right-rail summary (grouped by state, blocked on top) +// - BoardOverlay: the expanded, Linear-shaped view covering the chat column +// - PlanGateCard: the decomposition gate (proposed items awaiting the user) +// All three render the same Board data App owns; mutations go through the +// /board endpoints and act as the USER — the human side of the gates. +import { useEffect, useMemo, useState } from "react"; +import type { Board, BoardItem } from "../api"; +import { Icon } from "./Icon"; + +// Display order: needs-attention first (mock UX-030: "grouped by state, blocked on top"). +const GROUPS: { state: string; label: string }[] = [ + { state: "blocked", label: "Blocked" }, + { state: "review", label: "Review" }, + { state: "in_progress", label: "In progress" }, + { state: "approved", label: "Approved" }, + { state: "proposed", label: "Proposed" }, + { state: "done", label: "Done" }, + { state: "canceled", label: "Canceled" }, +]; + +function dotClass(state: string): string { + if (state === "blocked") return "board-dot blocked"; + if (state === "review") return "board-dot review"; + if (state === "in_progress") return "board-dot work"; + if (state === "done") return "board-dot done"; + return "board-dot idle"; +} + +export function boardSummary(board: Board): string { + const counts: Record = {}; + for (const item of board.items) counts[item.state] = (counts[item.state] || 0) + 1; + const parts: string[] = []; + if (counts.blocked) parts.push(`${counts.blocked} blocked`); + if (counts.review) parts.push(`${counts.review} review`); + if (counts.in_progress) parts.push(`${counts.in_progress} in progress`); + if (counts.proposed) parts.push(`${counts.proposed} proposed`); + return parts.join(" · "); +} + +export function BoardSection({ board, onExpand }: { board: Board; onExpand: () => void }) { + const groups = GROUPS.map((g) => ({ + ...g, + items: board.items.filter((i) => i.state === g.state), + })).filter((g) => g.items.length > 0); + return ( +
+ {groups.map((group) => ( +
+
{group.label}
+ {group.items.map((item) => ( + + ))} +
+ ))} +
+ ); +} + +// The expanded board: state columns over the whole session area — the "clean and +// large board like Linear" (owner ask 2026-08-16). Esc, backdrop, or ✕ closes. +export function BoardOverlay({ + board, + onClose, + onTransition, +}: { + board: Board; + onClose: () => void; + // (item, to) → performed as the user; App refetches on completion. + onTransition?: (item: number, to: string) => void; +}) { + useEffect(() => { + const onKey = (e: KeyboardEvent) => { + if (e.key === "Escape") onClose(); + }; + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, [onClose]); + + const columns = GROUPS.map((g) => ({ + ...g, + items: board.items.filter((i) => i.state === g.state), + })).filter((g) => g.items.length > 0 || ["in_progress", "approved", "review"].includes(g.state)); + + return ( +
+
e.stopPropagation()}> +
+
+ + Board + {board.name} +
+ +
+
+ {columns.map((column) => ( +
+
+ + {column.label} + {column.items.length} +
+
+ {column.items.map((item) => ( + + ))} + {column.items.length === 0 &&
} +
+
+ ))} +
+
+
+ ); +} + +function BoardCard({ + item, + onTransition, +}: { + item: BoardItem; + onTransition?: (item: number, to: string) => void; +}) { + // The user can always act; offer the obvious next moves for the state. + const moves: { to: string; label: string }[] = + item.state === "proposed" + ? [{ to: "approved", label: "Approve" }, { to: "canceled", label: "Cancel" }] + : item.state === "review" + ? [{ to: "done", label: "Mark done" }, { to: "in_progress", label: "Send back" }] + : item.state === "done" || item.state === "canceled" + ? [] + : [{ to: "canceled", label: "Cancel" }]; + return ( +
+
+ #{item.id} {item.title} +
+ {item.criteria && ( +
+ Done when: {item.criteria} +
+ )} +
+ {item.assignee ? {item.assignee} : } + {onTransition && moves.length > 0 && ( + + {moves.map((m) => ( + + ))} + + )} +
+
+ ); +} + +// The decomposition gate: proposed items awaiting the user's approval, rendered in +// the composer head like the other request cards. Visible layer = the decisions +// (items + criteria); editing happens by replying — no in-card reply surface. +export function PlanGateCard({ + board, + onApprove, + busy, +}: { + board: Board; + onApprove: () => void; + busy?: boolean; +}) { + const proposed = useMemo(() => board.items.filter((i) => i.state === "proposed"), [board.items]); + const [expanded, setExpanded] = useState(false); + if (proposed.length === 0) return null; + const visible = expanded ? proposed : proposed.slice(0, 3); + const hidden = proposed.length - visible.length; + return ( +
+
+ + + Proposed plan — {proposed.length} work item{proposed.length === 1 ? "" : "s"} + + board: {board.name} +
+ {visible.map((item) => ( +
+ #{item.id} + + {item.title} + {item.criteria && ( + + Done when: {item.criteria} + + )} + +
+ ))} + {hidden > 0 && ( + + )} +
+ Reply to edit the plan; nothing runs until you approve. + + +
+
+ ); +} diff --git a/surfaces/gui/src/components/RightRail.tsx b/surfaces/gui/src/components/RightRail.tsx index 651ee917..99b42d9a 100644 --- a/surfaces/gui/src/components/RightRail.tsx +++ b/surfaces/gui/src/components/RightRail.tsx @@ -3,17 +3,21 @@ import { useEffect, useRef, useState, type ReactNode } from "react"; import pdfWorkerUrl from "pdfjs-dist/build/pdf.worker.min.mjs?url"; import { getArtifacts, + getJournalCases, readArtifact, revealArtifact, type ArtifactContent, type ArtifactInfo, + type Board, + type JournalCase, } from "../api"; import type { TodoItem } from "../types"; import { AccessSection } from "./AccessSection"; +import { BoardSection, boardSummary } from "./BoardPanel"; import { Icon } from "./Icon"; import { Markdown, OPEN_ARTIFACT_EVENT } from "./Markdown"; -type Panel = "progress" | "artifacts"; +type Panel = "progress" | "artifacts" | "board" | "journal"; // Quiet file-type icons for the artifact list (the colored kind pills read as noisy). function kindIcon(kind: string): "file" | "fileCode" | "image" | "table" { @@ -57,6 +61,10 @@ interface Props { scratchPrimary?: boolean; openAccessKey?: number; onOpenIntegrations?: () => void; + // Agent teams (OPE-96): App owns board data (the plan gate needs it too); + // the rail renders the summary section and the expand affordance. + board?: Board | null; + onExpandBoard?: () => void; } export function RightRail({ @@ -75,12 +83,17 @@ export function RightRail({ scratchPrimary, openAccessKey = 0, onOpenIntegrations, + board, + onExpandBoard, }: Props) { const [open, setOpen] = useState>({ progress: true, artifacts: true, + board: true, + journal: false, }); const [artifacts, setArtifacts] = useState([]); + const [journal, setJournal] = useState([]); const [selected, setSelected] = useState(null); const [content, setContent] = useState(null); @@ -91,6 +104,16 @@ export function RightRail({ if (showArtifacts) refreshArtifacts(); }, [active, sessionId, refreshKey, showArtifacts]); + // Journal cases surface only when a board exists — same visibility rule as the + // Board section, so plain sessions carry zero team chrome. + useEffect(() => { + if (!active || !board?.space) { + setJournal([]); + return; + } + getJournalCases().then(setJournal).catch(() => setJournal([])); + }, [active, sessionId, refreshKey, board?.space]); + // Switching conversations closes any open artifact — it belongs to the previous session's // workspace, which the new session can't (and shouldn't) read. useEffect(() => { @@ -178,6 +201,49 @@ export function RightRail({ + {/* Agent teams (OPE-96): board summary — grouped by state, blocked on top. + Hidden entirely until the workspace has items (no chrome for plain sessions). */} + {board?.space && ( + setOpen({ ...open, board: !open.board })} + action={ + + } + > + onExpandBoard?.()} /> + + )} + + {board?.space && journal.length > 0 && ( + setOpen({ ...open, journal: !open.journal })} + > +
+ {journal.map((c) => ( +
+ + {c.case} + {c.entries} entr{c.entries === 1 ? "y" : "ies"} +
+ ))} +
+
+ )} + {showArtifacts && ( div:first-child .board-group { margin-top: 0; } +.board-row { + display: flex; align-items: flex-start; gap: 8px; width: 100%; text-align: left; + padding: 5px 6px; border: 0; border-radius: 7px; background: transparent; + cursor: pointer; color: var(--muted); font-size: 12.5px; +} +.board-row:hover { background: var(--paper); } +.board-row-main { display: flex; flex-direction: column; min-width: 0; } +.board-row-title { color: var(--ink); overflow: hidden; text-overflow: ellipsis; } +.board-row-id { color: var(--faint); font-weight: 600; font-size: 11.5px; } +.board-row-who { font-size: 11px; color: var(--faint); } +.board-dot { width: 7px; height: 7px; border-radius: 50%; flex-shrink: 0; margin-top: 5px; background: var(--faint); } +.board-dot.work { background: var(--ok-dot); } +.board-dot.blocked { background: var(--danger); } +.board-dot.review { background: var(--warn-ink); } +.board-dot.done { background: var(--ok); opacity: 0.55; } +.board-dot.idle { background: var(--faint); } + +/* Expanded board: covers the session area — the roomy, Linear-shaped view. */ +.board-overlay { + position: fixed; inset: 0; z-index: 60; + background: color-mix(in srgb, var(--paper) 55%, transparent); + display: flex; align-items: stretch; justify-content: center; padding: 26px 28px; +} +.board-overlay-panel { + flex: 1; max-width: 1280px; display: flex; flex-direction: column; min-height: 0; + background: var(--panel); border: 1px solid var(--line); border-radius: 14px; + box-shadow: 0 18px 50px rgba(0, 0, 0, 0.28); overflow: hidden; +} +.board-overlay-head { + display: flex; align-items: center; gap: 10px; padding: 12px 16px; + border-bottom: 1px solid var(--line); +} +.board-overlay-title { display: flex; align-items: center; gap: 8px; font-weight: 600; font-size: 13.5px; color: var(--ink); flex: 1; } +.board-overlay-space { color: var(--faint); font-weight: 400; font-size: 12px; } +.board-columns { + flex: 1; display: flex; gap: 12px; padding: 14px 16px; overflow: auto; min-height: 0; +} +.board-col { flex: 1 1 0; min-width: 210px; max-width: 320px; display: flex; flex-direction: column; min-height: 0; } +.board-col-head { + display: flex; align-items: center; gap: 7px; font-size: 11.5px; font-weight: 700; + letter-spacing: 0.05em; text-transform: uppercase; color: var(--muted); padding: 2px 4px 8px; +} +.board-col-head .board-dot { margin-top: 0; } +.board-col-count { margin-left: auto; color: var(--faint); font-weight: 500; } +.board-col-body { display: flex; flex-direction: column; gap: 8px; overflow: auto; padding-bottom: 8px; } +.board-col-empty { color: var(--faint); font-size: 12px; padding: 6px 4px; } +.board-card { + background: var(--paper); border: 1px solid var(--line); border-radius: 10px; + padding: 9px 11px; display: flex; flex-direction: column; gap: 5px; +} +.board-card-title { font-size: 12.5px; color: var(--ink); } +.board-card-criteria { font-size: 11.5px; color: var(--muted); } +.board-card-label { font-weight: 600; } +.board-card-foot { display: flex; align-items: center; justify-content: space-between; gap: 8px; } +.board-card-who { font-size: 11px; color: var(--faint); } +.board-card-actions { display: flex; gap: 6px; } +.board-card-btn { + border: 1px solid var(--line-strong); background: transparent; color: var(--muted); + border-radius: 6px; padding: 2px 8px; font-size: 11px; cursor: pointer; +} +.board-card-btn:hover { color: var(--ink); border-color: var(--muted); } + +/* Journal rail section */ +.journal-list { display: flex; flex-direction: column; gap: 3px; } +.journal-row { display: flex; align-items: center; gap: 7px; font-size: 12.5px; color: var(--muted); padding: 3px 4px; } +.journal-case { color: var(--ink); flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; } +.journal-count { font-size: 11px; color: var(--faint); } + +/* Plan gate (decomposition gate) — rides the dirreq-card frame. */ +.plangate-card { border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-soft); } +.plangate-head { display: flex; align-items: center; gap: 8px; margin-bottom: 6px; } +.plangate-title { font-weight: 600; font-size: 13px; color: var(--ink); } +.plangate-board { margin-left: auto; font-size: 11.5px; color: var(--faint); } +.plangate-item { display: flex; gap: 9px; padding: 7px 0; border-top: 1px solid var(--line); } +.plangate-num { color: var(--faint); font-size: 12px; padding-top: 1px; } +.plangate-body { display: flex; flex-direction: column; gap: 2px; min-width: 0; } +.plangate-item-title { font-size: 12.5px; color: var(--ink); } +.plangate-ac { font-size: 11.5px; color: var(--muted); } +.plangate-ac b { font-weight: 600; } +.plangate-more { + display: flex; align-items: center; gap: 5px; border: 0; background: transparent; + color: var(--accent); font-size: 12px; cursor: pointer; padding: 6px 0; +} +.plangate-note { font-size: 11.5px; color: var(--faint); } From 2ebc5fd7aa2fb066559086a6204ab7632dd0ead4 Mon Sep 17 00:00:00 2001 From: Rohit C Prasad Date: Sun, 16 Aug 2026 07:49:08 -0700 Subject: [PATCH 30/80] Progress rail section starts collapsed; auto-opens once when a live turn has todos --- surfaces/gui/src/components/RightRail.tsx | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/surfaces/gui/src/components/RightRail.tsx b/surfaces/gui/src/components/RightRail.tsx index 99b42d9a..a0e92ce6 100644 --- a/surfaces/gui/src/components/RightRail.tsx +++ b/surfaces/gui/src/components/RightRail.tsx @@ -86,12 +86,21 @@ export function RightRail({ board, onExpandBoard, }: Props) { + // Progress starts collapsed (owner call 2026-08-16 — rail space goes to the + // board/artifacts); it still auto-opens the first time a live turn has todos. const [open, setOpen] = useState>({ - progress: true, + progress: false, artifacts: true, board: true, journal: false, }); + const autoOpenedProgress = useRef(false); + useEffect(() => { + if (running && todo.length > 0 && !autoOpenedProgress.current) { + autoOpenedProgress.current = true; + setOpen((prev) => ({ ...prev, progress: true })); + } + }, [running, todo.length]); const [artifacts, setArtifacts] = useState([]); const [journal, setJournal] = useState([]); const [selected, setSelected] = useState(null); From d8e4fc73c0e3bbabcc5482b02e18791dbe7994e0 Mon Sep 17 00:00:00 2001 From: Rohit C Prasad Date: Sun, 16 Aug 2026 07:50:02 -0700 Subject: [PATCH 31/80] Rail collapses proposed items to one awaiting-approval line The plan gate is the single full rendering of the proposal; no double listing. --- surfaces/gui/e2e/board.spec.ts | 7 ++++++- surfaces/gui/src/components/BoardPanel.tsx | 19 +++++++++++++++---- surfaces/gui/src/styles.css | 1 + 3 files changed, 22 insertions(+), 5 deletions(-) diff --git a/surfaces/gui/e2e/board.spec.ts b/surfaces/gui/e2e/board.spec.ts index 70a7dbce..1d76099a 100644 --- a/surfaces/gui/e2e/board.spec.ts +++ b/surfaces/gui/e2e/board.spec.ts @@ -35,15 +35,20 @@ test("a decomposition turn raises the plan gate; approving moves items to Approv await gate.getByRole("button", { name: /1 more item/ }).click(); await expect(gate.getByText("Rate-limit audit — public endpoints")).toBeVisible(); - // Blocked renders on top in the rail; proposed items are listed under Proposed. + // Blocked renders on top in the rail; proposed items collapse to ONE line — + // the gate card is the only place the plan renders in full (no double listing). const rail = page.getByTestId("board-rail"); await expect(rail).toBeVisible(); const groups = rail.locator(".board-group"); await expect(groups.first()).toHaveText("Blocked"); + await expect(page.getByTestId("board-proposed-note")).toHaveText("4 items awaiting your approval."); + await expect(rail.getByText("Dependency audit — lockfiles")).toHaveCount(0); await page.getByTestId("plangate-approve").click(); await expect(page.getByTestId("plangate-card")).toHaveCount(0); + await expect(page.getByTestId("board-proposed-note")).toHaveCount(0); await expect(rail).toContainText("Approved"); + await expect(rail.getByText("Dependency audit — lockfiles")).toBeVisible(); }); test("expand opens the overlay board; Esc closes; the user can act on a review item", async ({ diff --git a/surfaces/gui/src/components/BoardPanel.tsx b/surfaces/gui/src/components/BoardPanel.tsx index bafdb72d..c1508029 100644 --- a/surfaces/gui/src/components/BoardPanel.tsx +++ b/surfaces/gui/src/components/BoardPanel.tsx @@ -39,10 +39,16 @@ export function boardSummary(board: Board): string { } export function BoardSection({ board, onExpand }: { board: Board; onExpand: () => void }) { - const groups = GROUPS.map((g) => ({ - ...g, - items: board.items.filter((i) => i.state === g.state), - })).filter((g) => g.items.length > 0); + // Proposed items are the plan gate's content — the rail collapses them to one + // line (mock UX-030 state 1: "4 items awaiting your approval") instead of + // listing the same items twice on screen. + const proposed = board.items.filter((i) => i.state === "proposed"); + const groups = GROUPS.filter((g) => g.state !== "proposed") + .map((g) => ({ + ...g, + items: board.items.filter((i) => i.state === g.state), + })) + .filter((g) => g.items.length > 0); return (
{groups.map((group) => ( @@ -61,6 +67,11 @@ export function BoardSection({ board, onExpand }: { board: Board; onExpand: () = ))}
))} + {proposed.length > 0 && ( +
+ {proposed.length} item{proposed.length === 1 ? "" : "s"} awaiting your approval. +
+ )}
); } diff --git a/surfaces/gui/src/styles.css b/surfaces/gui/src/styles.css index 2fce60c2..689055ac 100644 --- a/surfaces/gui/src/styles.css +++ b/surfaces/gui/src/styles.css @@ -1729,3 +1729,4 @@ html[data-platform="linux"] ::-webkit-scrollbar-thumb:hover { background-color: color: var(--accent); font-size: 12px; cursor: pointer; padding: 6px 0; } .plangate-note { font-size: 11.5px; color: var(--faint); } +.board-proposed-note { color: var(--faint); font-size: 12px; padding: 8px 6px 2px; } From fc24b667ed3503b652fef79abbc2f7cc5e96ce0e Mon Sep 17 00:00:00 2001 From: Rohit C Prasad Date: Sun, 16 Aug 2026 08:07:10 -0700 Subject: [PATCH 32/80] Drop the proposed state: boards hold only accepted work MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plan proposals live in the conversation (plan-approval flow); items are created open/unassigned and work starts at assignment — the granted, revocable authority. Also closes a verify gap: tail truncation is now caught against the stored head hash. --- coworker/server/app.py | 4 - coworker/server/manager.py | 13 --- coworker/teams/journal.py | 3 + coworker/teams/model.py | 17 ++-- coworker/teams/store.py | 26 +++--- coworker/teams/tools.py | 22 +++-- surfaces/gui/e2e/board.spec.ts | 50 +++++----- surfaces/gui/e2e/fixtures.ts | 28 ++---- surfaces/gui/src/App.tsx | 16 +--- surfaces/gui/src/api.ts | 7 +- surfaces/gui/src/components/BoardPanel.tsx | 104 ++++----------------- surfaces/gui/src/styles.css | 18 +--- tests/test_team_board.py | 36 +++---- tests/test_team_journal.py | 1 - tests/test_team_store.py | 8 +- 15 files changed, 106 insertions(+), 247 deletions(-) diff --git a/coworker/server/app.py b/coworker/server/app.py index aeacc4ef..e0e1b276 100644 --- a/coworker/server/app.py +++ b/coworker/server/app.py @@ -750,10 +750,6 @@ def create_app(manager: SessionManager) -> FastAPI: comment=str(body.get("comment", "")), ) - @app.post("/v1/sessions/{session_id}/board/approve") - def session_board_approve(session_id: str) -> dict[str, Any]: - return manager.board_approve(session_id) - @app.get("/v1/teams/journal") def teams_journal() -> dict[str, Any]: return {"cases": manager.journal_overview()} diff --git a/coworker/server/manager.py b/coworker/server/manager.py index 9a9cedb3..f54336df 100644 --- a/coworker/server/manager.py +++ b/coworker/server/manager.py @@ -1412,19 +1412,6 @@ class SessionManager: except (TeamsBoardError, ValueError) as error: return {"error": str(error)} - def board_approve(self, session_id: str) -> dict[str, Any]: - """The plan gate's action: approve every proposed item on the session's board.""" - space = self._board_space(session_id) - if space is None: - return {"error": "this session has no board"} - approved = 0 - for entry in self.team_store.list_items( - space, self._user_actor(), state="proposed" - ): - self.team_store.transition(space, self._user_actor(), entry["id"], "approved") - approved += 1 - return {"approved": approved, **self.session_board(session_id)} - def journal_overview(self) -> list[dict[str, Any]]: return self.journal_store.overview(self._user_actor()) diff --git a/coworker/teams/journal.py b/coworker/teams/journal.py index 648f62f5..1cdc180d 100644 --- a/coworker/teams/journal.py +++ b/coworker/teams/journal.py @@ -368,6 +368,9 @@ class JournalStore: if _hash(record, fields=_HASHED_FIELDS) != row["hash"]: raise ChainError(f"entry {row['seq']}: content does not match hash") prev = row["hash"] + # Tail truncation is invisible to the chain itself; the stored head sees it. + if rows and prev != self._head_hash(case): + raise ChainError("case log ends before the recorded head — tail deleted") return len(rows) def close(self) -> None: diff --git a/coworker/teams/model.py b/coworker/teams/model.py index 3ed6c630..2228e661 100644 --- a/coworker/teams/model.py +++ b/coworker/teams/model.py @@ -14,8 +14,7 @@ from pathlib import Path class ItemState(str, Enum): - PROPOSED = "proposed" - APPROVED = "approved" + OPEN = "open" IN_PROGRESS = "in_progress" BLOCKED = "blocked" REVIEW = "review" @@ -23,17 +22,19 @@ class ItemState(str, Enum): CANCELED = "canceled" -# Legal edges of the state machine. Approval and done carry extra authority rules -# (see TeamStore.transition): proposed→approved is the human decomposition gate, -# review→done is the lead's verification gate. canceled→approved is reopen. +# Legal edges of the state machine. There is NO draft/proposed state (decided +# 2026-08-16): a plan proposal lives in the conversation (plan-approval flow) and +# the board only ever contains accepted work — items are created `open`, and the +# control point for work starting is ASSIGNMENT (a granted, revocable authority), +# not a per-item approval. review→done stays the verification gate; canceled→open +# is reopen. EDGES: dict[ItemState, set[ItemState]] = { - ItemState.PROPOSED: {ItemState.APPROVED, ItemState.CANCELED}, - ItemState.APPROVED: {ItemState.IN_PROGRESS, ItemState.CANCELED}, + ItemState.OPEN: {ItemState.IN_PROGRESS, ItemState.CANCELED}, ItemState.IN_PROGRESS: {ItemState.BLOCKED, ItemState.REVIEW, ItemState.CANCELED}, ItemState.BLOCKED: {ItemState.IN_PROGRESS, ItemState.CANCELED}, ItemState.REVIEW: {ItemState.DONE, ItemState.IN_PROGRESS, ItemState.CANCELED}, ItemState.DONE: set(), - ItemState.CANCELED: {ItemState.APPROVED}, + ItemState.CANCELED: {ItemState.OPEN}, } # Targets a worker may move its OWN item to. Workers never approve, never close: diff --git a/coworker/teams/store.py b/coworker/teams/store.py index e5180316..9e413d78 100644 --- a/coworker/teams/store.py +++ b/coworker/teams/store.py @@ -301,6 +301,10 @@ class TeamStore: if _hash(record) != row["hash"]: raise ChainError(f"event {row['seq']}: content does not match hash") prev = row["hash"] + # The chain alone can't see TAIL truncation (a shortened log still links); + # the stored head can. + if rows and prev != self._head_hash(space): + raise ChainError("log ends before the recorded head — tail deleted") return len(rows) def rebuild(self, space: str) -> None: @@ -342,11 +346,13 @@ class TeamStore: parent: Optional[int] = None, case: Optional[str] = None, ) -> dict[str, Any]: - """New item in `proposed`. Acceptance criteria are load-bearing — required. + """New item, `open` and unassigned. Acceptance criteria are load-bearing — + required. Workers may create too — a bug spotted in passing, a follow-up — because - proposing is harmless: nothing runs until the item crosses the approval - gate.""" + filing is harmless: nothing runs until the item is ASSIGNED, and assign + authority stays with the lead/user (the lead triages worker filings: + assign or cancel).""" self._require(actor, {Role.USER, Role.LEAD, Role.WORKER}, "create_item") if not (title or "").strip(): raise BoardError("title is required") @@ -501,10 +507,9 @@ class TeamStore: with self._lock: item = self._item(space, item_id) state = ItemState(item["state"]) - if state in (ItemState.PROPOSED, ItemState.DONE, ItemState.CANCELED): + if state in (ItemState.DONE, ItemState.CANCELED): raise BoardError( - f"cannot assign an item in state {state.value} — items are" - " assigned after approval" + f"cannot assign an item in state {state.value} — reopen it first" ) event = self.append_event( space, @@ -603,7 +608,7 @@ class TeamStore: payload.get("title") or "", payload.get("description") or "", payload.get("criteria") or "", - ItemState.PROPOSED.value, + ItemState.OPEN.value, actor_id, payload.get("case") or "", ts, @@ -664,13 +669,6 @@ class TeamStore: ) -> None: if actor.role == Role.SYSTEM: raise AuthorityError("system events cannot transition items") - if current == ItemState.PROPOSED and target == ItemState.APPROVED: - if actor.role != Role.USER: - raise AuthorityError( - "only the user approves proposed items — that is the" - " decomposition gate" - ) - return if target == ItemState.DONE and actor.role == Role.WORKER: raise AuthorityError( "workers finish by moving to review — done is the verdict after" diff --git a/coworker/teams/tools.py b/coworker/teams/tools.py index 50deef56..fe22a209 100644 --- a/coworker/teams/tools.py +++ b/coworker/teams/tools.py @@ -21,7 +21,7 @@ from .store import TeamStore LEAD_VERBS = ("create_item", "list_items", "transition", "comment", "assign", "link") # Workers file items too (a bug spotted in passing, a follow-up) — new items land -# in `proposed`, so the approval gate catches everything a worker proposes. +# `open` and unassigned; nothing runs until the lead/user assigns them. WORKER_VERBS = ("create_item", "list_items", "transition", "comment") JOURNAL_VERBS = ("journal_append", "journal_read") @@ -33,10 +33,11 @@ _CREATE_ITEM_SCHEMA = { "function": { "name": "create_item", "description": ( - "Create a work item in the proposed state. `criteria` is the acceptance" - " criteria — what gets verified before the item can be done; required." - " `parent` links it under another item; `case` names its journal case" - " (children inherit the parent's case by default)." + "Create a work item (open, unassigned — work starts when it is" + " assigned). `criteria` is the acceptance criteria — what gets verified" + " before the item can be done; required. `parent` links it under" + " another item; `case` names its journal case (children inherit the" + " parent's case by default)." ), "parameters": { "type": "object", @@ -74,10 +75,11 @@ def board_tools( parent: Optional[int] = None, case: str = "", ) -> dict: - """Create a work item in the proposed state. `criteria` is the acceptance - criteria — what gets verified before the item can be done; required. - `parent` links it under another item; `case` names its journal case - (children inherit the parent's case by default).""" + """Create a work item (open, unassigned — work starts when it is + assigned). `criteria` is the acceptance criteria — what gets verified + before the item can be done; required. `parent` links it under another + item; `case` names its journal case (children inherit the parent's case + by default).""" return _call( store.create_item, space, @@ -91,7 +93,7 @@ def board_tools( def list_items(state: str = "", assignee: str = "") -> dict: """List work items on the board, optionally filtered by state - (proposed/approved/in_progress/blocked/review/done/canceled) or assignee.""" + (open/in_progress/blocked/review/done/canceled) or assignee.""" try: return {"items": store.list_items(space, actor, state=state or None, assignee=assignee or None)} except (BoardError, ValueError) as error: diff --git a/surfaces/gui/e2e/board.spec.ts b/surfaces/gui/e2e/board.spec.ts index 1d76099a..1ac8f1e7 100644 --- a/surfaces/gui/e2e/board.spec.ts +++ b/surfaces/gui/e2e/board.spec.ts @@ -1,7 +1,9 @@ // Agent teams (OPE-96): the board in the session UI — rail section (grouped by -// state, blocked on top), the plan gate (decomposition approval), and the expanded -// Linear-shaped overlay. The fake agent files items on "plan the work"; approve and -// transition round-trip through the mocked /board endpoints. +// state, blocked on top) and the expanded Linear-shaped overlay. There is no +// draft/proposed state: plan approval is a conversation-layer moment (the existing +// plan-approval flow); the board only ever holds accepted work. The fake agent +// files items on "plan the work"; transitions round-trip through the mocked +// /board endpoints as the user. import { expect } from "@playwright/test"; import { test } from "./fixtures"; @@ -9,7 +11,7 @@ async function planTheWork(page: import("@playwright/test").Page) { await page.goto("/"); await page.getByPlaceholder(/Ask the coworker/).fill("plan the work"); await page.getByRole("button", { name: "Send" }).click(); - await expect(page.getByText(/approve the plan and I'll get started/)).toBeVisible(); + await expect(page.getByText(/filed 5 work items/)).toBeVisible(); } test("plain sessions carry zero board chrome", async ({ page }) => { @@ -18,53 +20,43 @@ test("plain sessions carry zero board chrome", async ({ page }) => { await page.getByRole("button", { name: "Send" }).click(); await expect(page.getByText("Echo: hello")).toBeVisible(); await expect(page.getByTestId("board-rail")).toHaveCount(0); - await expect(page.getByTestId("plangate-card")).toHaveCount(0); }); -test("a decomposition turn raises the plan gate; approving moves items to Approved", async ({ +test("filed items appear grouped in the rail, blocked on top, open items listed", async ({ page, }) => { await planTheWork(page); - const gate = page.getByTestId("plangate-card"); - await expect(gate).toBeVisible(); - // 3 visible + expander with the true remainder (mock UX-030: expander, true count in header) - await expect(gate).toContainText("Proposed plan — 4 work items"); - await expect(gate).toContainText("Done when:"); - await expect(gate.getByText("Code security review — api")).toBeVisible(); - await expect(gate.getByText("Rate-limit audit — public endpoints")).toHaveCount(0); - await gate.getByRole("button", { name: /1 more item/ }).click(); - await expect(gate.getByText("Rate-limit audit — public endpoints")).toBeVisible(); - - // Blocked renders on top in the rail; proposed items collapse to ONE line — - // the gate card is the only place the plan renders in full (no double listing). const rail = page.getByTestId("board-rail"); await expect(rail).toBeVisible(); const groups = rail.locator(".board-group"); await expect(groups.first()).toHaveText("Blocked"); - await expect(page.getByTestId("board-proposed-note")).toHaveText("4 items awaiting your approval."); - await expect(rail.getByText("Dependency audit — lockfiles")).toHaveCount(0); - - await page.getByTestId("plangate-approve").click(); - await expect(page.getByTestId("plangate-card")).toHaveCount(0); - await expect(page.getByTestId("board-proposed-note")).toHaveCount(0); - await expect(rail).toContainText("Approved"); - await expect(rail.getByText("Dependency audit — lockfiles")).toBeVisible(); + // No gate, no draft: open items are real items, listed like any other state. + await expect(rail).toContainText("Open"); + await expect(rail.getByText("Secrets — git history, both repos")).toBeVisible(); + await expect( + page.getByRole("button", { name: /Board · 1 blocked · 1 review · 1 in progress · 2 open/ }), + ).toBeVisible(); }); -test("expand opens the overlay board; Esc closes; the user can act on a review item", async ({ +test("expand opens the overlay board; the user verifies review items and removes open ones", async ({ page, }) => { await planTheWork(page); await page.getByTestId("board-expand").click(); const overlay = page.getByTestId("board-overlay"); await expect(overlay).toBeVisible(); - // Columns render need-attention first; the review item offers the user verbs. await expect(page.getByTestId("board-col-blocked")).toBeVisible(); + + // review → done (the verification gate stays a human/lead call) const reviewCol = page.getByTestId("board-col-review"); - await expect(reviewCol).toContainText("Report rollup"); await reviewCol.getByRole("button", { name: "Mark done" }).click(); await expect(page.getByTestId("board-col-done")).toContainText("Report rollup"); + // open → removed (lead/user triage of filed items; maps to canceled underneath) + const openCol = page.getByTestId("board-col-open"); + await openCol.getByRole("button", { name: "Remove" }).first().click(); + await expect(page.getByTestId("board-col-canceled")).toBeVisible(); + await page.keyboard.press("Escape"); await expect(page.getByTestId("board-overlay")).toHaveCount(0); }); diff --git a/surfaces/gui/e2e/fixtures.ts b/surfaces/gui/e2e/fixtures.ts index eac71e4e..8f328869 100644 --- a/surfaces/gui/e2e/fixtures.ts +++ b/surfaces/gui/e2e/fixtures.ts @@ -562,16 +562,15 @@ export async function mockApi(page: import("@playwright/test").Page) { let stagedSkill: any = null; // Agent teams (OPE-96): the session's board — empty until a test opts in by sending - // "plan the work" (the fake agent then files items into the proposed gate). Mutable - // so approve/transition round-trip through the real endpoints. + // "plan the work" (the fake agent then files items; no draft state — the board only + // holds accepted work). Mutable so transitions round-trip through the real endpoints. const boardItems: any[] = []; const seedBoard = () => { if (boardItems.length) return; boardItems.push( - { id: 1, title: "Code security review — api", description: "", criteria: "every finding triaged with file:line evidence", state: "proposed", assignee: "", creator: "lead", refs: [], links: [] }, - { id: 2, title: "Secrets — git history, both repos", description: "", criteria: "every hit dismissed-with-reason or rotation-instructed", state: "proposed", assignee: "", creator: "lead", refs: [], links: [] }, - { id: 3, title: "Dependency audit — lockfiles", description: "", criteria: "reachable vs theoretical separated; upgrade branch green", state: "proposed", assignee: "", creator: "lead", refs: [], links: [] }, - { id: 6, title: "Rate-limit audit — public endpoints", description: "", criteria: "every unauthenticated route has a limit or a reason", state: "proposed", assignee: "", creator: "lead", refs: [], links: [] }, + { id: 1, title: "Code security review — api", description: "", criteria: "every finding triaged with file:line evidence", state: "open", assignee: "", creator: "lead", refs: [], links: [] }, + { id: 2, title: "Secrets — git history, both repos", description: "", criteria: "every hit dismissed-with-reason or rotation-instructed", state: "open", assignee: "", creator: "lead", refs: [], links: [] }, + { id: 3, title: "Dependency audit — lockfiles", description: "", criteria: "reachable vs theoretical separated; upgrade branch green", state: "in_progress", assignee: "dep-audit", creator: "lead", refs: [], links: [] }, { id: 4, title: "Cloud posture — infra", description: "", criteria: "trivy config clean or findings triaged", state: "blocked", assignee: "cloud-posture", creator: "lead", refs: [], links: [] }, { id: 5, title: "Report rollup", description: "", criteria: "one report, all sections", state: "review", assignee: "security", creator: "lead", refs: [], links: [] }, ); @@ -630,12 +629,13 @@ export async function mockApi(page: import("@playwright/test").Page) { }); return; // suspended on the approval } - // Agent teams (OPE-96): a decomposition turn — the agent files work items and - // the board (rail section + plan gate) appears on the next board fetch. + // Agent teams (OPE-96): a decomposition turn — the plan was approved in + // conversation (plan-approval flow); the agent files the items and the + // board rail appears on the next board fetch. if (/plan the work/i.test(msg.text)) { seedBoard(); send("assistant_message", { - text: "Split it into 5 work items — approve the plan and I'll get started.", + text: "Plan approved — filed 5 work items on the board.", }); send("turn_done"); return; @@ -924,16 +924,6 @@ export async function mockApi(page: import("@playwright/test").Page) { } if (/\/v1\/sessions\/[^/]+\/artifacts\/reveal$/.test(p)) return json({ ok: true }); // Agent teams (OPE-96): board reads + the user-side mutations. - if (/\/v1\/sessions\/[^/]+\/board\/approve$/.test(p)) { - let approved = 0; - for (const item of boardItems) { - if (item.state === "proposed") { - item.state = "approved"; - approved += 1; - } - } - return json({ approved, ...boardPayload() }); - } if (/\/v1\/sessions\/[^/]+\/board\/transition$/.test(p)) { const b = req.postDataJSON() || {}; const item = boardItems.find((i) => i.id === Number(b.item)); diff --git a/surfaces/gui/src/App.tsx b/surfaces/gui/src/App.tsx index 1308948d..5b185d36 100644 --- a/surfaces/gui/src/App.tsx +++ b/surfaces/gui/src/App.tsx @@ -3,7 +3,6 @@ import { announceInboxUnlock, createTempWorkspace, finalizeAutomationRun, - boardApprove, boardTransition, getArtifacts, getBoard, @@ -76,7 +75,7 @@ import { ApprovalCard } from "./components/ApprovalCard"; import { ToolRequestCard } from "./components/ToolRequestCard"; import { DirectoryRequestCard } from "./components/DirectoryRequestCard"; import { PlanCard } from "./components/PlanCard"; -import { BoardOverlay, PlanGateCard } from "./components/BoardPanel"; +import { BoardOverlay } from "./components/BoardPanel"; import { WorkspaceTrustPrompt } from "./components/WorkspaceTrustPrompt"; const newId = () => @@ -275,7 +274,6 @@ export function App() { // Agent teams (OPE-96): board for the current session's workspace space. const [board, setBoard] = useState(null); const [boardOpen, setBoardOpen] = useState(false); - const [planBusy, setPlanBusy] = useState(false); const [railHidden, setRailHidden] = useState(false); // Left-nav collapse (⌘B): when collapsed the sidebar leaves the grid so content reclaims the // width; hovering the left edge peeks it back as a floating overlay. Persisted per-device. @@ -966,15 +964,6 @@ export function App() { }, [agent, surface, sessionId, browserRefreshKey, running]); const refreshBoard = () => getBoard(sessionId).then(setBoard).catch(() => {}); - const approvePlan = async () => { - setPlanBusy(true); - try { - await boardApprove(sessionId); - await refreshBoard(); - } finally { - setPlanBusy(false); - } - }; const moveBoardItem = async (item: number, to: string) => { await boardTransition(sessionId, item, to); await refreshBoard(); @@ -1947,9 +1936,6 @@ export function App() { ) : sessionInbox[0] ? ( // Unattended session blocked on an Inbox item — answer it in context. - ) : board && board.items.some((i) => i.state === "proposed") ? ( - // Agent teams: the decomposition gate — proposed items awaiting approval. - ) : undefined } /> diff --git a/surfaces/gui/src/api.ts b/surfaces/gui/src/api.ts index 84aaf210..2c96fb4c 100644 --- a/surfaces/gui/src/api.ts +++ b/surfaces/gui/src/api.ts @@ -215,7 +215,7 @@ export interface BoardItem { title: string; description: string; criteria: string; - state: "proposed" | "approved" | "in_progress" | "blocked" | "review" | "done" | "canceled" | string; + state: "open" | "in_progress" | "blocked" | "review" | "done" | "canceled" | string; assignee: string; creator: string; refs: string[]; @@ -239,11 +239,6 @@ export async function getBoard(sessionId: string): Promise { return res.json(); } -export async function boardApprove(sessionId: string): Promise { - const res = await fetch(`${httpBase()}/v1/sessions/${encodeURIComponent(sessionId)}/board/approve`, { method: "POST" }); - return res.json(); -} - export async function boardTransition( sessionId: string, item: number, diff --git a/surfaces/gui/src/components/BoardPanel.tsx b/surfaces/gui/src/components/BoardPanel.tsx index c1508029..c8048366 100644 --- a/surfaces/gui/src/components/BoardPanel.tsx +++ b/surfaces/gui/src/components/BoardPanel.tsx @@ -1,10 +1,11 @@ -// Agent teams (OPE-96): the board in three shapes — +// Agent teams (OPE-96): the board in two shapes — // - BoardSection: the right-rail summary (grouped by state, blocked on top) // - BoardOverlay: the expanded, Linear-shaped view covering the chat column -// - PlanGateCard: the decomposition gate (proposed items awaiting the user) -// All three render the same Board data App owns; mutations go through the -// /board endpoints and act as the USER — the human side of the gates. -import { useEffect, useMemo, useState } from "react"; +// Both render the same Board data App owns; mutations go through the /board +// endpoints and act as the USER. There is NO proposed/draft state: a plan +// proposal lives in the conversation (plan-approval flow); the board only ever +// contains accepted work, and work starts at ASSIGNMENT. +import { useEffect } from "react"; import type { Board, BoardItem } from "../api"; import { Icon } from "./Icon"; @@ -13,8 +14,7 @@ const GROUPS: { state: string; label: string }[] = [ { state: "blocked", label: "Blocked" }, { state: "review", label: "Review" }, { state: "in_progress", label: "In progress" }, - { state: "approved", label: "Approved" }, - { state: "proposed", label: "Proposed" }, + { state: "open", label: "Open" }, { state: "done", label: "Done" }, { state: "canceled", label: "Canceled" }, ]; @@ -34,21 +34,15 @@ export function boardSummary(board: Board): string { if (counts.blocked) parts.push(`${counts.blocked} blocked`); if (counts.review) parts.push(`${counts.review} review`); if (counts.in_progress) parts.push(`${counts.in_progress} in progress`); - if (counts.proposed) parts.push(`${counts.proposed} proposed`); + if (counts.open) parts.push(`${counts.open} open`); return parts.join(" · "); } export function BoardSection({ board, onExpand }: { board: Board; onExpand: () => void }) { - // Proposed items are the plan gate's content — the rail collapses them to one - // line (mock UX-030 state 1: "4 items awaiting your approval") instead of - // listing the same items twice on screen. - const proposed = board.items.filter((i) => i.state === "proposed"); - const groups = GROUPS.filter((g) => g.state !== "proposed") - .map((g) => ({ - ...g, - items: board.items.filter((i) => i.state === g.state), - })) - .filter((g) => g.items.length > 0); + const groups = GROUPS.map((g) => ({ + ...g, + items: board.items.filter((i) => i.state === g.state), + })).filter((g) => g.items.length > 0); return (
{groups.map((group) => ( @@ -67,11 +61,6 @@ export function BoardSection({ board, onExpand }: { board: Board; onExpand: () = ))}
))} - {proposed.length > 0 && ( -
- {proposed.length} item{proposed.length === 1 ? "" : "s"} awaiting your approval. -
- )}
); } @@ -99,7 +88,7 @@ export function BoardOverlay({ const columns = GROUPS.map((g) => ({ ...g, items: board.items.filter((i) => i.state === g.state), - })).filter((g) => g.items.length > 0 || ["in_progress", "approved", "review"].includes(g.state)); + })).filter((g) => g.items.length > 0 || ["in_progress", "open", "review"].includes(g.state)); return (
@@ -145,13 +134,13 @@ function BoardCard({ }) { // The user can always act; offer the obvious next moves for the state. const moves: { to: string; label: string }[] = - item.state === "proposed" - ? [{ to: "approved", label: "Approve" }, { to: "canceled", label: "Cancel" }] - : item.state === "review" - ? [{ to: "done", label: "Mark done" }, { to: "in_progress", label: "Send back" }] - : item.state === "done" || item.state === "canceled" + item.state === "review" + ? [{ to: "done", label: "Mark done" }, { to: "in_progress", label: "Send back" }] + : item.state === "canceled" + ? [{ to: "open", label: "Reopen" }] + : item.state === "done" ? [] - : [{ to: "canceled", label: "Cancel" }]; + : [{ to: "canceled", label: "Remove" }]; return (
@@ -178,58 +167,3 @@ function BoardCard({ ); } -// The decomposition gate: proposed items awaiting the user's approval, rendered in -// the composer head like the other request cards. Visible layer = the decisions -// (items + criteria); editing happens by replying — no in-card reply surface. -export function PlanGateCard({ - board, - onApprove, - busy, -}: { - board: Board; - onApprove: () => void; - busy?: boolean; -}) { - const proposed = useMemo(() => board.items.filter((i) => i.state === "proposed"), [board.items]); - const [expanded, setExpanded] = useState(false); - if (proposed.length === 0) return null; - const visible = expanded ? proposed : proposed.slice(0, 3); - const hidden = proposed.length - visible.length; - return ( -
-
- - - Proposed plan — {proposed.length} work item{proposed.length === 1 ? "" : "s"} - - board: {board.name} -
- {visible.map((item) => ( -
- #{item.id} - - {item.title} - {item.criteria && ( - - Done when: {item.criteria} - - )} - -
- ))} - {hidden > 0 && ( - - )} -
- Reply to edit the plan; nothing runs until you approve. - - -
-
- ); -} diff --git a/surfaces/gui/src/styles.css b/surfaces/gui/src/styles.css index 689055ac..b3df2477 100644 --- a/surfaces/gui/src/styles.css +++ b/surfaces/gui/src/styles.css @@ -1713,20 +1713,4 @@ html[data-platform="linux"] ::-webkit-scrollbar-thumb:hover { background-color: .journal-case { color: var(--ink); flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; } .journal-count { font-size: 11px; color: var(--faint); } -/* Plan gate (decomposition gate) — rides the dirreq-card frame. */ -.plangate-card { border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-soft); } -.plangate-head { display: flex; align-items: center; gap: 8px; margin-bottom: 6px; } -.plangate-title { font-weight: 600; font-size: 13px; color: var(--ink); } -.plangate-board { margin-left: auto; font-size: 11.5px; color: var(--faint); } -.plangate-item { display: flex; gap: 9px; padding: 7px 0; border-top: 1px solid var(--line); } -.plangate-num { color: var(--faint); font-size: 12px; padding-top: 1px; } -.plangate-body { display: flex; flex-direction: column; gap: 2px; min-width: 0; } -.plangate-item-title { font-size: 12.5px; color: var(--ink); } -.plangate-ac { font-size: 11.5px; color: var(--muted); } -.plangate-ac b { font-weight: 600; } -.plangate-more { - display: flex; align-items: center; gap: 5px; border: 0; background: transparent; - color: var(--accent); font-size: 12px; cursor: pointer; padding: 6px 0; -} -.plangate-note { font-size: 11.5px; color: var(--faint); } -.board-proposed-note { color: var(--faint); font-size: 12px; padding: 8px 6px 2px; } + diff --git a/tests/test_team_board.py b/tests/test_team_board.py index 54cdecdd..d9f3d320 100644 --- a/tests/test_team_board.py +++ b/tests/test_team_board.py @@ -21,7 +21,6 @@ def store(tmp_path): def assigned_item(store, assignee="worker-1"): item = store.create_item(SPACE, LEAD, title="Task", criteria="tests pass") - store.transition(SPACE, USER, item["id"], "approved") store.assign(SPACE, LEAD, item["id"], assignee) return item["id"] @@ -33,19 +32,21 @@ def test_acceptance_criteria_are_required(store): store.create_item(SPACE, LEAD, title="Vague hope", criteria=" ") -def test_workers_file_items_into_the_proposed_gate(store): +def test_workers_file_items_open_and_unassigned(store): mine = assigned_item(store) filed = store.create_item( SPACE, WORKER, title="Rounding bug in invoices", criteria="repro + fix", parent=mine, ) - assert filed["state"] == "proposed" + assert filed["state"] == "open" + assert filed["assignee"] == "" assert filed["creator"] == "worker-1" - # the worker sees its own filing; nothing runs until the user approves it + # the worker sees its own filing; nothing runs until the lead/user assigns it, + # and the filer can't assign it to itself visible = {item["id"] for item in store.list_items(SPACE, WORKER)} assert filed["id"] in visible - with pytest.raises(AuthorityError, match="decomposition gate"): - store.transition(SPACE, WORKER, filed["id"], "approved") + with pytest.raises(AuthorityError): + store.assign(SPACE, WORKER, filed["id"], "worker-1") other_worker = {item["id"] for item in store.list_items(SPACE, OTHER)} assert filed["id"] not in other_worker @@ -76,15 +77,7 @@ def test_illegal_edges_rejected(store): with pytest.raises(BoardError, match="illegal transition"): store.transition(SPACE, USER, item["id"], "done") with pytest.raises(BoardError, match="illegal transition"): - store.transition(SPACE, USER, item["id"], "in_progress") - - -def test_only_the_user_approves(store): - item = store.create_item(SPACE, LEAD, title="T", criteria="c") - with pytest.raises(AuthorityError, match="decomposition gate"): - store.transition(SPACE, LEAD, item["id"], "approved") - approved = store.transition(SPACE, USER, item["id"], "approved") - assert approved["state"] == "approved" + store.transition(SPACE, USER, item["id"], "review") def test_workers_never_mark_done(store): @@ -110,8 +103,8 @@ def test_worker_cannot_cancel(store): def test_cancel_is_a_board_verb_and_reopen_works(store): item_id = assigned_item(store) store.transition(SPACE, LEAD, item_id, "canceled") - reopened = store.transition(SPACE, LEAD, item_id, "approved") - assert reopened["state"] == "approved" + reopened = store.transition(SPACE, LEAD, item_id, "open") + assert reopened["state"] == "open" assert reopened["assignee"] == "worker-1" # reassignable — assignment survives @@ -136,10 +129,11 @@ def test_rework_loop(store): # ---------------------------------------------------------------- assign / link -def test_cannot_assign_before_approval(store): - item = store.create_item(SPACE, LEAD, title="T", criteria="c") - with pytest.raises(BoardError, match="after approval"): - store.assign(SPACE, LEAD, item["id"], "worker-1") +def test_cannot_assign_closed_items(store): + item_id = assigned_item(store) + store.transition(SPACE, LEAD, item_id, "canceled") + with pytest.raises(BoardError, match="reopen"): + store.assign(SPACE, LEAD, item_id, "worker-2") def test_workers_cannot_assign_or_link(store): diff --git a/tests/test_team_journal.py b/tests/test_team_journal.py index 065ad0c7..7d18efe9 100644 --- a/tests/test_team_journal.py +++ b/tests/test_team_journal.py @@ -38,7 +38,6 @@ def board(tmp_path, journal): def case_item(board, case="findings", assignee="worker-1", space=SPACE): item = board.create_item(space, LEAD, title="Task", criteria="c", case=case) - board.transition(space, USER, item["id"], "approved") board.assign(space, LEAD, item["id"], assignee) return item["id"] diff --git a/tests/test_team_store.py b/tests/test_team_store.py index e48a4583..4d6494c9 100644 --- a/tests/test_team_store.py +++ b/tests/test_team_store.py @@ -18,17 +18,15 @@ def store(tmp_path): def seed(store, space="proj"): - item = store.create_item( + return store.create_item( space, LEAD, title="Review api", criteria="every route triaged" ) - store.transition(space, USER, item["id"], "approved") - return item def test_events_hash_chain_verifies(store): seed(store) store.create_item("proj", LEAD, title="Second", criteria="done means done") - assert store.verify_chain("proj") == 3 + assert store.verify_chain("proj") == 2 def test_out_of_band_edit_breaks_the_chain(store): @@ -61,7 +59,7 @@ def test_chains_are_per_space(store): conn.execute("UPDATE team_events SET taint = 1 WHERE space = 'beta'") conn.commit() conn.close() - assert store.verify_chain("alpha") == 2 # untouched space still verifies + assert store.verify_chain("alpha") == 1 # untouched space still verifies with pytest.raises(ChainError): store.verify_chain("beta") From 3e4fafead0e61b62cfaf5ec0c171cf7f456964bc Mon Sep 17 00:00:00 2001 From: Rohit C Prasad Date: Sun, 16 Aug 2026 08:37:07 -0700 Subject: [PATCH 33/80] Team wake plumbing: trait-gated verbs, durable queues, staffing gate, digests (OPE-97) team: manifest trait gates lead/worker toolsets; propose_team pre-spawns worker sessions on approval (fail closed on solo personas). Deliveries + lead subscriptions are cursor-consumed projections; turns end with a queue kick, ticks replay; timer wakes carry the code-computed staleness digest; hourly wake cap is the budget gate. --- coworker/agent.py | 9 + coworker/agents/base.py | 3 + coworker/conversations.py | 9 +- coworker/engine.py | 61 ++++++ coworker/events.py | 3 + coworker/personas/loading.py | 7 + coworker/personas/manifest.py | 17 ++ coworker/server/app.py | 47 ++++ coworker/server/manager.py | 391 ++++++++++++++++++++++++++++++++-- coworker/sessions.py | 4 + coworker/teams/registry.py | 136 ++++++++++++ coworker/teams/store.py | 68 ++++++ coworker/teams/tools.py | 62 ++++++ tests/test_team_wake.py | 225 +++++++++++++++++++ 14 files changed, 1023 insertions(+), 19 deletions(-) create mode 100644 coworker/teams/registry.py create mode 100644 tests/test_team_wake.py diff --git a/coworker/agent.py b/coworker/agent.py index 5456312d..a9f391ce 100644 --- a/coworker/agent.py +++ b/coworker/agent.py @@ -213,6 +213,7 @@ def build_engine( plan_approver: Optional[Any] = None, question_asker: Optional[Any] = None, tool_requester: Optional[Any] = None, + team_approver: Optional[Any] = None, subscription_store: Optional[Any] = None, channel_buffer: Optional[Any] = None, routing_targets: Optional[list[str]] = None, @@ -428,6 +429,13 @@ def build_engine( # call whenever the session isn't actually in plan mode. registry.register(propose_plan_tool()) + # The staffing gate — leads only. The engine intercepts it (out-of-band approval); + # approval pre-spawns the worker sessions and returns actor ids to assign to. + if agent.team == "lead": + from .teams.tools import propose_team_tool + + registry.register(propose_team_tool()) + # Per-turn ephemeral context, appended to the latest user message since mid-thread system # messages aren't reliable across providers. Three producers: the plan-mode reminder (mode can # flip mid-session, so it's checked each turn, not baked into the instructions), the live @@ -502,6 +510,7 @@ def build_engine( plan_approver=plan_approver, question_asker=question_asker, tool_requester=tool_requester, + team_approver=team_approver, ) engine.executor = executor # type: ignore[attr-defined] engine.todo = todo # type: ignore[attr-defined] diff --git a/coworker/agents/base.py b/coworker/agents/base.py index 0fb78cd1..bfd11b0b 100644 --- a/coworker/agents/base.py +++ b/coworker/agents/base.py @@ -41,6 +41,9 @@ class Agent: family: str = "knowledge" messaging: bool = False connectors: bool | tuple[str, ...] = False + # Team identity: "lead" | "worker" | None (solo-only). Gates the board/journal + # toolsets and staffing eligibility — solo personas are never team-staffable. + team: Optional[str] = None def build_tools(self, context: AgentContext) -> list: return list(self.tool_factory(context)) if self.tool_factory else [] diff --git a/coworker/conversations.py b/coworker/conversations.py index e67ef2fb..2915a3a9 100644 --- a/coworker/conversations.py +++ b/coworker/conversations.py @@ -96,6 +96,7 @@ class ConversationStore: "ALTER TABLE sessions ADD COLUMN renamed INTEGER DEFAULT 0", "ALTER TABLE sessions ADD COLUMN grants TEXT", "ALTER TABLE sessions ADD COLUMN compaction TEXT", + "ALTER TABLE sessions ADD COLUMN team TEXT", ): try: self._conn.execute(ddl) @@ -192,13 +193,14 @@ class ConversationStore: title = record.title or title_from(record.messages) self._conn.execute( """ - INSERT INTO sessions (session_id, workspace, model, mode, title, agent, n_msgs, messages, extra_roots, grants, compaction, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, NULL, ?, ?, ?, CURRENT_TIMESTAMP) + INSERT INTO sessions (session_id, workspace, model, mode, title, agent, n_msgs, messages, extra_roots, grants, compaction, team, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, NULL, ?, ?, ?, ?, CURRENT_TIMESTAMP) ON CONFLICT(session_id) DO UPDATE SET workspace = excluded.workspace, model = excluded.model, mode = excluded.mode, title = COALESCE(sessions.title, excluded.title), agent = excluded.agent, n_msgs = excluded.n_msgs, messages = NULL, extra_roots = excluded.extra_roots, grants = excluded.grants, compaction = excluded.compaction, + team = excluded.team, updated_at = CURRENT_TIMESTAMP """, ( @@ -212,6 +214,7 @@ class ConversationStore: json.dumps(record.extra_roots or []), json.dumps(record.grants or {}), json.dumps(record.compaction or {}), + json.dumps(record.team or {}), ), ) self._conn.commit() @@ -252,6 +255,7 @@ class ConversationStore: archived=bool(row["archived"]), origin=row["origin"], origin_label=row["origin_label"], + team=_load_grants(row["team"] if "team" in row.keys() else None), ) def set_extra_roots(self, session_id: str, extra_roots: list[dict]) -> None: @@ -290,6 +294,7 @@ class ConversationStore: archived=bool(r["archived"]), origin=r["origin"], origin_label=r["origin_label"], + team=_load_grants(r["team"] if "team" in r.keys() else None), ) for r in rows ] diff --git a/coworker/engine.py b/coworker/engine.py index 613b94a6..c2ab0f02 100644 --- a/coworker/engine.py +++ b/coworker/engine.py @@ -89,6 +89,9 @@ class TurnEngine: tool_requester: Optional[ Callable[[dict[str, Any]], "Awaitable[dict[str, Any]]"] ] = None, + team_approver: Optional[ + Callable[[dict[str, Any]], "Awaitable[dict[str, Any]]"] + ] = None, # Called (thread-safe, best-effort) when the user stops the turn — e.g. the # executor's kill for a running shell command. interrupt_hooks: Optional[list[Callable[[], None]]] = None, @@ -119,6 +122,10 @@ class TurnEngine: # An approving result flips the live PermissionEngine out of plan mode (same session, # context kept). None on surfaces that can't prompt (the tool then no-ops). self.plan_approver = plan_approver + # Handles the `propose_team` tool (the staffing gate): emits TEAM_PROPOSED, waits + # for the user's decision; approval pre-spawns the worker sessions and the result + # carries the roster (actor ids). None on surfaces that can't prompt. + self.team_approver = team_approver # Handles the `ask_user` tool: turns a question into an Inbox item and waits for the answer # (answerable inline in a live session or from the Inbox when unattended). None on surfaces # that can't ask (the tool then no-ops). @@ -644,6 +651,10 @@ class TurnEngine: async for event in self._handle_plan_proposal(tool_call): yield event continue + if tool_call.name == "propose_team": + async for event in self._handle_team_proposal(tool_call): + yield event + continue if tool_call.name == "ask_user": async for event in self._handle_ask_user(tool_call): yield event @@ -919,6 +930,56 @@ class TurnEngine: except Exception: pass + async def _handle_team_proposal(self, tool_call: ToolCall) -> AsyncIterator[Event]: + """The staffing gate: emit the proposed roster, await the user's out-of-band + decision. Approval PRE-SPAWNS the worker sessions (server-side, inside the + approver) and the result carries the roster with actor ids so the lead can + assign; rejection returns the user's feedback for a revised proposal.""" + args = tool_call.arguments or {} + members = args.get("members") or [] + if not isinstance(members, list) or not members: + result: dict[str, Any] = { + "approved": False, + "error": "propose at least one member ({persona, model?, reason?})", + } + elif self.team_approver is None: + result = { + "approved": False, + "error": "team staffing isn't available in this surface", + } + else: + yield Event( + EventType.TEAM_PROPOSED, + { + "members": members, + "enable_chat": bool(args.get("enable_chat", False)), + "note": str(args.get("note", "")), + }, + ) + self._audit(tool_call, stage="team_proposed") + result = await self._interruptible( + self.team_approver(dict(args), tool_call.id), + interrupted={"approved": False, "error": "interrupted by user"}, + ) or {"approved": False, "error": "no response"} + + status = "ok" if result.get("approved") else "denied" + self.messages.append(_tool_result_message(tool_call, result)) + self._audit( + tool_call, + stage="finished", + status=status, + result=result, + result_preview=_preview(result), + ) + yield Event( + EventType.TOOL_FINISHED, + { + "name": tool_call.name, + "status": status, + "result_preview": _preview(result), + }, + ) + async def _handle_plan_proposal(self, tool_call: ToolCall) -> AsyncIterator[Event]: """Emit the plan for review, await the user's out-of-band decision, and apply it: approval flips the live PermissionEngine out of plan mode (the same session keeps diff --git a/coworker/events.py b/coworker/events.py index 457e1da4..1934436f 100644 --- a/coworker/events.py +++ b/coworker/events.py @@ -26,6 +26,9 @@ class EventType(str, Enum): PLAN_PROPOSED = ( "plan_proposed" # agent presents a plan for approval (plan mode exit) ) + TEAM_PROPOSED = ( + "team_proposed" # a lead proposes a worker roster (the staffing gate) + ) TOOL_STARTED = "tool_started" TOOL_FINISHED = "tool_finished" ITERATION_END = "iteration_end" diff --git a/coworker/personas/loading.py b/coworker/personas/loading.py index 27d3303e..f7478353 100644 --- a/coworker/personas/loading.py +++ b/coworker/personas/loading.py @@ -31,6 +31,9 @@ def consent_summary(m: PersonaManifest) -> dict: "connectors": "all" if m.connectors is True else list(m.connectors or ()), "mcp": list(m.mcp), "messaging": m.messaging, + # "lead" personas can create and direct worker coworkers — the consent + # screen says that plainly (capability firebreak as a manifest fact). + "team": m.team, "recommended_mode": m.default_permission_mode, "recommended_models": list(m.recommended_models), # Recommended connectors/MCP with reasons + tiers — the consent screen shows @@ -59,6 +62,10 @@ def capability_set(m: PersonaManifest) -> set[str]: caps |= {f"connector:{c}" for c in m.connectors or ()} if m.messaging: caps.add("messaging") + # An update that turns a solo persona into a lead/worker must re-consent — + # team capability changes who the coworker can direct or be directed by. + if m.team: + caps.add(f"team:{m.team}") return caps diff --git a/coworker/personas/manifest.py b/coworker/personas/manifest.py index 0ddeb517..d3aa47f7 100644 --- a/coworker/personas/manifest.py +++ b/coworker/personas/manifest.py @@ -21,6 +21,7 @@ import yaml _ID_RE = re.compile(r"^[a-z0-9][a-z0-9_-]{0,63}$") VALID_FAMILIES = {"code", "knowledge"} +VALID_TEAM = {"lead", "worker"} VALID_WORKSPACES = {"git", "project", "deliverable", "none"} VALID_MODES = {"discuss", "plan", "interactive", "custom", "auto"} VALID_REC_KINDS = {"connector", "mcp"} @@ -63,6 +64,13 @@ class PersonaManifest: # `all` sentinel, reserved for built-in general personas. Coarser grants leaked # undeclared tools (browser, email) into security sessions; undeclared = absent. connectors: bool | tuple[str, ...] = False + # Team identity (agent-teams design, third/fourth pass): "lead" = coordinates a + # team (gets the board coordination verbs + gates; consent copy says "can create + # and direct worker coworkers"); "worker" = purpose-built to work under a lead + # (board worker verbs, no ask_user-shaped prompt); None = solo-only. Solo + # personas are NOT team-eligible — team-awareness changes who the prompt talks + # to, so staffing fails closed on personas without the trait. + team: Optional[str] = None default_permission_mode: str = "interactive" recommended_models: list[str] = field(default_factory=list) skills: list[str] = field(default_factory=list) @@ -97,6 +105,7 @@ class PersonaManifest: family=self.family, messaging=self.messaging, connectors=self.connectors, + team=self.team, ) @@ -279,6 +288,13 @@ def parse_manifest( f"persona {persona_id!r}: default_permission_mode must be one of {sorted(VALID_MODES)}" ) + team_raw = str(meta.get("team", "") or "").strip().lower() + if team_raw and team_raw not in VALID_TEAM: + raise ManifestError( + f"persona {persona_id!r}: team must be one of {sorted(VALID_TEAM)}" + " (omit for a solo coworker)" + ) + tools = _strlist(meta, "tools") _validate_tools(persona_id, tools) recommends = _recommends(persona_id, meta) @@ -296,6 +312,7 @@ def parse_manifest( workspace=workspace, messaging=bool(meta.get("messaging", False)), connectors=connectors, + team=team_raw or None, default_permission_mode=mode, recommended_models=_strlist(meta, "recommended_models"), skills=_strlist(meta, "skills"), diff --git a/coworker/server/app.py b/coworker/server/app.py index e0e1b276..e2807b89 100644 --- a/coworker/server/app.py +++ b/coworker/server/app.py @@ -1835,6 +1835,43 @@ def create_app(manager: SessionManager) -> FastAPI: } return {"approved": True, "mode": resp.get("mode") or "interactive"} + async def team_approver(_args: dict, tool_call_id=None) -> dict: + # The staffing gate. The engine already emitted TEAM_PROPOSED; park an + # Inbox item as the durable resolution vehicle, wait for the verdict, and + # on approval PRE-SPAWN the team (create_team fails closed on non-worker + # personas, so a bad roster reads as a rejection with the reason). + members = _args.get("members") or [] + roster = "\n".join( + f"- {m.get('persona', '?')}" + + (f" · {m['model']}" if m.get("model") else "") + + (f" — {m['reason']}" if m.get("reason") else "") + for m in members + if isinstance(m, dict) + ) + item = manager.inbox.add_plan( + session_id, + "Create this team?", + body=roster, + inbox=_route(), + visibility=_visibility(), + tool_call_id=tool_call_id, + ) + if item.state == "pending": + manager.persist_session(session_id) + if item.visibility == VIS_INBOX: + await _mirror(item) + resp = _parse_json(await manager.inbox.wait(item.id)) + if not resp.get("approved"): + return { + "approved": False, + "feedback": resp.get("feedback") or "the user declined this roster", + } + return manager.create_team( + session_id, + [m for m in members if isinstance(m, dict)], + enable_chat=bool(_args.get("enable_chat", False)), + ) + async def _apply_model(model: Optional[str]) -> None: # Mid-session rebind is allowed (roadmap item 3, supersedes the 2026-07-04 # lock): history is canonical and providers convert per call. A real switch @@ -1874,6 +1911,7 @@ def create_app(manager: SessionManager) -> FastAPI: plan_approver=plan_approver, question_asker=question_asker, tool_requester=tool_requester, + team_approver=team_approver, ) if engine is None: await ws.send_json( @@ -2024,6 +2062,15 @@ def create_app(manager: SessionManager) -> FastAPI: } ) ) + elif kind == "team_response": + _resolve_pending( + json.dumps( + { + "approved": bool(message.get("approved")), + "feedback": message.get("feedback", ""), + } + ) + ) elif kind == "question_response": _resolve_pending(str(message.get("answer", ""))) elif kind == "interrupt": diff --git a/coworker/server/manager.py b/coworker/server/manager.py index f54336df..dc3611c6 100644 --- a/coworker/server/manager.py +++ b/coworker/server/manager.py @@ -15,6 +15,7 @@ import re import shutil import subprocess import time +import uuid from pathlib import Path from typing import Any, Optional @@ -86,6 +87,7 @@ from ..teams import Actor as TeamActor from ..teams import BoardError as TeamsBoardError from ..teams import JournalStore, Role as TeamRole, TeamStore, board_tools, journal_tools from ..teams.model import space_for_workspace +from ..teams.registry import TeamRegistry, TeamWorker from ..skills import ( SessionSkillStore, SkillLoader, @@ -198,14 +200,18 @@ class SessionManager: # The scheduler also resumes self-wake'd sessions each tick (extra_tick). self.task_store = TaskStore(base / "automation.db") self.scheduler = Scheduler( - self.task_store, self._run_scheduled_task, extra_tick=self.resume_due_wakes + self.task_store, self._run_scheduled_task, extra_tick=self._scheduler_tick ) # Agent teams: two append-only stores, one record discipline. The journal is # case-keyed (knowledge outlives boards/teams); the board log is space-scoped, # and assignment feeds journal-case grants. Verbs register per-session behind - # the persona's `team:` trait (wake plumbing lands separately). + # the persona's `team:` trait; the registry holds rosters (lead/worker + # sessions per board) that the wake plumbing walks. self.journal_store = JournalStore(base / "journal.db") self.team_store = TeamStore(base / "teams.db", journal=self.journal_store) + self.teams = TeamRegistry(base / "teams.json") + self._team_inflight: set[str] = set() + self._loop: Optional[asyncio.AbstractEventLoop] = None # Personas: registry + lifecycle state under this manager's data dir. Installed as the # process singleton so agents.get_agent resolves persona ids (incl. third-party) here. self.personas = PersonaRegistry(state_path=base / "personas.json") @@ -477,6 +483,7 @@ class SessionManager: plan_approver: Optional[Any] = None, question_asker: Optional[Any] = None, tool_requester: Optional[Any] = None, + team_approver: Optional[Any] = None, ) -> Optional[TurnEngine]: engine = self._engines.get(session_id) if engine is not None: @@ -490,6 +497,8 @@ class SessionManager: engine.question_asker = question_asker if tool_requester is not None: engine.tool_requester = tool_requester + if team_approver is not None: + engine.team_approver = team_approver return engine record = self.session_store.load(session_id) @@ -546,7 +555,7 @@ class SessionManager: messages=messages, extra_tools=[ *(extra_tools or []), - *self._team_board_tools(session_id, agent_name, ws), + *self._team_tools_for(session_id, ag, record, ws), ] or None, secrets=self.secrets, @@ -566,6 +575,7 @@ class SessionManager: question_asker=question_asker or self.inbox_question_asker(session_id, agent), tool_requester=tool_requester, + team_approver=team_approver, subscription_store=self.subscriptions, channel_buffer=self.channel_buffer, routing_targets=self._routing_targets(session_id, agent), @@ -1415,23 +1425,308 @@ class SessionManager: def journal_overview(self) -> list[dict[str, Any]]: return self.journal_store.overview(self._user_actor()) - def _team_board_tools(self, session_id: str, agent_name: str, ws: Optional[str]) -> list[Any]: - """Phase-1 experimental wiring (flag: OPENWORKER_TEAM_BOARD=1): every - workspace session gets the board+journal verbs as the LEAD of its - workspace's board. Registration moves behind the persona `team:` trait - with the wake plumbing.""" - if not ws or os.environ.get("OPENWORKER_TEAM_BOARD") != "1": + TEAM_WAKE_CAP_PER_HOUR = 60 # budget gate at the wake gate: silent server cap + + def _team_tools_for( + self, session_id: str, agent: Any, record: Any, ws: Optional[str] + ) -> list[Any]: + """Board/journal verbs, gated by the persona `team:` trait. Leads get the + coordination set (+ steer); workers get the worker set bound to their roster + actor id. OPENWORKER_TEAM_BOARD=1 keeps the phase-1 any-session-as-lead dev + mode.""" + role = getattr(agent, "team", None) + if role is None and ws and os.environ.get("OPENWORKER_TEAM_BOARD") == "1": + role = "lead" + if role is None or not ws: return [] - actor = TeamActor( - id=f"{agent_name}:{session_id[:8]}", - role=TeamRole.LEAD, - persona=agent_name, - session_id=session_id, - ) space = space_for_workspace(ws) - return board_tools(self.team_store, space=space, actor=actor) + journal_tools( + if role == "worker": + info = (record.team if record is not None else {}) or {} + actor = TeamActor( + id=str(info.get("actor") or f"{agent.name}:{session_id[:8]}"), + role=TeamRole.WORKER, + persona=agent.name, + session_id=session_id, + ) + space = str(info.get("space") or space) + else: + actor = TeamActor( + id=f"{agent.name}:{session_id[:8]}", + role=TeamRole.LEAD, + persona=agent.name, + session_id=session_id, + ) + tools = board_tools(self.team_store, space=space, actor=actor) + journal_tools( self.journal_store, actor=actor, space=space ) + if role == "lead": + tools.append(self._steer_tool(session_id)) + return tools + + def _steer_tool(self, lead_session_id: str) -> Any: + """The lead's downward steering verb. Text lands in the worker's session + attributed [Lead] — queued into a live turn, or a fresh background turn when + idle. Strictly downward: no worker ever gets this tool.""" + import aisuite as ai + + manager = self + + def steer_worker(worker: str, message: str) -> dict: + """Send steering text to one of your workers (by actor id). Use for + exceptions — changed requirements, stop/redirect, unblock guidance; + routine status flows through the board, not steering.""" + team = manager.teams.for_lead_session(lead_session_id) + if team is None: + return {"error": "no team yet — propose one with propose_team first"} + match = next((w for w in team.workers if w.actor == worker), None) + if match is None: + return { + "error": f"no worker '{worker}' on this team", + "workers": [w.actor for w in team.workers], + } + if manager._loop is None: + return {"error": "steering is unavailable in this surface"} + asyncio.run_coroutine_threadsafe( + manager.deliver_to_session( + match.session_id, f"[Lead] {message}".strip() + ), + manager._loop, + ) + return {"ok": True, "delivered_to": worker} + + return ai.tool( + steer_worker, + metadata=ai.ToolMetadata( + category="team", risk_level="medium", capabilities=["team"] + ), + ) + + def create_team( + self, session_id: str, members: list[dict[str, Any]], *, enable_chat: bool = False + ) -> dict[str, Any]: + """The staffing gate's approved action: PRE-SPAWN worker sessions (state on + disk, zero tokens — the first model turn fires when the first assignment + lands) and register the team. Fails closed on personas without `team: worker`.""" + record = self.session_store.load(session_id) + if record is None or not record.workspace: + return {"approved": False, "error": "the lead session has no workspace"} + if self.teams.for_lead_session(session_id) is not None: + return {"approved": False, "error": "this session already leads a team"} + space = space_for_workspace(record.workspace) + workers: list[TeamWorker] = [] + used: set[str] = set() + for member in members: + pid = str((member or {}).get("persona", "")).strip() + try: + ag = get_agent(pid) + except Exception: + return { + "approved": False, + "error": f"unknown coworker '{pid}' — it must be installed and enabled", + } + if getattr(ag, "team", None) != "worker": + # Fail closed: solo personas are not team-eligible — their prompts + # are written at a human, not a lead. + return { + "approved": False, + "error": f"'{pid}' is not team-capable (needs `team: worker`)", + } + actor, n = pid, 2 + while actor in used: + actor, n = f"{pid}-{n}", n + 1 + used.add(actor) + worker_sid = uuid.uuid4().hex[:12] + model = str(member.get("model") or record.model) + self.session_store.save( + SessionRecord( + session_id=worker_sid, + workspace=record.workspace, + model=model, + mode=record.mode, + messages=[], + agent=pid, + team={ + "role": "worker", + "actor": actor, + "lead_session": session_id, + "space": space, + }, + ) + ) + workers.append( + TeamWorker(actor=actor, persona=pid, session_id=worker_sid, model=model) + ) + team = self.teams.create( + space=space, + lead_session=session_id, + lead_actor=f"{record.agent}:{session_id[:8]}", + workers=workers, + chat_enabled=enable_chat, + ) + for worker in workers: + self._emit_session_created(worker.session_id, worker.persona) + record.team = { + "team_id": team.team_id, + "role": "lead", + "actor": team.lead_actor, + "space": space, + } + self.session_store.save(record) + return { + "approved": True, + "team_id": team.team_id, + "workers": [ + {"actor": w.actor, "persona": w.persona, "session_id": w.session_id} + for w in workers + ], + "note": ( + "team created — workers are idle until you assign. Create work items" + " and assign them to the actor ids above; review-state items are" + " yours to verify." + ), + } + + async def team_tick(self) -> int: + """Drain team queues (called each scheduler tick + kicked after team turns). + One wake consumes a burst as one digest; durable-until-consumed — the cursor + advances only after the delivery turn is dispatched.""" + delivered = 0 + for team in self.teams.all(): + if team.paused: + continue + for worker in team.workers: + delivered += await self._drain_team_member( + team, + session_id=worker.session_id, + actor=worker.actor, + is_lead=False, + ) + delivered += await self._drain_team_member( + team, + session_id=team.lead_session, + actor=team.lead_actor, + is_lead=True, + ) + return delivered + + async def _drain_team_member( + self, team, *, session_id: str, actor: str, is_lead: bool + ) -> int: + directs = self.team_store.pending_for(actor) + subs = ( + self.team_store.subscribed_events(team.space, actor) if is_lead else [] + ) + if not directs and not subs: + return 0 + if self.is_running(session_id) or session_id in self._team_inflight: + return 0 # it will drain on its next turn end / next tick + if not self.teams.count_wake(team.team_id, cap=self.TEAM_WAKE_CAP_PER_HOUR): + logger.warning("team %s paused for budget this hour", team.team_id) + return 0 + message = self._team_digest(team, directs, subs, is_lead=is_lead) + self._team_inflight.add(session_id) + + async def _deliver() -> None: + try: + await self.deliver_to_session(session_id, message) + # Consume only after the turn dispatched: a crash before this replays + # the batch next tick (at-least-once, never silently lost). + if directs: + self.team_store.consume(actor, directs[-1]["seq"]) + if subs: + self.team_store.consume_subscription( + team.space, actor, subs[-1]["seq"] + ) + finally: + self._team_inflight.discard(session_id) + + asyncio.create_task(_deliver()) + return 1 + + def _team_digest( + self, team, directs: list[dict], subs: list[dict], *, is_lead: bool + ) -> str: + """Coalesce one queue batch into one wake message. Deterministic, computed + by code — the model does judgment, not arithmetic.""" + lines: list[str] = [] + for event in directs + subs: + item_id = event.get("item_id") + payload = event.get("payload") or {} + item = None + if item_id is not None: + try: + item = self.team_store.get_item(team.space, int(item_id)) + except Exception: + item = None + title = f"#{item_id} {item['title']}" if item else f"#{item_id}" + if event["kind"] == "item_assigned": + if item is None: + continue + lines.append( + f"You've been assigned work item {title}.\n" + f" Done when: {item['criteria']}" + + (f"\n Details: {item['description']}" if item["description"] else "") + ) + elif event["kind"] == "item_transitioned": + to = payload.get("to", "?") + note = f" — “{payload.get('comment')}”" if payload.get("comment") else "" + lines.append(f"{title} moved to {to} by {event['actor']}{note}") + elif event["kind"] == "item_created": + lines.append(f"New item filed by {event['actor']}: {title}") + elif event["kind"] == "item_commented": + lines.append( + f"Comment on {title} by {event['actor']}: {payload.get('body', '')}" + ) + body = "\n".join(f"- {line}" for line in lines) or "- (no detail)" + if is_lead: + return ( + "⏰ Board wake — your team needs decisions:\n" + + body + + "\n\nVerify review items against their acceptance criteria (then" + " done, or send back with a comment), unblock or reassign blocked" + " items, and triage new filings. Steer only where needed." + ) + return ( + "[Lead] Board update:\n" + + body + + "\n\nMove your item to in_progress when you start; blocked (with a" + " comment) if stuck; review with a hand-off comment when finished." + " Journal evidence as you go." + ) + + def team_staleness_digest(self, session_id: str) -> str: + """Attached to a lead's TIMER wakes: pure code over the board — a + nothing's-wrong wake is one cheap glance, never a re-survey. Scoped by + role membership: sessions with no team role get nothing.""" + team = self.teams.for_lead_session(session_id) + if team is None: + return "" + try: + items = self.team_store.list_items(team.space, self._user_actor()) + except Exception: + return "" + by_state: dict[str, int] = {} + for item in items: + by_state[item["state"]] = by_state.get(item["state"], 0) + 1 + unassigned = sum( + 1 for i in items if i["state"] == "open" and not i["assignee"] + ) + parts = [f"{n} {state}" for state, n in sorted(by_state.items())] + lines = [f"Board: {', '.join(parts) or 'empty'}."] + if unassigned: + lines.append(f"{unassigned} open item(s) have no assignee.") + reviews = [i for i in items if i["state"] == "review"] + if reviews: + lines.append( + "Awaiting your review: " + + ", ".join(f"#{i['id']} {i['title']}" for i in reviews[:5]) + ) + blocked = [i for i in items if i["state"] == "blocked"] + if blocked: + lines.append( + "Blocked: " + ", ".join(f"#{i['id']} {i['title']}" for i in blocked[:5]) + ) + return "\n".join(lines) def list_artifacts(self, session_id: str) -> list[dict[str, Any]]: record = self.session_store.load(session_id) @@ -2615,6 +2910,8 @@ class SessionManager: """Build the messaging gateway and start enabled listeners. Inbound messages route to durable sessions: a channel message to its subscribers, a DM to the designated DM session (else parked). Returns the platforms whose listeners came up.""" + # Team steering/kicks are dispatched from tool threads; they need the app loop. + self._loop = asyncio.get_running_loop() self.scheduler.start() # tick scheduler for automations (independent of connectors) return await self._build_and_start_gateway() @@ -3069,6 +3366,16 @@ class SessionManager: return resolve_from_reply(text, _resolve) is not None # -- self-wake resumption --------------------------------------------------- + async def _scheduler_tick(self) -> None: + """The shared per-tick work: resume due self-wakes, then drain team queues. + Team deliveries dispatch as tasks (a long worker turn must not stall the + scheduler).""" + await self.resume_due_wakes() + try: + await self.team_tick() + except Exception: + logger.exception("team tick failed") + async def resume_due_wakes(self) -> int: """Resume sessions whose self-wakes are due (called each scheduler tick). A suspended agent (it called sleep_for / wake_on / wake_on_event and ended its turn) is re-invoked on @@ -3101,12 +3408,26 @@ class SessionManager: # finishes — the one shared post-turn moment, so auto-titling hooks in here and # can never add latency to the response itself. self._maybe_autotitle(session_id) + # Team sessions: a finished turn is the moment new board events exist (an + # assign, a review transition) — kick the queue drain now instead of waiting + # for the next scheduler tick. Cheap no-op for teamless sessions. + if self._loop is not None and ( + self.teams.for_lead_session(session_id) + or self.teams.for_worker_session(session_id) + ): + asyncio.run_coroutine_threadsafe(self.team_tick(), self._loop) def is_running(self, session_id: str) -> bool: return session_id in self._running_sessions async def _resume_wake(self, wake) -> None: - await self.deliver_to_session(wake.session_id, self._wake_message(wake)) + message = self._wake_message(wake) + # A lead's timer wake carries the staleness digest — pure code over the + # board, scoped by role membership (teamless sessions get a bare wake). + digest = self.team_staleness_digest(wake.session_id) + if digest: + message = f"{message}\n\n{digest}" + await self.deliver_to_session(wake.session_id, message) async def deliver_to_session( self, session_id: str, message: str, *, source: Optional[dict[str, Any]] = None @@ -4004,11 +4325,47 @@ class SessionManager: "subscriptions": [ s.channel for s in self.subscriptions.for_session(r.session_id) ], + # Agent teams: {} for plain sessions. Workers carry role/lead_session + # (+ a computed current-item line); leads carry role/team_id — drives + # the sidebar's ONE expandable team entry. + "team": self._session_team_row(r), } for r in self.session_store.list(workspace=ws) if not r.session_id.startswith("__") # hide internal threads ] + def _session_team_row(self, record: SessionRecord) -> dict[str, Any]: + info = record.team or {} + if not info: + return {} + row = { + "role": info.get("role", ""), + "team_id": info.get("team_id", ""), + "lead_session": info.get("lead_session", ""), + } + if info.get("role") == "worker" and info.get("space") and info.get("actor"): + try: + items = self.team_store.list_items( + str(info["space"]), self._user_actor(), assignee=str(info["actor"]) + ) + except Exception: + items = [] + active = next( + ( + i + for state in ("blocked", "review", "in_progress", "open") + for i in items + if i["state"] == state + ), + None, + ) + row["actor"] = info["actor"] + row["current_item"] = ( + f"#{active['id']} {active['state'].replace('_', ' ')}" if active else "idle" + ) + row["status"] = active["state"] if active else "idle" + return row + def _session_liveness(self, session_id: str) -> str: if self.is_running(session_id): return "working" diff --git a/coworker/sessions.py b/coworker/sessions.py index 4bd857c7..33f176d5 100644 --- a/coworker/sessions.py +++ b/coworker/sessions.py @@ -37,3 +37,7 @@ class SessionRecord: # Auto-compaction state (OPE-27): CompactionState.as_dict(), {} when never compacted. # Persisted so a reloaded session keeps its compacted outbound view. compaction: dict[str, Any] = field(default_factory=dict) + # Agent teams: {} for plain sessions. Workers: {team_id, role: "worker", actor, + # lead_session, space}. Leads gain their entry when the staffing gate creates the + # team. Drives tool binding (board actor identity) + the sidebar's expandable entry. + team: dict[str, Any] = field(default_factory=dict) diff --git a/coworker/teams/registry.py b/coworker/teams/registry.py new file mode 100644 index 00000000..53d7a855 --- /dev/null +++ b/coworker/teams/registry.py @@ -0,0 +1,136 @@ +"""Team registry — which sessions form a team: one lead, its workers, their board. + +A team is created at the staffing gate ("Create team & start"): worker sessions are +PRE-SPAWNED as durable state on disk (spawn ≠ first turn — an unassigned worker costs +zero tokens; its first model turn fires when the first assignment lands). The registry +is the roster the wake plumbing walks each tick, and the tie that scopes staleness +digests by role membership. +""" + +from __future__ import annotations + +import json +import threading +import uuid +from dataclasses import asdict, dataclass, field +from datetime import datetime, timezone +from pathlib import Path +from typing import Optional + + +@dataclass +class TeamWorker: + actor: str # board actor id (stable; assignments address this) + persona: str + session_id: str + model: str = "" + + +@dataclass +class Team: + team_id: str + space: str + lead_session: str + lead_actor: str + workers: list[TeamWorker] = field(default_factory=list) + chat_enabled: bool = False + paused: bool = False # budget/user pause: the wake gate skips a paused team + created_at: str = field( + default_factory=lambda: datetime.now(timezone.utc).isoformat() + ) + # Rolling budget gate: automatic wakes this hour (reset when the hour rolls). + wake_hour: str = "" + wakes_this_hour: int = 0 + + +class TeamRegistry: + def __init__(self, path: Optional[str | Path] = None) -> None: + self.path = Path(path) if path else None + self._lock = threading.Lock() + self._teams: dict[str, Team] = {} + if self.path and self.path.is_file(): + for raw in json.loads(self.path.read_text(encoding="utf-8")).get( + "teams", [] + ): + workers = [TeamWorker(**w) for w in raw.pop("workers", [])] + team = Team(**{**raw, "workers": []}) + team.workers = workers + self._teams[team.team_id] = team + + def _save(self) -> None: + if not self.path: + return + self.path.parent.mkdir(parents=True, exist_ok=True) + self.path.write_text( + json.dumps( + {"teams": [asdict(t) for t in self._teams.values()]}, indent=2 + ), + encoding="utf-8", + ) + + def create( + self, + *, + space: str, + lead_session: str, + lead_actor: str, + workers: list[TeamWorker], + chat_enabled: bool = False, + ) -> Team: + team = Team( + team_id=uuid.uuid4().hex[:12], + space=space, + lead_session=lead_session, + lead_actor=lead_actor, + workers=workers, + chat_enabled=chat_enabled, + ) + with self._lock: + self._teams[team.team_id] = team + self._save() + return team + + def all(self) -> list[Team]: + return list(self._teams.values()) + + def get(self, team_id: str) -> Optional[Team]: + return self._teams.get(team_id) + + def for_lead_session(self, session_id: str) -> Optional[Team]: + for team in self._teams.values(): + if team.lead_session == session_id: + return team + return None + + def for_worker_session(self, session_id: str) -> Optional[tuple[Team, TeamWorker]]: + for team in self._teams.values(): + for worker in team.workers: + if worker.session_id == session_id: + return team, worker + return None + + def set_paused(self, team_id: str, paused: bool) -> None: + with self._lock: + team = self._teams.get(team_id) + if team is not None: + team.paused = paused + self._save() + + def count_wake(self, team_id: str, *, cap: int) -> bool: + """The budget gate at the wake gate: count one automatic wake against the + team's rolling hour; False = over cap (the caller skips the wake and the + team reads as paused-for-budget until the hour rolls). A runaway loop + stops BETWEEN turns, never mid-flight.""" + hour = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H") + with self._lock: + team = self._teams.get(team_id) + if team is None: + return False + if team.wake_hour != hour: + team.wake_hour, team.wakes_this_hour = hour, 0 + if team.wakes_this_hour >= cap: + self._save() + return False + team.wakes_this_hour += 1 + self._save() + return True diff --git a/coworker/teams/store.py b/coworker/teams/store.py index 9e413d78..4599c02e 100644 --- a/coworker/teams/store.py +++ b/coworker/teams/store.py @@ -133,6 +133,10 @@ class TeamStore: head_hash TEXT NOT NULL, watermark INTEGER NOT NULL ); + CREATE TABLE IF NOT EXISTS team_cursors ( + cursor_key TEXT PRIMARY KEY, + consumed_seq INTEGER NOT NULL + ); """) self._conn.commit() @@ -276,6 +280,70 @@ class TeamStore: ).fetchall() return [_row_to_event(row) for row in rows] + # -------------------------------------------------- delivery (durable queue) + + # The per-agent durable queue is a PROJECTION over the one log, never a second + # write path: entries are events addressed to a recipient, "consumed" is a + # cursor. Durable-until-consumed (a crash before consume() replays on the next + # drain); coalescing happens at dequeue — the caller turns one batch into one + # digest. "Mailbox" is banned as a concept; this is internal plumbing. + + def pending_for(self, recipient: str, *, limit: int = 200) -> list[dict[str, Any]]: + """Unconsumed events addressed to this agent, in order.""" + return self.for_recipient( + recipient, since_seq=self._cursor(f"to:{recipient}"), limit=limit + ) + + def consume(self, recipient: str, upto_seq: int) -> None: + self._set_cursor(f"to:{recipient}", upto_seq) + + # Lead subscriptions: an ALLOWLIST of decision-demanding event classes — a + # worker moving its item to review/blocked, or filing a new item. Journal + # appends and routine comments never wake anyone. + SUBSCRIBED_TRANSITIONS = ("review", "blocked") + + def subscribed_events( + self, space: str, subscriber: str, *, limit: int = 200 + ) -> list[dict[str, Any]]: + """Unconsumed subscription-worthy events on a space for one subscriber.""" + key = f"sub:{subscriber}:{space}" + events = self.events( + space, + kinds=[ITEM_TRANSITIONED, ITEM_CREATED], + since_seq=self._cursor(key), + limit=limit, + ) + out = [] + for event in events: + if event["actor"] == subscriber: + continue # your own verbs never wake you + if ( + event["kind"] == ITEM_TRANSITIONED + and event["payload"].get("to") not in self.SUBSCRIBED_TRANSITIONS + ): + continue + out.append(event) + return out + + def consume_subscription(self, space: str, subscriber: str, upto_seq: int) -> None: + self._set_cursor(f"sub:{subscriber}:{space}", upto_seq) + + def _cursor(self, key: str) -> int: + row = self._conn.execute( + "SELECT consumed_seq FROM team_cursors WHERE cursor_key = ?", (key,) + ).fetchone() + return int(row["consumed_seq"]) if row else 0 + + def _set_cursor(self, key: str, seq: int) -> None: + with self._lock: + self._conn.execute( + "INSERT INTO team_cursors (cursor_key, consumed_seq) VALUES (?, ?)" + " ON CONFLICT(cursor_key) DO UPDATE SET consumed_seq =" + " MAX(consumed_seq, ?)", + (key, int(seq), int(seq)), + ) + self._conn.commit() + def spaces(self) -> list[str]: with self._lock: rows = self._conn.execute( diff --git a/coworker/teams/tools.py b/coworker/teams/tools.py index fe22a209..52893c7a 100644 --- a/coworker/teams/tools.py +++ b/coworker/teams/tools.py @@ -218,6 +218,68 @@ def journal_tools( return [_wrap(local[name]) for name in JOURNAL_VERBS] +# The staffing gate's schema carrier. Like propose_plan, the real handling lives in +# the TurnEngine (it needs the out-of-band approval round-trip): it emits +# TEAM_PROPOSED and waits; approval PRE-SPAWNS the worker sessions and returns the +# roster (actor ids) to the lead. This body only runs when no approver is wired. +_PROPOSE_TEAM_SCHEMA = { + "type": "function", + "function": { + "name": "propose_team", + "description": ( + "Propose the worker coworkers you need for this board. The user sees the" + " roster and approves it; approval creates the worker sessions and" + " returns their actor ids so you can assign work items to them. Only" + " team-capable worker coworkers may be proposed." + ), + "parameters": { + "type": "object", + "properties": { + "members": { + "type": "array", + "items": { + "type": "object", + "properties": { + "persona": {"type": "string"}, + "model": {"type": "string"}, + "reason": {"type": "string"}, + }, + "required": ["persona"], + }, + }, + "enable_chat": {"type": "boolean"}, + "note": {"type": "string"}, + }, + "required": ["members"], + }, + }, +} + + +def propose_team_tool() -> object: + def propose_team( + members: Optional[list] = None, enable_chat: bool = False, note: str = "" + ) -> dict: + """Propose the worker roster for this board (the staffing gate). Each member + is {persona, model?, reason?}. The user approves; approval creates the + worker sessions and returns their actor ids for assignment.""" + return { + "approved": False, + "error": "team staffing isn't available in this surface", + } + + wrapped = ai.tool( + propose_team, + metadata=ai.ToolMetadata( + category="team", + risk_level="medium", + capabilities=["team"], + ), + ) + wrapped.__coworker_schema__ = _PROPOSE_TEAM_SCHEMA + return wrapped + + def _call(func, *args, **kwargs) -> dict: try: result = func(*args, **kwargs) diff --git a/tests/test_team_wake.py b/tests/test_team_wake.py new file mode 100644 index 00000000..64340f8d --- /dev/null +++ b/tests/test_team_wake.py @@ -0,0 +1,225 @@ +"""OPE-97 wake plumbing: the team trait, delivery cursors, lead subscriptions, +the registry + budget gate, pre-spawn at staffing, and digests.""" + +import pytest + +from coworker.personas.loading import capability_set +from coworker.personas.manifest import ManifestError, parse_manifest +from coworker.server.manager import SessionManager +from coworker.teams import Actor, Role, TeamStore +from coworker.teams.registry import TeamRegistry, TeamWorker + +USER = Actor(id="user", role=Role.USER) +LEAD = Actor(id="lead-1", role=Role.LEAD) +WORKER = Actor(id="swe-worker", role=Role.WORKER) +SPACE = "proj" + + +def manifest(team_line=""): + return f"""--- +id: t +name: T +family: code +tools: [search] +{team_line} +--- +Prompt body. +""" + + +# ------------------------------------------------------------------- team trait + +def test_team_trait_parses_and_gates_capabilities(): + lead = parse_manifest(manifest("team: lead")) + worker = parse_manifest(manifest("team: worker")) + solo = parse_manifest(manifest()) + assert lead.team == "lead" and worker.team == "worker" and solo.team is None + assert "team:lead" in capability_set(lead) + assert "team:worker" in capability_set(worker) + assert not any(c.startswith("team:") for c in capability_set(solo)) + # the trait reaches the runtime Agent (it gates tool registration) + assert lead.to_agent().team == "lead" + + +def test_invalid_team_trait_fails_loudly(): + with pytest.raises(ManifestError, match="team"): + parse_manifest(manifest("team: manager")) + + +# ------------------------------------------------------- delivery cursors/queue + +@pytest.fixture +def store(tmp_path): + store = TeamStore(tmp_path / "teams.db") + yield store + store.close() + + +def assigned(store, assignee="swe-worker"): + item = store.create_item(SPACE, LEAD, title="Task", criteria="tests pass") + store.assign(SPACE, LEAD, item["id"], assignee) + return item["id"] + + +def test_deliveries_are_durable_until_consumed(store): + assigned(store) + first = store.pending_for("swe-worker") + assert len(first) == 1 and first[0]["kind"] == "item_assigned" + # not consumed → still pending (crash-safe replay) + assert store.pending_for("swe-worker") == first + store.consume("swe-worker", first[-1]["seq"]) + assert store.pending_for("swe-worker") == [] + # a second assignment queues fresh + assigned(store) + assert len(store.pending_for("swe-worker")) == 1 + + +def test_lead_subscriptions_are_an_allowlist(store): + item_id = assigned(store) + store.transition(SPACE, WORKER, item_id, "in_progress") # not subscribed + store.comment(SPACE, WORKER, item_id, "halfway") # never wakes + store.transition(SPACE, WORKER, item_id, "review", comment="done, please check") + filed = store.create_item(SPACE, WORKER, title="Found a bug", criteria="fix") + subs = store.subscribed_events(SPACE, "lead-1") + # Exactly the worker's review transition + the worker's filing: the lead's own + # verbs, the in_progress transition, and the comment never wake it. + assert all(e["actor"] != "lead-1" for e in subs) + assert {(e["kind"], e["payload"].get("to")) for e in subs} == { + ("item_transitioned", "review"), + ("item_created", None), + } + store.consume_subscription(SPACE, "lead-1", subs[-1]["seq"]) + assert store.subscribed_events(SPACE, "lead-1") == [] + _ = filed + + +# --------------------------------------------------------------- registry/budget + +def test_registry_roundtrip_and_budget_cap(tmp_path): + path = tmp_path / "teams.json" + reg = TeamRegistry(path) + team = reg.create( + space=SPACE, + lead_session="lead-sid", + lead_actor="lead-1", + workers=[TeamWorker(actor="swe-worker", persona="swe-worker", session_id="w1")], + ) + again = TeamRegistry(path) + loaded = again.get(team.team_id) + assert loaded is not None and loaded.workers[0].session_id == "w1" + assert again.for_lead_session("lead-sid").team_id == team.team_id + assert again.for_worker_session("w1")[1].actor == "swe-worker" + # budget gate: cap wakes, then refuse until the hour rolls + assert all(again.count_wake(team.team_id, cap=3) for _ in range(3)) + assert again.count_wake(team.team_id, cap=3) is False + + +# -------------------------------------------------------- manager: spawn/digest + +@pytest.fixture +def manager(tmp_path, monkeypatch): + monkeypatch.setenv("COWORKER_STATE_DIR", str(tmp_path / "state")) + ws = tmp_path / "repo" + ws.mkdir() + m = SessionManager(data_dir=tmp_path / "data", workspace=str(ws)) + yield m + + +def test_create_team_fails_closed_on_solo_personas(manager, tmp_path): + from coworker.sessions import SessionRecord + + manager.session_store.save( + SessionRecord( + session_id="lead-sid", + workspace=manager.default_workspace, + model="m", + mode="interactive", + messages=[], + agent="cowork", + ) + ) + result = manager.create_team( + "lead-sid", [{"persona": "cowork"}] + ) # cowork is a solo builtin + assert result["approved"] is False + assert "team-capable" in result["error"] or "team: worker" in result["error"] + assert manager.teams.all() == [] # nothing half-created + + +def test_create_team_prespawns_worker_sessions(manager, monkeypatch): + from coworker.agents.base import Agent + from coworker.sessions import SessionRecord + + worker_agent = Agent( + name="swe-worker", title="SWE", system_prompt="p", team="worker" + ) + monkeypatch.setattr( + "coworker.server.manager.get_agent", lambda name: worker_agent + ) + manager.session_store.save( + SessionRecord( + session_id="lead-sid", + workspace=manager.default_workspace, + model="m", + mode="interactive", + messages=[], + agent="swe-lead", + ) + ) + result = manager.create_team( + "lead-sid", + [{"persona": "swe-worker"}, {"persona": "swe-worker", "model": "other"}], + ) + assert result["approved"] is True + actors = [w["actor"] for w in result["workers"]] + assert actors == ["swe-worker", "swe-worker-2"] # unique actor ids + # pre-spawn = state on disk, zero turns + for w in result["workers"]: + record = manager.session_store.load(w["session_id"]) + assert record is not None + assert record.messages == [] + assert record.team["role"] == "worker" + assert record.team["lead_session"] == "lead-sid" + # the lead session is marked and the registry ties the roster + lead = manager.session_store.load("lead-sid") + assert lead.team["role"] == "lead" + assert manager.teams.for_lead_session("lead-sid") is not None + # second team on the same session refuses + assert manager.create_team("lead-sid", [{"persona": "swe-worker"}])[ + "approved" + ] is False + + +def test_staleness_digest_is_role_scoped(manager, monkeypatch): + from coworker.agents.base import Agent + from coworker.sessions import SessionRecord + from coworker.teams.model import space_for_workspace + + # no team role → no digest (bare wake) + assert manager.team_staleness_digest("nobody") == "" + + worker_agent = Agent(name="swe-worker", title="SWE", system_prompt="p", team="worker") + monkeypatch.setattr("coworker.server.manager.get_agent", lambda name: worker_agent) + manager.session_store.save( + SessionRecord( + session_id="lead-sid", + workspace=manager.default_workspace, + model="m", + mode="interactive", + messages=[], + agent="swe-lead", + ) + ) + manager.create_team("lead-sid", [{"persona": "swe-worker"}]) + space = space_for_workspace(manager.default_workspace) + lead_actor = manager.teams.for_lead_session("lead-sid").lead_actor + item = manager.team_store.create_item( + space, + Actor(id=lead_actor, role=Role.LEAD), + title="Ship it", + criteria="tests green", + ) + digest = manager.team_staleness_digest("lead-sid") + assert "1 open" in digest + assert "no assignee" in digest + _ = item From 13f9c0b6c05fe222ffedd55fd988e957faedf924 Mon Sep 17 00:00:00 2001 From: Rohit C Prasad Date: Sun, 16 Aug 2026 08:53:26 -0700 Subject: [PATCH 34/80] SW team: staffing gate UI, expandable team entry, four team personas (OPE-97/98) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit swe-lead (minimal tools, coordination verbs) + swe/design/test workers with the shared worker contract; workers never surface in the picker — they're staffed, not started. Staffing card rides the approval slot; workers nest under the lead's ONE expandable RECENT entry in both sidebar layouts. --- .../builtin/design-worker/manifest.md | 32 +++++ .../personas/builtin/swe-lead/manifest.md | 48 ++++++++ .../personas/builtin/swe-worker/manifest.md | 39 ++++++ .../personas/builtin/test-worker/manifest.md | 41 +++++++ coworker/personas/registry.py | 4 + coworker/server/manager.py | 40 +++++++ surfaces/gui/e2e/fixtures.ts | 63 ++++++++++ surfaces/gui/e2e/team.spec.ts | 57 +++++++++ surfaces/gui/src/App.tsx | 34 ++++++ surfaces/gui/src/api.ts | 8 ++ surfaces/gui/src/components/Sidebar.tsx | 113 ++++++++++++++++-- .../gui/src/components/TeamRequestCard.tsx | 52 ++++++++ surfaces/gui/src/styles.css | 20 ++++ surfaces/gui/src/types.ts | 20 ++++ tests/test_persona_registry.py | 6 +- tests/test_server.py | 6 +- tests/test_team_wake.py | 8 ++ 17 files changed, 582 insertions(+), 9 deletions(-) create mode 100644 coworker/personas/builtin/design-worker/manifest.md create mode 100644 coworker/personas/builtin/swe-lead/manifest.md create mode 100644 coworker/personas/builtin/swe-worker/manifest.md create mode 100644 coworker/personas/builtin/test-worker/manifest.md create mode 100644 surfaces/gui/e2e/team.spec.ts create mode 100644 surfaces/gui/src/components/TeamRequestCard.tsx diff --git a/coworker/personas/builtin/design-worker/manifest.md b/coworker/personas/builtin/design-worker/manifest.md new file mode 100644 index 00000000..df084fab --- /dev/null +++ b/coworker/personas/builtin/design-worker/manifest.md @@ -0,0 +1,32 @@ +--- +id: design-worker +name: Design Worker +icon: layout +tagline: UI/UX implementation under a team lead +family: code +version: "1" +team: worker +tools: [code_files, git, search, shell, todo] +recommended_models: [anthropic:claude-opus-4-8] +default_permission_mode: interactive +description: A UI/UX-focused coworker that works team-style under a lead — layout, styling, interaction polish, and design-system consistency, handed off through review. +--- +You are a UI/UX engineer working ON A TEAM under a lead coworker. Your interlocutor is +the LEAD, not the end user — no ask_user; questions become item comments. + +The team contract (this is how you work): +- Your task arrives as a WORK ITEM: description = assignment, acceptance criteria = + definition of done. Ambiguous criteria → say so in a comment immediately. +- Move your item to in_progress when you start; blocked WITH a comment if stuck — + never stall silently. +- Journal design decisions and their rationale (journal_append, kind=decision): what + you chose, what you rejected, why. Reference files and components. +- File follow-ups you notice (create_item) rather than widening your diff. +- Finish = transition to review with a hand-off comment describing what changed + visually and where to look. Never mark your own work done. +- Steering arrives attributed [Lead]/[User]; [User] outranks. + +Design standards: work WITH the app's existing design system — its tokens, spacing, +typography and component idioms; never introduce a parallel style. State assumptions +(theme, viewport, empty states) in the hand-off. Keep interaction states (hover, +focus, disabled, loading) and both color themes covered; note anything deferred. diff --git a/coworker/personas/builtin/swe-lead/manifest.md b/coworker/personas/builtin/swe-lead/manifest.md new file mode 100644 index 00000000..ebee5b28 --- /dev/null +++ b/coworker/personas/builtin/swe-lead/manifest.md @@ -0,0 +1,48 @@ +--- +id: swe-lead +name: SWE Lead +icon: users +tagline: Leads a software team — plans, staffs, assigns, verifies +family: code +version: "1" +team: lead +tools: [code_files, search, todo] +recommended_models: [anthropic:claude-opus-4-8] +default_permission_mode: interactive +description: A tech-lead coworker that decomposes work onto a board, staffs a team of worker coworkers, assigns items, and verifies results at review. It coordinates — it does not build. +--- +You are the SWE Lead — a tech lead who runs a team of worker coworkers against a work +board. Your job is coordination and judgment: decompose, staff, assign, verify. You do +NOT implement — you carry no shell or git on purpose. The board is the shared ground +truth; your context window is disposable, the board is not. + +How you run a piece of work: +1. UNDERSTAND: read enough of the repo (files, search) to decompose honestly. +2. PLAN: split the work into items with crisp acceptance criteria — "Done when:" that a + verifier can actually check. Acceptance criteria are the single biggest quality lever + you own; vague criteria produce vague work. Present the decomposition to the user with + propose_plan and revise until approved. Only then create the items (create_item). +3. STAFF: propose the workers you need with propose_team ({persona, model, reason} per + member). Approval creates their sessions and returns actor ids. Only team-capable + worker coworkers can be staffed. +4. ASSIGN: assign items to actor ids. The item IS the worker's assignment — its + description and criteria must stand alone. Respect dependencies (link blocks/parent); + don't assign what's blocked. +5. VERIFY at review: when an item reaches review, check the result against its + acceptance criteria. Implementation items should be verified by the test worker when + one is on the team — a builder never grades its own work: create a linked + verification item, assign it to the tester, and judge on the tester's verdict. + Then mark done, or send back to in_progress with a precise comment. +6. TRIAGE: workers file items they discover (bugs, follow-ups). Assign what matters, + remove (cancel) what doesn't, tell the filer why via a comment. + +Communication doctrine: +- Instructions flow down, evidence flows up. Steer a worker (steer_worker) only for + exceptions: changed requirements, stop/redirect, unblock guidance. Routine status is + already on the board — never ask a worker "how's it going". +- The user outranks you everywhere; steering attributed [User] wins over yours. +- Journal decisions as you make them (journal_append, kind=decision) — the next lead + reads the journal, not your transcript. +- Use sleep_for to set your own check-in cadence when the team is quiet; your timer + wakes arrive with a board digest so a nothing's-wrong wake costs one glance. +- Report to the user plainly: what moved, what's blocked, what needs their decision. diff --git a/coworker/personas/builtin/swe-worker/manifest.md b/coworker/personas/builtin/swe-worker/manifest.md new file mode 100644 index 00000000..627b848f --- /dev/null +++ b/coworker/personas/builtin/swe-worker/manifest.md @@ -0,0 +1,39 @@ +--- +id: swe-worker +name: SWE Worker +icon: code +tagline: Implements work items under a team lead +family: code +version: "1" +team: worker +tools: [code_files, git, search, shell, todo] +recommended_models: [anthropic:claude-opus-4-8, openai:gpt-5.6-sol] +default_permission_mode: interactive +description: A software engineer coworker that works team-style — it takes assigned work items from a lead coworker, implements them against their acceptance criteria, and hands off through review. +--- +You are a software engineer working ON A TEAM under a lead coworker. Your interlocutor +is the LEAD, not the end user — you never use ask_user; questions become item comments, +and you keep working on what isn't blocked by the answer. + +The team contract (this is how you work): +- Your task arrives as a WORK ITEM: its description is the assignment, its acceptance + criteria are the definition of done. If criteria are ambiguous, say so in a comment + immediately — don't guess silently. +- Move your item to in_progress when you start. +- Blocked? Transition to blocked WITH a comment saying exactly what you need. Never + stall silently; never idle-wait. If other assigned items are workable, work them. +- Journal as you go (journal_append): findings, evidence, decisions — with file:line + refs and entities. Your transcript is disposable; the journal is what survives to + your successor if the item is reassigned. +- Discover a bug or follow-up outside your item's scope? File it (create_item) with + real acceptance criteria and keep moving. The lead triages it. +- Finish = transition to review with a hand-off comment: what you did, how you + verified it, refs (branch, files). You NEVER mark your own work done — done is the + verdict after verification. +- Steering arrives attributed [Lead] or [User]; [User] outranks [Lead]. +- House rules hold: no silent skips — if you couldn't do part of the work, the + hand-off comment says which part and why. + +Engineering standards: match the codebase's own patterns; keep diffs focused on the +item; add or update tests for what you changed; run the relevant test suite before +handing off and report the real result. diff --git a/coworker/personas/builtin/test-worker/manifest.md b/coworker/personas/builtin/test-worker/manifest.md new file mode 100644 index 00000000..b69e7c85 --- /dev/null +++ b/coworker/personas/builtin/test-worker/manifest.md @@ -0,0 +1,41 @@ +--- +id: test-worker +name: Test Worker +icon: check +tagline: Verifies teammates' work against acceptance criteria +family: code +version: "1" +team: worker +tools: [code_files, git, search, shell, todo] +recommended_models: [anthropic:claude-opus-4-8] +default_permission_mode: interactive +description: A verification coworker for teams — it independently tests what a builder coworker handed to review, against the item's acceptance criteria, and delivers a pass/fail verdict with evidence. The builder never grades its own work. +--- +You are the team's verifier. A builder coworker finished an item; the lead assigned you +a linked verification item. Your job: independently establish whether the work MEETS +ITS ACCEPTANCE CRITERIA — assume it doesn't until the evidence says otherwise. Your +interlocutor is the LEAD, not the end user — no ask_user; questions become item comments. + +How you verify: +- Start from the item under verification: its criteria are your checklist, one by one. + Test the actual behavior — run the app, run the tests, exercise the change — never + judge by reading the diff alone. +- Verification is media-heavy on purpose: take screenshots, capture outputs, diff + renders. That cost lands in YOUR context so the builder's stays for building. Save + captures as files in the workspace and reference them by path — never describe pixels + from memory. +- Journal evidence as you go (journal_append, kind=evidence): what you ran, what you + saw, refs to captures and file:line. +- Your deliverable is a VERDICT, delivered as the hand-off comment when you move your + verification item to review: PASS or FAIL per criterion, each with an evidence + pointer. The lead reads conclusions, not pixels — keep the verdict tight and the + evidence linked. +- FAIL is a good outcome when it's true: a precise failing verdict (what broke, how to + reproduce, where the evidence is) is exactly what the team needs. Never soften a + fail; never pass on vibes. +- Found a bug outside the criteria? File it as a new item (create_item); don't stretch + your verdict's scope. +- Steering arrives attributed [Lead]/[User]; [User] outranks. + +The team contract also binds you: in_progress when you start, blocked with a comment +if you can't verify (missing creds, un-runnable app), never mark items done yourself. diff --git a/coworker/personas/registry.py b/coworker/personas/registry.py index 1c19dc8a..52432d1c 100644 --- a/coworker/personas/registry.py +++ b/coworker/personas/registry.py @@ -198,6 +198,10 @@ class PersonaRegistry: workspace=m.workspace, tools=list(m.tools), manifest=m, + # Team workers never surface in the picker: they are purpose-built to be + # STAFFED by a lead, not started solo (their prompts talk to a lead, not + # a human). They stay enabled so the staffing gate can resolve them. + default_surfaced=m.team != "worker", ) def _load_installed(self) -> None: diff --git a/coworker/server/manager.py b/coworker/server/manager.py index dc3611c6..16743968 100644 --- a/coworker/server/manager.py +++ b/coworker/server/manager.py @@ -1461,8 +1461,48 @@ class SessionManager: ) if role == "lead": tools.append(self._steer_tool(session_id)) + tools.append(self._team_options_tool()) return tools + def _team_options_tool(self) -> Any: + """Registry-injected staffing knowledge: the lead's options come from the + persona registry at call time — installing a worker coworker automatically + widens what a lead can propose; nothing is hardcoded. Solo personas never + appear (fail closed at the source AND at create_team).""" + import aisuite as ai + + manager = self + + def team_options() -> dict: + """List the worker coworkers available for staffing (call before + propose_team). Only team-capable workers are listed — solo coworkers + cannot join a team.""" + out = [] + for row in manager.personas.list_all(): + pid = row.get("id", "") + entry = manager.personas.get(pid) + m = getattr(entry, "manifest", None) + if m is None or m.team != "worker": + continue + if not manager.personas.is_enabled(pid): + continue + out.append( + { + "persona": pid, + "name": m.name, + "tagline": m.tagline, + "recommended_models": list(m.recommended_models), + } + ) + return {"workers": out} + + return ai.tool( + team_options, + metadata=ai.ToolMetadata( + category="team", risk_level="low", capabilities=["team"] + ), + ) + def _steer_tool(self, lead_session_id: str) -> Any: """The lead's downward steering verb. Text lands in the worker's session attributed [Lead] — queued into a live turn, or a fresh background turn when diff --git a/surfaces/gui/e2e/fixtures.ts b/surfaces/gui/e2e/fixtures.ts index 8f328869..2a18f789 100644 --- a/surfaces/gui/e2e/fixtures.ts +++ b/surfaces/gui/e2e/fixtures.ts @@ -629,6 +629,20 @@ export async function mockApi(page: import("@playwright/test").Page) { }); return; // suspended on the approval } + // Agent teams (OPE-97): the staffing gate — the lead proposes a roster and + // SUSPENDS until the team_response verdict arrives. + if (/staff the team/i.test(msg.text)) { + send("team_proposed", { + members: [ + { persona: "swe-worker", model: "anthropic:claude-opus-4-8", reason: "implementation" }, + { persona: "design-worker", reason: "UI polish" }, + { persona: "test-worker", reason: "verifies against acceptance criteria" }, + ], + enable_chat: false, + note: "Three workers cover the plan; test-worker verifies before anything closes.", + }); + return; // suspended on the staffing decision + } // Agent teams (OPE-96): a decomposition turn — the plan was approved in // conversation (plan-approval flow); the agent files the items and the // board rail appears on the next board fetch. @@ -827,6 +841,55 @@ export async function mockApi(page: import("@playwright/test").Page) { send("assistant_message", { text: `Done via ${pendingTool} [decision=${msg.decision}]` }); } send("turn_done"); + } else if (msg.type === "team_response") { + if (msg.approved) { + // Server-side create_team pre-spawned the workers; surface them in the + // sessions fixture so the sidebar's expandable entry has children. + const lead = sessions.find((s) => s.session_id === "sess-lead") || { + session_id: "sess-lead", + title: "Build the statements page", + workspace: "/Users/test/OpenWorker/launch-note", + // The fixture keeps the lead on the default persona so it renders inside + // the already-open accordion; the expandable entry is what's under test. + agent: "cowork", + model: "m", + mode: "interactive", + updated_at: new Date().toISOString(), + messages: 2, + team: { role: "lead", team_id: "t1" }, + }; + if (!sessions.includes(lead)) sessions.unshift(lead); + for (const [actor, status, item] of [ + ["swe-worker", "in_progress", "#1 in progress"], + ["design-worker", "idle", "idle"], + ["test-worker", "blocked", "#4 blocked"], + ] as const) { + sessions.push({ + session_id: `sess-${actor}`, + title: actor, + workspace: "/Users/test/OpenWorker/launch-note", + agent: actor, + model: "m", + mode: "interactive", + updated_at: new Date().toISOString(), + messages: 0, + team: { + role: "worker", + team_id: "t1", + lead_session: "sess-lead", + actor, + status, + current_item: item, + }, + }); + } + send("assistant_message", { + text: "Team created — swe-worker, design-worker and test-worker are standing by. Assigning items now.", + }); + } else { + send("assistant_message", { text: "Understood — tell me how to change the roster." }); + } + send("turn_done"); } else if (msg.type === "tool_response") { // Either way the turn continues — the point of the contract is that declining // degrades the report openly instead of dropping the check. diff --git a/surfaces/gui/e2e/team.spec.ts b/surfaces/gui/e2e/team.spec.ts new file mode 100644 index 00000000..48c6ce29 --- /dev/null +++ b/surfaces/gui/e2e/team.spec.ts @@ -0,0 +1,57 @@ +// Agent teams (OPE-97): the staffing gate + the sidebar's expandable team entry. +// The fake lead proposes a roster on "staff the team" and suspends; approval +// "pre-spawns" workers (the fixture mirrors create_team by adding worker sessions), +// which then nest under the lead's ONE expandable RECENT entry. +import { expect } from "@playwright/test"; +import { test } from "./fixtures"; + +async function proposeTeam(page: import("@playwright/test").Page) { + await page.goto("/"); + await page.getByPlaceholder(/Ask the coworker/).fill("staff the team"); + await page.getByRole("button", { name: "Send" }).click(); + await expect(page.getByTestId("teamreq-card")).toBeVisible(); +} + +test("the staffing gate shows the roster and the grant sentence", async ({ page }) => { + await proposeTeam(page); + const card = page.getByTestId("teamreq-card"); + await expect(card).toContainText("Proposed team — 3 workers"); + await expect(card).toContainText("swe-worker"); + await expect(card).toContainText("implementation"); + await expect(card).toContainText("test-worker"); + await expect(card).toContainText( + "Approving grants the lead create, assign & steer — this team only, revocable.", + ); +}); + +test("declining the roster returns the turn to the lead", async ({ page }) => { + await proposeTeam(page); + await page.getByRole("button", { name: "Not now" }).click(); + await expect(page.getByText(/tell me how to change the roster/)).toBeVisible(); + await expect(page.getByTestId("teamreq-card")).toHaveCount(0); +}); + +test("approval creates the team; workers nest under the lead's expandable entry", async ({ + page, +}) => { + await proposeTeam(page); + await page.getByTestId("teamreq-approve").click(); + await expect(page.getByText(/Team created/)).toBeVisible(); + + // The workers exist as sessions now — but never as top-level RECENT rows. + // (The sidebar refreshes on its 5s poll, so allow one full cycle.) + await expect(page.getByTestId("team-toggle-sess-lead")).toBeVisible({ timeout: 12_000 }); + await expect(page.getByText("Build the statements page")).toBeVisible(); + await expect(page.getByTestId("team-children-sess-lead")).toHaveCount(0); + + await page.getByTestId("team-toggle-sess-lead").click(); + const children = page.getByTestId("team-children-sess-lead"); + await expect(children).toBeVisible(); + await expect(children).toContainText("swe-worker · #1 in progress"); + await expect(children).toContainText("design-worker · idle"); + await expect(children).toContainText("test-worker · #4 blocked"); + + // Collapse hides them again — the team is one entry, not a panel. + await page.getByTestId("team-toggle-sess-lead").click(); + await expect(page.getByTestId("team-children-sess-lead")).toHaveCount(0); +}); diff --git a/surfaces/gui/src/App.tsx b/surfaces/gui/src/App.tsx index 5b185d36..70096be7 100644 --- a/surfaces/gui/src/App.tsx +++ b/surfaces/gui/src/App.tsx @@ -76,6 +76,7 @@ import { ToolRequestCard } from "./components/ToolRequestCard"; import { DirectoryRequestCard } from "./components/DirectoryRequestCard"; import { PlanCard } from "./components/PlanCard"; import { BoardOverlay } from "./components/BoardPanel"; +import { TeamRequestCard } from "./components/TeamRequestCard"; import { WorkspaceTrustPrompt } from "./components/WorkspaceTrustPrompt"; const newId = () => @@ -756,6 +757,19 @@ export function App() { if (unattendedRef.current) break; setItems((p) => [...p, { kind: "planreq", plan: d.plan || "" }]); break; + case "team_proposed": + // The staffing gate (agent teams) — approval pre-spawns the worker sessions. + if (unattendedRef.current) break; + setItems((p) => [ + ...p, + { + kind: "teamreq", + members: Array.isArray(d.members) ? d.members : [], + enable_chat: !!d.enable_chat, + note: d.note || "", + }, + ]); + break; case "question_requested": // ask_user in an attended session — answered inline (not routed to the Inbox). setItems((p) => [ @@ -1016,6 +1030,11 @@ export function App() { sessionRef.current?.respondPlan(approved, mode, feedback); if (approved && mode) setMode(mode); // the server flips the live engine to this mode }; + const respondTeam = (approved: boolean, feedback?: string) => { + setItems((p) => resolveLastTeam(p, approved ? "approved" : "rejected")); + dropSessionInbox("plan"); // the gate parks as a plan-kind Inbox item + sessionRef.current?.respondTeam(approved, feedback); + }; const respondDirectory = (granted: boolean, path?: string, writable?: boolean) => { setItems((p) => resolveLastDirReq(p, granted ? "granted" : "denied")); dropSessionInbox("directory"); @@ -1389,6 +1408,7 @@ export function App() { const pendingDirReq = [...items].reverse().find((i) => i.kind === "dirreq" && !i.resolved); const pendingToolReq = [...items].reverse().find((i) => i.kind === "toolreq" && !i.resolved); const pendingPlan = [...items].reverse().find((i) => i.kind === "planreq" && !i.resolved); + const pendingTeam = [...items].reverse().find((i) => i.kind === "teamreq" && !i.resolved); const pendingQuestion = [...items].reverse().find((i) => i.kind === "question" && !i.resolved); // Facts subtitle (§22): the session's FIXED facts, not controls — model (+ the // workspace folder for project-scoped sessions). Renders only once the session has history; @@ -1904,6 +1924,8 @@ export function App() { // parked in the Inbox and surfaced via the answer-in-context card below. !unattended && pendingPlan?.kind === "planreq" ? ( + ) : !unattended && pendingTeam?.kind === "teamreq" ? ( + ) : !unattended && pendingToolReq?.kind === "toolreq" ? ( ) : !unattended && pendingDirReq?.kind === "dirreq" ? ( @@ -2107,6 +2129,18 @@ function resolveLastPlan(items: Item[], resolved: "approved" | "rejected"): Item return copy; } +function resolveLastTeam(items: Item[], resolved: "approved" | "rejected"): Item[] { + const copy = [...items]; + for (let i = copy.length - 1; i >= 0; i--) { + const it = copy[i]; + if (it.kind === "teamreq" && !it.resolved) { + copy[i] = { ...it, resolved }; + break; + } + } + return copy; +} + function resolveLastQuestion(items: Item[], answer: string): Item[] { const copy = [...items]; for (let i = copy.length - 1; i >= 0; i--) { diff --git a/surfaces/gui/src/api.ts b/surfaces/gui/src/api.ts index 2c96fb4c..7f959a9a 100644 --- a/surfaces/gui/src/api.ts +++ b/surfaces/gui/src/api.ts @@ -2189,6 +2189,14 @@ export class Session { }); } + respondTeam(approved: boolean, feedback?: string) { + this.send({ + type: "team_response", + approved, + ...(feedback ? { feedback } : {}), + }); + } + // Answer a live `ask_user` prompt (attended sessions; unattended ones answer via the Inbox). respondQuestion(answer: string) { this.send({ type: "question_response", answer }); diff --git a/surfaces/gui/src/components/Sidebar.tsx b/surfaces/gui/src/components/Sidebar.tsx index dc327d26..27fff0a0 100644 --- a/surfaces/gui/src/components/Sidebar.tsx +++ b/surfaces/gui/src/components/Sidebar.tsx @@ -402,7 +402,12 @@ export function Sidebar(props: Props) { // Body data is keyed to the BROWSED persona (only one body renders at a time). Pinned sessions are // EXCLUDED here: they live in the cross-persona Pinned band only, so they don't repeat inside the // persona group / project list (matching the flat layout's Recent, which also drops pinned). - const all = props.sessions.filter((s) => s.agent === browseKey && !s.session_id.startsWith("__")); + const all = props.sessions.filter( + (s) => + s.agent === browseKey && + !s.session_id.startsWith("__") && + s.team?.role !== "worker", // workers nest under their lead, never top-level + ); const mine = all.filter((s) => !s.archived && !s.pinned); const archived = all.filter((s) => s.archived); // Only PROJECT-SCOPED personas group sessions by project (git-bound Code, project-bound Ops). @@ -418,12 +423,32 @@ export function Sidebar(props: Props) { // Recent = every non-pinned, non-archived, real session across ALL personas, newest first // (by updated_at; missing timestamps keep store order), search-filtered. Drives the flat layout. + // Team workers never appear top-level: they nest under their lead's ONE expandable entry. const recentSessions = [...props.sessions] .filter((s) => !s.archived && !s.session_id.startsWith("__") && !s.pinned) + .filter((s) => s.team?.role !== "worker") .filter((s) => personaVisible(s.agent)) .filter(matches) .sort((a, b) => (b.updated_at || "").localeCompare(a.updated_at || "")); + // Agent teams (UX-030): lead session id → its worker sessions. The team is ONE + // expandable entry in RECENT — plain sessions never expand. + const teamWorkers = new Map(); + for (const s of props.sessions) { + if (s.team?.role === "worker" && s.team.lead_session) { + const list = teamWorkers.get(s.team.lead_session) || []; + list.push(s); + teamWorkers.set(s.team.lead_session, list); + } + } + const [teamOpen, setTeamOpen] = useState>(new Set()); + const toggleTeam = (id: string) => + setTeamOpen((prev) => { + const next = new Set(prev); + next.has(id) ? next.delete(id) : next.add(id); + return next; + }); + // Row actions live behind ONE ⋮ kebab per row (FB-011: four hover icons read as clutter) — // the menu offers Rename · Pin/Unpin · Archive/Unarchive · Delete, with the two-step delete // confirm kept inside it. Shared by BOTH row styles, so the chronological cardRow offers the @@ -541,6 +566,19 @@ export function Sidebar(props: Props) { }} title={editing ? undefined : title} > + {!editing && !!teamWorkers.get(s.session_id)?.length && ( + + )} {editing ? ( {/* No leading glyph on session rows (Rohit's call 2026-07-07: the per-session icon - read as noise in both grouped and chronological). */} + read as noise in both grouped and chronological) — except a chevron on TEAM + leads, whose entry expands to the worker rows. */} + {!editing && teamWorkers.has(s.session_id) && ( + + )} {editing ? ( { + const workers = teamWorkers.get(s.session_id) || []; + return ( +
+ {workers.map((w) => ( +
props.onSelectSession(w.session_id, w.workspace, w.agent)} + title={w.team?.actor} + > + + + {w.team?.actor || w.agent} + · {w.team?.current_item || "idle"} + + +
+ ))} +
+ ); + }; + + // A row plus (when expanded) its team children — used by BOTH row styles so the + // expandable team entry works in the flat AND grouped layouts. + const withTeamChildren = (s: SessionInfo, row: ReturnType) => { + if (!teamWorkers.get(s.session_id)?.length) return row; + return ( +
+ {row} + {teamOpen.has(s.session_id) && teamChildren(s)} +
+ ); + }; + + const teamAwareRow = (s: SessionInfo) => withTeamChildren(s, cardRow(s)); + // The cross-persona Pinned band (manual pins only) — icon-free rows. Appears in BOTH layouts // (flat list AND accordion), so it's factored here for reuse. const pinnedBand = () => @@ -661,7 +755,7 @@ export function Sidebar(props: Props) { Pinned
- {pinnedSessions.map((s) => cardRow(s))} + {pinnedSessions.map((s) => teamAwareRow(s))}
) : null; @@ -823,7 +917,12 @@ export function Sidebar(props: Props) { // persona from New Session, never orphan its conversations). const agentsWithSessions = new Set( props.sessions - .filter((s) => !s.archived && !s.session_id.startsWith("__")) + .filter( + (s) => + !s.archived && + !s.session_id.startsWith("__") && + s.team?.role !== "worker", + ) .map((s) => s.agent), ); const visibleSurfaces = ( @@ -916,7 +1015,7 @@ export function Sidebar(props: Props) { // pl-[19px] aligns each session's name under the folder NAME (folder icon // 15 + gap 6 + row px 6 − session px 8 = 19), per Rohit's clean-column ask.
- {shown.map((s) => sessionRow(s, { showTime: true }))} + {shown.map((s) => withTeamChildren(s, sessionRow(s, { showTime: true })))} {!showAll && list.length > peek && ( + +
+
+ ); +} diff --git a/surfaces/gui/src/styles.css b/surfaces/gui/src/styles.css index b3df2477..8479978a 100644 --- a/surfaces/gui/src/styles.css +++ b/surfaces/gui/src/styles.css @@ -1714,3 +1714,23 @@ html[data-platform="linux"] ::-webkit-scrollbar-thumb:hover { background-color: .journal-count { font-size: 11px; color: var(--faint); } + +/* Staffing gate (agent teams) */ +.teamreq-card { border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-soft); } +.teamreq-head { display: flex; align-items: center; gap: 8px; margin-bottom: 4px; } +.teamreq-title { font-weight: 600; font-size: 13px; color: var(--ink); } +.teamreq-note { font-size: 12px; color: var(--muted); margin-bottom: 4px; } +.teamreq-row { display: flex; gap: 9px; padding: 7px 0; border-top: 1px solid var(--line); font-size: 12.5px; } +.teamreq-diamond { color: var(--faint); font-size: 11px; padding-top: 1px; } +.teamreq-body code { background: var(--paper); border-radius: 5px; padding: 1px 6px; font-size: 12px; } +.teamreq-model { color: var(--muted); } +.teamreq-reason { color: var(--faint); } +.teamreq-grant { font-size: 11.5px; color: var(--faint); } + +/* Sidebar: the expandable team entry (workers nest under their lead) */ +.team-child { margin-left: 18px; } +.team-child .team-item { font-size: 11px; color: var(--faint); margin-left: 4px; } +.team-dot { width: 7px; height: 7px; border-radius: 50%; flex-shrink: 0; background: var(--faint); } +.team-dot.in_progress { background: var(--ok-dot); } +.team-dot.blocked { background: var(--danger); } +.team-dot.review { background: var(--warn-ink); } diff --git a/surfaces/gui/src/types.ts b/surfaces/gui/src/types.ts index f2680538..b0b25a0c 100644 --- a/surfaces/gui/src/types.ts +++ b/surfaces/gui/src/types.ts @@ -11,6 +11,7 @@ export type EventType = | "tool_requested" | "question_requested" | "plan_proposed" + | "team_proposed" | "tool_started" | "tool_finished" | "iteration_end" @@ -83,6 +84,17 @@ export interface SessionInfo { // "From Slack" group and the row's platform icon. origin?: string; origin_label?: string; + // Agent teams: {} / absent for plain sessions. Workers carry role/lead_session + // (+ a computed current-item line); leads carry role/team_id. Drives the sidebar's + // ONE expandable team entry (workers nest under their lead; plain rows never expand). + team?: { + role?: "lead" | "worker" | string; + team_id?: string; + lead_session?: string; + actor?: string; + current_item?: string; + status?: string; + }; } // Attachments (images, PDFs, text files) sent with a user message. @@ -144,6 +156,14 @@ export type Item = plan: string; resolved?: "approved" | "rejected"; } + | { + // The staffing gate (agent teams): a lead proposes its worker roster. + kind: "teamreq"; + members: { persona: string; model?: string; reason?: string }[]; + enable_chat?: boolean; + note?: string; + resolved?: "approved" | "rejected"; + } | { // A live ask_user prompt (attended sessions answer inline; unattended ones route to the Inbox). kind: "question"; diff --git a/tests/test_persona_registry.py b/tests/test_persona_registry.py index abc39ecd..15ade0b0 100644 --- a/tests/test_persona_registry.py +++ b/tests/test_persona_registry.py @@ -27,7 +27,11 @@ def test_sidebar_defaults_to_surfaced_builtins(tmp_path): # Built-ins ship enabled (UX-029: the coworker picker is their front door); Chat # stays default-hidden via the surfaced axis. Installed personas remain opt-in. assert ids[0] == "cowork" - assert set(ids) == {"cowork", "code", "ops", "security", "cloud-posture", "dep-audit"} + # swe-lead surfaces (the user's entry to a team); team workers never do. + assert set(ids) == { + "cowork", "code", "ops", "security", "cloud-posture", "dep-audit", "swe-lead", + } + assert not any(i in ids for i in ("swe-worker", "design-worker", "test-worker")) assert sidebar[0]["default"] is True # An explicit disable removes a builtin from the picker. reg.set_enabled("code", False) diff --git a/tests/test_server.py b/tests/test_server.py index d1e18a27..868f32e6 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -65,7 +65,11 @@ def test_agents_and_memory_rest(tmp_path): # ship in the picker out of the box. names = [a["name"] for a in agents] assert names[0] == "cowork" - assert set(names) == {"cowork", "code", "ops", "security", "cloud-posture", "dep-audit"} + # swe-lead surfaces (it's how a user starts a team); team WORKERS never do — + # they're staffed by a lead, not started solo. + assert set(names) == { + "cowork", "code", "ops", "security", "cloud-posture", "dep-audit", "swe-lead", + } assert "skills" in client.get("/v1/skills").json() # catalog (may be empty) added = client.post("/v1/memory", json={"content": "prefer pathlib"}).json() diff --git a/tests/test_team_wake.py b/tests/test_team_wake.py index 64340f8d..909477c6 100644 --- a/tests/test_team_wake.py +++ b/tests/test_team_wake.py @@ -223,3 +223,11 @@ def test_staleness_digest_is_role_scoped(manager, monkeypatch): assert "1 open" in digest assert "no assignee" in digest _ = item + + +def test_team_options_lists_only_enabled_workers(manager): + tool = manager._team_options_tool() + workers = {w["persona"] for w in tool()["workers"]} + assert {"swe-worker", "design-worker", "test-worker"} <= workers + assert "swe-lead" not in workers # leads staff, they aren't staffed + assert "security" not in workers # solo coworkers are not team-eligible From 844a6510a2f84072160043cd7c615b578ab63426 Mon Sep 17 00:00:00 2001 From: Rohit C Prasad Date: Sun, 16 Aug 2026 15:43:06 -0700 Subject: [PATCH 35/80] Dogfood round 1: propose_work_items gate, team-tie survives turn saves, board wakes render as cards MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Leads lose propose_plan (trait-derived exclusion — plan mode is meaningless without execution tools) and gain propose_work_items: mode-independent decomposition whose approval creates the items. Team field moves off the turn-save upsert to a dedicated setter (workers detached from their lead after one turn); board deliveries carry a MessageSource sidecar; test-worker prefers project-local tool installs. --- coworker/agent.py | 21 ++-- coworker/conversations.py | 13 ++- coworker/engine.py | 65 +++++++++++ coworker/events.py | 4 + .../personas/builtin/swe-lead/manifest.md | 6 +- .../personas/builtin/test-worker/manifest.md | 4 + coworker/server/app.py | 34 +++++- coworker/server/manager.py | 109 +++++++++++++++--- coworker/teams/tools.py | 62 ++++++++++ surfaces/gui/e2e/fixtures.ts | 24 ++++ surfaces/gui/e2e/team.spec.ts | 29 +++++ surfaces/gui/src/App.tsx | 34 ++++++ surfaces/gui/src/api.ts | 8 ++ surfaces/gui/src/components/WorkItemsCard.tsx | 63 ++++++++++ surfaces/gui/src/styles.css | 14 +++ surfaces/gui/src/types.ts | 8 ++ tests/test_team_wake.py | 35 ++++++ 17 files changed, 508 insertions(+), 25 deletions(-) create mode 100644 surfaces/gui/src/components/WorkItemsCard.tsx diff --git a/coworker/agent.py b/coworker/agent.py index a9f391ce..451871f8 100644 --- a/coworker/agent.py +++ b/coworker/agent.py @@ -214,6 +214,7 @@ def build_engine( question_asker: Optional[Any] = None, tool_requester: Optional[Any] = None, team_approver: Optional[Any] = None, + items_approver: Optional[Any] = None, subscription_store: Optional[Any] = None, channel_buffer: Optional[Any] = None, routing_targets: Optional[list[str]] = None, @@ -424,16 +425,21 @@ def build_engine( roots=root_list or None, risk_overrides=risk_overrides, ) - # The plan-mode exit door. Always registered (surfaces can flip a live session into - # plan mode via set_mode, and the registry is fixed at build); the engine rejects the - # call whenever the session isn't actually in plan mode. - registry.register(propose_plan_tool()) + # The plan-mode exit door — mutually exclusive with the board's decomposition + # gate, DERIVED from the team trait (owner call 2026-08-16): a lead never + # implements, so plan mode is meaningless for it, and shipping both tools made + # the lead pick the wrong one (dogfood-hit: propose_plan denied outside plan + # mode). Solo/worker personas keep propose_plan as always (mode can flip + # mid-session; the engine rejects the call outside plan mode). + if agent.team != "lead": + registry.register(propose_plan_tool()) - # The staffing gate — leads only. The engine intercepts it (out-of-band approval); - # approval pre-spawns the worker sessions and returns actor ids to assign to. + # The lead's gates: propose_work_items (decomposition → items on approval, any + # mode) and propose_team (staffing → pre-spawn on approval). if agent.team == "lead": - from .teams.tools import propose_team_tool + from .teams.tools import propose_team_tool, propose_work_items_tool + registry.register(propose_work_items_tool()) registry.register(propose_team_tool()) # Per-turn ephemeral context, appended to the latest user message since mid-thread system @@ -511,6 +517,7 @@ def build_engine( question_asker=question_asker, tool_requester=tool_requester, team_approver=team_approver, + items_approver=items_approver, ) engine.executor = executor # type: ignore[attr-defined] engine.todo = todo # type: ignore[attr-defined] diff --git a/coworker/conversations.py b/coworker/conversations.py index 2915a3a9..d77fdb8a 100644 --- a/coworker/conversations.py +++ b/coworker/conversations.py @@ -200,7 +200,6 @@ class ConversationStore: title = COALESCE(sessions.title, excluded.title), agent = excluded.agent, n_msgs = excluded.n_msgs, messages = NULL, extra_roots = excluded.extra_roots, grants = excluded.grants, compaction = excluded.compaction, - team = excluded.team, updated_at = CURRENT_TIMESTAMP """, ( @@ -258,6 +257,18 @@ class ConversationStore: team=_load_grants(row["team"] if "team" in row.keys() else None), ) + def set_team(self, session_id: str, team: dict) -> None: + """Persist the session's team tie independent of the turn-save path. The + upsert deliberately never touches `team` — a per-turn save rebuilds the + record without it, and letting the rebuild win detached workers from their + lead's sidebar entry the moment they ran a turn (owner-hit 2026-08-16).""" + with self._lock: + self._conn.execute( + "UPDATE sessions SET team = ? WHERE session_id = ?", + (json.dumps(team or {}), session_id), + ) + self._conn.commit() + def set_extra_roots(self, session_id: str, extra_roots: list[dict]) -> None: """Persist just the session's added folders, independent of its message log — used when the user adds/removes a folder (which may happen with no active engine).""" diff --git a/coworker/engine.py b/coworker/engine.py index c2ab0f02..bd389537 100644 --- a/coworker/engine.py +++ b/coworker/engine.py @@ -92,6 +92,9 @@ class TurnEngine: team_approver: Optional[ Callable[[dict[str, Any]], "Awaitable[dict[str, Any]]"] ] = None, + items_approver: Optional[ + Callable[[dict[str, Any]], "Awaitable[dict[str, Any]]"] + ] = None, # Called (thread-safe, best-effort) when the user stops the turn — e.g. the # executor's kill for a running shell command. interrupt_hooks: Optional[list[Callable[[], None]]] = None, @@ -126,6 +129,12 @@ class TurnEngine: # for the user's decision; approval pre-spawns the worker sessions and the result # carries the roster (actor ids). None on surfaces that can't prompt. self.team_approver = team_approver + # Handles `propose_work_items` (the decomposition gate): emits ITEMS_PROPOSED, + # waits; approval creates the items on the board. Mode-independent by design — + # unlike propose_plan it carries no permission-mode semantics: propose_plan is + # an IMPLEMENTATION plan (steps/files, plan-mode exit); this is a team + # decomposition onto the board. + self.items_approver = items_approver # Handles the `ask_user` tool: turns a question into an Inbox item and waits for the answer # (answerable inline in a live session or from the Inbox when unattended). None on surfaces # that can't ask (the tool then no-ops). @@ -655,6 +664,10 @@ class TurnEngine: async for event in self._handle_team_proposal(tool_call): yield event continue + if tool_call.name == "propose_work_items": + async for event in self._handle_items_proposal(tool_call): + yield event + continue if tool_call.name == "ask_user": async for event in self._handle_ask_user(tool_call): yield event @@ -930,6 +943,58 @@ class TurnEngine: except Exception: pass + async def _handle_items_proposal(self, tool_call: ToolCall) -> AsyncIterator[Event]: + """The decomposition gate: emit the proposed items, await the user's decision. + Approval creates them on the board (server-side, inside the approver) and the + result carries their ids; rejection returns feedback for a revised split.""" + args = tool_call.arguments or {} + items = args.get("items") or [] + valid = [ + i + for i in items + if isinstance(i, dict) + and str(i.get("title", "")).strip() + and str(i.get("criteria", "")).strip() + ] + if not valid or len(valid) != len(items): + result: dict[str, Any] = { + "approved": False, + "error": "every proposed item needs a title and acceptance criteria", + } + elif self.items_approver is None: + result = { + "approved": False, + "error": "item proposals aren't available in this surface", + } + else: + yield Event( + EventType.ITEMS_PROPOSED, + {"items": valid, "note": str(args.get("note", ""))}, + ) + self._audit(tool_call, stage="items_proposed") + result = await self._interruptible( + self.items_approver(dict(args), tool_call.id), + interrupted={"approved": False, "error": "interrupted by user"}, + ) or {"approved": False, "error": "no response"} + + status = "ok" if result.get("approved") else "denied" + self.messages.append(_tool_result_message(tool_call, result)) + self._audit( + tool_call, + stage="finished", + status=status, + result=result, + result_preview=_preview(result), + ) + yield Event( + EventType.TOOL_FINISHED, + { + "name": tool_call.name, + "status": status, + "result_preview": _preview(result), + }, + ) + async def _handle_team_proposal(self, tool_call: ToolCall) -> AsyncIterator[Event]: """The staffing gate: emit the proposed roster, await the user's out-of-band decision. Approval PRE-SPAWNS the worker sessions (server-side, inside the diff --git a/coworker/events.py b/coworker/events.py index 1934436f..bc01ea73 100644 --- a/coworker/events.py +++ b/coworker/events.py @@ -29,6 +29,10 @@ class EventType(str, Enum): TEAM_PROPOSED = ( "team_proposed" # a lead proposes a worker roster (the staffing gate) ) + ITEMS_PROPOSED = ( + "items_proposed" # a lead proposes work items (the decomposition gate); + # unlike propose_plan this is mode-independent — approval creates the items + ) TOOL_STARTED = "tool_started" TOOL_FINISHED = "tool_finished" ITERATION_END = "iteration_end" diff --git a/coworker/personas/builtin/swe-lead/manifest.md b/coworker/personas/builtin/swe-lead/manifest.md index ebee5b28..7e2e35a2 100644 --- a/coworker/personas/builtin/swe-lead/manifest.md +++ b/coworker/personas/builtin/swe-lead/manifest.md @@ -20,8 +20,10 @@ How you run a piece of work: 1. UNDERSTAND: read enough of the repo (files, search) to decompose honestly. 2. PLAN: split the work into items with crisp acceptance criteria — "Done when:" that a verifier can actually check. Acceptance criteria are the single biggest quality lever - you own; vague criteria produce vague work. Present the decomposition to the user with - propose_plan and revise until approved. Only then create the items (create_item). + you own; vague criteria produce vague work. Present the decomposition with + propose_work_items (works in any mode; approval creates the items on the board and + returns their ids) and revise until the user approves. Use create_item only for + one-off additions after the plan is approved. 3. STAFF: propose the workers you need with propose_team ({persona, model, reason} per member). Approval creates their sessions and returns actor ids. Only team-capable worker coworkers can be staffed. diff --git a/coworker/personas/builtin/test-worker/manifest.md b/coworker/personas/builtin/test-worker/manifest.md index b69e7c85..827616bf 100644 --- a/coworker/personas/builtin/test-worker/manifest.md +++ b/coworker/personas/builtin/test-worker/manifest.md @@ -20,6 +20,10 @@ How you verify: - Start from the item under verification: its criteria are your checklist, one by one. Test the actual behavior — run the app, run the tests, exercise the change — never judge by reading the diff alone. +- Missing a test tool? Prefer a PROJECT-LOCAL install first (`npm i -D playwright`, + `pip install pytest` — inside the workspace, like any developer would). Use + request_tool only for system-level binaries the project can't carry; if neither + works, verify what you can and say exactly which checks you couldn't run. - Verification is media-heavy on purpose: take screenshots, capture outputs, diff renders. That cost lands in YOUR context so the builder's stays for building. Save captures as files in the workspace and reference them by path — never describe pixels diff --git a/coworker/server/app.py b/coworker/server/app.py index e2807b89..aca7c588 100644 --- a/coworker/server/app.py +++ b/coworker/server/app.py @@ -1872,6 +1872,37 @@ def create_app(manager: SessionManager) -> FastAPI: enable_chat=bool(_args.get("enable_chat", False)), ) + async def items_approver(_args: dict, tool_call_id=None) -> dict: + # The decomposition gate. TEAMS-flavored sibling of plan_approver: + # park a durable Inbox item, wait, and on approval create the items. + items = _args.get("items") or [] + body = "\n".join( + f"- {i.get('title', '?')} — Done when: {i.get('criteria', '?')}" + for i in items + if isinstance(i, dict) + ) + item = manager.inbox.add_plan( + session_id, + "Approve the proposed work items?", + body=body, + inbox=_route(), + visibility=_visibility(), + tool_call_id=tool_call_id, + ) + if item.state == "pending": + manager.persist_session(session_id) + if item.visibility == VIS_INBOX: + await _mirror(item) + resp = _parse_json(await manager.inbox.wait(item.id)) + if not resp.get("approved"): + return { + "approved": False, + "feedback": resp.get("feedback") or "the user declined the split", + } + return manager.board_create_items( + session_id, [i for i in items if isinstance(i, dict)] + ) + async def _apply_model(model: Optional[str]) -> None: # Mid-session rebind is allowed (roadmap item 3, supersedes the 2026-07-04 # lock): history is canonical and providers convert per call. A real switch @@ -1912,6 +1943,7 @@ def create_app(manager: SessionManager) -> FastAPI: question_asker=question_asker, tool_requester=tool_requester, team_approver=team_approver, + items_approver=items_approver, ) if engine is None: await ws.send_json( @@ -2062,7 +2094,7 @@ def create_app(manager: SessionManager) -> FastAPI: } ) ) - elif kind == "team_response": + elif kind in ("team_response", "items_response"): _resolve_pending( json.dumps( { diff --git a/coworker/server/manager.py b/coworker/server/manager.py index 16743968..42889b91 100644 --- a/coworker/server/manager.py +++ b/coworker/server/manager.py @@ -484,6 +484,7 @@ class SessionManager: question_asker: Optional[Any] = None, tool_requester: Optional[Any] = None, team_approver: Optional[Any] = None, + items_approver: Optional[Any] = None, ) -> Optional[TurnEngine]: engine = self._engines.get(session_id) if engine is not None: @@ -499,6 +500,8 @@ class SessionManager: engine.tool_requester = tool_requester if team_approver is not None: engine.team_approver = team_approver + if items_approver is not None: + engine.items_approver = items_approver return engine record = self.session_store.load(session_id) @@ -576,6 +579,7 @@ class SessionManager: or self.inbox_question_asker(session_id, agent), tool_requester=tool_requester, team_approver=team_approver, + items_approver=items_approver, subscription_store=self.subscriptions, channel_buffer=self.channel_buffer, routing_targets=self._routing_targets(session_id, agent), @@ -1422,6 +1426,47 @@ class SessionManager: except (TeamsBoardError, ValueError) as error: return {"error": str(error)} + def board_create_items( + self, session_id: str, items: list[dict[str, Any]] + ) -> dict[str, Any]: + """The decomposition gate's approved action: create the proposed items as + the LEAD (its identity is the creator; the user's approval is the gate that + let this run). Validates everything up front so a bad batch creates nothing.""" + record = self.session_store.load(session_id) + if record is None or not record.workspace: + return {"approved": False, "error": "the session has no workspace"} + space = space_for_workspace(record.workspace) + actor = TeamActor( + id=f"{record.agent}:{session_id[:8]}", + role=TeamRole.LEAD, + persona=record.agent, + session_id=session_id, + ) + for entry in items: + if not str((entry or {}).get("title", "")).strip() or not str( + (entry or {}).get("criteria", "") + ).strip(): + return { + "approved": False, + "error": "every item needs a title and acceptance criteria", + } + created = [] + for entry in items: + item = self.team_store.create_item( + space, + actor, + title=str(entry["title"]), + criteria=str(entry["criteria"]), + description=str(entry.get("description", "")), + case=str(entry.get("case", "")) or None, + ) + created.append({"id": item["id"], "title": item["title"]}) + return { + "approved": True, + "items": created, + "note": "items created on the board — staff and assign to start work", + } + def journal_overview(self) -> list[dict[str, Any]]: return self.journal_store.overview(self._user_actor()) @@ -1585,14 +1630,20 @@ class SessionManager: mode=record.mode, messages=[], agent=pid, - team={ - "role": "worker", - "actor": actor, - "lead_session": session_id, - "space": space, - }, ) ) + # Written via the dedicated setter: the turn-save upsert never touches + # `team`, so a worker's first turn can't detach it from its lead. + self.session_store.set_team( + worker_sid, + { + "team_id": "", # patched below once the team exists + "role": "worker", + "actor": actor, + "lead_session": session_id, + "space": space, + }, + ) workers.append( TeamWorker(actor=actor, persona=pid, session_id=worker_sid, model=model) ) @@ -1604,14 +1655,26 @@ class SessionManager: chat_enabled=enable_chat, ) for worker in workers: + self.session_store.set_team( + worker.session_id, + { + "team_id": team.team_id, + "role": "worker", + "actor": worker.actor, + "lead_session": session_id, + "space": space, + }, + ) self._emit_session_created(worker.session_id, worker.persona) - record.team = { - "team_id": team.team_id, - "role": "lead", - "actor": team.lead_actor, - "space": space, - } - self.session_store.save(record) + self.session_store.set_team( + session_id, + { + "team_id": team.team_id, + "role": "lead", + "actor": team.lead_actor, + "space": space, + }, + ) return { "approved": True, "team_id": team.team_id, @@ -1665,10 +1728,11 @@ class SessionManager: return 0 message = self._team_digest(team, directs, subs, is_lead=is_lead) self._team_inflight.add(session_id) + source = self._board_source(team, message) async def _deliver() -> None: try: - await self.deliver_to_session(session_id, message) + await self.deliver_to_session(session_id, message, source=source) # Consume only after the turn dispatched: a crash before this replays # the batch next tick (at-least-once, never silently lost). if directs: @@ -1734,6 +1798,23 @@ class SessionManager: " Journal evidence as you go." ) + @staticmethod + def _board_source(team, message: str) -> dict[str, Any]: + """Display-only MessageSource sidecar for board deliveries — the same + mechanism connector messages use, so the GUI renders a structured card + instead of a fake user bubble (owner ask 2026-08-16). The framed message + stays the model-facing text; this only shapes presentation.""" + return { + "connector": "board", + "kind": "channel", + "channel_id": team.space, + "channel_name": "Team board", + "sender_id": "board", + "sender_name": "Board", + "ts": time.time(), + "text": message, + } + def team_staleness_digest(self, session_id: str) -> str: """Attached to a lead's TIMER wakes: pure code over the board — a nothing's-wrong wake is one cheap glance, never a re-survey. Scoped by diff --git a/coworker/teams/tools.py b/coworker/teams/tools.py index 52893c7a..39bbc142 100644 --- a/coworker/teams/tools.py +++ b/coworker/teams/tools.py @@ -256,6 +256,68 @@ _PROPOSE_TEAM_SCHEMA = { } +# The decomposition gate's schema carrier — the board-flavored sibling of +# propose_plan, usable in ANY permission mode (proposing costs nothing; the board +# only ever holds accepted work). The engine intercepts it; approval creates the +# items and returns their ids. +_PROPOSE_ITEMS_SCHEMA = { + "type": "function", + "function": { + "name": "propose_work_items", + "description": ( + "Present your decomposition to the user as proposed WORK ITEMS for the" + " team board. Approval creates them on the board (ids come back in the" + " result); rejection returns feedback to revise. Each item needs a" + " title and acceptance criteria — what gets verified before it can be" + " done. This is not propose_plan: it carries no implementation steps" + " and works in any mode — it is how a lead plans and coordinates via" + " the board." + ), + "parameters": { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "type": "object", + "properties": { + "title": {"type": "string"}, + "criteria": {"type": "string"}, + "description": {"type": "string"}, + "case": {"type": "string"}, + }, + "required": ["title", "criteria"], + }, + }, + "note": {"type": "string"}, + }, + "required": ["items"], + }, + }, +} + + +def propose_work_items_tool() -> object: + def propose_work_items(items: Optional[list] = None, note: str = "") -> dict: + """Present proposed work items ({title, criteria, description?, case?}) + for the user's approval; approval creates them on the board.""" + return { + "approved": False, + "error": "item proposals aren't available in this surface", + } + + wrapped = ai.tool( + propose_work_items, + metadata=ai.ToolMetadata( + category="team", + risk_level="low", + capabilities=["team"], + ), + ) + wrapped.__coworker_schema__ = _PROPOSE_ITEMS_SCHEMA + return wrapped + + def propose_team_tool() -> object: def propose_team( members: Optional[list] = None, enable_chat: bool = False, note: str = "" diff --git a/surfaces/gui/e2e/fixtures.ts b/surfaces/gui/e2e/fixtures.ts index 2a18f789..9d58a0fb 100644 --- a/surfaces/gui/e2e/fixtures.ts +++ b/surfaces/gui/e2e/fixtures.ts @@ -629,6 +629,20 @@ export async function mockApi(page: import("@playwright/test").Page) { }); return; // suspended on the approval } + // Agent teams: the decomposition gate — the lead proposes work items and + // SUSPENDS until the items_response verdict arrives (approval creates them). + if (/propose the split/i.test(msg.text)) { + send("items_proposed", { + items: [ + { title: "Statement API endpoint", criteria: "returns opening/closing balances; 8 endpoint tests green" }, + { title: "Statements dashboard page", criteria: "renders seeded data for Ada / Northgate; empty + error states covered" }, + { title: "Statement totals reconcile", criteria: "running balance matches invoices minus payments for the range" }, + { title: "Verification pass", criteria: "tester confirms page renders with live API data" }, + ], + note: "Shared journal case: statements.", + }); + return; // suspended on the items decision + } // Agent teams (OPE-97): the staffing gate — the lead proposes a roster and // SUSPENDS until the team_response verdict arrives. if (/staff the team/i.test(msg.text)) { @@ -841,6 +855,16 @@ export async function mockApi(page: import("@playwright/test").Page) { send("assistant_message", { text: `Done via ${pendingTool} [decision=${msg.decision}]` }); } send("turn_done"); + } else if (msg.type === "items_response") { + if (msg.approved) { + seedBoard(); // "created on the board" — the board fetch now shows them + send("assistant_message", { + text: "Items created on the board — staffing next.", + }); + } else { + send("assistant_message", { text: "Understood — reworking the split." }); + } + send("turn_done"); } else if (msg.type === "team_response") { if (msg.approved) { // Server-side create_team pre-spawned the workers; surface them in the diff --git a/surfaces/gui/e2e/team.spec.ts b/surfaces/gui/e2e/team.spec.ts index 48c6ce29..656be826 100644 --- a/surfaces/gui/e2e/team.spec.ts +++ b/surfaces/gui/e2e/team.spec.ts @@ -12,6 +12,35 @@ async function proposeTeam(page: import("@playwright/test").Page) { await expect(page.getByTestId("teamreq-card")).toBeVisible(); } +test("the decomposition gate shows items with criteria; approval lands them on the board", async ({ + page, +}) => { + await page.goto("/"); + await page.getByPlaceholder(/Ask the coworker/).fill("propose the split"); + await page.getByRole("button", { name: "Send" }).click(); + const card = page.getByTestId("itemsreq-card"); + await expect(card).toBeVisible(); + await expect(card).toContainText("Proposed work items — 4"); + await expect(card).toContainText("Done when:"); + // 3 visible + expander with the true remainder + await expect(card.getByText("Verification pass")).toHaveCount(0); + await card.getByRole("button", { name: /1 more item/ }).click(); + await expect(card.getByText("Verification pass")).toBeVisible(); + + await page.getByTestId("itemsreq-approve").click(); + await expect(page.getByText(/Items created on the board/)).toBeVisible(); + await expect(page.getByTestId("board-rail")).toBeVisible(); +}); + +test("declining the split returns feedback to the lead", async ({ page }) => { + await page.goto("/"); + await page.getByPlaceholder(/Ask the coworker/).fill("propose the split"); + await page.getByRole("button", { name: "Send" }).click(); + await page.getByTestId("itemsreq-card").waitFor(); + await page.getByRole("button", { name: "Not now" }).click(); + await expect(page.getByText(/reworking the split/)).toBeVisible(); +}); + test("the staffing gate shows the roster and the grant sentence", async ({ page }) => { await proposeTeam(page); const card = page.getByTestId("teamreq-card"); diff --git a/surfaces/gui/src/App.tsx b/surfaces/gui/src/App.tsx index 70096be7..4a9f636b 100644 --- a/surfaces/gui/src/App.tsx +++ b/surfaces/gui/src/App.tsx @@ -77,6 +77,7 @@ import { DirectoryRequestCard } from "./components/DirectoryRequestCard"; import { PlanCard } from "./components/PlanCard"; import { BoardOverlay } from "./components/BoardPanel"; import { TeamRequestCard } from "./components/TeamRequestCard"; +import { WorkItemsCard } from "./components/WorkItemsCard"; import { WorkspaceTrustPrompt } from "./components/WorkspaceTrustPrompt"; const newId = () => @@ -770,6 +771,18 @@ export function App() { }, ]); break; + case "items_proposed": + // The decomposition gate — approval creates the items on the board. + if (unattendedRef.current) break; + setItems((p) => [ + ...p, + { + kind: "itemsreq", + items: Array.isArray(d.items) ? d.items : [], + note: d.note || "", + }, + ]); + break; case "question_requested": // ask_user in an attended session — answered inline (not routed to the Inbox). setItems((p) => [ @@ -1035,6 +1048,12 @@ export function App() { dropSessionInbox("plan"); // the gate parks as a plan-kind Inbox item sessionRef.current?.respondTeam(approved, feedback); }; + const respondItemsReq = (approved: boolean, feedback?: string) => { + setItems((p) => resolveLastItemsReq(p, approved ? "approved" : "rejected")); + dropSessionInbox("plan"); + sessionRef.current?.respondItems(approved, feedback); + if (approved) setTimeout(refreshBoard, 400); // the items just landed + }; const respondDirectory = (granted: boolean, path?: string, writable?: boolean) => { setItems((p) => resolveLastDirReq(p, granted ? "granted" : "denied")); dropSessionInbox("directory"); @@ -1409,6 +1428,7 @@ export function App() { const pendingToolReq = [...items].reverse().find((i) => i.kind === "toolreq" && !i.resolved); const pendingPlan = [...items].reverse().find((i) => i.kind === "planreq" && !i.resolved); const pendingTeam = [...items].reverse().find((i) => i.kind === "teamreq" && !i.resolved); + const pendingItemsReq = [...items].reverse().find((i) => i.kind === "itemsreq" && !i.resolved); const pendingQuestion = [...items].reverse().find((i) => i.kind === "question" && !i.resolved); // Facts subtitle (§22): the session's FIXED facts, not controls — model (+ the // workspace folder for project-scoped sessions). Renders only once the session has history; @@ -1924,6 +1944,8 @@ export function App() { // parked in the Inbox and surfaced via the answer-in-context card below. !unattended && pendingPlan?.kind === "planreq" ? ( + ) : !unattended && pendingItemsReq?.kind === "itemsreq" ? ( + ) : !unattended && pendingTeam?.kind === "teamreq" ? ( ) : !unattended && pendingToolReq?.kind === "toolreq" ? ( @@ -2141,6 +2163,18 @@ function resolveLastTeam(items: Item[], resolved: "approved" | "rejected"): Item return copy; } +function resolveLastItemsReq(items: Item[], resolved: "approved" | "rejected"): Item[] { + const copy = [...items]; + for (let i = copy.length - 1; i >= 0; i--) { + const it = copy[i]; + if (it.kind === "itemsreq" && !it.resolved) { + copy[i] = { ...it, resolved }; + break; + } + } + return copy; +} + function resolveLastQuestion(items: Item[], answer: string): Item[] { const copy = [...items]; for (let i = copy.length - 1; i >= 0; i--) { diff --git a/surfaces/gui/src/api.ts b/surfaces/gui/src/api.ts index 7f959a9a..b428f65f 100644 --- a/surfaces/gui/src/api.ts +++ b/surfaces/gui/src/api.ts @@ -2197,6 +2197,14 @@ export class Session { }); } + respondItems(approved: boolean, feedback?: string) { + this.send({ + type: "items_response", + approved, + ...(feedback ? { feedback } : {}), + }); + } + // Answer a live `ask_user` prompt (attended sessions; unattended ones answer via the Inbox). respondQuestion(answer: string) { this.send({ type: "question_response", answer }); diff --git a/surfaces/gui/src/components/WorkItemsCard.tsx b/surfaces/gui/src/components/WorkItemsCard.tsx new file mode 100644 index 00000000..e3c3f48b --- /dev/null +++ b/surfaces/gui/src/components/WorkItemsCard.tsx @@ -0,0 +1,63 @@ +// The decomposition gate (agent teams): a lead proposes work items; approval +// creates them on the board. The board-flavored sibling of PlanCard — items with +// acceptance criteria as the primary text, 3 visible + expander with the true +// count in the header, no in-card reply surface (editing happens by replying). +import { useState } from "react"; +import type { Item } from "../types"; +import { Icon } from "./Icon"; + +export function WorkItemsCard({ + item, + onRespond, +}: { + item: Extract; + onRespond: (approved: boolean, feedback?: string) => void; +}) { + const [expanded, setExpanded] = useState(false); + const visible = expanded ? item.items : item.items.slice(0, 3); + const hidden = item.items.length - visible.length; + return ( +
+
+ + + Proposed work items — {item.items.length} + +
+ {item.note &&
{item.note}
} + {visible.map((entry, i) => ( +
+ {i + 1}. + + {entry.title} + + Done when: {entry.criteria} + + +
+ ))} + {hidden > 0 && ( + + )} +
+ + Reply to edit the split; approval creates these on the board. + + + + +
+
+ ); +} diff --git a/surfaces/gui/src/styles.css b/surfaces/gui/src/styles.css index 8479978a..4f890678 100644 --- a/surfaces/gui/src/styles.css +++ b/surfaces/gui/src/styles.css @@ -1734,3 +1734,17 @@ html[data-platform="linux"] ::-webkit-scrollbar-thumb:hover { background-color: .team-dot.in_progress { background: var(--ok-dot); } .team-dot.blocked { background: var(--danger); } .team-dot.review { background: var(--warn-ink); } + +/* Decomposition gate (proposed work items) */ +.itemsreq-card { border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-soft); } +.itemsreq-head { display: flex; align-items: center; gap: 8px; margin-bottom: 4px; } +.itemsreq-title { font-weight: 600; font-size: 13px; color: var(--ink); } +.itemsreq-note { font-size: 12px; color: var(--muted); margin-bottom: 4px; } +.itemsreq-item { display: flex; gap: 9px; padding: 7px 0; border-top: 1px solid var(--line); } +.itemsreq-num { color: var(--faint); font-size: 12px; padding-top: 1px; } +.itemsreq-body { display: flex; flex-direction: column; gap: 2px; min-width: 0; } +.itemsreq-item-title { font-size: 12.5px; color: var(--ink); } +.itemsreq-ac { font-size: 11.5px; color: var(--muted); } +.itemsreq-ac b { font-weight: 600; } +.itemsreq-more { display: flex; align-items: center; gap: 5px; border: 0; background: transparent; color: var(--accent); font-size: 12px; cursor: pointer; padding: 6px 0; } +.itemsreq-grant { font-size: 11.5px; color: var(--faint); } diff --git a/surfaces/gui/src/types.ts b/surfaces/gui/src/types.ts index b0b25a0c..7693e4c3 100644 --- a/surfaces/gui/src/types.ts +++ b/surfaces/gui/src/types.ts @@ -12,6 +12,7 @@ export type EventType = | "question_requested" | "plan_proposed" | "team_proposed" + | "items_proposed" | "tool_started" | "tool_finished" | "iteration_end" @@ -164,6 +165,13 @@ export type Item = note?: string; resolved?: "approved" | "rejected"; } + | { + // The decomposition gate: a lead proposes work items; approval creates them. + kind: "itemsreq"; + items: { title: string; criteria: string; description?: string }[]; + note?: string; + resolved?: "approved" | "rejected"; + } | { // A live ask_user prompt (attended sessions answer inline; unattended ones route to the Inbox). kind: "question"; diff --git a/tests/test_team_wake.py b/tests/test_team_wake.py index 909477c6..70ee36e1 100644 --- a/tests/test_team_wake.py +++ b/tests/test_team_wake.py @@ -231,3 +231,38 @@ def test_team_options_lists_only_enabled_workers(manager): assert {"swe-worker", "design-worker", "test-worker"} <= workers assert "swe-lead" not in workers # leads staff, they aren't staffed assert "security" not in workers # solo coworkers are not team-eligible + + +def test_turn_saves_never_detach_a_worker_from_its_team(manager, monkeypatch): + from coworker.agents.base import Agent + from coworker.sessions import SessionRecord + + worker_agent = Agent(name="swe-worker", title="SWE", system_prompt="p", team="worker") + monkeypatch.setattr("coworker.server.manager.get_agent", lambda name: worker_agent) + manager.session_store.save( + SessionRecord( + session_id="lead-sid", + workspace=manager.default_workspace, + model="m", + mode="interactive", + messages=[], + agent="swe-lead", + ) + ) + result = manager.create_team("lead-sid", [{"persona": "swe-worker"}]) + wid = result["workers"][0]["session_id"] + # A per-turn save rebuilds the record WITHOUT the team field (the engine doesn't + # carry it) — owner-hit 2026-08-16: this detached workers from the lead's entry. + record = manager.session_store.load(wid) + manager.session_store.save( + SessionRecord( + session_id=wid, + workspace=record.workspace, + model=record.model, + mode=record.mode, + messages=[{"role": "user", "content": "hi"}], + agent=record.agent, + ) + ) + assert manager.session_store.load(wid).team["lead_session"] == "lead-sid" + assert manager.session_store.load("lead-sid").team["role"] == "lead" From cf436d106979a54f5cffb6c64bf226adfdfd0c2c Mon Sep 17 00:00:00 2001 From: Rohit C Prasad Date: Sun, 16 Aug 2026 16:22:23 -0700 Subject: [PATCH 36/80] # team chat: own chat store, named workers, mention wakes, cancel interrupt (OPE-99) ChatStore = groups + append-only messages + per-member cursors; agent posts wake mentions only, user posts wake everyone; post_chat(record_on_item) also lands the answer as an item comment. Leads name workers (the callname is the handle everywhere); worker digests auto-carry the roster; gate checkbox is the user's call; canceling an assigned item now interrupts an in-flight worker. --- .../builtin/design-worker/manifest.md | 2 +- .../personas/builtin/swe-lead/manifest.md | 10 +- .../personas/builtin/swe-worker/manifest.md | 2 +- .../personas/builtin/test-worker/manifest.md | 2 +- coworker/server/app.py | 22 +- coworker/server/manager.py | 215 +++++++++++++++++- coworker/teams/chat.py | 215 ++++++++++++++++++ coworker/teams/registry.py | 6 +- coworker/teams/store.py | 11 + coworker/teams/tools.py | 11 +- surfaces/gui/e2e/fixtures.ts | 65 +++++- surfaces/gui/e2e/team.spec.ts | 52 ++++- surfaces/gui/src/App.tsx | 9 +- surfaces/gui/src/api.ts | 33 ++- surfaces/gui/src/components/Sidebar.tsx | 15 ++ surfaces/gui/src/components/TeamChatView.tsx | 129 +++++++++++ .../gui/src/components/TeamRequestCard.tsx | 32 ++- surfaces/gui/src/styles.css | 28 +++ surfaces/gui/src/types.ts | 4 +- tests/test_team_wake.py | 80 +++++++ 20 files changed, 899 insertions(+), 44 deletions(-) create mode 100644 coworker/teams/chat.py create mode 100644 surfaces/gui/src/components/TeamChatView.tsx diff --git a/coworker/personas/builtin/design-worker/manifest.md b/coworker/personas/builtin/design-worker/manifest.md index df084fab..12c6dab8 100644 --- a/coworker/personas/builtin/design-worker/manifest.md +++ b/coworker/personas/builtin/design-worker/manifest.md @@ -12,7 +12,7 @@ default_permission_mode: interactive description: A UI/UX-focused coworker that works team-style under a lead — layout, styling, interaction polish, and design-system consistency, handed off through review. --- You are a UI/UX engineer working ON A TEAM under a lead coworker. Your interlocutor is -the LEAD, not the end user — no ask_user; questions become item comments. +the LEAD, not the end user — no ask_user; questions become item comments (or @lead via post_chat when # team chat is enabled). The team contract (this is how you work): - Your task arrives as a WORK ITEM: description = assignment, acceptance criteria = diff --git a/coworker/personas/builtin/swe-lead/manifest.md b/coworker/personas/builtin/swe-lead/manifest.md index 7e2e35a2..60bcbbe7 100644 --- a/coworker/personas/builtin/swe-lead/manifest.md +++ b/coworker/personas/builtin/swe-lead/manifest.md @@ -24,9 +24,13 @@ How you run a piece of work: propose_work_items (works in any mode; approval creates the items on the board and returns their ids) and revise until the user approves. Use create_item only for one-off additions after the plan is approved. -3. STAFF: propose the workers you need with propose_team ({persona, model, reason} per - member). Approval creates their sessions and returns actor ids. Only team-capable - worker coworkers can be staffed. +3. STAFF: propose the workers you need with propose_team ({persona, name, model, + reason} per member). Give each a short callname (e.g. "nia", "webb", "checks") — + it becomes their handle for assignment and @mentions, and lets you staff two of + the same coworker. Approval creates their sessions and returns the handles. Only + team-capable worker coworkers can be staffed (team_options lists them). When you + assign work, teammates' names are shared automatically — add the context that + isn't: who owns what interface, who to ask about which decision. 4. ASSIGN: assign items to actor ids. The item IS the worker's assignment — its description and criteria must stand alone. Respect dependencies (link blocks/parent); don't assign what's blocked. diff --git a/coworker/personas/builtin/swe-worker/manifest.md b/coworker/personas/builtin/swe-worker/manifest.md index 627b848f..6acb8a0a 100644 --- a/coworker/personas/builtin/swe-worker/manifest.md +++ b/coworker/personas/builtin/swe-worker/manifest.md @@ -12,7 +12,7 @@ default_permission_mode: interactive description: A software engineer coworker that works team-style — it takes assigned work items from a lead coworker, implements them against their acceptance criteria, and hands off through review. --- You are a software engineer working ON A TEAM under a lead coworker. Your interlocutor -is the LEAD, not the end user — you never use ask_user; questions become item comments, +is the LEAD, not the end user — you never use ask_user; questions become item comments (or @lead via post_chat when # team chat is enabled), and you keep working on what isn't blocked by the answer. The team contract (this is how you work): diff --git a/coworker/personas/builtin/test-worker/manifest.md b/coworker/personas/builtin/test-worker/manifest.md index 827616bf..9935ea86 100644 --- a/coworker/personas/builtin/test-worker/manifest.md +++ b/coworker/personas/builtin/test-worker/manifest.md @@ -14,7 +14,7 @@ description: A verification coworker for teams — it independently tests what a You are the team's verifier. A builder coworker finished an item; the lead assigned you a linked verification item. Your job: independently establish whether the work MEETS ITS ACCEPTANCE CRITERIA — assume it doesn't until the evidence says otherwise. Your -interlocutor is the LEAD, not the end user — no ask_user; questions become item comments. +interlocutor is the LEAD, not the end user — no ask_user; questions become item comments (or @lead via post_chat when # team chat is enabled). How you verify: - Start from the item under verification: its criteria are your checklist, one by one. diff --git a/coworker/server/app.py b/coworker/server/app.py index aca7c588..b8ffd24e 100644 --- a/coworker/server/app.py +++ b/coworker/server/app.py @@ -750,6 +750,14 @@ def create_app(manager: SessionManager) -> FastAPI: comment=str(body.get("comment", "")), ) + @app.get("/v1/teams/{team_id}/chat") + def team_chat(team_id: str) -> dict[str, Any]: + return manager.team_chat(team_id) + + @app.post("/v1/teams/{team_id}/chat") + def team_chat_post(team_id: str, body: dict) -> dict[str, Any]: + return manager.post_team_chat(team_id, str((body or {}).get("text", ""))) + @app.get("/v1/teams/journal") def teams_journal() -> dict[str, Any]: return {"cases": manager.journal_overview()} @@ -1866,10 +1874,17 @@ def create_app(manager: SessionManager) -> FastAPI: "approved": False, "feedback": resp.get("feedback") or "the user declined this roster", } + # The gate checkbox is the USER's call: an explicit enable_chat in the + # response overrides whatever the lead proposed. + enable_chat = bool( + resp["enable_chat"] + if "enable_chat" in resp + else _args.get("enable_chat", False) + ) return manager.create_team( session_id, [m for m in members if isinstance(m, dict)], - enable_chat=bool(_args.get("enable_chat", False)), + enable_chat=enable_chat, ) async def items_approver(_args: dict, tool_call_id=None) -> dict: @@ -2100,6 +2115,11 @@ def create_app(manager: SessionManager) -> FastAPI: { "approved": bool(message.get("approved")), "feedback": message.get("feedback", ""), + **( + {"enable_chat": bool(message.get("enable_chat"))} + if "enable_chat" in message + else {} + ), } ) ) diff --git a/coworker/server/manager.py b/coworker/server/manager.py index 42889b91..4060b356 100644 --- a/coworker/server/manager.py +++ b/coworker/server/manager.py @@ -87,6 +87,7 @@ from ..teams import Actor as TeamActor from ..teams import BoardError as TeamsBoardError from ..teams import JournalStore, Role as TeamRole, TeamStore, board_tools, journal_tools from ..teams.model import space_for_workspace +from ..teams.chat import ChatStore from ..teams.registry import TeamRegistry, TeamWorker from ..skills import ( SessionSkillStore, @@ -209,6 +210,7 @@ class SessionManager: # sessions per board) that the wake plumbing walks. self.journal_store = JournalStore(base / "journal.db") self.team_store = TeamStore(base / "teams.db", journal=self.journal_store) + self.chat_store = ChatStore(base / "chat.db") self.teams = TeamRegistry(base / "teams.json") self._team_inflight: set[str] = set() self._loop: Optional[asyncio.AbstractEventLoop] = None @@ -1467,6 +1469,38 @@ class SessionManager: "note": "items created on the board — staff and assign to start work", } + def team_chat(self, team_id: str, *, mark_read: bool = True) -> dict[str, Any]: + """The chat view's payload. Viewing IS reading for the user: the badge + cursor advances on fetch.""" + team = self.teams.get(team_id) + if team is None or not team.chat_enabled or not team.chat_group: + return {"enabled": False, "messages": [], "members": []} + group = self.chat_store.get_group(team.chat_group) or {"members": []} + messages = self.chat_store.messages(team.chat_group) + if mark_read and messages: + self.chat_store.consume(team.chat_group, "user", messages[-1]["seq"]) + return { + "enabled": True, + "team_id": team_id, + "members": group["members"], + "messages": messages, + } + + def post_team_chat(self, team_id: str, text: str) -> dict[str, Any]: + team = self.teams.get(team_id) + if team is None or not team.chat_enabled or not team.chat_group: + return {"error": "chat is not enabled for this team"} + try: + message = self.chat_store.post( + team.chat_group, "user", text, author_role="user" + ) + except (TeamsBoardError, ValueError) as error: + return {"error": str(error)} + # A user post wakes every member — kick the drain rather than waiting a tick. + if self._loop is not None: + asyncio.run_coroutine_threadsafe(self.team_tick(), self._loop) + return message + def journal_overview(self) -> list[dict[str, Any]]: return self.journal_store.overview(self._user_actor()) @@ -1507,8 +1541,82 @@ class SessionManager: if role == "lead": tools.append(self._steer_tool(session_id)) tools.append(self._team_options_tool()) + # post_chat registers for every team persona; it resolves the group at call + # time (the team may not exist yet at engine build) and fails gracefully + # when chat is off. + tools.append(self._post_chat_tool(session_id, role)) return tools + def _post_chat_tool(self, session_id: str, role: str) -> Any: + import aisuite as ai + + manager = self + + def post_chat(text: str, record_on_item: Optional[int] = None) -> dict: + """Post to # team chat. Mention teammates with @name to reach them — + only mentioned members are woken (the user always sees it). Chat is for + questions and consensus; status lives on the board. If your message + answers something that matters, pass record_on_item to also record it + as a comment on that work item.""" + team, handle, actor = manager._chat_identity(session_id, role) + if team is None: + return {"error": "this session is not part of a team"} + if not team.chat_enabled or not team.chat_group: + return {"error": "team chat is not enabled for this team"} + try: + message = manager.chat_store.post( + team.chat_group, handle, text, author_role=role + ) + except (TeamsBoardError, ValueError) as error: + return {"error": str(error)} + result: dict[str, Any] = { + "ok": True, + "mentioned": message["mentions"], + } + if record_on_item is not None and actor is not None: + try: + manager.team_store.comment( + team.space, actor, int(record_on_item), text + ) + result["recorded_on"] = int(record_on_item) + except (TeamsBoardError, ValueError) as error: + result["record_error"] = str(error) + return result + + return ai.tool( + post_chat, + metadata=ai.ToolMetadata( + category="team", risk_level="low", capabilities=["team"] + ), + ) + + def _chat_identity(self, session_id: str, role: str): + """(team, chat handle, board actor) for a team session — the lead's chat + handle is "lead"; a worker's handle IS its board actor (the callname).""" + if role == "lead": + team = self.teams.for_lead_session(session_id) + if team is None: + return None, "", None + record = self.session_store.load(session_id) + actor = TeamActor( + id=team.lead_actor, + role=TeamRole.LEAD, + persona=record.agent if record else "", + session_id=session_id, + ) + return team, "lead", actor + found = self.teams.for_worker_session(session_id) + if found is None: + return None, "", None + team, worker = found + actor = TeamActor( + id=worker.actor, + role=TeamRole.WORKER, + persona=worker.persona, + session_id=session_id, + ) + return team, worker.actor, actor + def _team_options_tool(self) -> Any: """Registry-injected staffing knowledge: the lead's options come from the persona registry at call time — installing a worker coworker automatically @@ -1599,7 +1707,7 @@ class SessionManager: return {"approved": False, "error": "this session already leads a team"} space = space_for_workspace(record.workspace) workers: list[TeamWorker] = [] - used: set[str] = set() + used: set[str] = {"lead", "user", "board"} # reserved handles for member in members: pid = str((member or {}).get("persona", "")).strip() try: @@ -1616,9 +1724,17 @@ class SessionManager: "approved": False, "error": f"'{pid}' is not team-capable (needs `team: worker`)", } - actor, n = pid, 2 + # The lead-given callname is the HANDLE: board assignee, @mention target, + # sidebar label. It must be mention-safe and unique on the team. + name = str(member.get("name", "")).strip().lower() + if name and not re.fullmatch(r"[a-z0-9][a-z0-9._-]{0,23}", name): + return { + "approved": False, + "error": f"'{name}' isn't a usable callname — letters/digits/._- only, max 24", + } + actor, n = name or pid, 2 while actor in used: - actor, n = f"{pid}-{n}", n + 1 + actor, n = f"{name or pid}-{n}", n + 1 used.add(actor) worker_sid = uuid.uuid4().hex[:12] model = str(member.get("model") or record.model) @@ -1645,14 +1761,34 @@ class SessionManager: }, ) workers.append( - TeamWorker(actor=actor, persona=pid, session_id=worker_sid, model=model) + TeamWorker( + actor=actor, + persona=pid, + session_id=worker_sid, + model=model, + reason=str(member.get("reason", "")).strip(), + ) ) + chat_group = "" + if enable_chat: + group = self.chat_store.create_group( + "team chat", + [ + *( + {"name": w.actor, "persona": w.persona, "role": "worker"} + for w in workers + ), + {"name": "lead", "persona": record.agent, "role": "lead"}, + ], + ) + chat_group = group["group_id"] team = self.teams.create( space=space, lead_session=session_id, lead_actor=f"{record.agent}:{session_id[:8]}", workers=workers, chat_enabled=enable_chat, + chat_group=chat_group, ) for worker in workers: self.session_store.set_team( @@ -1719,14 +1855,32 @@ class SessionManager: subs = ( self.team_store.subscribed_events(team.space, actor) if is_lead else [] ) - if not directs and not subs: + chat_handle = "lead" if is_lead else actor + chats = ( + self.chat_store.unread_for(team.chat_group, chat_handle) + if team.chat_enabled and team.chat_group + else [] + ) + # Cancel is top-priority: an in-flight worker gets interrupted NOW; the + # queued notice (delivered when the turn dies) tells it why. + cancels = [ + e + for e in directs + if e["kind"] == "item_transitioned" + and (e.get("payload") or {}).get("to") == "canceled" + ] + if cancels and self.is_running(session_id): + engine = self._engines.get(session_id) + if engine is not None: + engine.request_interrupt() + if not directs and not subs and not chats: return 0 if self.is_running(session_id) or session_id in self._team_inflight: return 0 # it will drain on its next turn end / next tick if not self.teams.count_wake(team.team_id, cap=self.TEAM_WAKE_CAP_PER_HOUR): logger.warning("team %s paused for budget this hour", team.team_id) return 0 - message = self._team_digest(team, directs, subs, is_lead=is_lead) + message = self._team_digest(team, directs, subs, chats, is_lead=is_lead) self._team_inflight.add(session_id) source = self._board_source(team, message) @@ -1741,6 +1895,10 @@ class SessionManager: self.team_store.consume_subscription( team.space, actor, subs[-1]["seq"] ) + if chats: + self.chat_store.consume( + team.chat_group, chat_handle, chats[-1]["seq"] + ) finally: self._team_inflight.discard(session_id) @@ -1748,7 +1906,13 @@ class SessionManager: return 1 def _team_digest( - self, team, directs: list[dict], subs: list[dict], *, is_lead: bool + self, + team, + directs: list[dict], + subs: list[dict], + chats: Optional[list[dict]] = None, + *, + is_lead: bool, ) -> str: """Coalesce one queue batch into one wake message. Deterministic, computed by code — the model does judgment, not arithmetic.""" @@ -1774,13 +1938,22 @@ class SessionManager: elif event["kind"] == "item_transitioned": to = payload.get("to", "?") note = f" — “{payload.get('comment')}”" if payload.get("comment") else "" - lines.append(f"{title} moved to {to} by {event['actor']}{note}") + if to == "canceled" and not is_lead: + lines.append( + f"{title} was CANCELED by {event['actor']}{note} — stop any" + " work on it and pick up your other assignments." + ) + else: + lines.append(f"{title} moved to {to} by {event['actor']}{note}") elif event["kind"] == "item_created": lines.append(f"New item filed by {event['actor']}: {title}") elif event["kind"] == "item_commented": lines.append( f"Comment on {title} by {event['actor']}: {payload.get('body', '')}" ) + for chat in chats or []: + who = chat["author"] if chat["author_role"] != "user" else "[User]" + lines.append(f"# team chat — {who}: {chat['text']}") body = "\n".join(f"- {line}" for line in lines) or "- (no detail)" if is_lead: return ( @@ -1793,11 +1966,30 @@ class SessionManager: return ( "[Lead] Board update:\n" + body + + self._roster_note(team) + "\n\nMove your item to in_progress when you start; blocked (with a" " comment) if stuck; review with a hand-off comment when finished." " Journal evidence as you go." ) + @staticmethod + def _roster_note(team) -> str: + """Teammate awareness as a mechanism: every worker digest carries the + roster, so tagging teammates never depends on the lead remembering to + introduce them.""" + if not team.workers: + return "" + mates = "; ".join( + f"{w.actor} ({w.persona}" + (f" — {w.reason})" if w.reason else ")") + for w in team.workers + ) + reach = ( + " Reach them or the lead with @name in # team chat (post_chat)." + if team.chat_enabled + else " Coordinate through item comments; the lead reads the board." + ) + return f"\n\nYour team: {mates}; lead (coordinator).{reach}" + @staticmethod def _board_source(team, message: str) -> dict[str, Any]: """Display-only MessageSource sidecar for board deliveries — the same @@ -4464,6 +4656,13 @@ class SessionManager: "team_id": info.get("team_id", ""), "lead_session": info.get("lead_session", ""), } + if info.get("role") == "lead": + team = self.teams.get(str(info.get("team_id", ""))) + if team is not None and team.chat_enabled and team.chat_group: + row["chat_enabled"] = True + row["chat_unread"] = self.chat_store.unread_count( + team.chat_group, "user" + ) if info.get("role") == "worker" and info.get("space") and info.get("actor"): try: items = self.team_store.list_items( diff --git a/coworker/teams/chat.py b/coworker/teams/chat.py new file mode 100644 index 00000000..0c885c1c --- /dev/null +++ b/coworker/teams/chat.py @@ -0,0 +1,215 @@ +"""The chat store — group chat as its own abstraction (eighth pass, 2026-08-16). + +A GROUP is `{group_id, name, members[]}` plus an append-only message log and +per-member unread cursors. One group per team in v1 (created at the staffing gate +when chat is enabled), but nothing here knows about boards or teams — groups can +later serve non-team chats and the external-chat dialect. + +Wake semantics live in the read side: an agent post is "for" exactly its @mentioned +members; a USER post is for every member ([User] outranks — posting to the channel +is rare and deliberate). Un-mentioned agent chatter wakes nobody, which is what +keeps chat an exception channel structurally. +""" + +from __future__ import annotations + +import json +import re +import sqlite3 +import threading +import uuid +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Optional + +from .model import BoardError + + +class ChatStore: + def __init__(self, db_path: str | Path) -> None: + self.db_path = str(db_path) + if self.db_path != ":memory:": + Path(self.db_path).expanduser().parent.mkdir(parents=True, exist_ok=True) + self._lock = threading.RLock() + self._conn = sqlite3.connect(self.db_path, check_same_thread=False) + self._conn.row_factory = sqlite3.Row + self._conn.executescript(""" + CREATE TABLE IF NOT EXISTS chat_groups ( + group_id TEXT PRIMARY KEY, + name TEXT NOT NULL, + members TEXT NOT NULL, + created_ts TEXT NOT NULL + ); + CREATE TABLE IF NOT EXISTS chat_messages ( + seq INTEGER PRIMARY KEY AUTOINCREMENT, + group_id TEXT NOT NULL, + ts TEXT NOT NULL, + author TEXT NOT NULL, + author_role TEXT NOT NULL, + text TEXT NOT NULL, + mentions TEXT NOT NULL DEFAULT '[]' + ); + CREATE INDEX IF NOT EXISTS idx_chat_group ON chat_messages (group_id, seq); + CREATE TABLE IF NOT EXISTS chat_cursors ( + cursor_key TEXT PRIMARY KEY, + read_seq INTEGER NOT NULL + ); + """) + self._conn.commit() + + # ---------------------------------------------------------------------- groups + + def create_group(self, name: str, members: list[dict[str, Any]]) -> dict[str, Any]: + """`members`: [{name, persona, role}] — `name` is the member's handle + (@mention target). The user participates implicitly and is not a member row.""" + handles = [str(m.get("name", "")).strip() for m in members] + if not name.strip(): + raise BoardError("group name is required") + if not all(handles) or len(set(handles)) != len(handles): + raise BoardError("every member needs a unique name") + group = { + "group_id": uuid.uuid4().hex[:12], + "name": name.strip(), + "members": [ + { + "name": str(m.get("name")), + "persona": str(m.get("persona", "")), + "role": str(m.get("role", "worker")), + } + for m in members + ], + "created_ts": datetime.now(timezone.utc).isoformat(), + } + with self._lock: + self._conn.execute( + "INSERT INTO chat_groups (group_id, name, members, created_ts)" + " VALUES (?, ?, ?, ?)", + ( + group["group_id"], + group["name"], + json.dumps(group["members"]), + group["created_ts"], + ), + ) + self._conn.commit() + return group + + def get_group(self, group_id: str) -> Optional[dict[str, Any]]: + with self._lock: + row = self._conn.execute( + "SELECT * FROM chat_groups WHERE group_id = ?", (group_id,) + ).fetchone() + if row is None: + return None + group = dict(row) + group["members"] = json.loads(group.pop("members") or "[]") + return group + + # -------------------------------------------------------------------- messages + + def post( + self, group_id: str, author: str, text: str, *, author_role: str = "worker" + ) -> dict[str, Any]: + """Append one message. Mentions are parsed against member handles — + `@name` anywhere in the text — so tagging needs no separate parameter.""" + group = self.get_group(group_id) + if group is None: + raise BoardError(f"no chat group '{group_id}'") + if not (text or "").strip(): + raise BoardError("message text is required") + handles = {m["name"] for m in group["members"]} + mentions = sorted( + { + m.group(1) + for m in re.finditer(r"@([\w.-]+)", text) + if m.group(1) in handles + } + ) + message = { + "group_id": group_id, + "ts": datetime.now(timezone.utc).isoformat(), + "author": author, + "author_role": author_role, + "text": text, + "mentions": mentions, + } + with self._lock: + cursor = self._conn.execute( + "INSERT INTO chat_messages" + " (group_id, ts, author, author_role, text, mentions)" + " VALUES (?, ?, ?, ?, ?, ?)", + ( + group_id, + message["ts"], + author, + author_role, + text, + json.dumps(mentions), + ), + ) + self._conn.commit() + return {**message, "seq": cursor.lastrowid} + + def messages( + self, group_id: str, *, since_seq: int = 0, limit: int = 200 + ) -> list[dict[str, Any]]: + with self._lock: + rows = self._conn.execute( + "SELECT * FROM chat_messages WHERE group_id = ? AND seq > ?" + " ORDER BY seq LIMIT ?", + (group_id, since_seq, max(1, min(int(limit or 200), 2000))), + ).fetchall() + return [_row_to_message(row) for row in rows] + + # ------------------------------------------------------- unread / wake reads + + def unread_for(self, group_id: str, member: str) -> list[dict[str, Any]]: + """Messages this member should be WOKEN for: posts that @mention it, plus + every user post. Its own posts never count.""" + out = [] + for message in self.messages(group_id, since_seq=self._cursor(group_id, member)): + if message["author"] == member: + continue + if member in message["mentions"] or message["author_role"] == "user": + out.append(message) + return out + + def unread_count(self, group_id: str, member: str) -> int: + """Plain unread count (all messages since the member's cursor) — drives the + sidebar badge for the USER, whose 'member' key is "user".""" + with self._lock: + row = self._conn.execute( + "SELECT COUNT(*) AS n FROM chat_messages WHERE group_id = ?" + " AND seq > ? AND author != ?", + (group_id, self._cursor(group_id, member), member), + ).fetchone() + return int(row["n"]) + + def consume(self, group_id: str, member: str, upto_seq: int) -> None: + with self._lock: + self._conn.execute( + "INSERT INTO chat_cursors (cursor_key, read_seq) VALUES (?, ?)" + " ON CONFLICT(cursor_key) DO UPDATE SET read_seq =" + " MAX(read_seq, ?)", + (f"{group_id}:{member}", int(upto_seq), int(upto_seq)), + ) + self._conn.commit() + + def close(self) -> None: + self._conn.close() + + def _cursor(self, group_id: str, member: str) -> int: + row = self._conn.execute( + "SELECT read_seq FROM chat_cursors WHERE cursor_key = ?", + (f"{group_id}:{member}",), + ).fetchone() + return int(row["read_seq"]) if row else 0 + + +def _row_to_message(row: sqlite3.Row) -> dict[str, Any]: + message = dict(row) + try: + message["mentions"] = json.loads(message.get("mentions") or "[]") + except json.JSONDecodeError: + message["mentions"] = [] + return message diff --git a/coworker/teams/registry.py b/coworker/teams/registry.py index 53d7a855..ce522c69 100644 --- a/coworker/teams/registry.py +++ b/coworker/teams/registry.py @@ -20,10 +20,11 @@ from typing import Optional @dataclass class TeamWorker: - actor: str # board actor id (stable; assignments address this) + actor: str # the lead-given NAME — board actor id, assignee handle, @mention target persona: str session_id: str model: str = "" + reason: str = "" # why the lead staffed it — surfaces in teammates' rosters @dataclass @@ -34,6 +35,7 @@ class Team: lead_actor: str workers: list[TeamWorker] = field(default_factory=list) chat_enabled: bool = False + chat_group: str = "" # ChatStore group_id when chat is enabled paused: bool = False # budget/user pause: the wake gate skips a paused team created_at: str = field( default_factory=lambda: datetime.now(timezone.utc).isoformat() @@ -76,6 +78,7 @@ class TeamRegistry: lead_actor: str, workers: list[TeamWorker], chat_enabled: bool = False, + chat_group: str = "", ) -> Team: team = Team( team_id=uuid.uuid4().hex[:12], @@ -84,6 +87,7 @@ class TeamRegistry: lead_actor=lead_actor, workers=workers, chat_enabled=chat_enabled, + chat_group=chat_group, ) with self._lock: self._teams[team.team_id] = team diff --git a/coworker/teams/store.py b/coworker/teams/store.py index 4599c02e..81871eb4 100644 --- a/coworker/teams/store.py +++ b/coworker/teams/store.py @@ -517,12 +517,23 @@ class TeamStore: f"illegal transition {current.value} → {target.value}" ) self._check_transition_authority(actor, item, current, target) + # Canceling an assigned item ADDRESSES the notice to its assignee — the + # top-priority queue entry whose delivery (or in-flight interrupt) makes + # the worker actually stop, instead of finishing into the void. + recipient = ( + item["assignee"] + if target is ItemState.CANCELED + and item["assignee"] + and item["assignee"] != actor.id + else None + ) event = self.append_event( space, ITEM_TRANSITIONED, actor, item_id=item_id, case_id=item["case_id"] or None, + recipient=recipient, payload={ "from": current.value, "to": target.value, diff --git a/coworker/teams/tools.py b/coworker/teams/tools.py index 39bbc142..16054b80 100644 --- a/coworker/teams/tools.py +++ b/coworker/teams/tools.py @@ -227,9 +227,11 @@ _PROPOSE_TEAM_SCHEMA = { "function": { "name": "propose_team", "description": ( - "Propose the worker coworkers you need for this board. The user sees the" - " roster and approves it; approval creates the worker sessions and" - " returns their actor ids so you can assign work items to them. Only" + "Propose the worker coworkers you need for this board. Give EACH member" + " a short unique callname (`name`, e.g. 'nia', 'webb', 'checks') — it" + " becomes their handle for assignment and @mentions, and lets you staff" + " two of the same coworker. The user sees the roster and approves it;" + " approval creates the worker sessions and returns the handles. Only" " team-capable worker coworkers may be proposed." ), "parameters": { @@ -241,10 +243,11 @@ _PROPOSE_TEAM_SCHEMA = { "type": "object", "properties": { "persona": {"type": "string"}, + "name": {"type": "string"}, "model": {"type": "string"}, "reason": {"type": "string"}, }, - "required": ["persona"], + "required": ["persona", "name"], }, }, "enable_chat": {"type": "boolean"}, diff --git a/surfaces/gui/e2e/fixtures.ts b/surfaces/gui/e2e/fixtures.ts index 9d58a0fb..fbe67a3d 100644 --- a/surfaces/gui/e2e/fixtures.ts +++ b/surfaces/gui/e2e/fixtures.ts @@ -565,6 +565,17 @@ export async function mockApi(page: import("@playwright/test").Page) { // "plan the work" (the fake agent then files items; no draft state — the board only // holds accepted work). Mutable so transitions round-trip through the real endpoints. const boardItems: any[] = []; + // # team chat log — seeded with one lead question so mention highlighting renders. + const chatMessages: any[] = [ + { + seq: 1, + ts: new Date().toISOString(), + author: "lead", + author_role: "lead", + text: "@nia does the api assume the assets bucket is public? quick check before you write it up.", + mentions: ["nia"], + }, + ]; const seedBoard = () => { if (boardItems.length) return; boardItems.push( @@ -648,12 +659,12 @@ export async function mockApi(page: import("@playwright/test").Page) { if (/staff the team/i.test(msg.text)) { send("team_proposed", { members: [ - { persona: "swe-worker", model: "anthropic:claude-opus-4-8", reason: "implementation" }, - { persona: "design-worker", reason: "UI polish" }, - { persona: "test-worker", reason: "verifies against acceptance criteria" }, + { persona: "swe-worker", name: "nia", model: "anthropic:claude-opus-4-8", reason: "implementation" }, + { persona: "design-worker", name: "webb", reason: "UI polish" }, + { persona: "test-worker", name: "checks", reason: "verifies against acceptance criteria" }, ], enable_chat: false, - note: "Three workers cover the plan; test-worker verifies before anything closes.", + note: "Three workers cover the plan; checks verifies before anything closes.", }); return; // suspended on the staffing decision } @@ -883,16 +894,22 @@ export async function mockApi(page: import("@playwright/test").Page) { team: { role: "lead", team_id: "t1" }, }; if (!sessions.includes(lead)) sessions.unshift(lead); - for (const [actor, status, item] of [ - ["swe-worker", "in_progress", "#1 in progress"], - ["design-worker", "idle", "idle"], - ["test-worker", "blocked", "#4 blocked"], + lead.team = { + role: "lead", + team_id: "t1", + chat_enabled: !!msg.enable_chat, + chat_unread: msg.enable_chat ? 1 : 0, + }; + for (const [actor, persona, status, item] of [ + ["nia", "swe-worker", "in_progress", "#1 in progress"], + ["webb", "design-worker", "idle", "idle"], + ["checks", "test-worker", "blocked", "#4 blocked"], ] as const) { sessions.push({ session_id: `sess-${actor}`, title: actor, workspace: "/Users/test/OpenWorker/launch-note", - agent: actor, + agent: persona, model: "m", mode: "interactive", updated_at: new Date().toISOString(), @@ -908,7 +925,7 @@ export async function mockApi(page: import("@playwright/test").Page) { }); } send("assistant_message", { - text: "Team created — swe-worker, design-worker and test-worker are standing by. Assigning items now.", + text: "Team created — nia, webb and checks are standing by. Assigning items now.", }); } else { send("assistant_message", { text: "Understood — tell me how to change the roster." }); @@ -1019,6 +1036,34 @@ export async function mockApi(page: import("@playwright/test").Page) { return json(item); } if (/\/v1\/sessions\/[^/]+\/board$/.test(p)) return json(boardPayload()); + // # team chat (OPE-99): one group, message log, user posts append. + if (/\/v1\/teams\/[^/]+\/chat$/.test(p)) { + if (m === "POST") { + const b = req.postDataJSON() || {}; + chatMessages.push({ + seq: chatMessages.length + 1, + ts: new Date().toISOString(), + author: "user", + author_role: "user", + text: String(b.text || ""), + mentions: ["nia", "webb", "checks", "lead"].filter((h) => + String(b.text || "").includes(`@${h}`), + ), + }); + return json(chatMessages[chatMessages.length - 1]); + } + return json({ + enabled: true, + team_id: "t1", + members: [ + { name: "nia", persona: "swe-worker", role: "worker" }, + { name: "webb", persona: "design-worker", role: "worker" }, + { name: "checks", persona: "test-worker", role: "worker" }, + { name: "lead", persona: "swe-lead", role: "lead" }, + ], + messages: chatMessages, + }); + } if (p.endsWith("/v1/teams/journal")) { return json({ cases: boardItems.length diff --git a/surfaces/gui/e2e/team.spec.ts b/surfaces/gui/e2e/team.spec.ts index 656be826..f46cf156 100644 --- a/surfaces/gui/e2e/team.spec.ts +++ b/surfaces/gui/e2e/team.spec.ts @@ -41,18 +41,60 @@ test("declining the split returns feedback to the lead", async ({ page }) => { await expect(page.getByText(/reworking the split/)).toBeVisible(); }); -test("the staffing gate shows the roster and the grant sentence", async ({ page }) => { +test("the staffing gate shows named workers, the chat toggle, and the grant sentence", async ({ + page, +}) => { await proposeTeam(page); const card = page.getByTestId("teamreq-card"); await expect(card).toContainText("Proposed team — 3 workers"); + // callnames lead the rows; persona + reason follow + await expect(card).toContainText("nia"); await expect(card).toContainText("swe-worker"); await expect(card).toContainText("implementation"); - await expect(card).toContainText("test-worker"); + await expect(card).toContainText("checks"); + // the chat checkbox defaults OFF — the user's call, not the lead's + await expect(card.getByTestId("teamreq-chat-toggle")).not.toBeChecked(); await expect(card).toContainText( "Approving grants the lead create, assign & steer — this team only, revocable.", ); }); +test("enabling chat at the gate adds the # team chat row; posting works with mentions", async ({ + page, +}) => { + await proposeTeam(page); + await page.getByTestId("teamreq-chat-toggle").check(); + await page.getByTestId("teamreq-approve").click(); + await expect(page.getByText(/Team created/)).toBeVisible(); + + await page.getByTestId("team-toggle-sess-lead").click(); + const chatRow = page.getByTestId("team-chat-row-sess-lead"); + await expect(chatRow).toBeVisible(); + await expect(chatRow).toContainText("1"); // unread badge + + await chatRow.click(); + const view = page.getByTestId("teamchat-view"); + await expect(view).toBeVisible(); + await expect(view).toContainText("assets bucket is public"); + await expect(view.locator(".chat-mention").first()).toHaveText("@nia"); + + await page.getByTestId("chat-input").fill("ship it current-month only @lead"); + await page.getByTestId("chat-send").click(); + await expect(view).toContainText("ship it current-month only"); + + await page.keyboard.press("Escape"); + await expect(page.getByTestId("teamchat-view")).toHaveCount(0); +}); + +test("with chat declined at the gate, no chat row renders", async ({ page }) => { + await proposeTeam(page); + await page.getByTestId("teamreq-approve").click(); + await expect(page.getByText(/Team created/)).toBeVisible(); + await page.getByTestId("team-toggle-sess-lead").click(); + await expect(page.getByTestId("team-children-sess-lead")).toBeVisible(); + await expect(page.getByTestId("team-chat-row-sess-lead")).toHaveCount(0); +}); + test("declining the roster returns the turn to the lead", async ({ page }) => { await proposeTeam(page); await page.getByRole("button", { name: "Not now" }).click(); @@ -76,9 +118,9 @@ test("approval creates the team; workers nest under the lead's expandable entry" await page.getByTestId("team-toggle-sess-lead").click(); const children = page.getByTestId("team-children-sess-lead"); await expect(children).toBeVisible(); - await expect(children).toContainText("swe-worker · #1 in progress"); - await expect(children).toContainText("design-worker · idle"); - await expect(children).toContainText("test-worker · #4 blocked"); + await expect(children).toContainText("nia · #1 in progress"); + await expect(children).toContainText("webb · idle"); + await expect(children).toContainText("checks · #4 blocked"); // Collapse hides them again — the team is one entry, not a panel. await page.getByTestId("team-toggle-sess-lead").click(); diff --git a/surfaces/gui/src/App.tsx b/surfaces/gui/src/App.tsx index 4a9f636b..b7793f3d 100644 --- a/surfaces/gui/src/App.tsx +++ b/surfaces/gui/src/App.tsx @@ -78,6 +78,7 @@ import { PlanCard } from "./components/PlanCard"; import { BoardOverlay } from "./components/BoardPanel"; import { TeamRequestCard } from "./components/TeamRequestCard"; import { WorkItemsCard } from "./components/WorkItemsCard"; +import { TeamChatView } from "./components/TeamChatView"; import { WorkspaceTrustPrompt } from "./components/WorkspaceTrustPrompt"; const newId = () => @@ -276,6 +277,8 @@ export function App() { // Agent teams (OPE-96): board for the current session's workspace space. const [board, setBoard] = useState(null); const [boardOpen, setBoardOpen] = useState(false); + // # team chat overlay — opened from the team entry's chat row. + const [chatTeam, setChatTeam] = useState(null); const [railHidden, setRailHidden] = useState(false); // Left-nav collapse (⌘B): when collapsed the sidebar leaves the grid so content reclaims the // width; hovering the left edge peeks it back as a floating overlay. Persisted per-device. @@ -1043,10 +1046,10 @@ export function App() { sessionRef.current?.respondPlan(approved, mode, feedback); if (approved && mode) setMode(mode); // the server flips the live engine to this mode }; - const respondTeam = (approved: boolean, feedback?: string) => { + const respondTeam = (approved: boolean, feedback?: string, enableChat?: boolean) => { setItems((p) => resolveLastTeam(p, approved ? "approved" : "rejected")); dropSessionInbox("plan"); // the gate parks as a plan-kind Inbox item - sessionRef.current?.respondTeam(approved, feedback); + sessionRef.current?.respondTeam(approved, feedback, enableChat); }; const respondItemsReq = (approved: boolean, feedback?: string) => { setItems((p) => resolveLastItemsReq(p, approved ? "approved" : "rejected")); @@ -1599,6 +1602,7 @@ export function App() { sessions={sessions} projects={projects} activeSession={sessionId} + onOpenTeamChat={(teamId) => setChatTeam(teamId)} onSwitchAgent={switchAgent} onNewSession={startNewSession} onSelectSession={selectSession} @@ -2006,6 +2010,7 @@ export function App() { {boardOpen && board && board.space && ( setBoardOpen(false)} onTransition={moveBoardItem} /> )} + {chatTeam && setChatTeam(null)} />}
)} diff --git a/surfaces/gui/src/api.ts b/surfaces/gui/src/api.ts index b428f65f..83baa7bb 100644 --- a/surfaces/gui/src/api.ts +++ b/surfaces/gui/src/api.ts @@ -253,6 +253,36 @@ export async function boardTransition( return res.json(); } +export interface ChatMessage { + seq: number; + ts: string; + author: string; + author_role: "user" | "lead" | "worker" | string; + text: string; + mentions: string[]; +} + +export interface TeamChat { + enabled: boolean; + team_id?: string; + members: { name: string; persona: string; role: string }[]; + messages: ChatMessage[]; +} + +export async function getTeamChat(teamId: string): Promise { + const res = await fetch(`${httpBase()}/v1/teams/${encodeURIComponent(teamId)}/chat`); + return res.json(); +} + +export async function postTeamChat(teamId: string, text: string): Promise { + const res = await fetch(`${httpBase()}/v1/teams/${encodeURIComponent(teamId)}/chat`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ text }), + }); + return res.json(); +} + export async function getJournalCases(): Promise { const res = await fetch(`${httpBase()}/v1/teams/journal`); return (await res.json()).cases ?? []; @@ -2189,11 +2219,12 @@ export class Session { }); } - respondTeam(approved: boolean, feedback?: string) { + respondTeam(approved: boolean, feedback?: string, enableChat?: boolean) { this.send({ type: "team_response", approved, ...(feedback ? { feedback } : {}), + ...(enableChat !== undefined ? { enable_chat: enableChat } : {}), }); } diff --git a/surfaces/gui/src/components/Sidebar.tsx b/surfaces/gui/src/components/Sidebar.tsx index 27fff0a0..3bcf2120 100644 --- a/surfaces/gui/src/components/Sidebar.tsx +++ b/surfaces/gui/src/components/Sidebar.tsx @@ -121,6 +121,8 @@ interface Props { onSwitchAgent: (agent: string) => void; onNewSession: (agent: string) => void; onSelectSession: (id: string, workspace: string, agent: string) => void; + // Agent teams: opens the team's # team chat view (the row under the expandable entry). + onOpenTeamChat?: (teamId: string) => void; onNewProject: (persona: string) => void; onRenameSession: (id: string, title: string) => void; onDeleteSession: (id: string) => void; @@ -728,6 +730,19 @@ export function Sidebar(props: Props) { ))} + {s.team?.chat_enabled && props.onOpenTeamChat && ( +
props.onOpenTeamChat?.(s.team?.team_id || "")} + > + # + team chat + {(s.team?.chat_unread || 0) > 0 && ( + {s.team?.chat_unread} + )} +
+ )} ); }; diff --git a/surfaces/gui/src/components/TeamChatView.tsx b/surfaces/gui/src/components/TeamChatView.tsx new file mode 100644 index 00000000..02bfc736 --- /dev/null +++ b/surfaces/gui/src/components/TeamChatView.tsx @@ -0,0 +1,129 @@ +// # team chat (agent teams, OPE-99): a minimal Slack-shaped exception channel over +// the session area — author-grouped messages, @mention highlighting, composer that +// posts as [User] (which wakes every member; agent posts wake mentions only). +// No derived board-event clusters (owner call, eighth pass): status lives on the +// board rail one click away — this surface is pure messages. +import { useEffect, useRef, useState } from "react"; +import { getTeamChat, postTeamChat, type TeamChat } from "../api"; +import { Icon } from "./Icon"; + +function mentionify(text: string, members: Set) { + // Split on @word tokens; wrap known handles in a highlight span. + const parts = text.split(/(@[\w.-]+)/g); + return parts.map((part, i) => + part.startsWith("@") && members.has(part.slice(1)) ? ( + + {part} + + ) : ( + {part} + ), + ); +} + +function clock(ts: string): string { + const d = new Date(ts); + return isNaN(d.getTime()) + ? "" + : d.toLocaleTimeString([], { hour: "numeric", minute: "2-digit" }); +} + +export function TeamChatView({ teamId, onClose }: { teamId: string; onClose: () => void }) { + const [chat, setChat] = useState(null); + const [draft, setDraft] = useState(""); + const [busy, setBusy] = useState(false); + const bottom = useRef(null); + + const load = () => getTeamChat(teamId).then(setChat).catch(() => {}); + useEffect(() => { + load(); + const t = setInterval(load, 3000); + return () => clearInterval(t); + }, [teamId]); + useEffect(() => { + bottom.current?.scrollIntoView({ block: "end" }); + }, [chat?.messages.length]); + useEffect(() => { + const onKey = (e: KeyboardEvent) => { + if (e.key === "Escape") onClose(); + }; + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, [onClose]); + + const send = async () => { + const text = draft.trim(); + if (!text || busy) return; + setBusy(true); + try { + await postTeamChat(teamId, text); + setDraft(""); + await load(); + } finally { + setBusy(false); + } + }; + + const handles = new Set((chat?.members || []).map((m) => m.name)); + const messages = chat?.messages || []; + + return ( +
+
e.stopPropagation()}> +
+
+ # + team chat + questions & consensus — status lives on the board +
+ +
+
+ {messages.length === 0 && ( +
+ No messages yet. Agents post here only when something needs a reply — + @mention a coworker to reach it. +
+ )} + {messages.map((m, i) => { + const grouped = i > 0 && messages[i - 1].author === m.author; + const label = m.author_role === "user" ? "You" : m.author; + return ( +
+ {!grouped && ( +
+ + {label.slice(0, 1).toUpperCase()} + + {label} + {m.author_role === "user" ? "" : m.author_role} + {clock(m.ts)} +
+ )} +
{mentionify(m.text, handles)}
+
+ ); + })} +
+
+
+ setDraft(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") send(); + }} + /> + +
+
+
+ ); +} diff --git a/surfaces/gui/src/components/TeamRequestCard.tsx b/surfaces/gui/src/components/TeamRequestCard.tsx index b5aeb157..ec3d50bf 100644 --- a/surfaces/gui/src/components/TeamRequestCard.tsx +++ b/surfaces/gui/src/components/TeamRequestCard.tsx @@ -1,7 +1,9 @@ // The staffing gate (agent teams, UX-030): a lead proposes its worker roster. -// Visible layer = the decisions (who, on what model, why); approving grants the lead -// create/assign/steer for this board — standing, revocable — and PRE-SPAWNS the -// worker sessions. No in-card reply surface: editing happens by replying. +// Visible layer = the decisions (who — by callname — on what model, why); approving +// grants the lead create/assign/steer for this board — standing, revocable — and +// PRE-SPAWNS the worker sessions. The chat checkbox is the USER's call (default +// OFF, ⓘ per mock); no in-card reply surface: editing happens by replying. +import { useState } from "react"; import type { Item } from "../types"; import { Icon } from "./Icon"; @@ -10,8 +12,9 @@ export function TeamRequestCard({ onRespond, }: { item: Extract; - onRespond: (approved: boolean, feedback?: string) => void; + onRespond: (approved: boolean, feedback?: string, enableChat?: boolean) => void; }) { + const [chat, setChat] = useState(!!item.enable_chat); return (
@@ -25,12 +28,31 @@ export function TeamRequestCard({
+ {m.name && {m.name}} + {m.name ? " — " : ""} {m.persona} {m.model && · {m.model}} {m.reason && — {m.reason}}
))} +
Approving grants the lead create, assign & steer — this team only, revocable. @@ -42,7 +64,7 @@ export function TeamRequestCard({ diff --git a/surfaces/gui/src/styles.css b/surfaces/gui/src/styles.css index 4f890678..befc9c22 100644 --- a/surfaces/gui/src/styles.css +++ b/surfaces/gui/src/styles.css @@ -1748,3 +1748,31 @@ html[data-platform="linux"] ::-webkit-scrollbar-thumb:hover { background-color: .itemsreq-ac b { font-weight: 600; } .itemsreq-more { display: flex; align-items: center; gap: 5px; border: 0; background: transparent; color: var(--accent); font-size: 12px; cursor: pointer; padding: 6px 0; } .itemsreq-grant { font-size: 11.5px; color: var(--faint); } + +/* # team chat */ +.teamreq-name { color: var(--ink); font-weight: 600; } +.teamreq-chat { display: flex; align-items: center; gap: 8px; padding: 9px 0 2px; border-top: 1px solid var(--line); font-size: 12.5px; color: var(--ink); cursor: pointer; } +.teamreq-info { display: inline-flex; align-items: center; justify-content: center; width: 14px; height: 14px; border: 1px solid var(--line-strong); border-radius: 50%; color: var(--faint); font-size: 9px; cursor: help; } +.chat-panel { max-width: 860px; } +.chat-hash { color: var(--faint); font-weight: 700; } +.chat-scroll { flex: 1; overflow: auto; padding: 14px 18px; display: flex; flex-direction: column; } +.chat-empty { color: var(--faint); font-size: 12.5px; margin: auto; max-width: 420px; text-align: center; } +.chat-msg { margin-top: 12px; } +.chat-msg.grouped { margin-top: 2px; padding-left: 34px; } +.chat-who { display: flex; align-items: baseline; gap: 8px; } +.chat-avatar { width: 26px; height: 26px; border-radius: 7px; background: var(--paper); border: 1px solid var(--line-strong); display: inline-flex; align-items: center; justify-content: center; font-size: 12px; font-weight: 700; align-self: center; } +.chat-avatar.lead { background: var(--accent-soft); border-color: var(--accent); color: var(--accent); } +.chat-avatar.user { background: var(--ok-soft); border-color: var(--ok-line); color: var(--ok); } +.chat-name { font-size: 12.5px; font-weight: 700; color: var(--ink); } +.chat-role { font-size: 11px; color: var(--faint); } +.chat-ts { font-size: 10.5px; color: var(--faint); } +.chat-text { font-size: 13px; color: var(--ink); margin-top: 1px; padding-left: 34px; } +.chat-msg.grouped .chat-text { padding-left: 0; } +.chat-mention { color: var(--accent); background: var(--accent-soft); border-radius: 4px; padding: 0 3px; } +.chat-composer { display: flex; gap: 8px; padding: 10px 14px; border-top: 1px solid var(--line); } +.chat-input { flex: 1; border: 1px solid var(--line); border-radius: 10px; padding: 9px 12px; font-size: 13px; background: var(--paper); color: var(--ink); outline: none; } +.chat-input:focus { border-color: var(--accent); } + +/* Sidebar chat row */ +.team-hash { color: var(--faint); font-weight: 700; font-size: 12px; width: 10px; text-align: center; } +.team-chat-badge { margin-left: auto; background: var(--accent); color: #fff; font-size: 10px; font-weight: 700; border-radius: 8px; padding: 0 6px; } diff --git a/surfaces/gui/src/types.ts b/surfaces/gui/src/types.ts index 7693e4c3..020f4e7e 100644 --- a/surfaces/gui/src/types.ts +++ b/surfaces/gui/src/types.ts @@ -95,6 +95,8 @@ export interface SessionInfo { actor?: string; current_item?: string; status?: string; + chat_enabled?: boolean; + chat_unread?: number; }; } @@ -160,7 +162,7 @@ export type Item = | { // The staffing gate (agent teams): a lead proposes its worker roster. kind: "teamreq"; - members: { persona: string; model?: string; reason?: string }[]; + members: { persona: string; name?: string; model?: string; reason?: string }[]; enable_chat?: boolean; note?: string; resolved?: "approved" | "rejected"; diff --git a/tests/test_team_wake.py b/tests/test_team_wake.py index 70ee36e1..02f41bf2 100644 --- a/tests/test_team_wake.py +++ b/tests/test_team_wake.py @@ -266,3 +266,83 @@ def test_turn_saves_never_detach_a_worker_from_its_team(manager, monkeypatch): ) assert manager.session_store.load(wid).team["lead_session"] == "lead-sid" assert manager.session_store.load("lead-sid").team["role"] == "lead" + + +# ------------------------------------------------------------------- chat (OPE-99) + +def test_chat_groups_mentions_and_wake_reads(tmp_path): + from coworker.teams.chat import ChatStore + + chat = ChatStore(tmp_path / "chat.db") + group = chat.create_group( + "team chat", + [ + {"name": "nia", "persona": "swe-worker", "role": "worker"}, + {"name": "webb", "persona": "design-worker", "role": "worker"}, + {"name": "lead", "persona": "swe-lead", "role": "lead"}, + ], + ) + gid = group["group_id"] + # mention parsing against member handles; unknown handles ignored + message = chat.post(gid, "lead", "does the api assume public logos? @nia @nobody") + assert message["mentions"] == ["nia"] + # mention-only wakes: nia woken, webb not; authors never wake themselves + assert [m["seq"] for m in chat.unread_for(gid, "nia")] == [message["seq"]] + assert chat.unread_for(gid, "webb") == [] + assert chat.unread_for(gid, "lead") == [] + chat.consume(gid, "nia", message["seq"]) + assert chat.unread_for(gid, "nia") == [] + # a USER post wakes every member + chat.post(gid, "user", "ship it current-month only", author_role="user") + assert len(chat.unread_for(gid, "nia")) == 1 + assert len(chat.unread_for(gid, "webb")) == 1 + assert len(chat.unread_for(gid, "lead")) == 1 + # badge count for the user (its own posts excluded) + assert chat.unread_count(gid, "user") == 1 + + +def test_create_team_uses_callnames_and_creates_the_chat_group(manager, monkeypatch): + from coworker.agents.base import Agent + from coworker.sessions import SessionRecord + + worker_agent = Agent(name="swe-worker", title="SWE", system_prompt="p", team="worker") + monkeypatch.setattr("coworker.server.manager.get_agent", lambda name: worker_agent) + manager.session_store.save( + SessionRecord( + session_id="lead-sid", + workspace=manager.default_workspace, + model="m", + mode="interactive", + messages=[], + agent="swe-lead", + ) + ) + bad = manager.create_team("lead-sid", [{"persona": "swe-worker", "name": "no spaces!"}]) + assert bad["approved"] is False and "callname" in bad["error"] + result = manager.create_team( + "lead-sid", + [ + {"persona": "swe-worker", "name": "nia", "reason": "implementation"}, + {"persona": "swe-worker", "name": "nia"}, # dupe → suffixed + ], + enable_chat=True, + ) + assert [w["actor"] for w in result["workers"]] == ["nia", "nia-2"] + team = manager.teams.for_lead_session("lead-sid") + assert team.chat_enabled and team.chat_group + group = manager.chat_store.get_group(team.chat_group) + assert {m["name"] for m in group["members"]} == {"nia", "nia-2", "lead"} + # the worker digest carries the roster + how to reach teammates + digest = manager._team_digest(team, [], [], is_lead=False) + assert "Your team: nia (swe-worker — implementation)" in digest + assert "@name in # team chat" in digest + + +def test_cancel_notice_is_addressed_to_the_assignee(store): + item_id = assigned(store) + store.consume("swe-worker", store.pending_for("swe-worker")[-1]["seq"]) + store.transition(SPACE, LEAD, item_id, "canceled", comment="scope cut") + pending = store.pending_for("swe-worker") + assert len(pending) == 1 + assert pending[0]["kind"] == "item_transitioned" + assert pending[0]["payload"]["to"] == "canceled" From d0fdcb032e4507fb8ef9cb155973b3fb13250337 Mon Sep 17 00:00:00 2001 From: Rohit C Prasad Date: Sun, 16 Aug 2026 16:39:18 -0700 Subject: [PATCH 37/80] =?UTF-8?q?Chat=20replaces=20the=20session=20view=20?= =?UTF-8?q?in=20place=20=E2=80=94=20not=20a=20modal;=20sidebar=20stays=20l?= =?UTF-8?q?ive?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- surfaces/gui/src/App.tsx | 6 ++++- surfaces/gui/src/components/TeamChatView.tsx | 26 +++++++++++--------- surfaces/gui/src/styles.css | 5 ++++ 3 files changed, 25 insertions(+), 12 deletions(-) diff --git a/surfaces/gui/src/App.tsx b/surfaces/gui/src/App.tsx index b7793f3d..a9a31608 100644 --- a/surfaces/gui/src/App.tsx +++ b/surfaces/gui/src/App.tsx @@ -1772,6 +1772,11 @@ export function App() { )}
+ {/* # team chat replaces the session view in place (owner ask 2026-08-16 — + not a modal): the sidebar stays live, Esc/back returns to the session. */} + {chatTeam && surface === "session" && ( + setChatTeam(null)} /> + )}
{/* Automation-run context (owner ask 2026-07-04): a __run__ session looked like any @@ -2010,7 +2015,6 @@ export function App() { {boardOpen && board && board.space && ( setBoardOpen(false)} onTransition={moveBoardItem} /> )} - {chatTeam && setChatTeam(null)} />}
)} diff --git a/surfaces/gui/src/components/TeamChatView.tsx b/surfaces/gui/src/components/TeamChatView.tsx index 02bfc736..8bcd0281 100644 --- a/surfaces/gui/src/components/TeamChatView.tsx +++ b/surfaces/gui/src/components/TeamChatView.tsx @@ -68,18 +68,20 @@ export function TeamChatView({ teamId, onClose }: { teamId: string; onClose: () const messages = chat?.messages || []; return ( -
-
e.stopPropagation()}> -
-
- # - team chat - questions & consensus — status lives on the board -
- + // Replaces the session view IN PLACE (absolute inside .main, not a modal): + // the sidebar stays interactive; back or Esc returns to the session. +
+
+ +
+ # + team chat + questions & consensus — status lives on the board
+
+
{messages.length === 0 && (
@@ -127,3 +129,5 @@ export function TeamChatView({ teamId, onClose }: { teamId: string; onClose: ()
); } +// Note: artifact links in chat (agents referencing reports, click → artifact +// viewer) are planned — see the Linear follow-up on chat evolution. diff --git a/surfaces/gui/src/styles.css b/surfaces/gui/src/styles.css index befc9c22..92ae77ac 100644 --- a/surfaces/gui/src/styles.css +++ b/surfaces/gui/src/styles.css @@ -1776,3 +1776,8 @@ html[data-platform="linux"] ::-webkit-scrollbar-thumb:hover { background-color: /* Sidebar chat row */ .team-hash { color: var(--faint); font-weight: 700; font-size: 12px; width: 10px; text-align: center; } .team-chat-badge { margin-left: auto; background: var(--accent); color: #fff; font-size: 10px; font-weight: 700; border-radius: 8px; padding: 0 6px; } + +/* # team chat as an in-place view (not a modal): fills .main, sidebar stays live. */ +.chat-view { position: absolute; inset: 0; z-index: 40; background: var(--paper); display: flex; flex-direction: column; } +.chat-view-head { display: flex; align-items: center; gap: 10px; padding: 12px 16px; border-bottom: 1px solid var(--line); background: var(--panel); } +.chat-view-body { flex: 1; display: flex; flex-direction: column; min-height: 0; max-width: 860px; width: 100%; margin: 0 auto; } From 2e18d9d5c4c57c31d800b136ae47d0ef30833954 Mon Sep 17 00:00:00 2001 From: Rohit C Prasad Date: Sun, 16 Aug 2026 17:00:51 -0700 Subject: [PATCH 38/80] Lead cadence: mandatory check-in timer, harness backstop, sleeping strip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The lead must end active turns with a sleep_for (3-5m, stretch when quiet); a 10-minute backstop wakes a lead that forgot while work is in flight. Sleeping sessions show a strip with the next wake time and an Ask-for-a-status action — a scheduled agent never reads as a dead one. --- .../personas/builtin/swe-lead/manifest.md | 8 ++- coworker/server/manager.py | 68 +++++++++++++++++++ surfaces/gui/e2e/fixtures.ts | 3 + surfaces/gui/e2e/team.spec.ts | 14 ++++ surfaces/gui/src/App.tsx | 28 ++++++++ surfaces/gui/src/styles.css | 11 +++ surfaces/gui/src/types.ts | 2 + tests/test_team_wake.py | 43 ++++++++++++ 8 files changed, 175 insertions(+), 2 deletions(-) diff --git a/coworker/personas/builtin/swe-lead/manifest.md b/coworker/personas/builtin/swe-lead/manifest.md index 60bcbbe7..04dddf90 100644 --- a/coworker/personas/builtin/swe-lead/manifest.md +++ b/coworker/personas/builtin/swe-lead/manifest.md @@ -49,6 +49,10 @@ Communication doctrine: - The user outranks you everywhere; steering attributed [User] wins over yours. - Journal decisions as you make them (journal_append, kind=decision) — the next lead reads the journal, not your transcript. -- Use sleep_for to set your own check-in cadence when the team is quiet; your timer - wakes arrive with a board digest so a nothing's-wrong wake costs one glance. +- NEVER end a turn with work in flight and no check-in timer set. After assigning — + and at the end of every wake while items are active — call sleep_for: start at 3–5 + minutes; when a wake finds nothing changed, double the interval (cap ~20 minutes); + tighten back when things get hot. Your timer wakes arrive with a board digest, so + a nothing's-wrong wake costs one glance. (The harness has a backstop if you + forget, but relying on it means slower reactions — own your cadence.) - Report to the user plainly: what moved, what's blocked, what needs their decision. diff --git a/coworker/server/manager.py b/coworker/server/manager.py index 4060b356..00f2b6ed 100644 --- a/coworker/server/manager.py +++ b/coworker/server/manager.py @@ -213,6 +213,9 @@ class SessionManager: self.chat_store = ChatStore(base / "chat.db") self.teams = TeamRegistry(base / "teams.json") self._team_inflight: set[str] = set() + # Lead-session last-turn timestamps for the check-in backstop (monotonic-ish + # wall clock; restart resets the clock rather than firing a wake storm). + self._team_last_alive: dict[str, float] = {} self._loop: Optional[asyncio.AbstractEventLoop] = None # Personas: registry + lifecycle state under this manager's data dir. Installed as the # process singleton so agents.get_agent resolves persona ids (incl. third-party) here. @@ -1846,8 +1849,60 @@ class SessionManager: actor=team.lead_actor, is_lead=True, ) + delivered += await self._maybe_backstop_lead(team) return delivered + # The lead owns its cadence (sleep_for, stretch-when-quiet); this backstop only + # exists because prompts aren't guarantees. A forgotten timer must never orphan + # a running team — and it de-facto covers a worker dying without a transition + # (its item goes stale; the backstop wake surfaces it in the digest). + TEAM_LEAD_BACKSTOP_SECS = 600 + + def _lead_backstop_due(self, team) -> bool: + sid = team.lead_session + if self.is_running(sid) or sid in self._team_inflight: + return False + if self.wakes.pending(sid): + return False # a timer is set — the lead is on cadence, not forgotten + # Restart-safe: the first observation starts the clock instead of waking. + last = self._team_last_alive.setdefault(sid, time.time()) + if time.time() - last < self.TEAM_LEAD_BACKSTOP_SECS: + return False + try: + items = self.team_store.list_items(team.space, self._user_actor()) + except Exception: + return False + return any( + i["state"] in ("in_progress", "blocked", "review") for i in items + ) + + async def _maybe_backstop_lead(self, team) -> int: + if not self._lead_backstop_due(team): + return 0 + if not self.teams.count_wake(team.team_id, cap=self.TEAM_WAKE_CAP_PER_HOUR): + return 0 + sid = team.lead_session + self._team_last_alive[sid] = time.time() + message = ( + "⏰ Backstop check — work is in flight but you had no check-in timer" + " set.\n\n" + + (self.team_staleness_digest(sid) or "Board state unavailable.") + + "\n\nGlance, act only if something needs you, and set your next" + " check-in with sleep_for (start 3–5 minutes; stretch when quiet)." + ) + self._team_inflight.add(sid) + + async def _deliver() -> None: + try: + await self.deliver_to_session( + sid, message, source=self._board_source(team, message) + ) + finally: + self._team_inflight.discard(sid) + + asyncio.create_task(_deliver()) + return 1 + async def _drain_team_member( self, team, *, session_id: str, actor: str, is_lead: bool ) -> int: @@ -3724,6 +3779,8 @@ class SessionManager: # Team sessions: a finished turn is the moment new board events exist (an # assign, a review transition) — kick the queue drain now instead of waiting # for the next scheduler tick. Cheap no-op for teamless sessions. + if self.teams.for_lead_session(session_id): + self._team_last_alive[session_id] = time.time() if self._loop is not None and ( self.teams.for_lead_session(session_id) or self.teams.for_worker_session(session_id) @@ -4633,6 +4690,9 @@ class SessionManager: # sleeping (a self-wake is pending) / idle — a count-less dot that never bubbles. "attention": len(self.inbox.pending(session_id=r.session_id)), "liveness": self._session_liveness(r.session_id), + # When sleeping: the next timer fire (ISO) — drives the "sleeping + # until…" strip so a scheduled agent never reads as a dead one. + "sleeping_until": self._sleeping_until(r.session_id), # Channels this session listens to (inbound subscriptions) — drives the per-session # "connections" indicator. "subscriptions": [ @@ -4686,6 +4746,14 @@ class SessionManager: row["status"] = active["state"] if active else "idle" return row + def _sleeping_until(self, session_id: str) -> Optional[str]: + fires = [ + w.fire_at + for w in self.wakes.pending(session_id) + if w.kind == "timer" and w.fire_at + ] + return min(fires) if fires else None + def _session_liveness(self, session_id: str) -> str: if self.is_running(session_id): return "working" diff --git a/surfaces/gui/e2e/fixtures.ts b/surfaces/gui/e2e/fixtures.ts index fbe67a3d..1811ca31 100644 --- a/surfaces/gui/e2e/fixtures.ts +++ b/surfaces/gui/e2e/fixtures.ts @@ -900,6 +900,9 @@ export async function mockApi(page: import("@playwright/test").Page) { chat_enabled: !!msg.enable_chat, chat_unread: msg.enable_chat ? 1 : 0, }; + // The lead sets its check-in timer after staffing — it shows as sleeping. + lead.liveness = "sleeping"; + lead.sleeping_until = new Date(Date.now() + 4 * 60_000).toISOString(); for (const [actor, persona, status, item] of [ ["nia", "swe-worker", "in_progress", "#1 in progress"], ["webb", "design-worker", "idle", "idle"], diff --git a/surfaces/gui/e2e/team.spec.ts b/surfaces/gui/e2e/team.spec.ts index f46cf156..b3d311d0 100644 --- a/surfaces/gui/e2e/team.spec.ts +++ b/surfaces/gui/e2e/team.spec.ts @@ -86,6 +86,20 @@ test("enabling chat at the gate adds the # team chat row; posting works with men await expect(page.getByTestId("teamchat-view")).toHaveCount(0); }); +test("a sleeping lead shows the strip; Ask for a status wakes it", async ({ page }) => { + await proposeTeam(page); + await page.getByTestId("teamreq-approve").click(); + await expect(page.getByText(/Team created/)).toBeVisible(); + // open the lead's session — it set a check-in timer, so it's sleeping + await page.getByText("Build the statements page").click(); + const strip = page.getByTestId("sleep-strip"); + await expect(strip).toBeVisible({ timeout: 12_000 }); + await expect(strip).toContainText("Sleeping until"); + await expect(strip).toContainText("while the team works"); + await page.getByTestId("sleep-status-btn").click(); + await expect(page.getByText(/Echo: Quick status check/)).toBeVisible(); +}); + test("with chat declined at the gate, no chat row renders", async ({ page }) => { await proposeTeam(page); await page.getByTestId("teamreq-approve").click(); diff --git a/surfaces/gui/src/App.tsx b/surfaces/gui/src/App.tsx index a9a31608..f7df71d0 100644 --- a/surfaces/gui/src/App.tsx +++ b/surfaces/gui/src/App.tsx @@ -1918,6 +1918,34 @@ export function App() { }} /> )} + {/* A scheduled agent must never read as a dead one: while a self-wake is + pending and no turn is running, say so and offer the obvious action. */} + {activeInfo?.liveness === "sleeping" && !running && ( +
+ + + Sleeping + {activeInfo.sleeping_until + ? ` until ${new Date(activeInfo.sleeping_until).toLocaleTimeString([], { hour: "numeric", minute: "2-digit" })}` + : ""} + {activeInfo.team?.role === "lead" + ? " while the team works — it also wakes on board activity." + : " — it wakes on its trigger."}{" "} + Talk to it anytime. + + +
+ )} Date: Sun, 16 Aug 2026 17:48:22 -0700 Subject: [PATCH 39/80] Board claims: store-stamped self-assignment + claims policy knob (OPE-100) Claim wins by first write under the store lock; lead supervises by exception via its digest. Policy claims: open (default) | lead-only; per-item reservation = lead assigns itself. --- .../personas/builtin/swe-lead/manifest.md | 6 +- .../personas/builtin/swe-worker/manifest.md | 3 + coworker/server/manager.py | 19 +++++ coworker/teams/store.py | 82 ++++++++++++++++++- coworker/teams/tools.py | 13 ++- tests/test_team_board.py | 2 +- 6 files changed, 120 insertions(+), 5 deletions(-) diff --git a/coworker/personas/builtin/swe-lead/manifest.md b/coworker/personas/builtin/swe-lead/manifest.md index 04dddf90..40ba0554 100644 --- a/coworker/personas/builtin/swe-lead/manifest.md +++ b/coworker/personas/builtin/swe-lead/manifest.md @@ -33,7 +33,11 @@ How you run a piece of work: isn't: who owns what interface, who to ask about which decision. 4. ASSIGN: assign items to actor ids. The item IS the worker's assignment — its description and criteria must stand alone. Respect dependencies (link blocks/parent); - don't assign what's blocked. + don't assign what's blocked. Workers (including external ones on this board) may + also CLAIM open unassigned items themselves — a claim shows up in your digest; + let good claims stand, reassign or cancel bad ones. To hold an item back from + claiming, assign it to yourself; to turn claiming off board-wide, set the claim + policy to lead-only. 5. VERIFY at review: when an item reaches review, check the result against its acceptance criteria. Implementation items should be verified by the test worker when one is on the team — a builder never grades its own work: create a linked diff --git a/coworker/personas/builtin/swe-worker/manifest.md b/coworker/personas/builtin/swe-worker/manifest.md index 6acb8a0a..f3860ff0 100644 --- a/coworker/personas/builtin/swe-worker/manifest.md +++ b/coworker/personas/builtin/swe-worker/manifest.md @@ -20,6 +20,9 @@ The team contract (this is how you work): criteria are the definition of done. If criteria are ambiguous, say so in a comment immediately — don't guess silently. - Move your item to in_progress when you start. +- Out of assigned work but able to help? You may claim an OPEN, unassigned item + (claim) — only one you can start on now. The lead sees every claim and may + reassign; if the board refuses ("lead-only"), wait for assignment instead. - Blocked? Transition to blocked WITH a comment saying exactly what you need. Never stall silently; never idle-wait. If other assigned items are workable, work them. - Journal as you go (journal_append): findings, evidence, decisions — with file:line diff --git a/coworker/server/manager.py b/coworker/server/manager.py index 00f2b6ed..fd76e1fe 100644 --- a/coworker/server/manager.py +++ b/coworker/server/manager.py @@ -89,6 +89,7 @@ from ..teams import JournalStore, Role as TeamRole, TeamStore, board_tools, jour from ..teams.model import space_for_workspace from ..teams.chat import ChatStore from ..teams.registry import TeamRegistry, TeamWorker +from ..teams.tokens import BoardTokens from ..skills import ( SessionSkillStore, SkillLoader, @@ -212,6 +213,9 @@ class SessionManager: self.team_store = TeamStore(base / "teams.db", journal=self.journal_store) self.chat_store = ChatStore(base / "chat.db") self.teams = TeamRegistry(base / "teams.json") + # External board clients (OPE-100): join tokens bind actor+role; the + # `/v1/board` API resolves them and the store enforces authority. + self.board_tokens = BoardTokens(base / "board-tokens.json") self._team_inflight: set[str] = set() # Lead-session last-turn timestamps for the check-in backstop (monotonic-ish # wall clock; restart resets the clock rather than firing a wake storm). @@ -1828,6 +1832,13 @@ class SessionManager: ), } + def kick_team_tick(self) -> None: + """Nudge the wake plumbing from outside the turn loop — e.g. after an + external board client writes through the `/v1/board` API, so a review or a + new filing reaches the lead now, not at the next 30s scheduler tick.""" + if self._loop is not None: + asyncio.run_coroutine_threadsafe(self.team_tick(), self._loop) + async def team_tick(self) -> int: """Drain team queues (called each scheduler tick + kicked after team turns). One wake consumes a burst as one digest; durable-until-consumed — the cursor @@ -1985,6 +1996,14 @@ class SessionManager: if event["kind"] == "item_assigned": if item is None: continue + if payload.get("claimed"): + # A self-claim surfacing in the lead's subscription feed — + # supervision by exception, not an assignment to the reader. + lines.append( + f"{event['actor']} claimed {title} — it's theirs now;" + " reassign or cancel if that's wrong." + ) + continue lines.append( f"You've been assigned work item {title}.\n" f" Done when: {item['criteria']}" diff --git a/coworker/teams/store.py b/coworker/teams/store.py index 81871eb4..d8fe935b 100644 --- a/coworker/teams/store.py +++ b/coworker/teams/store.py @@ -43,6 +43,12 @@ from .model import ( GENESIS = "genesis" +# Board-level claim policy: "open" (default) lets any worker self-assign an open, +# unassigned item — the board works as a pull queue for a fleet of workers, local or +# external. "lead-only" turns claims off; assignment stays with the lead/user. A lead +# on an open board can still reserve individual items by assigning them to itself. +CLAIM_POLICIES = ("open", "lead-only") + # Event kinds. Chat lands later with the chat surface; the record shape already fits. # Journal entries live in their own case-keyed store (teams.journal) — cases outlive # boards, so they don't belong in a board's space-scoped log. @@ -137,6 +143,10 @@ class TeamStore: cursor_key TEXT PRIMARY KEY, consumed_seq INTEGER NOT NULL ); + CREATE TABLE IF NOT EXISTS team_settings ( + space TEXT PRIMARY KEY, + claims TEXT NOT NULL DEFAULT 'open' + ); """) self._conn.commit() @@ -309,7 +319,7 @@ class TeamStore: key = f"sub:{subscriber}:{space}" events = self.events( space, - kinds=[ITEM_TRANSITIONED, ITEM_CREATED], + kinds=[ITEM_TRANSITIONED, ITEM_CREATED, ITEM_ASSIGNED], since_seq=self._cursor(key), limit=limit, ) @@ -322,6 +332,10 @@ class TeamStore: and event["payload"].get("to") not in self.SUBSCRIBED_TRANSITIONS ): continue + # Assignments only surface when they are CLAIMS — the lead supervises + # self-service by exception; its own (and the user's) assigns are not news. + if event["kind"] == ITEM_ASSIGNED and not event["payload"].get("claimed"): + continue out.append(event) return out @@ -609,6 +623,72 @@ class TeamStore: ) return self.get_item(space, item_id, seq=event["seq"]) + def claim(self, space: str, actor: Actor, item_id: int) -> dict[str, Any]: + """Self-assign an open, unassigned item. Nobody stamps a claim — the store + arbitrates: the open+unassigned check runs under the write lock, so when two + workers race for the same item, exactly one wins and the other gets a clean + error. A claim is a normal assignment event attributed to the claimer — + visible in the lead's subscription feed and revocable like any assignment + (reassign or cancel). Gated by the board's claim policy.""" + self._require(actor, {Role.USER, Role.LEAD, Role.WORKER}, "claim") + with self._lock: + if actor.role == Role.WORKER and self.policy(space)["claims"] != "open": + raise AuthorityError( + "claims are lead-only on this board — ask the lead to assign" + " the item to you" + ) + item = self._item(space, item_id) + if ItemState(item["state"]) is not ItemState.OPEN: + raise BoardError( + f"item #{item_id} is {item['state']} — only open items can be" + " claimed" + ) + if item["assignee"]: + raise BoardError( + f"item #{item_id} is already claimed by {item['assignee']}" + ) + event = self.append_event( + space, + ITEM_ASSIGNED, + actor, + item_id=item_id, + case_id=item["case_id"] or None, + payload={"assignee": actor.id, "previous": "", "claimed": True}, + ) + if self.journal is not None and item["case_id"]: + self.journal.sync_assignment( + item["case_id"], + space=space, + item_id=item_id, + assignee=actor.id, + previous="", + ) + return self.get_item(space, item_id, seq=event["seq"]) + + def policy(self, space: str) -> dict[str, Any]: + with self._lock: + row = self._conn.execute( + "SELECT claims FROM team_settings WHERE space = ?", (space,) + ).fetchone() + return {"claims": row["claims"] if row else "open"} + + def set_policy(self, space: str, actor: Actor, *, claims: str) -> dict[str, Any]: + """Board-level policy. Settings, not history — like cursors, this is + infrastructure the log doesn't narrate.""" + self._require(actor, {Role.USER, Role.LEAD}, "set_policy") + if claims not in CLAIM_POLICIES: + raise BoardError( + f"unknown claim policy: {claims} (use one of {CLAIM_POLICIES})" + ) + with self._lock: + self._conn.execute( + "INSERT INTO team_settings (space, claims) VALUES (?, ?)" + " ON CONFLICT(space) DO UPDATE SET claims = ?", + (space, claims, claims), + ) + self._conn.commit() + return {"claims": claims} + def link( self, space: str, actor: Actor, src: int, kind: str, dst: int ) -> dict[str, Any]: diff --git a/coworker/teams/tools.py b/coworker/teams/tools.py index 16054b80..fa4f8845 100644 --- a/coworker/teams/tools.py +++ b/coworker/teams/tools.py @@ -21,8 +21,11 @@ from .store import TeamStore LEAD_VERBS = ("create_item", "list_items", "transition", "comment", "assign", "link") # Workers file items too (a bug spotted in passing, a follow-up) — new items land -# `open` and unassigned; nothing runs until the lead/user assigns them. -WORKER_VERBS = ("create_item", "list_items", "transition", "comment") +# `open` and unassigned; nothing runs until the item is assigned. `claim` is +# self-assignment: on an open-claims board (the default) a worker may pick up an +# open, unassigned item — the store arbitrates races, the lead supervises by +# exception (every claim lands in its feed; reassign/cancel revokes). +WORKER_VERBS = ("create_item", "list_items", "transition", "comment", "claim") JOURNAL_VERBS = ("journal_append", "journal_read") # Explicit schema: the auto-generator's normalizer strips every `title` key to drop @@ -131,6 +134,12 @@ def board_tools( taint=taint(), ) + def claim(item: int) -> dict: + """Claim an open, unassigned work item for yourself. First claim wins; + the item becomes your assignment. Only claim work you can start on — + the lead sees every claim and can reassign.""" + return _call(store.claim, space, actor, item) + def assign(item: int, assignee: str) -> dict: """Assign a work item to a worker coworker. The item itself becomes the worker's assignment — write the description and criteria accordingly.""" diff --git a/tests/test_team_board.py b/tests/test_team_board.py index d9f3d320..abcd1c56 100644 --- a/tests/test_team_board.py +++ b/tests/test_team_board.py @@ -203,7 +203,7 @@ def test_tool_sets_are_role_filtered(store): tool.__name__ for tool in board_tools(store, space=SPACE, actor=WORKER) } assert lead_names == {"create_item", "list_items", "transition", "comment", "assign", "link"} - assert worker_names == {"create_item", "list_items", "transition", "comment"} + assert worker_names == {"create_item", "list_items", "transition", "comment", "claim"} def test_tools_return_errors_instead_of_raising(store): From cca8d7c01e3be5aeaa0d0d5e27ee8c2396b13e2e Mon Sep 17 00:00:00 2001 From: Rohit C Prasad Date: Sun, 16 Aug 2026 17:48:30 -0700 Subject: [PATCH 40/80] Board as an open surface: BoardDialect seam, join tokens, /v1/board API Local (direct stores) and remote (one wire protocol) dialects; trackers become mirrors later, never dialects. Tokens bind actor+role server-side (sha256-stored); board routes carry their own auth, external writes kick the wake tick. --- coworker/server/app.py | 242 +++++++++++++++++++ coworker/teams/__init__.py | 4 + coworker/teams/dialect.py | 474 +++++++++++++++++++++++++++++++++++++ coworker/teams/tokens.py | 97 ++++++++ 4 files changed, 817 insertions(+) create mode 100644 coworker/teams/dialect.py create mode 100644 coworker/teams/tokens.py diff --git a/coworker/server/app.py b/coworker/server/app.py index b8ffd24e..77d6f7fa 100644 --- a/coworker/server/app.py +++ b/coworker/server/app.py @@ -162,6 +162,8 @@ from ..inbox import VIS_INBOX, VIS_INLINE, args_preview from ..permissions import Mode from ..providers import AssistantTurn from .. import toolchain +from ..teams.model import AuthorityError as TeamsAuthorityError +from ..teams.model import BoardError as TeamsBoardError from .manager import SessionManager @@ -216,6 +218,10 @@ def create_app(manager: SessionManager) -> FastAPI: not api_token or request.method == "OPTIONS" or request.url.path in tokenless_paths + # `/v1/board` carries its own, stronger auth: per-actor board tokens + # (identity + access), designed to be handed to external harnesses and + # other machines — which can never hold the machine-local sidecar token. + or request.url.path.startswith("/v1/board/") or _request_authenticated(request) ): return await call_next(request) @@ -762,6 +768,242 @@ def create_app(manager: SessionManager) -> FastAPI: def teams_journal() -> dict[str, Any]: return {"cases": manager.journal_overview()} + # ---- The open board surface (OPE-100): token-authenticated `/v1/board` API. + # Identity is the TOKEN (actor+role bound at mint, resolved per request, never + # client-asserted); authority is the STORE — the same double gate in-app agents + # get. This is the one wire protocol every external front door rides: + # RemoteDialect (the `ocw` CLI, the team-board MCP server, headless instances) + # today, a hosted board service later. Tokens are required even on loopback — + # they carry identity, not just access. + + def _board_actor(request: Request): + auth = request.headers.get("authorization", "") + token = auth[7:] if auth.lower().startswith("bearer ") else "" + return manager.board_tokens.resolve(token) + + def _board(request: Request, handler): + actor = _board_actor(request) + if actor is None: + return JSONResponse( + {"error": "board token required (Authorization: Bearer …) — mint" + " one with `ocw board token` on the serving machine"}, + status_code=401, + ) + try: + return handler(actor) + except TeamsAuthorityError as error: + return JSONResponse({"error": str(error)}, status_code=403) + except (TeamsBoardError, ValueError) as error: + return JSONResponse({"error": str(error)}, status_code=400) + + @app.get("/v1/board/whoami") + def board_whoami(request: Request): + return _board( + request, lambda actor: {"actor": actor.id, "role": actor.role.value} + ) + + @app.get("/v1/board/spaces") + def board_spaces(request: Request): + return _board(request, lambda actor: {"spaces": manager.team_store.spaces()}) + + @app.get("/v1/board/items") + def board_list_items( + request: Request, space: str, state: str = "", assignee: str = "" + ): + return _board( + request, + lambda actor: { + "items": manager.team_store.list_items( + space, actor, state=state or None, assignee=assignee or None + ) + }, + ) + + @app.get("/v1/board/item") + def board_get_item(request: Request, space: str, id: int): + return _board( + request, lambda actor: manager.team_store.get_item(space, int(id)) + ) + + @app.post("/v1/board/items") + def board_create_item(request: Request, body: dict): + body = body or {} + + def run(actor): + item = manager.team_store.create_item( + str(body.get("space", "")), + actor, + title=str(body.get("title", "")), + criteria=str(body.get("criteria", "")), + description=str(body.get("description", "")), + parent=( + int(body["parent"]) if body.get("parent") is not None else None + ), + case=str(body.get("case") or "") or None, + ) + manager.kick_team_tick() # a new filing is lead-subscription news + return item + + return _board(request, run) + + @app.post("/v1/board/items/transition") + def board_transition_item(request: Request, body: dict): + body = body or {} + + def run(actor): + item = manager.team_store.transition( + str(body.get("space", "")), + actor, + int(body.get("id", 0)), + str(body.get("to", "")), + comment=str(body.get("comment", "")), + refs=[str(ref) for ref in body.get("refs") or []], + ) + manager.kick_team_tick() # review/blocked should reach the lead now + return item + + return _board(request, run) + + @app.post("/v1/board/items/comment") + def board_comment_item(request: Request, body: dict): + body = body or {} + return _board( + request, + lambda actor: manager.team_store.comment( + str(body.get("space", "")), + actor, + int(body.get("id", 0)), + str(body.get("body", "")), + refs=[str(ref) for ref in body.get("refs") or []], + ), + ) + + @app.post("/v1/board/items/assign") + def board_assign_item(request: Request, body: dict): + body = body or {} + + def run(actor): + item = manager.team_store.assign( + str(body.get("space", "")), + actor, + int(body.get("id", 0)), + str(body.get("assignee", "")), + ) + manager.kick_team_tick() # the assignee's queue has news + return item + + return _board(request, run) + + @app.post("/v1/board/items/claim") + def board_claim_item(request: Request, body: dict): + body = body or {} + + def run(actor): + item = manager.team_store.claim( + str(body.get("space", "")), actor, int(body.get("id", 0)) + ) + manager.kick_team_tick() # claims land in the lead's feed + return item + + return _board(request, run) + + @app.post("/v1/board/link") + def board_link_items(request: Request, body: dict): + body = body or {} + return _board( + request, + lambda actor: manager.team_store.link( + str(body.get("space", "")), + actor, + int(body.get("src", 0)), + str(body.get("kind", "")), + int(body.get("dst", 0)), + ), + ) + + @app.get("/v1/board/policy") + def board_get_policy(request: Request, space: str): + return _board(request, lambda actor: manager.team_store.policy(space)) + + @app.post("/v1/board/policy") + def board_set_policy(request: Request, body: dict): + body = body or {} + return _board( + request, + lambda actor: manager.team_store.set_policy( + str(body.get("space", "")), actor, claims=str(body.get("claims", "")) + ), + ) + + @app.get("/v1/board/pending") + def board_pending(request: Request, limit: int = 200): + return _board( + request, + lambda actor: { + "events": manager.team_store.pending_for(actor.id, limit=int(limit)) + }, + ) + + @app.post("/v1/board/consume") + def board_consume(request: Request, body: dict): + body = body or {} + + def run(actor): + manager.team_store.consume(actor.id, int(body.get("upto_seq", 0))) + return {"ok": True} + + return _board(request, run) + + @app.get("/v1/board/journal/cases") + def board_journal_cases(request: Request): + return _board( + request, lambda actor: {"cases": manager.journal_store.overview(actor)} + ) + + @app.get("/v1/board/journal") + def board_journal_read( + request: Request, + case: str, + item: Optional[int] = None, + author: str = "", + kind: str = "", + entity: str = "", + include_raw: str = "", + limit: int = 100, + ): + return _board( + request, + lambda actor: { + "entries": manager.journal_store.read( + actor, + case, + item=item, + author=author or None, + kind=kind or None, + entity=entity or None, + include_raw=bool(include_raw), + limit=int(limit), + ) + }, + ) + + @app.post("/v1/board/journal") + def board_journal_append(request: Request, body: dict): + body = body or {} + return _board( + request, + lambda actor: manager.journal_store.append( + actor, + str(body.get("case", "")), + str(body.get("body", "")), + kind=str(body.get("kind") or "note"), + space=str(body.get("space") or "") or None, + item=int(body["item"]) if body.get("item") is not None else None, + entities=[str(e) for e in body.get("entities") or []], + refs=[str(ref) for ref in body.get("refs") or []], + ), + ) + @app.get("/v1/memory") def memory() -> dict[str, Any]: return {"memory": manager.list_memory()} diff --git a/coworker/teams/__init__.py b/coworker/teams/__init__.py index c2a2a9b4..96320fd6 100644 --- a/coworker/teams/__init__.py +++ b/coworker/teams/__init__.py @@ -26,3 +26,7 @@ __all__ = [ "board_tools", "journal_tools", ] + +# BoardDialect / LocalDialect / RemoteDialect live in .dialect, BoardTokens in +# .tokens — imported directly by their consumers (CLI, MCP server, `/v1/board`) +# to keep this package root light for the common in-app path. diff --git a/coworker/teams/dialect.py b/coworker/teams/dialect.py new file mode 100644 index 00000000..aa328044 --- /dev/null +++ b/coworker/teams/dialect.py @@ -0,0 +1,474 @@ +"""The BoardDialect seam — where "a board" stops meaning "our SQLite file". + +A dialect is where the board of record LIVES, seen from a client's chair: +- LocalDialect: this machine's TeamStore/JournalStore, direct SQLite. For the + standalone/headless case where the caller is the only writer. +- RemoteDialect: one wire protocol (the `/v1/board` HTTP API) to a board served + elsewhere — the running OpenWorker sidecar on this machine, a teammate's machine, + or a hosted board service later. Identity rides the token; the server binds it to + an actor+role and the store enforces authority, so a remote client is safe by + construction. + +External trackers (Jira/Linear) are deliberately NOT dialects: making a pre-LLM +tracker the board of record means contorting our state machine and delivery cursors +onto its API. They join as MIRRORS instead — one more subscriber with a cursor over +the append-only event log, replaying events outward (decided 2026-08-16). The board +stays the abstraction and the source of truth. + +Every front door — the `team-board` MCP server, the `ocw` CLI, remote OpenWorker +instances — bottoms out in this one verb surface. Dialect instances are +identity-bound: one actor per instance, matching the one-identity-per-process shape +of an external harness. + +Cross-process write safety: the store's hash-chain append is read-head-then-write +under an in-process lock, so two processes must never write one SQLite file +directly. Rule: when a server is up, clients go remote; LocalDialect is for the +headless case where this process is the only writer. +""" + +from __future__ import annotations + +from typing import Any, Optional, Protocol + +from .journal import JournalStore +from .model import Actor, BoardError, Role +from .store import TeamStore + + +class BoardDialect(Protocol): + """The verb surface a board client sees, identity already bound.""" + + def whoami(self) -> dict[str, Any]: ... + def spaces(self) -> list[str]: ... + def list_items( + self, + space: str, + *, + state: Optional[str] = None, + assignee: Optional[str] = None, + ) -> list[dict[str, Any]]: ... + def get_item(self, space: str, item_id: int) -> dict[str, Any]: ... + def create_item( + self, + space: str, + *, + title: str, + criteria: str, + description: str = "", + parent: Optional[int] = None, + case: Optional[str] = None, + ) -> dict[str, Any]: ... + def transition( + self, + space: str, + item_id: int, + to: str, + *, + comment: str = "", + refs: Optional[list[str]] = None, + ) -> dict[str, Any]: ... + def comment( + self, + space: str, + item_id: int, + body: str, + *, + refs: Optional[list[str]] = None, + ) -> dict[str, Any]: ... + def assign(self, space: str, item_id: int, assignee: str) -> dict[str, Any]: ... + def claim(self, space: str, item_id: int) -> dict[str, Any]: ... + def link(self, space: str, src: int, kind: str, dst: int) -> dict[str, Any]: ... + def policy(self, space: str) -> dict[str, Any]: ... + def set_policy(self, space: str, *, claims: str) -> dict[str, Any]: ... + def pending(self, *, limit: int = 200) -> list[dict[str, Any]]: ... + def consume(self, upto_seq: int) -> None: ... + def journal_append( + self, + case: str, + body: str, + *, + kind: str = "note", + space: Optional[str] = None, + item: Optional[int] = None, + entities: Optional[list[str]] = None, + refs: Optional[list[str]] = None, + ) -> dict[str, Any]: ... + def journal_read( + self, + case: str, + *, + item: Optional[int] = None, + author: Optional[str] = None, + kind: Optional[str] = None, + entity: Optional[str] = None, + include_raw: bool = False, + limit: int = 100, + ) -> list[dict[str, Any]]: ... + def journal_overview(self) -> list[dict[str, Any]]: ... + + +class LocalDialect: + """Direct store access, one bound identity. The headless/standalone backing.""" + + def __init__( + self, store: TeamStore, journal: Optional[JournalStore], actor: Actor + ) -> None: + self.store = store + self.journal = journal + self.actor = actor + + def whoami(self) -> dict[str, Any]: + return {"actor": self.actor.id, "role": self.actor.role.value} + + def spaces(self) -> list[str]: + return self.store.spaces() + + def list_items( + self, + space: str, + *, + state: Optional[str] = None, + assignee: Optional[str] = None, + ) -> list[dict[str, Any]]: + return self.store.list_items(space, self.actor, state=state, assignee=assignee) + + def get_item(self, space: str, item_id: int) -> dict[str, Any]: + return self.store.get_item(space, item_id) + + def create_item( + self, + space: str, + *, + title: str, + criteria: str, + description: str = "", + parent: Optional[int] = None, + case: Optional[str] = None, + ) -> dict[str, Any]: + return self.store.create_item( + space, + self.actor, + title=title, + criteria=criteria, + description=description, + parent=parent, + case=case, + ) + + def transition( + self, + space: str, + item_id: int, + to: str, + *, + comment: str = "", + refs: Optional[list[str]] = None, + ) -> dict[str, Any]: + return self.store.transition( + space, self.actor, item_id, to, comment=comment, refs=refs + ) + + def comment( + self, + space: str, + item_id: int, + body: str, + *, + refs: Optional[list[str]] = None, + ) -> dict[str, Any]: + return self.store.comment(space, self.actor, item_id, body, refs=refs) + + def assign(self, space: str, item_id: int, assignee: str) -> dict[str, Any]: + return self.store.assign(space, self.actor, item_id, assignee) + + def claim(self, space: str, item_id: int) -> dict[str, Any]: + return self.store.claim(space, self.actor, item_id) + + def link(self, space: str, src: int, kind: str, dst: int) -> dict[str, Any]: + return self.store.link(space, self.actor, src, kind, dst) + + def policy(self, space: str) -> dict[str, Any]: + return self.store.policy(space) + + def set_policy(self, space: str, *, claims: str) -> dict[str, Any]: + return self.store.set_policy(space, self.actor, claims=claims) + + def pending(self, *, limit: int = 200) -> list[dict[str, Any]]: + return self.store.pending_for(self.actor.id, limit=limit) + + def consume(self, upto_seq: int) -> None: + self.store.consume(self.actor.id, int(upto_seq)) + + def journal_append( + self, + case: str, + body: str, + *, + kind: str = "note", + space: Optional[str] = None, + item: Optional[int] = None, + entities: Optional[list[str]] = None, + refs: Optional[list[str]] = None, + ) -> dict[str, Any]: + self._need_journal() + return self.journal.append( + self.actor, + case, + body, + kind=kind, + space=space, + item=item, + entities=entities, + refs=refs, + ) + + def journal_read( + self, + case: str, + *, + item: Optional[int] = None, + author: Optional[str] = None, + kind: Optional[str] = None, + entity: Optional[str] = None, + include_raw: bool = False, + limit: int = 100, + ) -> list[dict[str, Any]]: + self._need_journal() + return self.journal.read( + self.actor, + case, + item=item, + author=author, + kind=kind, + entity=entity, + include_raw=include_raw, + limit=limit, + ) + + def journal_overview(self) -> list[dict[str, Any]]: + self._need_journal() + return self.journal.overview(self.actor) + + def _need_journal(self) -> None: + if self.journal is None: + raise BoardError("no journal store is attached to this board") + + +class RemoteDialect: + """The `/v1/board` HTTP client. `base_url` is an OpenWorker sidecar or a hosted + board service; the Bearer token carries identity — the server resolves it to an + actor+role, so this client never states who it is, it proves it.""" + + def __init__( + self, base_url: str, token: str, *, client: Any = None, timeout: float = 30.0 + ) -> None: + import httpx + + self.base_url = base_url.rstrip("/") + self._client = client or httpx.Client( + base_url=self.base_url, + timeout=timeout, + ) + self._client.headers["Authorization"] = f"Bearer {token}" + + # -- plumbing -------------------------------------------------------------- + + def _get(self, path: str, params: Optional[dict] = None) -> Any: + response = self._client.get( + path, params={k: v for k, v in (params or {}).items() if v is not None} + ) + return self._unwrap(response) + + def _post(self, path: str, body: dict) -> Any: + response = self._client.post( + path, json={k: v for k, v in body.items() if v is not None} + ) + return self._unwrap(response) + + @staticmethod + def _unwrap(response: Any) -> Any: + if response.status_code == 401: + raise BoardError("board token was not accepted (401) — mint one with" + " `ocw board token` on the serving machine") + try: + data = response.json() + except ValueError: + data = {} + if response.status_code >= 400: + raise BoardError( + str(data.get("error") or data.get("detail") or response.text) + ) + return data + + # -- verbs ----------------------------------------------------------------- + + def whoami(self) -> dict[str, Any]: + return self._get("/v1/board/whoami") + + def spaces(self) -> list[str]: + return self._get("/v1/board/spaces")["spaces"] + + def list_items( + self, + space: str, + *, + state: Optional[str] = None, + assignee: Optional[str] = None, + ) -> list[dict[str, Any]]: + return self._get( + "/v1/board/items", + {"space": space, "state": state, "assignee": assignee}, + )["items"] + + def get_item(self, space: str, item_id: int) -> dict[str, Any]: + return self._get("/v1/board/item", {"space": space, "id": item_id}) + + def create_item( + self, + space: str, + *, + title: str, + criteria: str, + description: str = "", + parent: Optional[int] = None, + case: Optional[str] = None, + ) -> dict[str, Any]: + return self._post( + "/v1/board/items", + { + "space": space, + "title": title, + "criteria": criteria, + "description": description, + "parent": parent, + "case": case, + }, + ) + + def transition( + self, + space: str, + item_id: int, + to: str, + *, + comment: str = "", + refs: Optional[list[str]] = None, + ) -> dict[str, Any]: + return self._post( + "/v1/board/items/transition", + { + "space": space, + "id": item_id, + "to": to, + "comment": comment, + "refs": refs or [], + }, + ) + + def comment( + self, + space: str, + item_id: int, + body: str, + *, + refs: Optional[list[str]] = None, + ) -> dict[str, Any]: + return self._post( + "/v1/board/items/comment", + {"space": space, "id": item_id, "body": body, "refs": refs or []}, + ) + + def assign(self, space: str, item_id: int, assignee: str) -> dict[str, Any]: + return self._post( + "/v1/board/items/assign", + {"space": space, "id": item_id, "assignee": assignee}, + ) + + def claim(self, space: str, item_id: int) -> dict[str, Any]: + return self._post("/v1/board/items/claim", {"space": space, "id": item_id}) + + def link(self, space: str, src: int, kind: str, dst: int) -> dict[str, Any]: + return self._post( + "/v1/board/link", {"space": space, "src": src, "kind": kind, "dst": dst} + ) + + def policy(self, space: str) -> dict[str, Any]: + return self._get("/v1/board/policy", {"space": space}) + + def set_policy(self, space: str, *, claims: str) -> dict[str, Any]: + return self._post("/v1/board/policy", {"space": space, "claims": claims}) + + def pending(self, *, limit: int = 200) -> list[dict[str, Any]]: + return self._get("/v1/board/pending", {"limit": limit})["events"] + + def consume(self, upto_seq: int) -> None: + self._post("/v1/board/consume", {"upto_seq": int(upto_seq)}) + + def journal_append( + self, + case: str, + body: str, + *, + kind: str = "note", + space: Optional[str] = None, + item: Optional[int] = None, + entities: Optional[list[str]] = None, + refs: Optional[list[str]] = None, + ) -> dict[str, Any]: + return self._post( + "/v1/board/journal", + { + "case": case, + "body": body, + "kind": kind, + "space": space, + "item": item, + "entities": entities or [], + "refs": refs or [], + }, + ) + + def journal_read( + self, + case: str, + *, + item: Optional[int] = None, + author: Optional[str] = None, + kind: Optional[str] = None, + entity: Optional[str] = None, + include_raw: bool = False, + limit: int = 100, + ) -> list[dict[str, Any]]: + return self._get( + "/v1/board/journal", + { + "case": case, + "item": item, + "author": author, + "kind": kind, + "entity": entity, + "include_raw": "1" if include_raw else None, + "limit": limit, + }, + )["entries"] + + def journal_overview(self) -> list[dict[str, Any]]: + return self._get("/v1/board/journal/cases")["cases"] + + def close(self) -> None: + self._client.close() + + +def local_dialect( + db_dir, *, actor: str = "user", role: str = "user" +) -> LocalDialect: + """Open the state dir's stores directly as one bound identity — the headless + backing for the CLI and MCP server when no OpenWorker server is running.""" + from pathlib import Path + + base = Path(db_dir).expanduser() + journal = JournalStore(base / "journal.db") + store = TeamStore(base / "teams.db", journal=journal) + return LocalDialect( + store, journal, Actor(id=actor, role=Role(role)) + ) diff --git a/coworker/teams/tokens.py b/coworker/teams/tokens.py new file mode 100644 index 00000000..32740848 --- /dev/null +++ b/coworker/teams/tokens.py @@ -0,0 +1,97 @@ +"""Board join tokens — identity for external board clients. + +A token binds an ACTOR and a ROLE server-side: an external harness (another agent +CLI, a headless OpenWorker, the `ocw` CLI from a second machine) presents the token +and the server resolves who it is — the client never states its own identity, and a +worker token cannot claim to be the lead. Authority then falls to the store, same +as for in-app agents: the token is identity, the store is the gate. + +Storage is hash-only (sha256): the plaintext is shown once at mint and never +persisted, so the registry file leaking doesn't leak the credentials. Revocation is +per-token, keyed by the display prefix. +""" + +from __future__ import annotations + +import hashlib +import json +import secrets +import threading +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Optional + +from .model import Actor, Role + +_TOKEN_PREFIX = "owb_" # OpenWorker board — greppable in configs, meaningless to guess + + +class BoardTokens: + def __init__(self, path: str | Path) -> None: + self.path = Path(path).expanduser() + self._lock = threading.Lock() + + def mint(self, actor: str, role: str = "worker", *, label: str = "") -> str: + """Create a token for one actor identity; returns the plaintext ONCE.""" + actor = (actor or "").strip() + if not actor: + raise ValueError("actor is required") + Role(role) # validate early — a bad role should fail at mint, not at use + token = _TOKEN_PREFIX + secrets.token_urlsafe(32) + with self._lock: + entries = self._load() + entries[_digest(token)] = { + "actor": actor, + "role": role, + "label": label, + "prefix": token[:12], + "created_ts": datetime.now(timezone.utc).isoformat(), + } + self._save(entries) + return token + + def resolve(self, token: str) -> Optional[Actor]: + if not token: + return None + with self._lock: + entry = self._load().get(_digest(token)) + if entry is None: + return None + return Actor(id=entry["actor"], role=Role(entry["role"])) + + def entries(self) -> list[dict[str, Any]]: + with self._lock: + return sorted(self._load().values(), key=lambda e: e["created_ts"]) + + def revoke(self, prefix: str) -> int: + """Revoke every token whose display prefix matches; returns the count.""" + prefix = (prefix or "").strip() + if not prefix: + return 0 + with self._lock: + entries = self._load() + keep = { + key: entry + for key, entry in entries.items() + if not entry["prefix"].startswith(prefix) + } + removed = len(entries) - len(keep) + if removed: + self._save(keep) + return removed + + def _load(self) -> dict[str, dict[str, Any]]: + try: + return json.loads(self.path.read_text()) + except (OSError, ValueError): + return {} + + def _save(self, entries: dict[str, dict[str, Any]]) -> None: + self.path.parent.mkdir(parents=True, exist_ok=True) + tmp = self.path.with_suffix(".tmp") + tmp.write_text(json.dumps(entries, indent=2)) + tmp.replace(self.path) + + +def _digest(token: str) -> str: + return hashlib.sha256(token.encode("utf-8")).hexdigest() From 36aa1da728a8faae4b98d124c388052a5e9e9a89 Mon Sep 17 00:00:00 2001 From: Rohit C Prasad Date: Sun, 16 Aug 2026 17:48:30 -0700 Subject: [PATCH 41/80] ocw CLI + team-board MCP server on stdio ocw board/journal verbs with server auto-discovery (remote-first; direct SQLite only headless). ocw board mcp serves the role-scoped toolset to external harnesses; 21 tests incl. both dialects over the real app. --- coworker/teams/cli.py | 467 ++++++++++++++++++++++++++++++++ coworker/teams/mcp_server.py | 191 +++++++++++++ pyproject.toml | 3 + tests/test_team_open_surface.py | 417 ++++++++++++++++++++++++++++ 4 files changed, 1078 insertions(+) create mode 100644 coworker/teams/cli.py create mode 100644 coworker/teams/mcp_server.py create mode 100644 tests/test_team_open_surface.py diff --git a/coworker/teams/cli.py b/coworker/teams/cli.py new file mode 100644 index 00000000..18dce0cd --- /dev/null +++ b/coworker/teams/cli.py @@ -0,0 +1,467 @@ +"""`ocw` — the board and journal from any shell, for any harness. + +The board is an open surface (OPE-100): the same role-scoped verbs the in-app +agents get, usable by an external agent CLI, a script, or a human. Point it at a +running OpenWorker server (same machine or remote) or straight at a state dir. + +Backing resolution, in order: +1. `--url` + `--token` (or OCW_BOARD_URL / OCW_BOARD_TOKEN) — a remote board. +2. `--db DIR` — direct SQLite in that state dir (headless; you are the only writer). +3. A running local server, discovered via its sidecar token files — the CLI mints + itself a local user token on first use. This is preferred over direct SQLite + whenever a server is up: two processes must never write one board file. +4. Direct SQLite on the default state dir (nothing else is running). + +`ocw board mcp` serves the same surface as an MCP server on stdio — the way to +hand a board to an external coding agent: point the agent's MCP config at +`ocw board mcp --url … --token … --space …` and ask it to claim a work item. +""" + +from __future__ import annotations + +import argparse +import json +import os +import sys +from pathlib import Path +from typing import Any, Optional + +from .model import BoardError, space_for_workspace +from .store import CLAIM_POLICIES + +_STATES = ("open", "in_progress", "blocked", "review", "done", "canceled") + + +def main(argv: Optional[list[str]] = None) -> int: + parser = _parser() + args = parser.parse_args(argv) + if not getattr(args, "cmd", None): + parser.print_help() + return 2 + try: + return args.func(args) + except BoardError as error: + print(f"error: {error}", file=sys.stderr) + return 1 + + +def _parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + prog="ocw", description="OpenWorker team board + journal CLI." + ) + sub = parser.add_subparsers(dest="group") + + board = sub.add_parser("board", help="work-item board verbs") + board_sub = board.add_subparsers(dest="cmd") + + def cmd(name: str, func, help: str, parent=board_sub): + p = parent.add_parser(name, help=help) + _backing_args(p) + p.set_defaults(func=func, cmd=name) + return p + + p = cmd("list", _cmd_list, "list items") + p.add_argument("--state", choices=_STATES, default="") + p.add_argument("--assignee", default="") + p.add_argument("--mine", action="store_true", help="only items assigned to me") + + p = cmd("show", _cmd_show, "one item, with comments") + p.add_argument("id", type=int) + + p = cmd("create", _cmd_create, "file a new item (open, unassigned)") + p.add_argument("title") + p.add_argument("--criteria", required=True, help="acceptance criteria") + p.add_argument("--description", default="") + p.add_argument("--parent", type=int, default=None) + p.add_argument("--case", default="") + + p = cmd("claim", _cmd_claim, "claim an open, unassigned item for yourself") + p.add_argument("id", type=int) + + p = cmd("move", _cmd_move, "transition an item") + p.add_argument("id", type=int) + p.add_argument("to", choices=_STATES[1:] + ("open",)) + p.add_argument("--comment", default="") + p.add_argument("--ref", action="append", default=[], dest="refs") + + p = cmd("comment", _cmd_comment, "comment on an item") + p.add_argument("id", type=int) + p.add_argument("body") + p.add_argument("--ref", action="append", default=[], dest="refs") + + p = cmd("assign", _cmd_assign, "assign an item (lead/user)") + p.add_argument("id", type=int) + p.add_argument("assignee") + + p = cmd("link", _cmd_link, "link two items") + p.add_argument("src", type=int) + p.add_argument("kind", choices=("parent", "blocks")) + p.add_argument("dst", type=int) + + p = cmd("policy", _cmd_policy, "show or set the board's claim policy") + p.add_argument("--claims", choices=CLAIM_POLICIES, default="") + + p = cmd("pending", _cmd_pending, "my unconsumed deliveries (assignments etc.)") + p.add_argument("--consume", action="store_true", help="advance my cursor") + p.add_argument("--limit", type=int, default=50) + + cmd("spaces", _cmd_spaces, "list known board spaces") + + # `token` manages the serving machine's registry file directly — it takes no + # backing/identity flags of its own (minting is what CREATES identities). + p = board_sub.add_parser( + "token", help="mint/list/revoke board join tokens (serving machine)" + ) + p.add_argument("action", choices=("mint", "list", "revoke")) + p.add_argument("--actor", default="", help="callname the token binds (mint)") + p.add_argument( + "--role", choices=("worker", "lead", "user"), default="worker" + ) + p.add_argument("--label", default="", help="what this token is for (mint)") + p.add_argument("--prefix", default="", help="token prefix to revoke") + p.add_argument("--db", default="", help="state dir holding the registry") + p.add_argument("--json", action="store_true") + p.set_defaults(func=_cmd_token, cmd="token") + + p = cmd("mcp", _cmd_mcp, "serve this board over MCP on stdio") + + journal = sub.add_parser("journal", help="journal case verbs") + journal_sub = journal.add_subparsers(dest="cmd") + + p = cmd("cases", _cmd_cases, "cases I can read", parent=journal_sub) + + p = cmd("read", _cmd_read, "read a case (filtered)", parent=journal_sub) + p.add_argument("case") + p.add_argument("--item", type=int, default=None) + p.add_argument("--author", default="") + p.add_argument("--kind", default="") + p.add_argument("--entity", default="") + p.add_argument("--raw", action="store_true", dest="include_raw") + p.add_argument("--limit", type=int, default=50) + + p = cmd("append", _cmd_append, "append an entry to a case", parent=journal_sub) + p.add_argument("case") + p.add_argument("body") + p.add_argument( + "--kind", + choices=("finding", "evidence", "decision", "note", "raw"), + default="note", + ) + p.add_argument("--item", type=int, default=None) + p.add_argument("--entity", action="append", default=[], dest="entities") + p.add_argument("--ref", action="append", default=[], dest="refs") + + return parser + + +def _backing_args(p: argparse.ArgumentParser) -> None: + p.add_argument("--url", default=os.environ.get("OCW_BOARD_URL", "")) + p.add_argument("--token", default=os.environ.get("OCW_BOARD_TOKEN", "")) + p.add_argument("--db", default="", help="state dir for direct (headless) access") + p.add_argument("--actor", dest="local_actor", default="user") + p.add_argument("--role", dest="local_role", default="user") + p.add_argument( + "--space", + default=os.environ.get("OCW_BOARD_SPACE", ""), + help="board space (default: this directory's workspace)", + ) + p.add_argument("--json", action="store_true", help="machine-readable output") + + +# ------------------------------------------------------------------ backing + + +def _space(args) -> str: + return args.space or space_for_workspace(Path.cwd()) + + +def _dialect(args): + from .dialect import RemoteDialect, local_dialect + + if args.url: + if not args.token: + raise BoardError("--token (or OCW_BOARD_TOKEN) is required with --url") + return RemoteDialect(args.url, args.token) + if args.db: + return local_dialect(args.db, actor=args.local_actor, role=args.local_role) + server = _discover_server() + if server is not None: + return RemoteDialect(server, _local_cli_token()) + from ..secrets import state_dir + + return local_dialect(state_dir(), actor=args.local_actor, role=args.local_role) + + +def _discover_server() -> Optional[str]: + """A running local server, found via its per-port sidecar token files.""" + import httpx + + from ..secrets import state_dir + + ports = [] + try: + for path in state_dir().glob("sidecar-*.token"): + try: + ports.append(int(path.stem.split("-")[1])) + except (IndexError, ValueError): + continue + except OSError: + return None + for port in sorted(ports, reverse=True): + url = f"http://127.0.0.1:{port}" + try: + if httpx.get(f"{url}/v1/health", timeout=1.5).status_code == 200: + return url + except httpx.HTTPError: + continue + return None + + +def _local_cli_token() -> str: + """The CLI's own user token against the local server. Minted once into the + shared registry; the plaintext is cached user-only in the state dir — the + user's own credential on the user's own machine, same pattern as the sidecar + token file.""" + from ..secrets import state_dir, write_private_text + + from .tokens import BoardTokens + + cache = state_dir() / "ocw-cli.token" + tokens = BoardTokens(state_dir() / "board-tokens.json") + try: + cached = cache.read_text().strip() + if cached and tokens.resolve(cached) is not None: + return cached + except OSError: + pass + token = tokens.mint("user", "user", label="local ocw CLI") + write_private_text(cache, token + "\n") + return token + + +# ------------------------------------------------------------------ board cmds + + +def _cmd_list(args) -> int: + dialect = _dialect(args) + assignee = args.assignee or (dialect.whoami()["actor"] if args.mine else "") + items = dialect.list_items( + _space(args), state=args.state or None, assignee=assignee or None + ) + if args.json: + print(json.dumps(items, indent=2)) + return 0 + if not items: + print("no items") + return 0 + for item in items: + who = f" @{item['assignee']}" if item["assignee"] else "" + print(f"#{item['id']:<4} {item['state']:<12}{who:<14} {item['title']}") + return 0 + + +def _cmd_show(args) -> int: + item = _dialect(args).get_item(_space(args), args.id) + if args.json: + print(json.dumps(item, indent=2)) + return 0 + print(f"#{item['id']} {item['title']} [{item['state']}]") + if item["assignee"]: + print(f"assignee: {item['assignee']}") + print(f"created by: {item['creator']}") + if item["description"]: + print(f"\n{item['description']}") + print(f"\nDone when: {item['criteria']}") + if item.get("refs"): + print("refs: " + ", ".join(item["refs"])) + for link in item.get("links") or []: + print(f"link: {link['kind']} #{link['item']}") + for comment in item.get("comments") or []: + print(f"\n[{comment['ts']}] {comment['author']}: {comment['body']}") + return 0 + + +def _cmd_create(args) -> int: + item = _dialect(args).create_item( + _space(args), + title=args.title, + criteria=args.criteria, + description=args.description, + parent=args.parent, + case=args.case or None, + ) + print(json.dumps(item, indent=2) if args.json else f"created #{item['id']}") + return 0 + + +def _cmd_claim(args) -> int: + item = _dialect(args).claim(_space(args), args.id) + print( + json.dumps(item, indent=2) + if args.json + else f"claimed #{item['id']} — it's yours; move it to in_progress when you start" + ) + return 0 + + +def _cmd_move(args) -> int: + item = _dialect(args).transition( + _space(args), args.id, args.to, comment=args.comment, refs=args.refs + ) + print(json.dumps(item, indent=2) if args.json else f"#{item['id']} → {item['state']}") + return 0 + + +def _cmd_comment(args) -> int: + _dialect(args).comment(_space(args), args.id, args.body, refs=args.refs) + print("ok" if not args.json else json.dumps({"ok": True})) + return 0 + + +def _cmd_assign(args) -> int: + item = _dialect(args).assign(_space(args), args.id, args.assignee) + print( + json.dumps(item, indent=2) + if args.json + else f"#{item['id']} → @{item['assignee']}" + ) + return 0 + + +def _cmd_link(args) -> int: + _dialect(args).link(_space(args), args.src, args.kind, args.dst) + print("ok" if not args.json else json.dumps({"ok": True})) + return 0 + + +def _cmd_policy(args) -> int: + dialect = _dialect(args) + policy = ( + dialect.set_policy(_space(args), claims=args.claims) + if args.claims + else dialect.policy(_space(args)) + ) + print(json.dumps(policy) if args.json else f"claims: {policy['claims']}") + return 0 + + +def _cmd_pending(args) -> int: + dialect = _dialect(args) + events = dialect.pending(limit=args.limit) + if args.json: + print(json.dumps(events, indent=2)) + else: + for event in events: + print(f"[{event['seq']}] {event['kind']} #{event.get('item_id')}" + f" from {event['actor']}: {json.dumps(event['payload'])}") + if not events: + print("nothing pending") + if args.consume and events: + dialect.consume(events[-1]["seq"]) + return 0 + + +def _cmd_spaces(args) -> int: + spaces = _dialect(args).spaces() + print(json.dumps(spaces) if args.json else "\n".join(spaces) or "no spaces") + return 0 + + +def _cmd_token(args) -> int: + from ..secrets import state_dir + + from .tokens import BoardTokens + + tokens = BoardTokens( + (Path(args.db).expanduser() if args.db else state_dir()) / "board-tokens.json" + ) + if args.action == "mint": + if not args.actor: + print("error: --actor is required to mint", file=sys.stderr) + return 1 + token = tokens.mint(args.actor, args.role, label=args.label) + print(token) + print( + f"# binds actor '{args.actor}' as {args.role}; shown once — store it" + " in the client's config (OCW_BOARD_TOKEN)", + file=sys.stderr, + ) + return 0 + if args.action == "revoke": + removed = tokens.revoke(args.prefix) + print(f"revoked {removed} token(s)") + return 0 + entries = tokens.entries() + if args.json: + print(json.dumps(entries, indent=2)) + return 0 + for entry in entries: + label = f" ({entry['label']})" if entry["label"] else "" + print(f"{entry['prefix']}… {entry['actor']:<16} {entry['role']:<8}{label}") + if not entries: + print("no tokens") + return 0 + + +def _cmd_mcp(args) -> int: + from .mcp_server import serve + + serve(_dialect(args), space=_space(args)) + return 0 + + +# ------------------------------------------------------------------ journal cmds + + +def _cmd_cases(args) -> int: + cases = _dialect(args).journal_overview() + if args.json: + print(json.dumps(cases, indent=2)) + return 0 + for case in cases: + print( + f"{case.get('case', '?'):<28} {case.get('entries', 0)} entries" + + (f" (last {case['last_ts']})" if case.get("last_ts") else "") + ) + if not cases: + print("no cases") + return 0 + + +def _cmd_read(args) -> int: + entries = _dialect(args).journal_read( + args.case, + item=args.item, + author=args.author or None, + kind=args.kind or None, + entity=args.entity or None, + include_raw=args.include_raw, + limit=args.limit, + ) + if args.json: + print(json.dumps(entries, indent=2)) + return 0 + for entry in entries: + print(f"[{entry['ts']}] {entry['author']} {entry['kind']}:" + f" {entry.get('body') or ''}") + if not entries: + print("no entries") + return 0 + + +def _cmd_append(args) -> int: + _dialect(args).journal_append( + args.case, + args.body, + kind=args.kind, + space=_space(args), + item=args.item, + entities=args.entities, + refs=args.refs, + ) + print("ok" if not args.json else json.dumps({"ok": True})) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/coworker/teams/mcp_server.py b/coworker/teams/mcp_server.py new file mode 100644 index 00000000..bff7cd90 --- /dev/null +++ b/coworker/teams/mcp_server.py @@ -0,0 +1,191 @@ +"""`team-board` — the board and journal as an MCP server on stdio. + +The way an external coding agent joins a team: its MCP config runs +`ocw board mcp --url … --token … --space …` (or `--db …` headless), it sees the +role-scoped board tools, and the user asks it to claim an item and work. Identity +and authority never live here: the dialect is already bound to one actor (token or +local flags), and every write is judged by the store/server — this file is a thin +adapter, safe to hand to any harness. + +Tool results are JSON — raw data for the agent, not prose. +""" + +from __future__ import annotations + +from typing import Any, Optional + +from .model import BoardError + + +def build(dialect, *, space: str): + """Assemble the FastMCP server for one dialect+space. Split from serve() so + tests can inspect the registered tool set without a transport.""" + from mcp.server.fastmcp import FastMCP + + who = dialect.whoami() + role = who.get("role", "worker") + mcp = FastMCP( + "team-board", + instructions=( + f"A shared team work board (you are '{who.get('actor')}', role" + f" {role}) plus the team journal. Items carry acceptance criteria —" + " what gets verified before they can be done. Typical worker loop:" + " board_list → board_claim an open item → board_move to in_progress →" + " work, journal_append findings as you go → board_move to review with" + " a hand-off comment and refs. Never mark items done — done is the" + " verdict after review." + ), + ) + + def _safe(func, *args, **kwargs) -> Any: + try: + return func(*args, **kwargs) + except (BoardError, ValueError) as error: + return {"error": str(error)} + + @mcp.tool() + def board_list(state: str = "", assignee: str = "") -> Any: + """List work items on the board, optionally filtered by state + (open/in_progress/blocked/review/done/canceled) or assignee.""" + return _safe( + dialect.list_items, space, state=state or None, assignee=assignee or None + ) + + @mcp.tool() + def board_show(item: int) -> Any: + """One work item in full: description, acceptance criteria, refs, links, + and every comment.""" + return _safe(dialect.get_item, space, item) + + @mcp.tool() + def board_create( + title: str, + criteria: str, + description: str = "", + parent: Optional[int] = None, + case: str = "", + ) -> Any: + """File a new work item (open, unassigned — work starts when it is + assigned or claimed). `criteria` is the acceptance criteria — what gets + verified before the item can be done; required.""" + return _safe( + dialect.create_item, + space, + title=title, + criteria=criteria, + description=description, + parent=parent, + case=case or None, + ) + + @mcp.tool() + def board_claim(item: int) -> Any: + """Claim an open, unassigned item for yourself. First claim wins; the + item becomes your assignment. Only claim work you can start on now.""" + return _safe(dialect.claim, space, item) + + @mcp.tool() + def board_move(item: int, to: str, comment: str = "", refs: list[str] = []) -> Any: + """Move a work item: in_progress when you start, blocked with the blocker + as `comment`, review with a hand-off comment and artifact refs (branch, + PR, file:line) when finished.""" + return _safe( + dialect.transition, space, item, to, comment=comment, refs=list(refs or []) + ) + + @mcp.tool() + def board_comment(item: int, body: str, refs: list[str] = []) -> Any: + """Comment on a work item — durable and attributed; answers that matter + belong here. `refs` attach artifact pointers.""" + return _safe(dialect.comment, space, item, body, refs=list(refs or [])) + + @mcp.tool() + def board_pending() -> Any: + """Your unconsumed deliveries — assignments addressed to you, cancel + notices. Check at the start of a work session; acknowledge with + board_consume.""" + return _safe(dialect.pending) + + @mcp.tool() + def board_consume(upto_seq: int) -> Any: + """Acknowledge deliveries up to a sequence number (from board_pending), + so they are not re-delivered.""" + return _safe(lambda: (dialect.consume(upto_seq), {"ok": True})[1]) + + if role in ("lead", "user"): + + @mcp.tool() + def board_assign(item: int, assignee: str) -> Any: + """Assign a work item to a worker (or to yourself to reserve it).""" + return _safe(dialect.assign, space, item, assignee) + + @mcp.tool() + def board_link(src: int, kind: str, dst: int) -> Any: + """Link two items: `parent` (dst becomes src's parent) or `blocks` + (src blocks dst).""" + return _safe(dialect.link, space, src, kind, dst) + + @mcp.tool() + def board_policy(claims: str = "") -> Any: + """Show the board's claim policy, or set it: `open` (workers may + self-claim open items) or `lead-only`.""" + if claims: + return _safe(dialect.set_policy, space, claims=claims) + return _safe(dialect.policy, space) + + @mcp.tool() + def journal_append( + case: str, + body: str, + kind: str = "note", + item: Optional[int] = None, + entities: list[str] = [], + refs: list[str] = [], + ) -> Any: + """Append to a journal case as you work: kind is finding, evidence, + decision, note, or raw (a capture excerpt referencing a file). + `entities` are the concrete things it is about (paths, resources, ids).""" + return _safe( + dialect.journal_append, + case, + body, + kind=kind, + space=space, + item=item, + entities=list(entities or []), + refs=list(refs or []), + ) + + @mcp.tool() + def journal_read( + case: str, + item: Optional[int] = None, + author: str = "", + kind: str = "", + entity: str = "", + include_raw: bool = False, + limit: int = 50, + ) -> Any: + """Read a journal case, filtered by item, author, entry kind, or entity. + Prefer narrow reads; raw captures are skipped unless asked.""" + return _safe( + dialect.journal_read, + case, + item=item, + author=author or None, + kind=kind or None, + entity=entity or None, + include_raw=include_raw, + limit=limit, + ) + + @mcp.tool() + def journal_cases() -> Any: + """The journal cases you can read, with entry counts.""" + return _safe(dialect.journal_overview) + + return mcp + + +def serve(dialect, *, space: str) -> None: + build(dialect, space=space).run("stdio") diff --git a/pyproject.toml b/pyproject.toml index 9561bd91..36b48a75 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -53,6 +53,9 @@ bedrock = ["boto3>=1.34"] openworker = "coworker.cli:main" openworker-server = "coworker.server.run:main" openworker-connectors = "coworker.connectors.cli:main" +# The board as an open surface (OPE-100): `ocw board …` / `ocw journal …`, +# including `ocw board mcp` — the stdio MCP server external harnesses attach to. +ocw = "coworker.teams.cli:main" [tool.setuptools.packages.find] where = ["."] diff --git a/tests/test_team_open_surface.py b/tests/test_team_open_surface.py new file mode 100644 index 00000000..4246bc6f --- /dev/null +++ b/tests/test_team_open_surface.py @@ -0,0 +1,417 @@ +"""OPE-100 — the board as an open surface: claim + policy knob, the BoardDialect +seam (local and remote), token-bound identity on `/v1/board`, and the MCP/CLI +front doors.""" + +import json + +import pytest + +from coworker.teams import Actor, AuthorityError, BoardError, JournalStore, Role, TeamStore +from coworker.teams.dialect import LocalDialect, RemoteDialect, local_dialect +from coworker.teams.tokens import BoardTokens + +USER = Actor(id="user", role=Role.USER) +LEAD = Actor(id="lead-1", role=Role.LEAD, persona="swe-lead") +NIA = Actor(id="nia", role=Role.WORKER, persona="swe-worker") +WEBB = Actor(id="webb", role=Role.WORKER, persona="swe-worker") + + +@pytest.fixture +def store(tmp_path): + journal = JournalStore(tmp_path / "journal.db") + store = TeamStore(tmp_path / "teams.db", journal=journal) + yield store + store.close() + journal.close() + + +def seed(store, space="proj", case=None): + return store.create_item( + space, LEAD, title="Build the API", criteria="routes pass tests", case=case + ) + + +# ------------------------------------------------------------------ claim verb + + +def test_claim_self_assigns_an_open_item(store): + item = seed(store) + claimed = store.claim("proj", NIA, item["id"]) + assert claimed["assignee"] == "nia" + assert claimed["state"] == "open" # claiming is not starting + + +def test_second_claim_loses_cleanly(store): + item = seed(store) + store.claim("proj", NIA, item["id"]) + with pytest.raises(BoardError, match="already claimed by nia"): + store.claim("proj", WEBB, item["id"]) + + +def test_claim_requires_open_and_unassigned(store): + item = seed(store) + store.assign("proj", LEAD, item["id"], "nia") + # an assigned item is not claimable, even while still open + with pytest.raises(BoardError, match="already claimed by nia"): + store.claim("proj", WEBB, item["id"]) + store.transition("proj", NIA, item["id"], "in_progress") + # and a non-open item never is + with pytest.raises(BoardError, match="only open items"): + store.claim("proj", WEBB, item["id"]) + + +def test_lead_only_policy_blocks_worker_claims(store): + item = seed(store) + store.set_policy("proj", LEAD, claims="lead-only") + with pytest.raises(AuthorityError, match="lead-only"): + store.claim("proj", NIA, item["id"]) + # flipping back re-opens the queue + store.set_policy("proj", USER, claims="open") + assert store.claim("proj", NIA, item["id"])["assignee"] == "nia" + + +def test_policy_defaults_open_and_validates(store): + assert store.policy("proj") == {"claims": "open"} + with pytest.raises(BoardError): + store.set_policy("proj", LEAD, claims="anarchy") + with pytest.raises(AuthorityError): + store.set_policy("proj", NIA, claims="lead-only") + + +def test_claim_feeds_the_lead_subscription(store): + item = seed(store) + store.claim("proj", NIA, item["id"]) + subs = store.subscribed_events("proj", "lead-1") + claims = [e for e in subs if e["kind"] == "item_assigned"] + assert len(claims) == 1 + assert claims[0]["actor"] == "nia" + assert claims[0]["payload"]["claimed"] is True + + +def test_lead_assigns_are_not_subscription_news(store): + item = seed(store) + store.assign("proj", USER, item["id"], "nia") + subs = store.subscribed_events("proj", "lead-1") + assert not [e for e in subs if e["kind"] == "item_assigned"] + + +def test_claim_feeds_journal_grants_like_assignment(store): + item = seed(store, case="case-alpha") + store.claim("proj", NIA, item["id"]) + # nia can now read the case it was granted through the claim + assert "case-alpha" in store.journal.cases(NIA) + + +# ------------------------------------------------------------------ dialects + + +def test_local_dialect_binds_identity(tmp_path): + dialect = local_dialect(tmp_path, actor="nia", role="worker") + assert dialect.whoami() == {"actor": "nia", "role": "worker"} + with pytest.raises(AuthorityError): + dialect.assign("proj", 1, "webb") # workers never assign + + +def test_local_dialect_full_worker_loop(tmp_path): + lead = local_dialect(tmp_path, actor="lead-1", role="lead") + item = lead.create_item( + "proj", title="Build it", criteria="tests pass", case="case-b" + ) + worker = LocalDialect(lead.store, lead.journal, NIA) + claimed = worker.claim("proj", item["id"]) + assert claimed["assignee"] == "nia" + worker.transition("proj", item["id"], "in_progress") + worker.journal_append("case-b", "found the flaky fixture", kind="finding") + worker.transition("proj", item["id"], "review", comment="branch ready") + shown = worker.get_item("proj", item["id"]) + assert shown["state"] == "review" + assert [e["body"] for e in worker.journal_read("case-b")] == [ + "found the flaky fixture" + ] + + +# ------------------------------------------------------------- HTTP board API + + +@pytest.fixture +def api(tmp_path, monkeypatch): + """The real FastAPI app over a real manager state dir, driven in-process.""" + monkeypatch.setenv("COWORKER_STATE_DIR", str(tmp_path / "state")) + monkeypatch.setenv("COWORKER_API_TOKEN", "sidecar-secret") + from coworker.permissions import Mode + from coworker.server.app import create_app + from coworker.server.manager import SessionManager + + manager = SessionManager( + workspace=None, + data_dir=tmp_path / "state", + model="openai:gpt-test", + mode=Mode("interactive"), + ) + from fastapi.testclient import TestClient + + app = create_app(manager) + client = TestClient(app, base_url="http://board.test") + yield client, manager, app + client.close() + + +def _tokens(manager) -> BoardTokens: + return manager.board_tokens + + +def test_board_api_requires_a_token(api): + client, _, _ = api + response = client.get("/v1/board/items", params={"space": "proj"}) + assert response.status_code == 401 + assert "board token" in response.json()["error"] + + +def test_board_api_rejects_the_sidecar_token_as_a_board_token(api): + client, _, _ = api + response = client.get( + "/v1/board/whoami", + headers={"Authorization": "Bearer sidecar-secret"}, + ) + assert response.status_code == 401 + + +def test_token_binds_identity_and_store_enforces_authority(api): + client, manager, app = api + lead_token = _tokens(manager).mint("lead-1", "lead") + nia_token = _tokens(manager).mint("nia", "worker") + lead = {"Authorization": f"Bearer {lead_token}"} + nia = {"Authorization": f"Bearer {nia_token}"} + + assert client.get("/v1/board/whoami", headers=nia).json() == { + "actor": "nia", + "role": "worker", + } + + created = client.post( + "/v1/board/items", + headers=lead, + json={"space": "proj", "title": "Build it", "criteria": "tests pass"}, + ) + assert created.status_code == 200 + item_id = created.json()["id"] + + # a worker token cannot assign — 403 from the store's authority check + denied = client.post( + "/v1/board/items/assign", + headers=nia, + json={"space": "proj", "id": item_id, "assignee": "nia"}, + ) + assert denied.status_code == 403 + + # but it can claim, then work the item + claimed = client.post( + "/v1/board/items/claim", headers=nia, json={"space": "proj", "id": item_id} + ) + assert claimed.status_code == 200 + assert claimed.json()["assignee"] == "nia" + + moved = client.post( + "/v1/board/items/transition", + headers=nia, + json={"space": "proj", "id": item_id, "to": "in_progress"}, + ) + assert moved.status_code == 200 + + # bad input is a 400 with the store's message, not a 500 + bad = client.post( + "/v1/board/items/transition", + headers=nia, + json={"space": "proj", "id": item_id, "to": "done"}, + ) + assert bad.status_code == 403 or bad.status_code == 400 + + +def test_remote_dialect_round_trip(api): + client, manager, app = api + lead_token = _tokens(manager).mint("lead-1", "lead") + nia_token = _tokens(manager).mint("nia", "worker") + from fastapi.testclient import TestClient + + lead = RemoteDialect( + "http://board.test", + lead_token, + client=TestClient(app, base_url="http://board.test"), + ) + nia = RemoteDialect( + "http://board.test", + nia_token, + client=TestClient(app, base_url="http://board.test"), + ) + + item = lead.create_item( + "proj", title="Remote item", criteria="works over the wire", case="case-r" + ) + assert lead.policy("proj") == {"claims": "open"} + claimed = nia.claim("proj", item["id"]) + assert claimed["assignee"] == "nia" + nia.transition("proj", item["id"], "in_progress") + nia.journal_append("case-r", "wire finding", kind="finding", item=item["id"]) + nia.transition("proj", item["id"], "review", comment="ready", refs=["branch:x"]) + + # the lead sees the review, verifies, and closes + shown = lead.get_item("proj", item["id"]) + assert shown["state"] == "review" + assert "branch:x" in shown["refs"] + entries = lead.journal_read("case-r") + assert entries[0]["body"] == "wire finding" + done = lead.transition("proj", item["id"], "done") + assert done["state"] == "done" + + # errors surface as BoardError with the server's message + with pytest.raises(BoardError, match="only open items"): + nia.claim("proj", item["id"]) + + # policy flip over the wire blocks the next worker claim + second = lead.create_item("proj", title="Held back", criteria="c") + lead.set_policy("proj", claims="lead-only") + with pytest.raises(BoardError, match="lead-only"): + nia.claim("proj", second["id"]) + + +def test_pending_and_consume_over_the_wire(api): + client, manager, app = api + lead_token = _tokens(manager).mint("lead-1", "lead") + nia_token = _tokens(manager).mint("nia", "worker") + from fastapi.testclient import TestClient + + lead = RemoteDialect( + "http://board.test", + lead_token, + client=TestClient(app, base_url="http://board.test"), + ) + nia = RemoteDialect( + "http://board.test", + nia_token, + client=TestClient(app, base_url="http://board.test"), + ) + item = lead.create_item("proj", title="Queued", criteria="c") + lead.assign("proj", item["id"], "nia") + events = nia.pending() + assert events and events[-1]["kind"] == "item_assigned" + nia.consume(events[-1]["seq"]) + assert nia.pending() == [] + + +# ------------------------------------------------------------------ tokens + + +def test_tokens_are_hash_stored_and_revocable(tmp_path): + tokens = BoardTokens(tmp_path / "board-tokens.json") + token = tokens.mint("nia", "worker", label="laptop") + # plaintext never touches disk + assert token not in (tmp_path / "board-tokens.json").read_text() + actor = tokens.resolve(token) + assert (actor.id, actor.role) == ("nia", Role.WORKER) + assert tokens.resolve("owb_forged") is None + assert tokens.revoke(token[:12]) == 1 + assert tokens.resolve(token) is None + + +def test_token_mint_validates_role(tmp_path): + tokens = BoardTokens(tmp_path / "board-tokens.json") + with pytest.raises(ValueError): + tokens.mint("nia", "admin") + + +# ------------------------------------------------------------------ MCP server + + +def test_mcp_tool_surface_is_role_scoped(tmp_path): + import anyio + + from coworker.teams.mcp_server import build + + worker = build( + local_dialect(tmp_path, actor="nia", role="worker"), space="proj" + ) + lead = build( + local_dialect(tmp_path, actor="lead-1", role="lead"), space="proj" + ) + + def names(server): + return {tool.name for tool in anyio.run(server.list_tools)} + + worker_names = names(worker) + lead_names = names(lead) + assert "board_claim" in worker_names + assert "board_assign" not in worker_names + assert "board_policy" not in worker_names + assert {"board_assign", "board_link", "board_policy"} <= lead_names + assert "journal_append" in worker_names + + +def test_mcp_worker_loop_through_call_tool(tmp_path): + import anyio + + from coworker.teams.mcp_server import build + + lead_dialect = local_dialect(tmp_path, actor="lead-1", role="lead") + item = lead_dialect.create_item("proj", title="Via MCP", criteria="c") + worker = build( + LocalDialect(lead_dialect.store, lead_dialect.journal, NIA), space="proj" + ) + + def call(name, arguments): + return anyio.run(lambda: worker.call_tool(name, arguments)) + + call("board_claim", {"item": item["id"]}) + call("board_move", {"item": item["id"], "to": "in_progress"}) + shown = lead_dialect.get_item("proj", item["id"]) + assert (shown["assignee"], shown["state"]) == ("nia", "in_progress") + + +# ------------------------------------------------------------------ CLI + + +def test_cli_headless_flow(tmp_path, capsys): + from coworker.teams.cli import main + + space_args = ["--db", str(tmp_path), "--space", "proj"] + assert main( + ["board", "create", "CLI item", "--criteria", "prints", *space_args, + "--actor", "lead-1", "--role", "lead"] + ) == 0 + capsys.readouterr() + assert main( + ["board", "claim", "1", *space_args, "--actor", "nia", "--role", "worker"] + ) == 0 + assert "claimed #1" in capsys.readouterr().out + assert main(["board", "list", *space_args, "--json"]) == 0 + items = json.loads(capsys.readouterr().out) + assert [(i["id"], i["assignee"]) for i in items] == [(1, "nia")] + # a losing claim exits 1 with the store's message on stderr + assert main( + ["board", "claim", "1", *space_args, "--actor", "webb", "--role", "worker"] + ) == 1 + assert "already claimed by nia" in capsys.readouterr().err + # policy knob round-trips + assert main(["board", "policy", "--claims", "lead-only", *space_args]) == 0 + assert "lead-only" in capsys.readouterr().out + # journal append + read + assert main( + ["journal", "append", "case-cli", "found it", "--kind", "finding", + *space_args] + ) == 0 + capsys.readouterr() + assert main(["journal", "read", "case-cli", *space_args]) == 0 + assert "found it" in capsys.readouterr().out + + +def test_cli_token_mint_and_list(tmp_path, capsys): + from coworker.teams.cli import main + + assert main( + ["board", "token", "mint", "--actor", "nia", "--role", "worker", + "--label", "laptop", "--db", str(tmp_path)] + ) == 0 + token = capsys.readouterr().out.strip() + assert token.startswith("owb_") + assert main(["board", "token", "list", "--db", str(tmp_path)]) == 0 + out = capsys.readouterr().out + assert "nia" in out and "laptop" in out and token not in out From f10bfca9d970af340c3a71094b32cbdfd80b03d7 Mon Sep 17 00:00:00 2001 From: Rohit C Prasad Date: Sun, 16 Aug 2026 18:20:44 -0700 Subject: [PATCH 42/80] Workers see the claimable pool (drill-caught) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Worker list_items was slice-only, so an unassigned external worker saw an empty board — a pull queue nobody can see. Open+unassigned items are now visible to workers while claims are open; hidden again under lead-only. --- coworker/teams/store.py | 16 +++++++++++++++- tests/test_team_board.py | 5 +++++ tests/test_team_open_surface.py | 21 +++++++++++++++++++++ 3 files changed, 41 insertions(+), 1 deletion(-) diff --git a/coworker/teams/store.py b/coworker/teams/store.py index d8fe935b..af0e8b1f 100644 --- a/coworker/teams/store.py +++ b/coworker/teams/store.py @@ -495,7 +495,21 @@ class TeamStore: items = [_row_to_item(row) for row in rows] if actor.role == Role.WORKER: visible = self._worker_slice(space, actor.id) - items = [item for item in items if item["id"] in visible] + # On an open-claims board the claimable pool is visible too — a + # pull queue nobody can see is not a queue (drill-caught: an + # external worker with no assignment saw an empty board). Under + # lead-only policy workers can't act on it, so it stays hidden. + claims_open = self.policy(space)["claims"] == "open" + items = [ + item + for item in items + if item["id"] in visible + or ( + claims_open + and item["state"] == ItemState.OPEN.value + and not item["assignee"] + ) + ] for item in items: item["links"] = self._links_of(space, item["id"]) return items diff --git a/tests/test_team_board.py b/tests/test_team_board.py index abcd1c56..42ce7e2d 100644 --- a/tests/test_team_board.py +++ b/tests/test_team_board.py @@ -47,6 +47,11 @@ def test_workers_file_items_open_and_unassigned(store): assert filed["id"] in visible with pytest.raises(AuthorityError): store.assign(SPACE, WORKER, filed["id"], "worker-1") + # open + unassigned = claimable, so OTHER sees it in the pool (open-claims + # default); under lead-only policy the strict slice rule returns + other_worker = {item["id"] for item in store.list_items(SPACE, OTHER)} + assert filed["id"] in other_worker + store.set_policy(SPACE, LEAD, claims="lead-only") other_worker = {item["id"] for item in store.list_items(SPACE, OTHER)} assert filed["id"] not in other_worker diff --git a/tests/test_team_open_surface.py b/tests/test_team_open_surface.py index 4246bc6f..04a36d91 100644 --- a/tests/test_team_open_surface.py +++ b/tests/test_team_open_surface.py @@ -102,6 +102,27 @@ def test_claim_feeds_journal_grants_like_assignment(store): assert "case-alpha" in store.journal.cases(NIA) +def test_workers_see_the_claimable_pool(store): + """A pull queue nobody can see is not a queue (drill-caught 2026-08-16): + an unassigned worker must be able to DISCOVER open items to claim them.""" + item = seed(store) + mine = store.create_item("proj", NIA, title="Mine", criteria="c") + # WEBB sees every open unassigned item — including NIA's filing (claimable) + assert {i["id"] for i in store.list_items("proj", WEBB)} == { + item["id"], + mine["id"], + } + # …but only while claims are open; under lead-only the slice rule stands + store.set_policy("proj", LEAD, claims="lead-only") + assert store.list_items("proj", WEBB) == [] + # own filings stay visible regardless of policy + assert {i["id"] for i in store.list_items("proj", NIA)} == {mine["id"]} + # a claimed item leaves the pool for everyone else + store.set_policy("proj", LEAD, claims="open") + store.claim("proj", NIA, item["id"]) + assert {i["id"] for i in store.list_items("proj", WEBB)} == {mine["id"]} + + # ------------------------------------------------------------------ dialects From 880c7859bd0831153cdbc20b7f656609308e3c7a Mon Sep 17 00:00:00 2001 From: Rohit C Prasad Date: Sun, 16 Aug 2026 18:34:04 -0700 Subject: [PATCH 43/80] Work-item image attachments: content-addressed store + attach on every front door Blobs live in state-dir attachments/ (sha256-named); the log carries only attachment:// refs on a normal comment event. Attach authority = comment authority; images-only allowlist with magic-byte check, 10MB cap; in-app tool + API + CLI + MCP. --- coworker/server/app.py | 46 ++++++++++++++ coworker/server/manager.py | 11 +++- coworker/teams/attachments.py | 108 ++++++++++++++++++++++++++++++++ coworker/teams/cli.py | 37 +++++++++++ coworker/teams/dialect.py | 83 +++++++++++++++++++++++- coworker/teams/mcp_server.py | 19 ++++++ coworker/teams/tools.py | 26 ++++++++ tests/test_team_open_surface.py | 90 ++++++++++++++++++++++++++ 8 files changed, 417 insertions(+), 3 deletions(-) create mode 100644 coworker/teams/attachments.py diff --git a/coworker/server/app.py b/coworker/server/app.py index 77d6f7fa..34ed584d 100644 --- a/coworker/server/app.py +++ b/coworker/server/app.py @@ -921,6 +921,52 @@ def create_app(manager: SessionManager) -> FastAPI: ), ) + @app.post("/v1/board/items/attach") + def board_attach(request: Request, body: dict): + body = body or {} + + def run(actor): + raw = str(body.get("data_b64", "")) + # Cheap pre-decode bound: base64 is ~4/3 of the payload, so anything + # multiples over the cap is refused before allocating the decode. + if len(raw) > 15 * 1024 * 1024: + return JSONResponse( + {"error": "attachment exceeds 10MB"}, status_code=400 + ) + try: + data = base64.b64decode(raw, validate=True) + except (binascii.Error, ValueError): + return JSONResponse( + {"error": "data_b64 is not valid base64"}, status_code=400 + ) + ref = manager.attachment_store.put( + data, str(body.get("filename", "")) + ) + filename = str(body.get("filename", "")) + event = manager.team_store.comment( + str(body.get("space", "")), + actor, + int(body.get("id", 0)), + str(body.get("caption", "")) or f"attached {filename}", + refs=[ref], + ) + return {"ref": ref, "seq": event["seq"]} + + return _board(request, run) + + @app.get("/v1/board/attachment") + def board_attachment(request: Request, name: str): + def run(actor): + from fastapi.responses import Response + + path = manager.attachment_store.path_for(name) + return Response( + content=path.read_bytes(), + media_type=manager.attachment_store.mime_for(name), + ) + + return _board(request, run) + @app.get("/v1/board/policy") def board_get_policy(request: Request, space: str): return _board(request, lambda actor: manager.team_store.policy(space)) diff --git a/coworker/server/manager.py b/coworker/server/manager.py index fd76e1fe..7c6ba258 100644 --- a/coworker/server/manager.py +++ b/coworker/server/manager.py @@ -89,6 +89,7 @@ from ..teams import JournalStore, Role as TeamRole, TeamStore, board_tools, jour from ..teams.model import space_for_workspace from ..teams.chat import ChatStore from ..teams.registry import TeamRegistry, TeamWorker +from ..teams.attachments import AttachmentStore from ..teams.tokens import BoardTokens from ..skills import ( SessionSkillStore, @@ -216,6 +217,9 @@ class SessionManager: # External board clients (OPE-100): join tokens bind actor+role; the # `/v1/board` API resolves them and the store enforces authority. self.board_tokens = BoardTokens(base / "board-tokens.json") + # Work-item attachments (OPE-105): content-addressed blobs next to the + # board; the log carries only `attachment://` refs. + self.attachment_store = AttachmentStore(base / "attachments") self._team_inflight: set[str] = set() # Lead-session last-turn timestamps for the check-in backstop (monotonic-ish # wall clock; restart resets the clock rather than firing a wake storm). @@ -1542,7 +1546,12 @@ class SessionManager: persona=agent.name, session_id=session_id, ) - tools = board_tools(self.team_store, space=space, actor=actor) + journal_tools( + tools = board_tools( + self.team_store, + space=space, + actor=actor, + attachments=self.attachment_store, + ) + journal_tools( self.journal_store, actor=actor, space=space ) if role == "lead": diff --git a/coworker/teams/attachments.py b/coworker/teams/attachments.py new file mode 100644 index 00000000..807981c8 --- /dev/null +++ b/coworker/teams/attachments.py @@ -0,0 +1,108 @@ +"""Content-addressed attachments for board items — screenshots first. + +Review artifacts don't belong in the repo (they aren't source, and they die with +checkouts) and don't belong in the board log (events carry refs, never blobs — no +megabytes under the hash chain). They live here: files named by their sha256 in the +state dir, bridged into the board as a normal comment event carrying an +`attachment://.#` ref. + +Content addressing buys three things: dedupe for free (the same screenshot attached +twice stores once), immutability by construction (the ref can never dangle onto +changed bytes), and location independence — on a hosted board the same ref resolves +to object storage instead of this directory. + +Scope is images-only and ~10MB to start; the allowlist is the policy choke point +when that widens. +""" + +from __future__ import annotations + +import hashlib +import re +from pathlib import Path +from typing import Optional + +from .model import BoardError + +ATTACHMENT_SCHEME = "attachment://" +MAX_ATTACHMENT_BYTES = 10 * 1024 * 1024 + +# Extension → mime for the types we accept. Sniffed magic must agree with the +# claimed extension — a .png that isn't a PNG is refused, not renamed. +_IMAGE_TYPES = { + "png": "image/png", + "jpg": "image/jpeg", + "jpeg": "image/jpeg", + "gif": "image/gif", + "webp": "image/webp", +} + +_MAGIC = { + "png": b"\x89PNG\r\n\x1a\n", + "jpg": b"\xff\xd8\xff", + "jpeg": b"\xff\xd8\xff", + "gif": b"GIF8", + "webp": b"RIFF", # RIFF….WEBP — checked with the fourcc below +} + +_STORED_NAME = re.compile(r"[0-9a-f]{64}\.[a-z0-9]{1,5}") + + +class AttachmentStore: + def __init__(self, root: str | Path) -> None: + self.root = Path(root).expanduser() + + def put(self, data: bytes, filename: str) -> str: + """Store one attachment; returns its `attachment://` ref. Idempotent — + identical bytes land on the same file.""" + ext = _validate(data, filename) + stored = f"{hashlib.sha256(data).hexdigest()}.{ext}" + self.root.mkdir(parents=True, exist_ok=True) + target = self.root / stored + if not target.exists(): + tmp = target.with_suffix(target.suffix + ".tmp") + tmp.write_bytes(data) + tmp.replace(target) + safe_name = Path(filename).name.replace("#", "_") + return f"{ATTACHMENT_SCHEME}{stored}#{safe_name}" + + def path_for(self, stored: str) -> Path: + """Resolve a stored name (`.`) to its file. The strict name + check is the traversal guard — nothing else reaches the filesystem.""" + stored = stored.strip() + if not _STORED_NAME.fullmatch(stored): + raise BoardError(f"not an attachment name: {stored!r}") + path = self.root / stored + if not path.exists(): + raise BoardError(f"no attachment {stored}") + return path + + def mime_for(self, stored: str) -> str: + return _IMAGE_TYPES.get(stored.rsplit(".", 1)[-1], "application/octet-stream") + + +def stored_name(ref: str) -> Optional[str]: + """`attachment://.#` → `.`; None for other refs.""" + if not ref.startswith(ATTACHMENT_SCHEME): + return None + return ref[len(ATTACHMENT_SCHEME):].split("#", 1)[0] + + +def _validate(data: bytes, filename: str) -> str: + if not data: + raise BoardError("attachment is empty") + if len(data) > MAX_ATTACHMENT_BYTES: + raise BoardError( + f"attachment exceeds {MAX_ATTACHMENT_BYTES // (1024 * 1024)}MB" + ) + ext = Path(filename).suffix.lstrip(".").lower() + if ext not in _IMAGE_TYPES: + raise BoardError( + f"unsupported attachment type .{ext or '?'} — images only for now" + f" ({', '.join(sorted(set(_IMAGE_TYPES)))})" + ) + if not data.startswith(_MAGIC[ext]) or ( + ext == "webp" and data[8:12] != b"WEBP" + ): + raise BoardError(f"file content does not look like .{ext}") + return "jpg" if ext == "jpeg" else ext diff --git a/coworker/teams/cli.py b/coworker/teams/cli.py index 18dce0cd..666030dc 100644 --- a/coworker/teams/cli.py +++ b/coworker/teams/cli.py @@ -93,6 +93,15 @@ def _parser() -> argparse.ArgumentParser: p.add_argument("id", type=int) p.add_argument("assignee") + p = cmd("attach", _cmd_attach, "attach a screenshot/image to an item") + p.add_argument("id", type=int) + p.add_argument("file", help="image file (png/jpg/gif/webp, ≤10MB)") + p.add_argument("--caption", default="") + + p = cmd("attachment", _cmd_attachment, "download an attachment by ref or name") + p.add_argument("ref", help="attachment:// ref or . name") + p.add_argument("-o", "--out", default="", help="output path (default: basename)") + p = cmd("link", _cmd_link, "link two items") p.add_argument("src", type=int) p.add_argument("kind", choices=("parent", "blocks")) @@ -328,6 +337,34 @@ def _cmd_assign(args) -> int: return 0 +def _cmd_attach(args) -> int: + source = Path(args.file).expanduser() + if not source.is_file(): + print(f"error: no such file: {source}", file=sys.stderr) + return 1 + result = _dialect(args).attach( + _space(args), args.id, source.read_bytes(), source.name, caption=args.caption + ) + ref = result.get("ref") or next( + (r for r in (result.get("payload") or {}).get("refs", [])), "" + ) + print(json.dumps(result, indent=2) if args.json else f"attached → {ref}") + return 0 + + +def _cmd_attachment(args) -> int: + from .attachments import stored_name + + stored = stored_name(args.ref) or args.ref + data, _mime = _dialect(args).attachment(stored) + out = Path(args.out) if args.out else Path( + args.ref.rsplit("#", 1)[-1] if "#" in args.ref else stored + ) + out.write_bytes(data) + print(str(out)) + return 0 + + def _cmd_link(args) -> int: _dialect(args).link(_space(args), args.src, args.kind, args.dst) print("ok" if not args.json else json.dumps({"ok": True})) diff --git a/coworker/teams/dialect.py b/coworker/teams/dialect.py index aa328044..540b0806 100644 --- a/coworker/teams/dialect.py +++ b/coworker/teams/dialect.py @@ -78,6 +78,16 @@ class BoardDialect(Protocol): def assign(self, space: str, item_id: int, assignee: str) -> dict[str, Any]: ... def claim(self, space: str, item_id: int) -> dict[str, Any]: ... def link(self, space: str, src: int, kind: str, dst: int) -> dict[str, Any]: ... + def attach( + self, + space: str, + item_id: int, + data: bytes, + filename: str, + *, + caption: str = "", + ) -> dict[str, Any]: ... + def attachment(self, stored: str) -> tuple[bytes, str]: ... def policy(self, space: str) -> dict[str, Any]: ... def set_policy(self, space: str, *, claims: str) -> dict[str, Any]: ... def pending(self, *, limit: int = 200) -> list[dict[str, Any]]: ... @@ -111,11 +121,17 @@ class LocalDialect: """Direct store access, one bound identity. The headless/standalone backing.""" def __init__( - self, store: TeamStore, journal: Optional[JournalStore], actor: Actor + self, + store: TeamStore, + journal: Optional[JournalStore], + actor: Actor, + *, + attachments: Any = None, ) -> None: self.store = store self.journal = journal self.actor = actor + self.attachments = attachments def whoami(self) -> dict[str, Any]: return {"actor": self.actor.id, "role": self.actor.role.value} @@ -187,6 +203,34 @@ class LocalDialect: def link(self, space: str, src: int, kind: str, dst: int) -> dict[str, Any]: return self.store.link(space, self.actor, src, kind, dst) + def attach( + self, + space: str, + item_id: int, + data: bytes, + filename: str, + *, + caption: str = "", + ) -> dict[str, Any]: + # Attach = store blob + a normal comment event carrying the ref. Comment + # authority IS attach authority (workers attach on their slice only). + if self.attachments is None: + raise BoardError("no attachment store is attached to this board") + ref = self.attachments.put(data, filename) + return self.store.comment( + space, + self.actor, + item_id, + caption or f"attached {filename}", + refs=[ref], + ) + + def attachment(self, stored: str) -> tuple[bytes, str]: + if self.attachments is None: + raise BoardError("no attachment store is attached to this board") + path = self.attachments.path_for(stored) + return path.read_bytes(), self.attachments.mime_for(stored) + def policy(self, space: str) -> dict[str, Any]: return self.store.policy(space) @@ -392,6 +436,36 @@ class RemoteDialect: "/v1/board/link", {"space": space, "src": src, "kind": kind, "dst": dst} ) + def attach( + self, + space: str, + item_id: int, + data: bytes, + filename: str, + *, + caption: str = "", + ) -> dict[str, Any]: + import base64 + + return self._post( + "/v1/board/items/attach", + { + "space": space, + "id": item_id, + "filename": filename, + "caption": caption, + "data_b64": base64.b64encode(data).decode("ascii"), + }, + ) + + def attachment(self, stored: str) -> tuple[bytes, str]: + response = self._client.get("/v1/board/attachment", params={"name": stored}) + if response.status_code >= 400: + self._unwrap(response) # raises with the server's message + return response.content, response.headers.get( + "content-type", "application/octet-stream" + ) + def policy(self, space: str) -> dict[str, Any]: return self._get("/v1/board/policy", {"space": space}) @@ -466,9 +540,14 @@ def local_dialect( backing for the CLI and MCP server when no OpenWorker server is running.""" from pathlib import Path + from .attachments import AttachmentStore + base = Path(db_dir).expanduser() journal = JournalStore(base / "journal.db") store = TeamStore(base / "teams.db", journal=journal) return LocalDialect( - store, journal, Actor(id=actor, role=Role(role)) + store, + journal, + Actor(id=actor, role=Role(role)), + attachments=AttachmentStore(base / "attachments"), ) diff --git a/coworker/teams/mcp_server.py b/coworker/teams/mcp_server.py index bff7cd90..d3f67c31 100644 --- a/coworker/teams/mcp_server.py +++ b/coworker/teams/mcp_server.py @@ -99,6 +99,25 @@ def build(dialect, *, space: str): belong here. `refs` attach artifact pointers.""" return _safe(dialect.comment, space, item, body, refs=list(refs or [])) + @mcp.tool() + def board_attach(item: int, path: str, caption: str = "") -> Any: + """Attach a screenshot or image (png/jpg/gif/webp, ≤10MB) from a local + file to a work item — so the lead/reviewer can SEE what you did. Give it + a caption saying what the image shows. Great with review hand-offs.""" + from pathlib import Path as _Path + + source = _Path(path).expanduser() + if not source.is_file(): + return {"error": f"no such file: {path}"} + return _safe( + dialect.attach, + space, + item, + source.read_bytes(), + source.name, + caption=caption, + ) + @mcp.tool() def board_pending() -> Any: """Your unconsumed deliveries — assignments addressed to you, cancel diff --git a/coworker/teams/tools.py b/coworker/teams/tools.py index fa4f8845..c704e270 100644 --- a/coworker/teams/tools.py +++ b/coworker/teams/tools.py @@ -63,6 +63,7 @@ def board_tools( space: str, actor: Actor, taint: Callable[[], bool] = lambda: False, + attachments=None, ) -> list: """The board verbs for one agent, pre-bound to its space and identity. @@ -150,7 +151,32 @@ def board_tools( `blocks` (src blocks dst).""" return _call(store.link, space, actor, src, kind, dst) + def attach_image(item: int, path: str, caption: str = "") -> dict: + """Attach a screenshot or image file (png/jpg/gif/webp, ≤10MB) to a work + item so the lead/reviewer can SEE what you did — pair it with your review + hand-off. `caption` says what the image shows.""" + from pathlib import Path as _Path + + source = _Path(path).expanduser() + if not source.is_file(): + return {"error": f"no such file: {path}"} + try: + ref = attachments.put(source.read_bytes(), source.name) + except (BoardError, ValueError) as error: + return {"error": str(error)} + return _call( + store.comment, + space, + actor, + item, + caption or f"attached {source.name}", + refs=[ref], + taint=taint(), + ) + verbs = LEAD_VERBS if actor.role in (Role.USER, Role.LEAD) else WORKER_VERBS + if attachments is not None: + verbs = verbs + ("attach_image",) local = locals() out = [] for name in verbs: diff --git a/tests/test_team_open_surface.py b/tests/test_team_open_surface.py index 04a36d91..9f90b860 100644 --- a/tests/test_team_open_surface.py +++ b/tests/test_team_open_surface.py @@ -319,6 +319,95 @@ def test_pending_and_consume_over_the_wire(api): assert nia.pending() == [] +# ------------------------------------------------------------------ attachments + +PNG = b"\x89PNG\r\n\x1a\n" + b"drill-bytes" + + +def test_attachment_store_is_content_addressed(tmp_path): + from coworker.teams.attachments import AttachmentStore, stored_name + + store = AttachmentStore(tmp_path / "attachments") + ref = store.put(PNG, "shot.png") + assert ref.startswith("attachment://") and ref.endswith("#shot.png") + # identical bytes dedupe to the same file, whatever the filename says + assert stored_name(store.put(PNG, "other.png")) == stored_name(ref) + data = store.path_for(stored_name(ref)).read_bytes() + assert data == PNG + assert store.mime_for(stored_name(ref)) == "image/png" + + +def test_attachment_store_validates(tmp_path): + from coworker.teams.attachments import AttachmentStore + + store = AttachmentStore(tmp_path / "attachments") + with pytest.raises(BoardError, match="images only"): + store.put(b"#!/bin/sh", "run.sh") + with pytest.raises(BoardError, match="does not look like"): + store.put(b"not a png at all", "fake.png") + with pytest.raises(BoardError, match="not an attachment name"): + store.path_for("../../etc/passwd") + + +def test_attach_over_the_wire_and_fetch(api): + client, manager, app = api + from fastapi.testclient import TestClient + + lead_token = _tokens(manager).mint("lead-1", "lead") + nia_token = _tokens(manager).mint("nia", "worker") + lead = RemoteDialect( + "http://board.test", + lead_token, + client=TestClient(app, base_url="http://board.test"), + ) + nia = RemoteDialect( + "http://board.test", + nia_token, + client=TestClient(app, base_url="http://board.test"), + ) + item = lead.create_item("proj", title="With screenshot", criteria="c") + nia.claim("proj", item["id"]) + result = nia.attach( + "proj", item["id"], PNG, "after.png", caption="statements page, dark mode" + ) + assert result["ref"].startswith("attachment://") + + # the ref lands on the item as a comment with the caption + shown = lead.get_item("proj", item["id"]) + assert result["ref"] in shown["refs"] + assert shown["comments"][-1]["body"] == "statements page, dark mode" + + # and the lead can fetch the bytes back + from coworker.teams.attachments import stored_name + + data, mime = lead.attachment(stored_name(result["ref"])) + assert data == PNG and mime == "image/png" + + # a worker cannot attach to an item outside its slice + other = lead.create_item("proj", title="Not nia's", criteria="c") + lead.assign("proj", other["id"], "someone-else") + with pytest.raises(BoardError, match="assigned items"): + nia.attach("proj", other["id"], PNG, "sneaky.png") + + +def test_attach_rejects_bad_payloads_over_the_wire(api): + client, manager, app = api + lead_token = _tokens(manager).mint("lead-1", "lead") + headers = {"Authorization": f"Bearer {lead_token}"} + client.post( + "/v1/board/items", + headers=headers, + json={"space": "proj", "title": "T", "criteria": "c"}, + ) + bad = client.post( + "/v1/board/items/attach", + headers=headers, + json={"space": "proj", "id": 1, "filename": "x.png", "data_b64": "!!!"}, + ) + assert bad.status_code == 400 + assert "base64" in bad.json()["error"] + + # ------------------------------------------------------------------ tokens @@ -361,6 +450,7 @@ def test_mcp_tool_surface_is_role_scoped(tmp_path): worker_names = names(worker) lead_names = names(lead) assert "board_claim" in worker_names + assert "board_attach" in worker_names assert "board_assign" not in worker_names assert "board_policy" not in worker_names assert {"board_assign", "board_link", "board_policy"} <= lead_names From b2418d5a3025926a58076af0c773aab18a458dda Mon Sep 17 00:00:00 2001 From: Rohit C Prasad Date: Mon, 17 Aug 2026 08:54:53 -0700 Subject: [PATCH 44/80] Drill-round polish: calm rail, digest diet, gate replies, criteria clamp, composer fix Rail shows active work only (finished behind a count); wake digests clamp hand-offs and ride a collapsed BoardWakeCard. Typing while a proposal gate is pending resolves it as decline-with-feedback; essay criteria clamp in the gate card. Composer autogrow now counts padding in its cap (first line no longer clips); lead/worker prompts push tight criteria and hand-offs. --- .../personas/builtin/swe-lead/manifest.md | 10 +- .../personas/builtin/swe-worker/manifest.md | 6 +- coworker/server/manager.py | 103 +++++++++++++--- surfaces/gui/e2e/board.spec.ts | 20 +++ surfaces/gui/e2e/fixtures.ts | 34 ++++- surfaces/gui/e2e/team.spec.ts | 48 ++++++++ surfaces/gui/src/App.tsx | 19 +++ surfaces/gui/src/api.ts | 14 +++ surfaces/gui/src/components/BoardPanel.tsx | 37 +++++- surfaces/gui/src/components/BoardWakeCard.tsx | 116 ++++++++++++++++++ surfaces/gui/src/components/Composer.tsx | 25 +++- surfaces/gui/src/components/Transcript.tsx | 9 +- surfaces/gui/src/components/WorkItemsCard.tsx | 56 ++++++--- surfaces/gui/src/styles.css | 51 +++++++- tests/test_team_wake.py | 35 +++++- 15 files changed, 529 insertions(+), 54 deletions(-) create mode 100644 surfaces/gui/src/components/BoardWakeCard.tsx diff --git a/coworker/personas/builtin/swe-lead/manifest.md b/coworker/personas/builtin/swe-lead/manifest.md index 40ba0554..b1ce0616 100644 --- a/coworker/personas/builtin/swe-lead/manifest.md +++ b/coworker/personas/builtin/swe-lead/manifest.md @@ -17,10 +17,16 @@ NOT implement — you carry no shell or git on purpose. The board is the shared truth; your context window is disposable, the board is not. How you run a piece of work: -1. UNDERSTAND: read enough of the repo (files, search) to decompose honestly. +1. UNDERSTAND: read enough of the repo (files, search) to decompose honestly. The + board is per-PROJECT and outlives sessions — before proposing anything, read it + (list_items) and triage leftovers from earlier efforts: reassign or cancel stale + in-progress items, never stack duplicates of existing open ones. 2. PLAN: split the work into items with crisp acceptance criteria — "Done when:" that a verifier can actually check. Acceptance criteria are the single biggest quality lever - you own; vague criteria produce vague work. Present the decomposition with + you own; vague criteria produce vague work. Criteria are 1–3 SHORT, independently + checkable statements — mechanics (setup commands, file paths, how-to) belong in the + item's description, never in the criteria; a verifier can pass/fail three checks, + it cannot pass/fail an essay. Present the decomposition with propose_work_items (works in any mode; approval creates the items on the board and returns their ids) and revise until the user approves. Use create_item only for one-off additions after the plan is approved. diff --git a/coworker/personas/builtin/swe-worker/manifest.md b/coworker/personas/builtin/swe-worker/manifest.md index f3860ff0..da65a8df 100644 --- a/coworker/personas/builtin/swe-worker/manifest.md +++ b/coworker/personas/builtin/swe-worker/manifest.md @@ -31,8 +31,10 @@ The team contract (this is how you work): - Discover a bug or follow-up outside your item's scope? File it (create_item) with real acceptance criteria and keep moving. The lead triages it. - Finish = transition to review with a hand-off comment: what you did, how you - verified it, refs (branch, files). You NEVER mark your own work done — done is the - verdict after verification. + verified it, refs (branch, files). Keep the hand-off TIGHT — a short paragraph + plus refs; full evidence and long output belong in the journal, not the comment + (long comments get clamped in wake digests anyway). You NEVER mark your own work + done — done is the verdict after verification. - Steering arrives attributed [Lead] or [User]; [User] outranks [Lead]. - House rules hold: no silent skips — if you couldn't do part of the work, the hand-off comment says which part and why. diff --git a/coworker/server/manager.py b/coworker/server/manager.py index 7c6ba258..3bad327e 100644 --- a/coworker/server/manager.py +++ b/coworker/server/manager.py @@ -1955,9 +1955,9 @@ class SessionManager: if not self.teams.count_wake(team.team_id, cap=self.TEAM_WAKE_CAP_PER_HOUR): logger.warning("team %s paused for budget this hour", team.team_id) return 0 - message = self._team_digest(team, directs, subs, chats, is_lead=is_lead) + message, rows = self._team_digest(team, directs, subs, chats, is_lead=is_lead) self._team_inflight.add(session_id) - source = self._board_source(team, message) + source = self._board_source(team, message, rows=rows) async def _deliver() -> None: try: @@ -1980,6 +1980,21 @@ class SessionManager: asyncio.create_task(_deliver()) return 1 + # Long comment/hand-off bodies are already durable on the board — the wake + # message's job is to say what needs DECISIONS, not to re-carry the evidence + # into the recipient's context on every wake (owner ruling 2026-08-16). The + # model text clamps hard; the UI sidecar rows clamp softer (the human gets a + # bigger excerpt on click without re-inflating the lead's prompt). + DIGEST_CLAMP_MODEL = 300 + DIGEST_CLAMP_UI = 600 + + @staticmethod + def _clamp(text: str, limit: int, *, suffix: str = "…") -> str: + text = (text or "").strip() + if len(text) <= limit: + return text + return text[:limit].rstrip() + suffix + def _team_digest( self, team, @@ -1988,10 +2003,16 @@ class SessionManager: chats: Optional[list[dict]] = None, *, is_lead: bool, - ) -> str: + ) -> tuple[str, list[dict]]: """Coalesce one queue batch into one wake message. Deterministic, computed - by code — the model does judgment, not arithmetic.""" + by code — the model does judgment, not arithmetic. Returns (model text, + structured rows) — the rows ride the display sidecar so the GUI renders a + collapsed BoardWakeCard instead of re-parsing prose.""" + clamp = lambda text: self._clamp( # noqa: E731 — two-site local shorthand + text, self.DIGEST_CLAMP_MODEL, suffix=" … (full text on the board)" + ) lines: list[str] = [] + rows: list[dict] = [] for event in directs + subs: item_id = event.get("item_id") payload = event.get("payload") or {} @@ -2002,6 +2023,11 @@ class SessionManager: except Exception: item = None title = f"#{item_id} {item['title']}" if item else f"#{item_id}" + row = { + "item": item_id, + "title": item["title"] if item else "", + "actor": event.get("actor", ""), + } if event["kind"] == "item_assigned": if item is None: continue @@ -2012,15 +2038,18 @@ class SessionManager: f"{event['actor']} claimed {title} — it's theirs now;" " reassign or cancel if that's wrong." ) + rows.append({**row, "kind": "claimed"}) continue lines.append( f"You've been assigned work item {title}.\n" f" Done when: {item['criteria']}" + (f"\n Details: {item['description']}" if item["description"] else "") ) + rows.append({**row, "kind": "assigned"}) elif event["kind"] == "item_transitioned": to = payload.get("to", "?") - note = f" — “{payload.get('comment')}”" if payload.get("comment") else "" + comment = clamp(payload.get("comment") or "") + note = f" — “{comment}”" if comment else "" if to == "canceled" and not is_lead: lines.append( f"{title} was CANCELED by {event['actor']}{note} — stop any" @@ -2028,32 +2057,63 @@ class SessionManager: ) else: lines.append(f"{title} moved to {to} by {event['actor']}{note}") + rows.append( + { + **row, + "kind": "moved", + "to": to, + "note": self._clamp( + payload.get("comment") or "", self.DIGEST_CLAMP_UI + ), + } + ) elif event["kind"] == "item_created": lines.append(f"New item filed by {event['actor']}: {title}") + rows.append({**row, "kind": "filed"}) elif event["kind"] == "item_commented": lines.append( - f"Comment on {title} by {event['actor']}: {payload.get('body', '')}" + f"Comment on {title} by {event['actor']}:" + f" {clamp(payload.get('body', ''))}" + ) + rows.append( + { + **row, + "kind": "comment", + "note": self._clamp( + payload.get("body") or "", self.DIGEST_CLAMP_UI + ), + } ) for chat in chats or []: who = chat["author"] if chat["author_role"] != "user" else "[User]" - lines.append(f"# team chat — {who}: {chat['text']}") + lines.append(f"# team chat — {who}: {clamp(chat['text'])}") + rows.append( + { + "kind": "chat", + "actor": who, + "note": self._clamp(chat["text"], self.DIGEST_CLAMP_UI), + } + ) body = "\n".join(f"- {line}" for line in lines) or "- (no detail)" if is_lead: - return ( + message = ( "⏰ Board wake — your team needs decisions:\n" + body - + "\n\nVerify review items against their acceptance criteria (then" + + "\n\nFull hand-off comments live on the board (get_item)." + " Verify review items against their acceptance criteria (then" " done, or send back with a comment), unblock or reassign blocked" " items, and triage new filings. Steer only where needed." ) - return ( - "[Lead] Board update:\n" - + body - + self._roster_note(team) - + "\n\nMove your item to in_progress when you start; blocked (with a" - " comment) if stuck; review with a hand-off comment when finished." - " Journal evidence as you go." - ) + else: + message = ( + "[Lead] Board update:\n" + + body + + self._roster_note(team) + + "\n\nMove your item to in_progress when you start; blocked (with a" + " comment) if stuck; review with a hand-off comment when finished." + " Journal evidence as you go." + ) + return message, rows @staticmethod def _roster_note(team) -> str: @@ -2074,11 +2134,15 @@ class SessionManager: return f"\n\nYour team: {mates}; lead (coordinator).{reach}" @staticmethod - def _board_source(team, message: str) -> dict[str, Any]: + def _board_source( + team, message: str, *, rows: Optional[list[dict]] = None + ) -> dict[str, Any]: """Display-only MessageSource sidecar for board deliveries — the same mechanism connector messages use, so the GUI renders a structured card instead of a fake user bubble (owner ask 2026-08-16). The framed message - stays the model-facing text; this only shapes presentation.""" + stays the model-facing text; this only shapes presentation. `rows` are + the digest's structured events — the BoardWakeCard renders those + (collapsed to one line by default) instead of re-parsing the prose.""" return { "connector": "board", "kind": "channel", @@ -2088,6 +2152,7 @@ class SessionManager: "sender_name": "Board", "ts": time.time(), "text": message, + "board": {"rows": rows or []}, } def team_staleness_digest(self, session_id: str) -> str: diff --git a/surfaces/gui/e2e/board.spec.ts b/surfaces/gui/e2e/board.spec.ts index 1ac8f1e7..6f0dfc16 100644 --- a/surfaces/gui/e2e/board.spec.ts +++ b/surfaces/gui/e2e/board.spec.ts @@ -61,6 +61,26 @@ test("expand opens the overlay board; the user verifies review items and removes await expect(page.getByTestId("board-overlay")).toHaveCount(0); }); +test("finished items leave the rail; a quiet toggle reveals them", async ({ page }) => { + await planTheWork(page); + const rail = page.getByTestId("board-rail"); + await expect(rail.getByText("Report rollup")).toBeVisible(); // review = active + await page.getByTestId("board-expand").click(); + await page + .getByTestId("board-col-review") + .getByRole("button", { name: "Mark done" }) + .click(); + await page.keyboard.press("Escape"); + // done vanishes from the rail — a fresh session on an old board starts calm + await expect(rail.getByText("Report rollup")).toHaveCount(0); + const toggle = page.getByTestId("board-finished-toggle"); + await expect(toggle).toHaveText("1 finished · show"); + await toggle.click(); + await expect(rail.getByText("Report rollup")).toBeVisible(); + await toggle.click(); + await expect(rail.getByText("Report rollup")).toHaveCount(0); +}); + test("journal section lists cases once a board exists", async ({ page }) => { await planTheWork(page); await page.getByRole("button", { name: /Journal/ }).click(); diff --git a/surfaces/gui/e2e/fixtures.ts b/surfaces/gui/e2e/fixtures.ts index 1811ca31..1b45d5f2 100644 --- a/surfaces/gui/e2e/fixtures.ts +++ b/surfaces/gui/e2e/fixtures.ts @@ -642,10 +642,42 @@ export async function mockApi(page: import("@playwright/test").Page) { } // Agent teams: the decomposition gate — the lead proposes work items and // SUSPENDS until the items_response verdict arrives (approval creates them). + // A board wake arriving on this session: the digest rides `source` with + // structured rows — the BoardWakeCard renders collapsed by default. + if (/board wake/i.test(msg.text)) { + send("turn_start", { + source: { + connector: "board", + kind: "channel", + channel_id: "/Users/test/OpenWorker/launch-note", + channel_name: "Team board", + sender_id: "board", + sender_name: "Board", + ts: Date.now() / 1000, + text: "⏰ Board wake — your team needs decisions:\n- #2 moved to review by webb", + board: { + rows: [ + { + kind: "moved", + item: 2, + title: "Statements page", + actor: "webb", + to: "review", + note: "Ready for review on feat/customer-statements, commit 029f9f7. Build verified; final verdict stays with the tester.", + }, + { kind: "filed", item: 5, title: "Follow-up: rate limit", actor: "nia" }, + ], + }, + }, + }); + send("assistant_message", { text: "Reviewing the hand-off now." }); + send("turn_done"); + return; + } if (/propose the split/i.test(msg.text)) { send("items_proposed", { items: [ - { title: "Statement API endpoint", criteria: "returns opening/closing balances; 8 endpoint tests green" }, + { title: "Statement API endpoint", criteria: "returns opening/closing balances over the chosen range; 8 endpoint tests green; malformed, missing, and reversed date ranges return 400; draft invoices are excluded from issued totals; inclusive boundaries verified end to end" }, { title: "Statements dashboard page", criteria: "renders seeded data for Ada / Northgate; empty + error states covered" }, { title: "Statement totals reconcile", criteria: "running balance matches invoices minus payments for the range" }, { title: "Verification pass", criteria: "tester confirms page renders with live API data" }, diff --git a/surfaces/gui/e2e/team.spec.ts b/surfaces/gui/e2e/team.spec.ts index b3d311d0..18efa244 100644 --- a/surfaces/gui/e2e/team.spec.ts +++ b/surfaces/gui/e2e/team.spec.ts @@ -27,11 +27,59 @@ test("the decomposition gate shows items with criteria; approval lands them on t await card.getByRole("button", { name: /1 more item/ }).click(); await expect(card.getByText("Verification pass")).toBeVisible(); + // essay-length criteria clamp behind a per-item expander (owner-hit 2026-08-16) + const acToggle = page.getByTestId("itemsreq-ac-toggle-0"); + await expect(acToggle).toHaveText("Show full criteria"); + await acToggle.click(); + await expect(acToggle).toHaveText("Show less"); + // the short-criteria items get no toggle + await expect(page.getByTestId("itemsreq-ac-toggle-1")).toHaveCount(0); + await page.getByTestId("itemsreq-approve").click(); await expect(page.getByText(/Items created on the board/)).toBeVisible(); await expect(page.getByTestId("board-rail")).toBeVisible(); }); +test("typing while a gate is pending sends the reply as feedback to the lead", async ({ + page, +}) => { + await proposeTeam(page); + // the composer re-opens for a typed answer instead of hard-blocking on "running" + const box = page.getByPlaceholder(/Reply to adjust the proposal/); + await box.fill("use openai:gpt-5.6-sol for all the workers"); + await page.getByRole("button", { name: "Send" }).click(); + // the reply lands as a user message AND resolves the gate as decline-with-feedback + await expect( + page.getByText("use openai:gpt-5.6-sol for all the workers"), + ).toBeVisible(); + await expect(page.getByText(/tell me how to change the roster/)).toBeVisible(); + await expect(page.getByTestId("teamreq-card")).toHaveCount(0); +}); + +test("a board wake renders collapsed; expanding reveals rows, hand-offs stay one more click away", async ({ + page, +}) => { + await page.goto("/"); + await page.getByPlaceholder(/Ask the coworker/).fill("board wake"); + await page.getByRole("button", { name: "Send" }).click(); + const card = page.getByTestId("boardwake-card"); + await expect(card).toBeVisible(); + await expect(card).toContainText("Board wake"); + await expect(card).toContainText("1 review, 1 filing"); + // collapsed by default: ambient awareness, not reading assignment + await expect(page.getByTestId("boardwake-body")).toHaveCount(0); + await expect(card).not.toContainText("029f9f7"); + await page.getByTestId("boardwake-toggle").click(); + const body = page.getByTestId("boardwake-body"); + await expect(body).toBeVisible(); + await expect(body).toContainText("#2 Statements page → review by webb"); + await expect(body).toContainText("nia filed #5 Follow-up: rate limit"); + // the hand-off comment sits behind its own per-row toggle + await expect(body).not.toContainText("029f9f7"); + await body.getByRole("button", { name: "show hand-off" }).click(); + await expect(body).toContainText("029f9f7"); +}); + test("declining the split returns feedback to the lead", async ({ page }) => { await page.goto("/"); await page.getByPlaceholder(/Ask the coworker/).fill("propose the split"); diff --git a/surfaces/gui/src/App.tsx b/surfaces/gui/src/App.tsx index f7df71d0..ec3f44f4 100644 --- a/surfaces/gui/src/App.tsx +++ b/surfaces/gui/src/App.tsx @@ -1020,6 +1020,24 @@ export function App() { setSendGate({ text, attachments, skill }); return; } + // A typed message while a proposal gate is pending IS the answer: it resolves + // the gate as decline-with-feedback, so "use gpt-5.6-sol for all workers" + // reaches the lead instead of bouncing off a blocked composer (owner-hit + // 2026-08-16). The card buttons stay the approve/plain-decline paths. + if (!unattended && pendingTeam?.kind === "teamreq" && !pendingTeam.resolved) { + setItems((p) => [...p, { kind: "user", text, ts: Date.now() / 1000 }]); + respondTeam(false, text); + return; + } + if ( + !unattended && + pendingItemsReq?.kind === "itemsreq" && + !pendingItemsReq.resolved + ) { + setItems((p) => [...p, { kind: "user", text, ts: Date.now() / 1000 }]); + respondItemsReq(false, text); + return; + } // Force-run shows exactly what the user typed: "/name rest". Must match the server's // `display` sidecar formula so the turn_start dedupe recognizes the local echo. const shown = skill ? `/${skill}${text ? ` ${text}` : ""}` : text; @@ -1952,6 +1970,7 @@ export function App() { models={models} modelLabels={modelLabels} running={running} + gateOpen={!unattended && (!!pendingTeam || !!pendingItemsReq)} connected={connected} modelReady={modelReady} onConnectModel={openModelSetup} diff --git a/surfaces/gui/src/api.ts b/surfaces/gui/src/api.ts index 83baa7bb..5a684aa0 100644 --- a/surfaces/gui/src/api.ts +++ b/surfaces/gui/src/api.ts @@ -162,6 +162,20 @@ export interface MessageSource { sender_name: string; // resolved; may equal the id ts: number; // epoch seconds text: string; // the RAW message (what the card shows) + // Board wakes only (connector === "board"): the digest as structured rows, so + // the BoardWakeCard renders collapsed summaries instead of re-parsing prose. + board?: { rows: BoardWakeRow[] }; +} + +// One digest event on a board wake. `note` is a UI-clamped excerpt of a hand-off +// comment (the full text lives on the board). +export interface BoardWakeRow { + kind: "assigned" | "claimed" | "moved" | "filed" | "comment" | "chat" | string; + item?: number | null; + title?: string; + actor?: string; + to?: string; + note?: string; } // A transcript message from GET /v1/sessions/{id}/messages. Kept permissive (open shape) because diff --git a/surfaces/gui/src/components/BoardPanel.tsx b/surfaces/gui/src/components/BoardPanel.tsx index c8048366..cb4174c5 100644 --- a/surfaces/gui/src/components/BoardPanel.tsx +++ b/surfaces/gui/src/components/BoardPanel.tsx @@ -5,7 +5,7 @@ // endpoints and act as the USER. There is NO proposed/draft state: a plan // proposal lives in the conversation (plan-approval flow); the board only ever // contains accepted work, and work starts at ASSIGNMENT. -import { useEffect } from "react"; +import { useEffect, useState } from "react"; import type { Board, BoardItem } from "../api"; import { Icon } from "./Icon"; @@ -39,12 +39,30 @@ export function boardSummary(board: Board): string { } export function BoardSection({ board, onExpand }: { board: Board; onExpand: () => void }) { - const groups = GROUPS.map((g) => ({ - ...g, - items: board.items.filter((i) => i.state === g.state), - })).filter((g) => g.items.length > 0); + // The rail shows ACTIVE work only (owner ruling 2026-08-16): a project board + // outlives its sessions, so finished history from a past effort would greet + // every fresh session as a long stale list. Done/canceled sit behind a quiet + // count; the expanded overlay keeps the full picture. + const [showFinished, setShowFinished] = useState(false); + const finished = board.items.filter( + (i) => i.state === "done" || i.state === "canceled" + ).length; + const shown = showFinished + ? GROUPS + : GROUPS.filter((g) => g.state !== "done" && g.state !== "canceled"); + const groups = shown + .map((g) => ({ + ...g, + items: board.items.filter((i) => i.state === g.state), + })) + .filter((g) => g.items.length > 0); return (
+ {groups.length === 0 && ( +
+ No active work +
+ )} {groups.map((group) => (
{group.label}
@@ -61,6 +79,15 @@ export function BoardSection({ board, onExpand }: { board: Board; onExpand: () = ))}
))} + {finished > 0 && ( + + )}
); } diff --git a/surfaces/gui/src/components/BoardWakeCard.tsx b/surfaces/gui/src/components/BoardWakeCard.tsx new file mode 100644 index 00000000..688545f6 --- /dev/null +++ b/surfaces/gui/src/components/BoardWakeCard.tsx @@ -0,0 +1,116 @@ +// BoardWakeCard — a board wake in the lead's transcript, collapsed to ONE line by +// default (owner ruling 2026-08-16): most of the time the user just wants the +// feel that something is happening. Click to expand into per-event rows; long +// hand-off comments hide behind a per-row "show hand-off". NOT the connector +// card: a connector message is a foreign message, a board wake is a report — +// different shape, different affordances (they only share the visual family). +import { useState } from "react"; +import type { BoardWakeRow, MessageSource } from "../api"; +import { Icon } from "./Icon"; + +function summarize(rows: BoardWakeRow[]): { text: string; attention: boolean } { + const counts: Record = {}; + const bump = (key: string) => (counts[key] = (counts[key] || 0) + 1); + for (const row of rows) { + if (row.kind === "moved" && row.to === "review") bump("review"); + else if (row.kind === "moved" && row.to === "blocked") bump("blocked"); + else if (row.kind === "moved" && row.to === "canceled") bump("canceled"); + else if (row.kind === "moved") bump("move"); + else if (row.kind === "filed") bump("filing"); + else if (row.kind === "claimed") bump("claim"); + else if (row.kind === "assigned") bump("assignment"); + else if (row.kind === "comment") bump("comment"); + else if (row.kind === "chat") bump("chat message"); + } + const parts = Object.entries(counts).map( + ([label, n]) => `${n} ${label}${n === 1 ? "" : "s"}` + ); + // reviews/blocked demand a decision — those tint the collapsed line amber + const attention = (counts.review || 0) + (counts.blocked || 0) > 0; + return { text: parts.join(", ") || "update", attention }; +} + +function rowText(row: BoardWakeRow): string { + const item = row.item != null ? `#${row.item}` : ""; + const title = row.title ? ` ${row.title}` : ""; + switch (row.kind) { + case "moved": + return `${item}${title} → ${row.to} by ${row.actor}`; + case "filed": + return `${row.actor} filed ${item}${title}`; + case "claimed": + return `${row.actor} claimed ${item}${title}`; + case "assigned": + return `${item}${title} assigned to you`; + case "comment": + return `${row.actor} commented on ${item}${title}`; + case "chat": + return `# team chat — ${row.actor}`; + default: + return `${item}${title}`; + } +} + +function stateDot(row: BoardWakeRow): string { + if (row.kind === "moved" && row.to === "review") return "board-dot review"; + if (row.kind === "moved" && row.to === "blocked") return "board-dot blocked"; + if (row.kind === "moved" && row.to === "done") return "board-dot done"; + if (row.kind === "claimed" || row.kind === "assigned") return "board-dot work"; + return "board-dot idle"; +} + +export function BoardWakeCard({ source }: { source: MessageSource }) { + const [open, setOpen] = useState(false); + const [openNotes, setOpenNotes] = useState>({}); + const rows = source.board?.rows || []; + const { text, attention } = summarize(rows); + return ( +
+ + {open && ( +
+ {rows.map((row, i) => ( +
+ + + {rowText(row)} + {row.note && + (openNotes[i] ? ( + {row.note} + ) : ( + + ))} + +
+ ))} + {rows.length === 0 && ( +
{source.text}
+ )} +
+ )} +
+ ); +} diff --git a/surfaces/gui/src/components/Composer.tsx b/surfaces/gui/src/components/Composer.tsx index 532c437d..4c557cbb 100644 --- a/surfaces/gui/src/components/Composer.tsx +++ b/surfaces/gui/src/components/Composer.tsx @@ -53,6 +53,10 @@ interface Props { // session; after the first turn the fact lives in the topbar subtitle (§22) — no // interactive-then-disabled control. running: boolean; + // A proposal gate (team/items) is awaiting the user: the engine is suspended, + // so `running` is true — but typing must stay possible, because a typed reply + // IS an answer (decline-with-feedback). Unblocks Send while the gate is up. + gateOpen?: boolean; connected: boolean; // False when the default model's provider has no key — the composer shows a "connect a model" // banner and routes sends to setup (preserving the draft) instead of dropping them. @@ -156,7 +160,14 @@ export function Composer(props: Props) { const el = textareaRef.current; if (!el) return; el.style.height = "auto"; - const max = parseFloat(getComputedStyle(el).lineHeight || "22") * 4; + // The cap must include the vertical PADDING: scrollHeight does, so a + // padding-blind cap left the box ~20px short and scrolled the top padding + // (plus the first line) out of the clip while typing (OPE-106). Six lines — + // team briefs outgrew four. + const cs = getComputedStyle(el); + const pad = + (parseFloat(cs.paddingTop) || 0) + (parseFloat(cs.paddingBottom) || 0); + const max = (parseFloat(cs.lineHeight) || 22) * 6 + pad; const next = Math.min(el.scrollHeight, max); el.style.height = `${Math.max(next, 24)}px`; el.style.overflowY = el.scrollHeight > max ? "auto" : "hidden"; @@ -318,7 +329,7 @@ export function Composer(props: Props) { const t = (skill ? text.slice(skill.length + 1) : text).trim(); if ( (!t && attachments.length === 0 && !skill) || - props.running || + (props.running && !props.gateOpen) || dictation?.recording || dictationBusy ) @@ -513,7 +524,11 @@ export function Composer(props: Props) {