diff --git a/coworker/agent.py b/coworker/agent.py index d24f5c59..9ed546fa 100644 --- a/coworker/agent.py +++ b/coworker/agent.py @@ -7,7 +7,7 @@ the skill catalog (progressive disclosure) + load_skill into a TurnEngine. from __future__ import annotations from pathlib import Path -from typing import Any, Optional +from typing import Any, Callable, Optional from .agents import Agent, AgentContext, code_agent from .automation import scheduling_tools @@ -30,7 +30,7 @@ from .roots import RootDir, normalize_roots, render_context from .providers import ProviderClient, ProviderRouter from .overrides import RiskOverrideStore from .secrets import SecretStore, state_dir -from .skills import SkillLoader, skill_catalog_text, skill_tools +from .skills import SkillLoader, save_skill_tool, skill_catalog_text, skill_tools from .tools import ToolRegistry from .tools.ask import ask_user_tool from .tools.directories import request_directory_tool @@ -99,6 +99,38 @@ def _enabled_connector_tools(secrets: SecretStore) -> tuple[set[str], set[str]]: return enabled_connectors, enabled_tools +def _loaded_skill_names(messages: list[dict[str, Any]]) -> set[str]: + """Skills whose instructions successfully entered THIS conversation (a load_skill call + with a non-error result). Drives the disable countermand: a menu quietly shrinking is + passive, but instructions already in history keep steering the model unless it is + explicitly asked to stop.""" + import json as _json + + results: dict[str, str] = {} + for m in messages: + if m.get("role") == "tool" and m.get("tool_call_id"): + content = m.get("content") + results[m["tool_call_id"]] = ( + content if isinstance(content, str) else _json.dumps(content) + ) + loaded: set[str] = set() + for m in messages: + if m.get("role") != "assistant" or not m.get("tool_calls"): + continue + for tc in m["tool_calls"]: + fn = tc.get("function") or {} + if fn.get("name") != "load_skill": + continue + try: + name = str(_json.loads(fn.get("arguments") or "{}").get("name", "")) + except Exception: + continue + result = results.get(tc.get("id", ""), "") + if name and '"instructions"' in result: + loaded.add(name) + return loaded + + def _skill_dirs(workspace: Optional[Path]) -> list[Path]: dirs = [state_dir() / "skills"] if workspace is not None: @@ -133,6 +165,8 @@ def build_engine( channel_buffer: Optional[Any] = None, routing_targets: Optional[list[str]] = None, 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, ) -> TurnEngine: ws = Path(workspace).expanduser().resolve() if workspace else None if agent.needs_workspace and ws is None: @@ -260,10 +294,22 @@ def build_engine( instructions = f"{instructions}\n\n{block}" skill_loader = SkillLoader(_skill_dirs(ws)) - registry.register_all(skill_tools(skill_loader)) - catalog = skill_catalog_text(skill_loader) - if catalog: - instructions = f"{instructions}\n\n{catalog}" + # 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 + # is injected per turn via context_provider (below), NOT here — so the menu the model + # sees is also live: skill changes apply from the next message, no new session needed. + # Default None preserves CLI / direct callers. + registry.register_all(skill_tools(skill_loader, allowed=skill_filter)) + # The worker-authors door (SKILLS-SPEC §5.2): save_skill proposes installing a finished + # skill; requires_approval routes it through the standard approval card, so the review- + # before-save rule holds without any bespoke plumbing. Bundled files may only come from + # this session's roots. + registry.register( + save_skill_tool( + allowed_dirs=[r.path for r in (root_list or [])] or ([ws] if ws else []) + ) + ) # User-local risk overrides (mainly to relax MCP's conservative default). Empty store → # no-op; never written by persona loading (the no-self-grant rule). @@ -294,6 +340,10 @@ def build_engine( else None ) + # Late-bound engine ref: the closure needs the conversation history (for the disable + # countermand) but the engine is constructed after the closure. Filled below. + _engine_box: list = [] + def context_provider() -> str: parts = [] if permissions.mode is Mode.PLAN: @@ -304,6 +354,26 @@ def build_engine( ctx = roots_context() if ctx: parts.append(ctx) + # Live skill menu (SKILLS-SPEC §4.1): recomputed every turn like the roots list, so + # a skill installed/enabled/disabled mid-session applies from the NEXT MESSAGE — + # no new session, no lost context. + skill_loader.rescan() + allowed = skill_filter() if callable(skill_filter) else skill_filter + skills_ctx = skill_catalog_text(skill_loader, allowed=allowed) + if skills_ctx: + parts.append(skills_ctx) + # Disable countermand (§3): instructions already loaded into this conversation keep + # steering the model even after the skill is turned off/deleted — history can't be + # un-read. So a loaded-but-no-longer-available skill gets an explicit stop note, + # recomputed fresh each turn (re-enable → the note disappears; never persisted). + eng = _engine_box[0] if _engine_box else None + if eng is not None: + available = set(skill_loader.names()) if allowed is None else set(allowed) + for name in sorted(_loaded_skill_names(eng.messages) - available): + parts.append( + f'Note: the skill "{name}" has been disabled by the user — stop ' + "following its instructions from here on." + ) return "\n\n".join(parts) engine = TurnEngine( @@ -336,6 +406,7 @@ def build_engine( "workspace": str(ws) if ws else "", } engine.skill_loader = skill_loader # type: ignore[attr-defined] + _engine_box.append(engine) # late-bind for the countermand (see context_provider) return engine diff --git a/coworker/connectors/catalog_copy.py b/coworker/connectors/catalog_copy.py index 2e5c801e..25e09b30 100644 --- a/coworker/connectors/catalog_copy.py +++ b/coworker/connectors/catalog_copy.py @@ -49,7 +49,7 @@ ABOUT: dict[str, str] = { "Multiple accounts connect side by side.", "monday": "Work with your monday.com boards — read items, summarize and " "aggregate board data, create items, and post updates. One-click sign-in " - "runs entirely on this Mac against monday.com's own agent service; agents " + "runs entirely on this computer against monday.com's own agent service; agents " "get a small curated set of its tools, never the full catalog.", "asana": "Keep up with your Asana work — search and read tasks and " "projects, create tasks, and comment. Connects with a personal access " diff --git a/coworker/connectors/descriptors.py b/coworker/connectors/descriptors.py index 9a6a34ab..32e103c6 100644 --- a/coworker/connectors/descriptors.py +++ b/coworker/connectors/descriptors.py @@ -66,7 +66,7 @@ class ConnectorDescriptor: # doesn't carry (e.g. "calendar" must surface Outlook, not just Google Calendar). aliases: tuple = () # Vendor-hosted MCP server URL → this connector is MCP-BACKED: one-click connect - # runs the local MCP OAuth flow (DCR, tokens on this Mac — no broker), and the + # runs the local MCP OAuth flow (DCR, tokens on this computer — no broker), and the # tool surface is the PINNED subset in tool_defs (names `mcp____`), # never the vendor's full catalog (drift can only shrink capability, not grow it). # A connector may carry BOTH mcp_url and manual fields (jira): the profile's @@ -704,7 +704,7 @@ DESCRIPTORS: list[ConnectorDescriptor] = [ fields=[], instructions=[ "One click connects via monday.com sign-in in your browser.", - "Sign-in is fully local — tokens stay on this Mac.", + "Sign-in is fully local — tokens stay on this computer.", ], available=True, ), diff --git a/coworker/engine.py b/coworker/engine.py index ae34d4a0..341eba05 100644 --- a/coworker/engine.py +++ b/coworker/engine.py @@ -163,13 +163,20 @@ class TurnEngine: # -- main loop -------------------------------------------------------------- async def run( - self, user_input: "str | list", *, source: Optional[dict[str, Any]] = None + self, + user_input: "str | list", + *, + source: Optional[dict[str, Any]] = None, + display: Optional[str] = None, ) -> AsyncIterator[Event]: # `user_input` is a string, or OpenAI content-parts (text + image_url) for attachments. # `source` (a MessageSource dict) is a display-only sidecar for connector messages: it # rides on the persisted user message + the TURN_START event, but is stripped before the # message reaches a provider (see `_outbound_messages`). `content` stays the framed text. - # `ts` (unix seconds, stamped on every appended message) is the same kind of sidecar. + # `display` is the same split for force-run skills (SKILLS-SPEC §4.1 #3): the user's + # literal "/skill …" line for the transcript, while `content` carries the model-facing + # framing. `ts` (unix seconds, stamped on every appended message) is the same kind of + # sidecar. message: dict[str, Any] = { "role": "user", "content": user_input, @@ -177,11 +184,15 @@ class TurnEngine: } if source is not None: message["source"] = source + if display is not None: + message["_display"] = display self.messages.append(message) self._cancel.clear() data: dict[str, Any] = {"input": user_input} if source is not None: data["source"] = source + if display is not None: + data["display"] = display yield Event(EventType.TURN_START, data) async for event in self._loop(): yield event diff --git a/coworker/server/app.py b/coworker/server/app.py index 1902ead6..65eea877 100644 --- a/coworker/server/app.py +++ b/coworker/server/app.py @@ -8,6 +8,8 @@ proxy so any OpenAI-format client can use the runtime as a backend. from __future__ import annotations import asyncio +import base64 +import binascii import json import os import re @@ -388,6 +390,29 @@ def create_app(manager: SessionManager) -> FastAPI: manager.unattended.set(session_id, on) return {"ok": True, "session_id": session_id, "unattended": on} + @app.get("/v1/sessions/{session_id}/skills") + def session_skills(session_id: str, workspace: str = "") -> dict[str, Any]: + # The rail's Skills group + the composer popup both read this (SKILLS-SPEC §4.1). + return manager.session_skills_view(session_id, workspace or None) + + @app.post("/v1/sessions/{session_id}/skills") + def set_session_skill(session_id: str, body: dict) -> dict[str, Any]: + # A session mute. `clear` drops the override (inherit again); otherwise explicit + # on/off. Nothing on disk changes — Settings owns permanent state. + body = body or {} + skill = str(body.get("skill", "")).strip() + if not skill: + return {"ok": False, "error": "skill required"} + if body.get("clear"): + manager.session_skills.clear(session_id, skill) + else: + manager.session_skills.set( + session_id, skill, bool(body.get("enabled", False)) + ) + return manager.session_skills_view( + session_id, str(body.get("workspace", "")) or None + ) + @app.get("/v1/sessions/{session_id}/connections") def session_connections(session_id: str, persona: str = "") -> dict[str, Any]: # `persona` is the GUI's hint for brand-new sessions (no record yet) — without it the @@ -555,8 +580,45 @@ def create_app(manager: SessionManager) -> FastAPI: ) @app.get("/v1/skills") - def skills() -> dict[str, Any]: - return {"skills": manager.list_skills()} + def skills(workspace: str = "") -> dict[str, Any]: + return {"skills": manager.list_skills(workspace or None)} + + @app.post("/v1/skills") + def create_skill(body: dict) -> dict[str, Any]: + return manager.create_skill(body or {}) + + @app.patch("/v1/skills/{name}") + def update_skill(name: str, body: dict) -> dict[str, Any]: + return manager.update_skill(name, body or {}) + + @app.delete("/v1/skills/{name}") + def delete_skill(name: str, workspace: str = "") -> dict[str, Any]: + return manager.delete_skill(name, workspace or None) + + @app.post("/v1/skills/{name}/move") + def move_skill(name: str, body: dict) -> dict[str, Any]: + return manager.move_skill(name, body or {}) + + @app.post("/v1/skills/{name}/reveal") + def reveal_skill(name: str, body: dict) -> dict[str, Any]: + # §6 "Show folder": open the skill's folder in the OS file manager (local machine). + return manager.reveal_skill(name, str((body or {}).get("workspace", "")) or None) + + @app.post("/v1/skills/upload") + def stage_skill_upload(body: dict) -> dict[str, Any]: + # Stage → preview; nothing is installed until /upload/confirm (SKILLS-SPEC §4.2). + data_b64 = str((body or {}).get("data_b64", "")) + if not data_b64: + return {"ok": False, "error": "No archive supplied."} + try: + data = base64.b64decode(data_b64, validate=True) + except (ValueError, binascii.Error): + return {"ok": False, "error": "Invalid archive encoding."} + return manager.stage_skill_upload(data, str((body or {}).get("filename", ""))) + + @app.post("/v1/skills/upload/confirm") + def confirm_skill_upload(body: dict) -> dict[str, Any]: + return manager.confirm_skill_upload(body or {}) @app.get("/v1/workspaces/recent") def recent_workspaces() -> dict[str, Any]: @@ -1729,11 +1791,15 @@ def create_app(manager: SessionManager) -> FastAPI: "iteration_end", } - async def run_turn(content, *, retry: bool = False) -> None: + async def run_turn(content, *, retry: bool = False, display=None) -> None: # The receive loop atomically claims this session before scheduling the task. # Keeping the claim outside prevents two back-to-back frames from both starting. try: - events = engine.retry() if retry else engine.run(content) + events = ( + engine.retry() + if retry + else engine.run(content, display=display) + ) async for event in events: # Broadcast to every socket viewing this session (this socket included — it's a # registered client), so a second view of the same session stays in sync too. @@ -1759,13 +1825,13 @@ def create_app(manager: SessionManager) -> FastAPI: # or flush an in-progress assistant stream in the GUI. await ws.send_json({"type": "input_rejected", "data": {"error": reason}}) - async def claim_turn(*, retry: bool = False, content=None) -> None: + async def claim_turn(*, retry: bool = False, content=None, display=None) -> None: if not manager.try_mark_running(session_id): await reject_input( "This session is already running a turn. Wait for it to finish or stop it." ) return - asyncio.create_task(run_turn(content, retry=retry)) + asyncio.create_task(run_turn(content, retry=retry, display=display)) try: while True: @@ -1918,10 +1984,34 @@ def create_app(manager: SessionManager) -> FastAPI: if model is not None and not isinstance(model, str): await reject_input("Invalid model: expected a string.") continue + # Force-run (SKILLS-SPEC §4.1 #3): the composer's `/skill` pick rides as a + # separate field. Validated against the session's effective menu — a muted + # or unknown skill is a visible error, never a silent no-op (§4.6 #15). + # The model-facing framing goes into `content`; the transcript shows the + # user's literal "/name …" line via the `_display` sidecar (one bubble). + skill = message.get("skill") + display = None + if skill is not None: + if not isinstance(skill, str) or not skill.strip(): + await reject_input("Invalid skill: expected a name.") + continue + skill = skill.strip() + menu = manager.effective_skill_names(session_id, workspace) + if skill not in menu: + await reject_input( + f"Skill '{skill}' is not available in this session." + ) + continue + display = f"/{skill}" + (f" {text}" if text else "") + text = ( + f'Use the skill "{skill}" for this request: first call ' + f'load_skill("{skill}") and follow its instructions.' + + (f"\n\n{text}" if text else "") + ) await _apply_model(model) if text or attachments: content = build_user_content(text, attachments) - await claim_turn(content=content) + await claim_turn(content=content, display=display) else: await reject_input(f"Unknown WebSocket message type: {kind}.") except WebSocketDisconnect: diff --git a/coworker/server/manager.py b/coworker/server/manager.py index fb237ee8..ad76e996 100644 --- a/coworker/server/manager.py +++ b/coworker/server/manager.py @@ -82,7 +82,12 @@ from ..providers import ( ) from ..secrets import SecretStore, state_dir from ..sessions import SessionRecord -from ..skills import SkillLoader +from ..skills import ( + SessionSkillStore, + SkillLoader, + SkillStore, + effective_skills, +) _SCOPES = {s.value for s in Scope} @@ -225,6 +230,11 @@ class SessionManager: self.session_connections = SessionConnectionStore( base / "session_connections.json" ) + # Skills (SKILLS-SPEC §4): folder-backed CRUD + per-session mutes. The effective menu + # gates the engine's skill catalog the same way effective_connectors gates connector + # tools — one resolver feeds the catalog injection, the rail, and the composer popup. + self.skill_store = SkillStore() + self.session_skills = SessionSkillStore(base / "session_skills.json") # Dead-letter: inbound messages with no destination + background-turn failures, so neither # vanishes silently (a debugging/visibility surface, not a redelivery queue). self.unrouted = UnroutedStore(base / "unrouted.json") @@ -454,6 +464,9 @@ class SessionManager: routing_targets=self._routing_targets(session_id, agent), # Per-session connection hierarchy: expose only effective-enabled connectors' tools. 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), ) # 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. @@ -1290,7 +1303,7 @@ class SessionManager: MAX_BINARY_PREVIEW = 25 * 1024 * 1024 # base64-over-JSON gets heavy past this def _artifact_target( - self, session_id: str, path: str + self, session_id: str, path: str, *, allow_dir: bool = False ) -> tuple[Optional[Path], Optional[str]]: """Resolve an artifact path under the session's workspace, or (None, error).""" record = self.session_store.load(session_id) @@ -1303,14 +1316,36 @@ class SessionManager: target.relative_to(root) except ValueError: return None, "path escapes workspace" + if allow_dir and target.is_dir(): + return target, None if not target.is_file(): - return None, "not found" + return None, ( + "This isn't in the conversation's folder anymore — it may have been " + "moved or deleted." + ) return target, None def read_artifact(self, session_id: str, path: str) -> dict[str, Any]: - target, err = self._artifact_target(session_id, path) + # Folders are readable too (a model sometimes links a whole package, e.g. a skill + # build dir): return a listing the viewer can render instead of a dead end. + target, err = self._artifact_target(session_id, path, allow_dir=True) if target is None: return {"ok": False, "error": err} + if target.is_dir(): + entries: list[dict[str, Any]] = [] + try: + children = sorted( + target.iterdir(), key=lambda c: (c.is_file(), c.name.lower()) + ) + except OSError as exc: + return {"ok": False, "error": str(exc)} + for child in children[:500]: + try: + size = 0 if child.is_dir() else child.stat().st_size + except OSError: + continue + entries.append({"name": child.name, "dir": child.is_dir(), "size": size}) + return {"ok": True, "path": path, "kind": "folder", "entries": entries} kind = _artifact_kind(target) if kind == "office": # PowerPoint/Word binaries can't be previewed inline; the UI offers @@ -1364,27 +1399,29 @@ class SessionManager: import subprocess import sys - target, err = self._artifact_target(session_id, path) + target, err = self._artifact_target(session_id, path, allow_dir=True) if target is None: return {"ok": False, "error": err} + # A folder "opens" as itself in the file manager, whatever the mode. + is_dir = target.is_dir() try: if sys.platform == "darwin": args = ( ["open", "-R", str(target)] - if mode == "reveal" + if mode == "reveal" and not is_dir else ["open", str(target)] ) subprocess.Popen( args, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL ) elif sys.platform == "win32": - if mode == "reveal": + if mode == "reveal" and not is_dir: # Explorer wants the path glued to the switch: /select, subprocess.Popen(["explorer", f"/select,{target}"]) else: os.startfile(str(target)) # type: ignore[attr-defined] # open in default app else: # Linux/BSD - tgt = str(target.parent) if mode == "reveal" else str(target) + tgt = str(target.parent) if mode == "reveal" and not is_dir else str(target) subprocess.Popen( ["xdg-open", tgt], stdout=subprocess.DEVNULL, @@ -2700,6 +2737,9 @@ 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) + ), ) self._seed_task_permissions(engine, task) return engine @@ -3683,6 +3723,8 @@ class SessionManager: self.mention_sessions.remove_session(session_id) # ...and drops its per-session connector overrides (§4.2, like subscriptions). self.session_connections.remove_session(session_id) + # ...and its per-session skill mutes (SKILLS-SPEC §3 — mutes die with the session). + self.session_skills.remove_session(session_id) # ...and closes its pending Inbox items — an orphaned approval/question can never be # meaningfully answered (owner call, 2026-07-03). self.inbox.resolve_session(session_id) @@ -3758,9 +3800,173 @@ class SessionManager: def list_agents(self) -> list[dict[str, Any]]: return _list_agents() - def list_skills(self) -> list[dict[str, Any]]: - loader = SkillLoader([state_dir() / "skills"]) - return loader.catalog() + # -- skills (SKILLS-SPEC §4.4) ------------------------------------------------ + def list_skills(self, workspace: Optional[str] = None) -> list[dict[str, Any]]: + """Enriched rows for the Settings screen (scope/source/enabled). Optional workspace + adds that project's skills, with project copies shadowing same-named global ones.""" + return self.skill_store.rows(workspace or None) + + def reveal_skill( + self, name: str, workspace: Optional[str] = None + ) -> dict[str, Any]: + """Open the skill's folder in the OS file manager (§6 "Show folder" — the power-user + window into folder-is-truth). Same local-machine rationale as reveal_artifact.""" + import subprocess + import sys + + try: + folder, _scope = self.skill_store.find(name, workspace or None) + except ValueError as exc: + return {"ok": False, "error": str(exc)} + try: + if sys.platform == "darwin": + subprocess.Popen( + ["open", str(folder)], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + elif sys.platform == "win32": + import os + + os.startfile(str(folder)) # type: ignore[attr-defined] + else: + subprocess.Popen( + ["xdg-open", str(folder)], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + except OSError as exc: + return {"ok": False, "error": str(exc)} + return {"ok": True} + + def effective_skill_names( + self, session_id: str, workspace: Optional[str | Path] = 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.""" + dirs = [self.skill_store.global_dir] + if workspace: + dirs.append(self.skill_store.project_dir(workspace)) + loader = SkillLoader(dirs) + return effective_skills( + names=set(loader.names()), + disabled=self.skill_store.disabled_names(), + session_overrides=self.session_skills.get(session_id), + ) + + 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.""" + disabled = self.skill_store.disabled_names() + overrides = self.session_skills.get(session_id) + rows = [ + { + "name": r["name"], + "description": r["description"], + "scope": r["scope"], + "enabled": overrides.get(r["name"], True), + } + for r in self.skill_store.rows(workspace or None) + if r["name"] not in disabled + ] + return {"skills": rows} + + def _scratch_workspace_error(self, workspace: Any) -> Optional[dict[str, Any]]: + """Refuse skill WRITES into a per-conversation scratch dir — a skill saved there is + stranded in a throwaway folder. Backend chokepoint: guards every entry path (UI, + REST, future import), not just the flows the GUI happens to gate.""" + if not workspace: + return None + try: + ws = Path(str(workspace)).expanduser().resolve() + if ws.is_relative_to(self.scratch_base().resolve()): + return { + "ok": False, + "error": ( + "That folder is a temporary session space — skills saved there " + "would be lost. Save it globally or pick a real project." + ), + } + except OSError: + pass + return None + + def create_skill(self, body: dict[str, Any]) -> dict[str, Any]: + blocked = self._scratch_workspace_error(body.get("workspace")) + if blocked: + return blocked + try: + created = self.skill_store.create( + name=str(body.get("name", "")), + description=str(body.get("description", "")), + instructions=str(body.get("instructions", "")), + scope=str(body.get("scope", "global") or "global"), + workspace=body.get("workspace") or None, + ) + except ValueError as exc: + return {"ok": False, "error": str(exc)} + return {"ok": True, "skill": created} + + def update_skill(self, name: str, body: dict[str, Any]) -> dict[str, Any]: + try: + if "enabled" in body: + self.skill_store.set_enabled(name, bool(body["enabled"])) + if body.get("description") is not None or body.get("instructions") is not None: + self.skill_store.update( + name, + description=body.get("description"), + instructions=body.get("instructions"), + workspace=body.get("workspace") or None, + ) + except ValueError as exc: + return {"ok": False, "error": str(exc)} + return {"ok": True} + + def delete_skill(self, name: str, workspace: Optional[str] = None) -> dict[str, Any]: + try: + self.skill_store.delete(name, workspace or None) + except ValueError as exc: + return {"ok": False, "error": str(exc)} + return {"ok": True} + + def move_skill(self, name: str, body: dict[str, Any]) -> dict[str, Any]: + # Moving INTO project scope must not target a scratch dir (moving OUT is fine — + # that's the rescue path for already-stranded skills). + if str(body.get("scope", "")) == "project": + blocked = self._scratch_workspace_error(body.get("workspace")) + if blocked: + return blocked + try: + moved = self.skill_store.move( + name, + to_scope=str(body.get("scope", "")), + workspace=body.get("workspace") or None, + ) + except ValueError as exc: + return {"ok": False, "error": str(exc)} + return {"ok": True, "skill": moved} + + def stage_skill_upload(self, data: bytes, filename: str = "") -> dict[str, Any]: + try: + preview = self.skill_store.stage_upload(data, filename) + except ValueError as exc: + return {"ok": False, "error": str(exc)} + return {"ok": True, **preview} + + def confirm_skill_upload(self, body: dict[str, Any]) -> dict[str, Any]: + blocked = self._scratch_workspace_error(body.get("workspace")) + if blocked: + return blocked + try: + saved = self.skill_store.confirm_upload( + str(body.get("token", "")), + scope=str(body.get("scope", "global") or "global"), + workspace=body.get("workspace") or None, + ) + except ValueError as exc: + return {"ok": False, "error": str(exc)} + return {"ok": True, "skill": saved} def list_memory(self) -> list[dict[str, Any]]: return [ diff --git a/coworker/skills/__init__.py b/coworker/skills/__init__.py index 674db7c4..18ffe48d 100644 --- a/coworker/skills/__init__.py +++ b/coworker/skills/__init__.py @@ -1,3 +1,20 @@ from .base import Skill, SkillLoader, skill_catalog_text, skill_tools +from .store import ( + SessionSkillStore, + SkillStore, + effective_skills, + save_skill_tool, + validate_name, +) -__all__ = ["Skill", "SkillLoader", "skill_catalog_text", "skill_tools"] +__all__ = [ + "Skill", + "SkillLoader", + "skill_catalog_text", + "skill_tools", + "SkillStore", + "SessionSkillStore", + "effective_skills", + "save_skill_tool", + "validate_name", +] diff --git a/coworker/skills/base.py b/coworker/skills/base.py index 6037acdc..a24617c7 100644 --- a/coworker/skills/base.py +++ b/coworker/skills/base.py @@ -11,7 +11,7 @@ from __future__ import annotations from dataclasses import dataclass, field from pathlib import Path -from typing import Optional +from typing import Callable, Optional, Union import aisuite as ai @@ -27,9 +27,17 @@ class Skill: class SkillLoader: def __init__(self, dirs: list[str | Path]) -> None: + self._dirs = [Path(d) for d in dirs] self._skills: dict[str, Skill] = {} - for directory in dirs: - self._discover(Path(directory)) + self.rescan() + + def rescan(self) -> None: + """Re-read the skill dirs. load_skill rescans on a miss so a skill created AFTER + the session's engine was built is still loadable (the catalog line stays static + until the next session, but an explicitly requested skill must not 404).""" + self._skills = {} + for directory in self._dirs: + self._discover(directory) def _discover(self, directory: Path) -> None: if not directory.is_dir(): @@ -81,8 +89,12 @@ def _parse_skill(md: Path) -> Skill: ) -def skill_catalog_text(loader: SkillLoader) -> str: - catalog = loader.catalog() +def skill_catalog_text( + loader: SkillLoader, allowed: Optional[set[str]] = None +) -> str: + catalog = [ + c for c in loader.catalog() if allowed is None or c["name"] in allowed + ] if not catalog: return "" lines = [f"- {c['name']}: {c['description']}" for c in catalog] @@ -92,13 +104,31 @@ def skill_catalog_text(loader: SkillLoader) -> str: ) -def skill_tools(loader: SkillLoader) -> list: +AllowedSkills = Union[set, Callable[[], set], None] + + +def skill_tools(loader: SkillLoader, allowed: AllowedSkills = None) -> list: + """`allowed` gates load_skill: a set is a build-time snapshot; a CALLABLE is consulted + on every call — the manager passes one so Settings disables apply to live sessions + immediately, and skills created after the engine was built are still loadable + (loader rescans on a miss).""" + + def _allowed_now() -> Optional[set]: + return allowed() if callable(allowed) else allowed + def load_skill(name: str) -> dict: """Load a skill's full instructions + resources path by name. Call this when a skill from the catalog is relevant to the current task.""" skill = loader.get(name) if skill is None: - return {"error": f"unknown skill: {name}", "available": loader.names()} + loader.rescan() # created after this session started? pick it up now + skill = loader.get(name) + gate = _allowed_now() + if skill is None or (gate is not None and name not in gate): + available = sorted( + n for n in loader.names() if gate is None or n in gate + ) + return {"error": f"unknown skill: {name}", "available": available} return { "name": skill.name, "instructions": skill.instructions, diff --git a/coworker/skills/store.py b/coworker/skills/store.py new file mode 100644 index 00000000..65ccc66b --- /dev/null +++ b/coworker/skills/store.py @@ -0,0 +1,620 @@ +"""Skill management — CRUD over skill folders + per-session mutes (SKILLS-SPEC §4). + +Scope = folder location (folder-is-truth): global skills live in ``state_dir()/skills``, +project skills in ``/.coworker/skills``. There is no database; every operation +is a folder + ``SKILL.md`` operation, which keeps project skills shareable via git for free. + +Disable state is deliberately NOT a marker inside the skill folder: project folders travel +with the repo and one user's disable must not be committed to teammates. It lives in the +personal ``state_dir()/skills-settings.json`` instead. + +Uploads are staged (parse → preview → confirm) so the user always reviews exactly what will +be saved before anything lands in a scope dir. Staged content sits under +``state_dir()/skills-staged/`` until confirmed or discarded. +""" + +from __future__ import annotations + +import io +import json +import re +import shutil +import threading +import uuid +import zipfile +from pathlib import Path +from typing import Any, Callable, Optional + +import aisuite as ai + +from ..secrets import state_dir +from .base import Skill, _parse_skill + +_NAME_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*$") +_MAX_NAME = 64 +GLOBAL_SCOPE = "global" +PROJECT_SCOPE = "project" + + +def validate_name(name: str) -> str: + """Skill names become folder names — reject anything that could escape the scope dir.""" + name = (name or "").strip() + if not name: + raise ValueError("Skill name is required.") + if len(name) > _MAX_NAME: + raise ValueError(f"Skill name too long (limit {_MAX_NAME} characters).") + if ".." in name or "/" in name or "\\" in name or not _NAME_RE.match(name): + raise ValueError( + "Skill name may only contain letters, digits, dots, dashes, and underscores." + ) + return name + + +def _frontmatter_source(md: Path) -> str: + """Read the optional ``source:`` frontmatter key (``uploaded`` etc.). Absent → created here.""" + try: + text = md.read_text(encoding="utf-8") + except OSError: + return "" + if not text.startswith("---"): + return "" + end = text.find("\n---", 3) + if end == -1: + return "" + for line in text[3:end].splitlines(): + if ":" in line: + key, value = line.split(":", 1) + if key.strip().lower() == "source": + return value.strip() + return "" + + +def _write_skill_md( + folder: Path, *, name: str, description: str, instructions: str, source: str = "" +) -> None: + lines = ["---", f"name: {name}", f"description: {description}"] + if source: + lines.append(f"source: {source}") + lines += ["---", "", instructions.strip(), ""] + folder.mkdir(parents=True, exist_ok=True) + (folder / "SKILL.md").write_text("\n".join(lines), encoding="utf-8") + + +class SkillStore: + """Folder-backed skill CRUD across the global + project scopes.""" + + def __init__(self, global_dir: Optional[str | Path] = None) -> None: + self.global_dir = Path(global_dir) if global_dir else state_dir() / "skills" + self._settings_path = state_dir() / "skills-settings.json" + self._staging_dir = state_dir() / "skills-staged" + self._lock = threading.Lock() + + # -- scope dirs --------------------------------------------------------------- + def project_dir(self, workspace: str | Path) -> Path: + return Path(workspace).expanduser().resolve() / ".coworker" / "skills" + + def _base(self, scope: str, workspace: Optional[str | Path]) -> Path: + if scope == GLOBAL_SCOPE: + return self.global_dir + if scope == PROJECT_SCOPE: + if not workspace: + raise ValueError("A workspace is required for a project-scoped skill.") + ws = Path(workspace).expanduser() + if not ws.is_dir(): + raise ValueError(f"Unknown workspace: {workspace}") + return self.project_dir(ws) + raise ValueError(f"Unknown scope: {scope}") + + def _folder_of(self, base: Path, name: str) -> Path: + """The skill's folder, guarded against escaping its scope dir (symlinked folders + that resolve elsewhere are treated as absent rather than followed).""" + folder = base / name + try: + resolved = folder.resolve() + base_resolved = base.resolve() + except OSError: + raise ValueError(f"Unreadable skill folder: {name}") + if base_resolved not in resolved.parents and resolved != base_resolved / name: + raise ValueError(f"Skill folder escapes its scope: {name}") + return folder + + # -- queries ------------------------------------------------------------------ + def find( + self, name: str, workspace: Optional[str | Path] = None + ) -> tuple[Path, str]: + """Locate a skill by name, most-local first (project before global) — mirrors the + loader's collision precedence so management operates on the copy the model sees.""" + name = validate_name(name) + if workspace: + project = self.project_dir(Path(workspace).expanduser()) + if (project / name / "SKILL.md").is_file(): + return self._folder_of(project, name), PROJECT_SCOPE + if (self.global_dir / name / "SKILL.md").is_file(): + return self._folder_of(self.global_dir, name), GLOBAL_SCOPE + raise ValueError(f"Unknown skill: {name}") + + def rows(self, workspace: Optional[str | Path] = None) -> list[dict[str, Any]]: + """Enriched listing for the Settings screen: scope, source, enabled. Global first, + then project (a project row with a colliding name is the effective copy).""" + disabled = self.disabled_names() + out: list[dict[str, Any]] = [] + seen: dict[str, int] = {} + scopes: list[tuple[Path, str]] = [(self.global_dir, GLOBAL_SCOPE)] + if workspace: + scopes.append((self.project_dir(Path(workspace).expanduser()), PROJECT_SCOPE)) + for base, scope in scopes: + if not base.is_dir(): + continue + for sub in sorted(base.iterdir()): + md = sub / "SKILL.md" + if not md.is_file(): + continue + skill = _parse_skill(md) + try: + # Bundled resources beyond SKILL.md (§6): a rich skill must not look + # identical to a one-file one in the Settings list. + bundled = sum(1 for p in sub.rglob("*") if p.is_file()) - 1 + except OSError: + bundled = 0 + row = { + "name": skill.name, + "description": skill.description, + "instructions": skill.instructions, # Settings editor prefill + "scope": scope, + "source": _frontmatter_source(md) or "local", + "enabled": skill.name not in disabled, + "path": str(sub), + "files": max(bundled, 0), + } + if skill.name in seen: # project copy shadows the global one + out[seen[skill.name]] = row + else: + seen[skill.name] = len(out) + out.append(row) + return out + + # -- mutations ---------------------------------------------------------------- + def create( + self, + *, + name: str, + description: str, + instructions: str, + scope: str = GLOBAL_SCOPE, + workspace: Optional[str | Path] = None, + source: str = "", + ) -> dict[str, Any]: + name = validate_name(name) + description = (description or "").strip() + if not (instructions or "").strip(): + raise ValueError("Skill instructions are required.") + base = self._base(scope, workspace) + folder = self._folder_of(base, name) + if (folder / "SKILL.md").is_file(): + raise ValueError(f"A skill named '{name}' already exists in that scope.") + _write_skill_md( + folder, + name=name, + description=description, + instructions=instructions, + source=source, + ) + return {"name": name, "scope": scope, "path": str(folder)} + + def update( + self, + name: str, + *, + description: Optional[str] = None, + instructions: Optional[str] = None, + workspace: Optional[str | Path] = None, + ) -> dict[str, Any]: + """Rewrite SKILL.md fields in place; sibling resource files are untouched.""" + folder, scope = self.find(name, workspace) + current = _parse_skill(folder / "SKILL.md") + if instructions is not None and not instructions.strip(): + raise ValueError("Skill instructions are required.") + _write_skill_md( + folder, + name=current.name, + description=( + description if description is not None else current.description + ), + instructions=( + instructions if instructions is not None else current.instructions + ), + source=_frontmatter_source(folder / "SKILL.md"), + ) + return {"name": current.name, "scope": scope} + + def delete(self, name: str, workspace: Optional[str | Path] = None) -> None: + folder, _scope = self.find(name, workspace) + if folder.is_symlink(): # never follow a link out of the scope dir + folder.unlink() + return + shutil.rmtree(folder) + + def move( + self, + name: str, + *, + to_scope: str, + workspace: Optional[str | Path] = None, + ) -> dict[str, Any]: + folder, from_scope = self.find(name, workspace) + if from_scope == to_scope: + return {"name": name, "scope": to_scope} + target_base = self._base(to_scope, workspace) + target = self._folder_of(target_base, name) + if (target / "SKILL.md").is_file(): + raise ValueError( + f"A skill named '{name}' already exists in the target scope." + ) + target_base.mkdir(parents=True, exist_ok=True) + shutil.move(str(folder), str(target)) + return {"name": name, "scope": to_scope} + + # -- enable / disable (personal, survives restarts) ----------------------------- + def disabled_names(self) -> set[str]: + try: + data = json.loads(self._settings_path.read_text(encoding="utf-8")) + return {str(n) for n in data.get("disabled", [])} + except (OSError, ValueError): + return set() + + def set_enabled(self, name: str, enabled: bool) -> None: + name = validate_name(name) + with self._lock: + disabled = self.disabled_names() + if enabled: + disabled.discard(name) + else: + disabled.add(name) + self._settings_path.parent.mkdir(parents=True, exist_ok=True) + self._settings_path.write_text( + json.dumps({"disabled": sorted(disabled)}, indent=2), + encoding="utf-8", + ) + + # -- uploads: stage → preview → confirm ----------------------------------------- + def stage_upload(self, data: bytes, filename: str = "") -> dict[str, Any]: + """Stage an upload and return the parsed preview. Accepts a ``.zip`` (folder skill) + or a bare ``SKILL.md`` with YAML frontmatter. Nothing is installed until + :meth:`confirm_upload`. (A ``.skill`` file is a renamed zip and still unpacks — + just not advertised.)""" + try: + archive = zipfile.ZipFile(io.BytesIO(data)) + except zipfile.BadZipFile: + return self._stage_single_md(data, filename) + # macOS Finder's "Compress" injects __MACOSX/ shadow entries (._*) and .DS_Store — + # metadata, not skill content. Strip them so a Mac-made zip installs clean. + names = [ + n + for n in archive.namelist() + if not n.endswith("/") + and "__MACOSX" not in Path(n).parts + and Path(n).name != ".DS_Store" + and not Path(n).name.startswith("._") + ] + for entry in names: + p = Path(entry) + if p.is_absolute() or ".." in p.parts or (p.parts and ":" in p.parts[0]): + raise ValueError("Archive contains unsafe paths.") + # SKILL.md at the root, or inside exactly one top-level folder. + md_entries = [n for n in names if Path(n).name == "SKILL.md"] + roots = {Path(n).parts[0] if len(Path(n).parts) > 1 else "" for n in md_entries} + if not md_entries or len(roots) != 1: + raise ValueError("Archive must contain exactly one skill (one SKILL.md).") + root = roots.pop() + token = uuid.uuid4().hex + staged = self._staging_dir / token + staged.mkdir(parents=True, exist_ok=True) + for entry in names: + parts = Path(entry).parts + rel = Path(*parts[1:]) if root and parts[0] == root else Path(entry) + if not str(rel): + continue + target = staged / rel + target.parent.mkdir(parents=True, exist_ok=True) + target.write_bytes(archive.read(entry)) + skill = _parse_skill(staged / "SKILL.md") + name = skill.name if skill.name else staged.name + try: + validate_name(name) + except ValueError: + shutil.rmtree(staged, ignore_errors=True) + raise + extras = sorted( + str(p.relative_to(staged)) + for p in staged.rglob("*") + if p.is_file() and p.name != "SKILL.md" + ) + return { + "token": token, + "name": name, + "description": skill.description, + "instructions": skill.instructions, + "files": extras, + } + + def _stage_single_md(self, data: bytes, filename: str) -> dict[str, Any]: + """The bare-.md path: one SKILL.md, no resources. Frontmatter must carry the name + (there is no folder to fall back to).""" + if filename.lower().endswith((".zip", ".skill")): + raise ValueError("Not a valid .zip archive.") + try: + text = data.decode("utf-8") + except UnicodeDecodeError: + raise ValueError("Not a valid skill file — upload a .zip or a SKILL.md.") + token = uuid.uuid4().hex + staged = self._staging_dir / token + staged.mkdir(parents=True, exist_ok=True) + (staged / "SKILL.md").write_text(text, encoding="utf-8") + skill = _parse_skill(staged / "SKILL.md") + if skill.name == token: # no frontmatter name → parser fell back to the folder + shutil.rmtree(staged, ignore_errors=True) + raise ValueError( + "The .md file needs YAML frontmatter with at least a skill name." + ) + try: + validate_name(skill.name) + except ValueError: + shutil.rmtree(staged, ignore_errors=True) + raise + return { + "token": token, + "name": skill.name, + "description": skill.description, + "instructions": skill.instructions, + "files": [], + } + + def confirm_upload( + self, + token: str, + *, + scope: str = GLOBAL_SCOPE, + workspace: Optional[str | Path] = None, + ) -> dict[str, Any]: + staged = self._staging_dir / str(token) + if not (staged / "SKILL.md").is_file(): + raise ValueError("Unknown or expired upload.") + skill = _parse_skill(staged / "SKILL.md") + name = validate_name(skill.name) + base = self._base(scope, workspace) + folder = self._folder_of(base, name) + if (folder / "SKILL.md").is_file(): + raise ValueError(f"A skill named '{name}' already exists in that scope.") + base.mkdir(parents=True, exist_ok=True) + shutil.move(str(staged), str(folder)) + # Stamp provenance so the Settings screen can distinguish uploaded from local. + if not _frontmatter_source(folder / "SKILL.md"): + _write_skill_md( + folder, + name=name, + description=skill.description, + instructions=skill.instructions, + source="uploaded", + ) + return {"name": name, "scope": scope, "path": str(folder)} + + def discard_upload(self, token: str) -> None: + staged = self._staging_dir / str(token) + shutil.rmtree(staged, ignore_errors=True) + + +class SessionSkillStore: + """``{session_id: {skill: bool}}`` — per-session mutes only; an absent entry means the + session inherits (enabled unless disabled in Settings). Mirrors SessionConnectionStore.""" + + def __init__(self, path: Optional[str | Path] = None) -> None: + self.path = Path(path) if path else None + self._lock = threading.Lock() + self._rows: dict[str, dict[str, bool]] = {} + self._load() + + def _load(self) -> None: + if self.path and self.path.is_file(): + try: + data = json.loads(self.path.read_text(encoding="utf-8")) + except (OSError, ValueError): + return + self._rows = { + sid: {str(s): bool(v) for s, v in (row or {}).items()} + for sid, row in data.get("sessions", {}).items() + } + + def _save(self) -> None: + if not self.path: + return + self.path.parent.mkdir(parents=True, exist_ok=True) + self.path.write_text( + json.dumps({"sessions": self._rows}, indent=2), encoding="utf-8" + ) + + def get(self, session_id: str) -> dict[str, bool]: + return dict(self._rows.get(session_id, {})) + + def set(self, session_id: str, skill: str, enabled: bool) -> None: + with self._lock: + self._rows.setdefault(session_id, {})[skill] = bool(enabled) + self._save() + + def clear(self, session_id: str, skill: str) -> None: + with self._lock: + row = self._rows.get(session_id) + if row and skill in row: + del row[skill] + if not row: + del self._rows[session_id] + self._save() + + def remove_session(self, session_id: str) -> None: + with self._lock: + if session_id in self._rows: + del self._rows[session_id] + self._save() + + +def effective_skills( + *, + names: set[str], + disabled: set[str], + session_overrides: dict[str, bool], +) -> set[str]: + """The single source of truth for a session's skill menu (SKILLS-SPEC §3): any-off-wins. + A Settings disable removes the skill everywhere — a session override can NOT resurrect + it. Absent any opinion, a skill is on.""" + out: set[str] = set() + for name in names: + if name in disabled: + continue + if not session_overrides.get(name, True): + continue + out.add(name) + return out + + +# -- the worker-authors door (SKILLS-SPEC §5.2) ------------------------------------- + +_SAVE_SKILL_SCHEMA = { + "type": "function", + "function": { + "name": "save_skill", + "description": ( + "Propose adding a finished skill to the user's skills. The user reviews the " + "name, description, full instructions, and any bundled files on an approval " + "card before anything is saved; once they approve, the skill is usable in " + "every conversation. Use this after building or refining a skill in " + "conversation, and offer it in words like: 'Want me to add to your " + "skills?' — say 'your skills', never the app name; say 'add', never " + "'install'. If a skill with this name already exists, approving overwrites " + "its instructions and adds the files." + ), + "parameters": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Short folder-safe skill name (letters, digits, dots, dashes, underscores).", + }, + "description": { + "type": "string", + "description": "One line saying when the skill applies — this is its menu entry.", + }, + "instructions": { + "type": "string", + "description": "The full instruction body (markdown). Becomes SKILL.md.", + }, + "files": { + "type": "array", + "items": {"type": "string"}, + "description": ( + "Optional paths of files in this session's folders to bundle into " + "the skill (scripts, examples, README). Copied in by basename." + ), + }, + }, + "required": ["name", "description", "instructions"], + }, + }, +} + + +def save_skill_tool( + store: Optional[SkillStore] = None, + *, + allowed_dirs: Optional[list[str | Path]] = None, +) -> Callable: + """Build the `save_skill` tool (SKILLS-SPEC §5.2). `requires_approval=True` routes every + call through the standard approval card — the tool's ARGUMENTS are the review surface, + which is why the schema carries the full instructions and file list. Bundled files may + only be read from `allowed_dirs` (the session's roots): the worker must never bundle + arbitrary machine paths into a skill.""" + store = store or SkillStore() + dirs: list[Path] = [] + for d in allowed_dirs or []: + try: + dirs.append(Path(d).expanduser().resolve()) + except OSError: + continue + + def save_skill( + name: str, + description: str = "", + instructions: str = "", + files: Optional[list[str]] = None, + ) -> dict[str, Any]: + try: + name = validate_name(name) + except ValueError as exc: + return {"error": str(exc)} + if not (description or "").strip(): + return {"error": "A one-line description is required — it becomes the skill's menu entry."} + if not (instructions or "").strip(): + return {"error": "Skill instructions are required."} + + # Resolve + vet the bundle BEFORE touching disk, so a bad file never leaves a + # half-written skill behind. + staged: list[tuple[Path, str]] = [] + for raw in files or []: + p = Path(str(raw)).expanduser() + if not p.is_absolute(): + if not dirs: + return {"error": f"File is outside this session's folders: {raw}"} + p = dirs[0] / p + try: + rp = p.resolve() + except OSError: + return {"error": f"Unreadable file: {raw}"} + if not rp.is_file(): + return {"error": f"Not a file: {raw}"} + if not any(d == rp or d in rp.parents for d in dirs): + return {"error": f"File is outside this session's folders: {raw}"} + base = rp.name + if base.lower() == "skill.md": + # The instructions argument BECOMES SKILL.md; models routinely draft one in + # the workspace and bundle it. Skip silently — erroring here cost the user a + # second approval round for a self-healing retry (live drive 2026-07-27). + continue + if any(base == b for _, b in staged): + return {"error": f"Duplicate bundled filename: {base}"} + staged.append((rp, base)) + + # Worker-authored skills always land GLOBAL (§3.4: never a throwaway location). + try: + folder, _scope = store.find(name) + action = "updated" + store.update(name, description=description.strip(), instructions=instructions) + except ValueError: + action = "added" + created = store.create( + name=name, description=description.strip(), instructions=instructions + ) + folder = Path(created["path"]) + for src, base in staged: + shutil.copy2(src, folder / base) + return { + "ok": True, + "name": name, + "action": action, + "files": [b for _, b in staged], + "note": ( + "Saved to the user's skills — usable in every conversation from now on. " + "Confirm in one short sentence. To browse the installed files, point the " + "user to Settings > Skills (the file-count chip opens the folder) — do NOT " + "link the workspace build folder as an artifact; folders don't open there." + ), + } + + save_skill.__name__ = "save_skill" + save_skill.__doc__ = _SAVE_SKILL_SCHEMA["function"]["description"] + save_skill.__aisuite_tool_metadata__ = ai.ToolMetadata( + name="save_skill", + category="skills", + risk_level="medium", + capabilities=["save_skill"], + requires_approval=True, + ) + save_skill.__coworker_schema__ = _SAVE_SKILL_SCHEMA + return save_skill diff --git a/surfaces/gui/e2e/approval-card.spec.ts b/surfaces/gui/e2e/approval-card.spec.ts index ac9ecd21..d6fece38 100644 --- a/surfaces/gui/e2e/approval-card.spec.ts +++ b/surfaces/gui/e2e/approval-card.spec.ts @@ -45,7 +45,7 @@ test("run_shell → full card: description title, command preview, stays-on-this // The mocked proposal has no description → plain "Run a command" title; the command is // the preview; the reason still renders; the scope note replaces the old badge. await expect(page.getByText("Run a command").last()).toBeVisible(); - await expect(page.getByText("stays on this Mac").last()).toBeVisible(); + await expect(page.getByText("stays on this computer").last()).toBeVisible(); await expect(page.getByText("The coworker wants to run a command.").first()).toBeVisible(); await expect(page.getByRole("button", { name: "Always allow this command" }).last()).toBeVisible(); await expect(page.getByText(/local action/)).toHaveCount(0); diff --git a/surfaces/gui/e2e/fixtures.ts b/surfaces/gui/e2e/fixtures.ts index 81f21a3c..e6dfa409 100644 --- a/surfaces/gui/e2e/fixtures.ts +++ b/surfaces/gui/e2e/fixtures.ts @@ -158,7 +158,7 @@ const CONNECTORS = { // MCP-BACKED connectors (§42): vendor-hosted MCP + local OAuth, pinned tool subset. // monday is one-click ONLY (no manual fields); jira also has a manual token path // (two-mode modal). Neither needs cloud sign-in. - { name: "monday", title: "monday.com", icon: "▦", blurb: "Read boards and items, track work, create items and post updates.", aliases: ["project management", "tasks", "boards"], auth: "oauth", two_way: false, channels: false, available: true, brand_color: "#6161ff", logo: "monday", mcp: true, fields: [], instructions: ["One click connects via monday.com sign-in in your browser.", "Sign-in is fully local — tokens stay on this Mac."], connected: false, account: null, enabled: false, allowed_users: [], tools: [{ name: "mcp__monday__get_board_info", label: "Read board", kind: "read", description: "Read a board's columns and groups.", enabled: true, requires_approval: false }, { name: "mcp__monday__create_item", label: "Create item", kind: "write", description: "Create an item on a board.", enabled: true, requires_approval: true }], managed: false, managed_profile: false }, + { name: "monday", title: "monday.com", icon: "▦", blurb: "Read boards and items, track work, create items and post updates.", aliases: ["project management", "tasks", "boards"], auth: "oauth", two_way: false, channels: false, available: true, brand_color: "#6161ff", logo: "monday", mcp: true, fields: [], instructions: ["One click connects via monday.com sign-in in your browser.", "Sign-in is fully local — tokens stay on this computer."], connected: false, account: null, enabled: false, allowed_users: [], tools: [{ name: "mcp__monday__get_board_info", label: "Read board", kind: "read", description: "Read a board's columns and groups.", enabled: true, requires_approval: false }, { name: "mcp__monday__create_item", label: "Create item", kind: "write", description: "Create an item on a board.", enabled: true, requires_approval: true }], managed: false, managed_profile: false }, { name: "jira", title: "Jira", icon: "◆", blurb: "Search, summarize, create, and update issues.", aliases: ["issues", "tickets", "atlassian"], auth: "api_token", two_way: false, channels: false, available: true, brand_color: "#0052cc", logo: "jira", mcp: true, fields: [{ key: "base_url", label: "Atlassian site URL", secret: false, required: true, help: "", placeholder: "" }, { key: "email", label: "Account email", secret: false, required: true, help: "", placeholder: "" }, { key: "api_token", label: "API token", secret: true, required: true, help: "", placeholder: "" }], instructions: [], connected: false, account: null, enabled: false, allowed_users: [], tools: [], managed: false, managed_profile: false }, ], }; @@ -552,6 +552,14 @@ export async function mockApi(page: import("@playwright/test").Page) { // Per-session unattended flag — mutable so the composer's "Send to Inbox" toggle persists and // the app reads it back (which is what gates parking approvals to the Inbox vs an inline card). const unattended: Record = {}; + // Skills (SKILLS-SPEC) — mutable folder-is-truth mirror: Settings CRUD, the enabled flag, + // staged uploads (stage → preview → confirm), and the composer's per-session menu all + // round-trip through this one list. + const skills: any[] = [ + { name: "weekly-report", description: "Monday status report", instructions: "1. Collect updates\n2. Write it up", scope: "global", source: "local", enabled: true, path: "/state/skills/weekly-report", files: 0 }, + { name: "html-to-markdown", description: "Convert an HTML document or fragment to clean markdown.", instructions: "Convert the given HTML to markdown, preserving structure.", scope: "global", source: "uploaded", enabled: true, path: "/state/skills/html-to-markdown", files: 2 }, + ]; + let stagedSkill: any = null; // Fresh cloud sign-in state per test (module state outlives a page). Object.assign(CLOUD_STATE, { @@ -585,7 +593,12 @@ export async function mockApi(page: import("@playwright/test").Page) { const msg = JSON.parse(String(raw)); if (msg.type === "user_message") { hadTurn = true; - send("turn_start", { input: msg.text }); + // Force-run (SKILLS-SPEC §6): like the real server, TURN_START ships the user's + // literal "/name …" line as `display` so the client dedupes on what the user sees. + send("turn_start", { + input: msg.text, + ...(msg.skill ? { display: `/${msg.skill}${msg.text ? ` ${msg.text}` : ""}` } : {}), + }); if (/run a tool/i.test(msg.text)) { pendingTool = "run_shell"; send("tool_proposed", { name: "run_shell", arguments: { command: "ls" } }); @@ -721,10 +734,11 @@ export async function mockApi(page: import("@playwright/test").Page) { send("assistant_delta", { text: msg.text }); // Echo the model the message carried — pins the model-per-message contract (the // composer's visible model must ride on every user_message; 2026-07-04 fix). + // Same for `skill`: the force-run pick must ride as its OWN FIELD, never as text. // `usage` mirrors the real engine's assistant_message sidecar (OPE-42): fixed // counts per turn so the usage-chip specs can assert exact accumulation. send("assistant_message", { - text: `Echo: ${msg.text} [model=${msg.model || "none"}]`, + text: `Echo: ${msg.text} [model=${msg.model || "none"}]${msg.skill ? ` [skill=${msg.skill}]` : ""}`, usage: { model: msg.model || "anthropic:claude-opus-4-8", input: 1_000, @@ -836,6 +850,73 @@ export async function mockApi(page: import("@playwright/test").Page) { return json(i >= 0 ? sessions[i] : PINNED_SESSION); } + // Skills (SKILLS-SPEC §5/§6). Order matters: upload/confirm before the {name} regexes. + if (/\/v1\/sessions\/[^/]+\/skills$/.test(p)) { + // The composer's live menu: every Settings-enabled skill (§4 — disabled = invisible). + return json({ + skills: skills + .filter((s) => s.enabled) + .map((s) => ({ name: s.name, description: s.description, scope: s.scope, enabled: true })), + }); + } + if (p.endsWith("/v1/skills/upload/confirm") && m === "POST") { + const b = req.postDataJSON() || {}; + if (!stagedSkill || b.token !== stagedSkill.token) + return json({ ok: false, error: "Upload expired — pick the file again." }); + skills.push({ + name: stagedSkill.name, description: stagedSkill.description, + instructions: stagedSkill.instructions, scope: "global", source: "uploaded", + enabled: true, path: `/state/skills/${stagedSkill.name}`, files: stagedSkill.files.length, + }); + stagedSkill = null; + return json({ ok: true }); + } + if (p.endsWith("/v1/skills/upload") && m === "POST") { + // Stage → preview; nothing lands until confirm. Fixed parse (the mock reads no zips). + stagedSkill = { + token: "stage-1", name: "greet", description: "says hello", + instructions: "Say hello warmly.", files: ["notes.txt"], + }; + return json({ ok: true, ...stagedSkill }); + } + { + const mr = p.match(/\/v1\/skills\/([^/]+)\/reveal$/); + if (mr && m === "POST") { + return json( + skills.some((s) => s.name === decodeURIComponent(mr[1])) + ? { ok: true } + : { ok: false, error: `Unknown skill: ${decodeURIComponent(mr[1])}` }, + ); + } + } + if (/\/v1\/skills\/[^/]+$/.test(p) && (m === "PATCH" || m === "DELETE")) { + const name = decodeURIComponent(p.split("/").pop()!); + const i = skills.findIndex((s) => s.name === name); + if (i < 0) return json({ ok: false, error: `Unknown skill: ${name}` }); + if (m === "DELETE") { + skills.splice(i, 1); + return json({ ok: true }); + } + const b = req.postDataJSON() || {}; + if (typeof b.enabled === "boolean") skills[i].enabled = b.enabled; + if (typeof b.description === "string") skills[i].description = b.description; + if (typeof b.instructions === "string") skills[i].instructions = b.instructions; + return json({ ok: true }); + } + if (p.endsWith("/v1/skills") && m === "POST") { + const b = req.postDataJSON() || {}; + if (!b.name || !(b.instructions || "").trim()) + return json({ ok: false, error: "Skill name and instructions are required." }); + if (skills.some((s) => s.name === b.name)) + return json({ ok: false, error: `A skill named '${b.name}' already exists in that scope.` }); + skills.push({ + name: b.name, description: b.description || "", instructions: b.instructions, + scope: "global", source: "local", enabled: true, path: `/state/skills/${b.name}`, files: 0, + }); + return json({ ok: true }); + } + if (p.endsWith("/v1/skills")) return json({ skills }); + if (p.endsWith("/v1/health")) return json(HEALTH); if (p.endsWith("/v1/settings")) return json(SETTINGS); if (p.endsWith("/v1/settings/context-bar") && m === "POST") { diff --git a/surfaces/gui/e2e/skills-forcerun.spec.ts b/surfaces/gui/e2e/skills-forcerun.spec.ts new file mode 100644 index 00000000..3e44b428 --- /dev/null +++ b/surfaces/gui/e2e/skills-forcerun.spec.ts @@ -0,0 +1,30 @@ +import { test, expect } from "./fixtures"; + +// SKILLS-SPEC §9 journey 4 — the "/" force-run: popup pick inserts the inline `/name ` +// prefix, the send carries the skill as its OWN WebSocket field (never as message text), +// and the transcript shows ONE truthful bubble with exactly what the user typed. + +test("skills-forcerun: popup pick → inline /name → skill rides the frame → one bubble", async ({ page }) => { + await page.goto("/"); + await page.getByText("Draft the launch note").first().click(); + + // "/" opens the popup; picking inserts the inline prefix (no chip) and keeps focus. + const box = page.getByPlaceholder(/Ask the coworker/); + await box.fill("/"); + await expect(page.getByTestId("skill-popup")).toBeVisible(); + await page.getByText("/weekly-report").click(); + await expect(box).toHaveValue("/weekly-report "); + + await box.type("cover last week"); + await box.press("Enter"); + + // ONE user bubble, showing the literal line the user typed — never the model-facing + // "load this skill…" framing (§6: the _display contract). + await expect(page.getByText("/weekly-report cover last week")).toHaveCount(1); + await expect(page.getByText(/Use the skill/)).toHaveCount(0); + + // The fake agent echoes what actually rode the wire: text WITHOUT the prefix, and the + // skill as its own field. + await expect(page.getByText(/\[skill=weekly-report\]/)).toBeVisible(); + await expect(page.getByText(/Echo: cover last week/)).toBeVisible(); +}); diff --git a/surfaces/gui/e2e/skills-session.spec.ts b/surfaces/gui/e2e/skills-session.spec.ts new file mode 100644 index 00000000..7ee928d4 --- /dev/null +++ b/surfaces/gui/e2e/skills-session.spec.ts @@ -0,0 +1,39 @@ +import { test, expect } from "./fixtures"; + +// SKILLS-SPEC §9 journey 2 — liveness from the session's seat: the composer's "/" popup is +// the live "what can my worker use right now" view. A skill created in Settings is offered; +// a disabled one vanishes. Hermetic: the popup reads /v1/sessions/{id}/skills from fixtures. + +test("skills-session: new skill offered in '/', disabled one absent", async ({ page }) => { + await page.goto("/"); + await page.getByText("Draft the launch note").first().click(); + + // The seeded menu: both enabled skills offered on "/". + const box = page.getByPlaceholder(/Ask the coworker/); + await box.fill("/"); + await expect(page.getByTestId("skill-popup")).toBeVisible(); + await expect(page.getByText("/weekly-report")).toBeVisible(); + await expect(page.getByText("/html-to-markdown")).toBeVisible(); + await box.fill(""); // close the popup + + // Settings round-trip: create one skill, disable another. + await page.getByTestId("account-row").click(); + await page.getByRole("button", { name: "Settings", exact: true }).click(); + await page.getByRole("button", { name: "Skills", exact: true }).click(); + await page.getByRole("button", { name: /Add skill/ }).click(); + await page.getByText("Write it myself").click(); + await page.getByLabel("Name").fill("fresh-skill"); + await page.getByLabel("Instructions").fill("Do the fresh thing."); + await page.getByRole("button", { name: "Save skill" }).click(); + await expect(page.getByRole("status")).toContainText("fresh-skill"); + await page.getByLabel("weekly-report enabled").click(); + await expect(page.getByRole("status")).toContainText("turned off everywhere"); + + // Back in the session: the popup reflects the new state — created offered, disabled gone. + await page.getByText("Draft the launch note").first().click(); + await box.fill("/"); + await expect(page.getByTestId("skill-popup")).toBeVisible(); + await expect(page.getByText("/fresh-skill")).toBeVisible(); + await expect(page.getByText("/weekly-report")).toHaveCount(0); + await expect(page.getByText("/html-to-markdown")).toBeVisible(); // untouched one persists +}); diff --git a/surfaces/gui/e2e/skills-settings.spec.ts b/surfaces/gui/e2e/skills-settings.spec.ts new file mode 100644 index 00000000..e17311d0 --- /dev/null +++ b/surfaces/gui/e2e/skills-settings.spec.ts @@ -0,0 +1,65 @@ +import { test, expect } from "./fixtures"; + +// SKILLS-SPEC §9 journey 1 — Settings ▸ Skills as the management home: create through the +// Add-skill menu, edit in place, disable with the amber clean-slate banner, and the +// rich-skill folder chip. Hermetic: every /v1 call lands in fixtures.ts. + +const openSkills = async (page: import("@playwright/test").Page) => { + await page.goto("/"); + await page.getByTestId("account-row").click(); + await page.getByRole("button", { name: "Settings", exact: true }).click(); + await page.getByRole("button", { name: "Skills", exact: true }).click(); +}; + +test("skills-settings: create via the menu → name-first banner; edit persists", async ({ page }) => { + await openSkills(page); + + // The seeded rows render; the rich one wears its folder chip; the list is the page + // (no standing add-surfaces). + await expect(page.getByText("weekly-report")).toBeVisible(); + await expect(page.getByText("uploaded")).toBeVisible(); + await expect(page.getByTitle("Show folder")).toContainText("2 files"); + await expect(page.getByText("Start a conversation")).toHaveCount(0); + + // Add skill ▾ → the three doors, then Write it myself. + await page.getByRole("button", { name: /Add skill/ }).click(); + await expect(page.getByText("Import a file")).toBeVisible(); + await expect(page.getByText("Create with OpenWorker")).toBeVisible(); + await page.getByText("Write it myself").click(); + + await page.getByLabel("Name").fill("greet-warmly"); + await page.getByLabel("Description").fill("Greets people warmly"); + await page.getByLabel("Instructions").fill("Always greet warmly."); + await page.getByRole("button", { name: "Save skill" }).click(); + + // Name-first teal confirmation (§7) + the new row. + const status = page.getByRole("status"); + await expect(status).toContainText("greet-warmly"); + await expect(status).toContainText("can now use it in every conversation"); + await expect(page.getByText("Greets people warmly")).toBeVisible(); + + // Edit: pencil prefills, name locked, save PATCHes through to the re-fetched list. + await page.getByTitle("Edit").first().click(); + const name = page.getByLabel("Name"); + await expect(name).toBeDisabled(); + await page.getByLabel("Description").fill("Monday status report, sharper"); + await page.getByRole("button", { name: "Save skill" }).click(); + await expect(page.getByText("Monday status report, sharper")).toBeVisible(); +}); + +test("skills-settings: disable → amber everywhere/clean-slate banner; delete is two-step", async ({ page }) => { + await openSkills(page); + + await page.getByLabel("weekly-report enabled").click(); + const status = page.getByRole("status"); + await expect(status).toContainText("weekly-report"); + await expect(status).toContainText("turned off everywhere"); + await expect(status).toContainText("start a new one for a completely clean slate"); + + // Two-step delete: arm, confirm, row gone, banner names the skill. + await page.getByLabel("Delete html-to-markdown").click(); + await expect(page.getByText("html-to-markdown")).toBeVisible(); // armed ≠ deleted + await page.getByText("Confirm delete").click(); + await expect(page.getByText("html-to-markdown")).toHaveCount(1); // only the banner remains + await expect(page.getByRole("status")).toContainText("removed"); +}); diff --git a/surfaces/gui/e2e/skills-upload.spec.ts b/surfaces/gui/e2e/skills-upload.spec.ts new file mode 100644 index 00000000..129188e3 --- /dev/null +++ b/surfaces/gui/e2e/skills-upload.spec.ts @@ -0,0 +1,37 @@ +import { test, expect } from "./fixtures"; + +// SKILLS-SPEC §9 journey 3 — import with the mandatory review gate: the preview installs +// NOTHING; confirm installs and the row wears the `uploaded` provenance badge. Hermetic: +// stage/confirm round-trip through fixtures.ts state. + +test("skills-upload: preview installs nothing → confirm → uploaded badge", 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: "Skills", exact: true }).click(); + + // Add skill ▾ → Import a file → straight to the (hidden) picker. + await page.getByRole("button", { name: /Add skill/ }).click(); + await page.getByText("Import a file").click(); + await page.getByLabel("Upload a skill archive").setInputFiles({ + name: "greet.zip", + mimeType: "application/zip", + buffer: Buffer.from("PKfake"), + }); + + // The mandatory review screen: everything parsed, nothing installed yet. + await expect(page.getByText("Review before installing")).toBeVisible(); + await expect(page.getByText("says hello")).toBeVisible(); + await expect(page.getByText("Say hello warmly.")).toBeVisible(); + await expect(page.getByText(/notes\.txt/)).toBeVisible(); + await expect(page.getByText("greet", { exact: true })).toHaveCount(1); // preview only, no row + + await page.getByRole("button", { name: "Install skill" }).click(); + + // Installed: teal name-first banner, a real row with the provenance badge + folder chip. + const status = page.getByRole("status"); + await expect(status).toContainText("greet"); + await expect(status).toContainText("can now use it in every conversation"); + await expect(page.getByText("greet", { exact: true })).toHaveCount(2); // banner + the new row + await expect(page.getByText("uploaded")).toHaveCount(2); // html-to-markdown + greet +}); diff --git a/surfaces/gui/src/App.tsx b/surfaces/gui/src/App.tsx index 57f1ba8c..d18825cf 100644 --- a/surfaces/gui/src/App.tsx +++ b/surfaces/gui/src/App.tsx @@ -211,10 +211,12 @@ export function App() { const [scheduledOpenId, setScheduledOpenId] = useState(null); const [gateCreate, setGateCreate] = useState(false); // Which Settings section the full-page Settings surface opens on (§ Settings-as-page). - const [settingsTab, setSettingsTab] = useState<"appearance" | "models" | "voice" | "personas">( - "appearance", - ); - const openSettings = (tab: "appearance" | "models" | "voice" | "personas" = "appearance") => { + const [settingsTab, setSettingsTab] = useState< + "appearance" | "models" | "skills" | "voice" | "personas" + >("appearance"); + const openSettings = ( + tab: "appearance" | "models" | "skills" | "voice" | "personas" = "appearance", + ) => { setSettingsTab(tab); setSurface("settings"); }; @@ -613,11 +615,14 @@ export function App() { : [...p, { kind: "connector", source: src }]; }); } else if (typeof d.input === "string" && d.input) { + // `display` (force-run) is the user's literal "/name …" line; the framed + // `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]; - return last && last.kind === "user" && last.text === d.input + return last && last.kind === "user" && last.text === shown ? p - : [...p, { kind: "user", text: d.input as string, ts: Date.now() / 1000 }]; + : [...p, { kind: "user", text: shown, ts: Date.now() / 1000 }]; }); } break; @@ -861,10 +866,13 @@ export function App() { return () => clearInterval(t); }, [surface, sessionId, browserRefreshKey, markUnattended]); - const send = (text: string, attachments?: Attachment[]) => { - setItems((p) => [...p, { kind: "user", text, attachments, ts: Date.now() / 1000 }]); + const send = (text: string, attachments?: Attachment[], skill?: string) => { + // 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; + setItems((p) => [...p, { kind: "user", text: shown, attachments, ts: Date.now() / 1000 }]); // The visible model rides along with the message (single source of truth per turn). - sessionRef.current?.userMessage(text, attachments, model); + sessionRef.current?.userMessage(text, attachments, model, skill); followLatest(); // sending always re-engages stream-following, wherever the user had scrolled }; // Resolving a LIVE prompt also resolves its parked Inbox mirror server-side, but the polled @@ -1349,6 +1357,17 @@ export function App() { key={settingsTab} initialTab={settingsTab} onOpenPersona={(id) => openPersona(id, "settings")} + onCreateSkill={(description) => { + // The Skills doorway (SKILLS-SPEC §5.2): creation is a conversation. Fresh + // session, description in the composer — the user reads and hits send. With + // no description, the prefill invites them to finish the sentence there. + startNewSession(); + prefillComposer( + description + ? `Build a new skill for me: ${description}` + : "Build a new skill for me: (describe what the skill should do)", + ); + }} /> ) : surface === "audit" ? ( @@ -1584,6 +1603,7 @@ export function App() { onInterrupt={interrupt} onModeChange={changeMode} onModelChange={changeModel} + sessionId={sessionId} workspace={needsWorkspace(agent) ? workspace || "" : undefined} unattended={unattended} onUnattendedChange={agent !== "chat" ? toggleUnattended : undefined} diff --git a/surfaces/gui/src/api.ts b/surfaces/gui/src/api.ts index e7caf8a9..ad9debd5 100644 --- a/surfaces/gui/src/api.ts +++ b/surfaces/gui/src/api.ts @@ -195,6 +195,8 @@ export interface ArtifactContent { content?: string; data_url?: string; truncated?: boolean; + // kind === "folder": a directory listing (models sometimes link a whole package dir). + entries?: { name: string; dir: boolean; size: number }[]; } export async function getArtifacts(sessionId: string): Promise { @@ -1089,6 +1091,141 @@ export async function setSessionConnection( return res.json(); } +// -- Skills (SKILLS-SPEC §4) ---------------------------------------------------- +// Scope = folder location: "global" (every session) or "project" (one workspace). +// The session endpoints resolve the effective menu (Settings disables + session mutes). + +export interface SkillRow { + name: string; + description: string; + instructions: string; + scope: "global" | "project"; + source: string; // "local" | "uploaded" + enabled: boolean; + path: string; + files?: number; // bundled resources beyond SKILL.md (§6 — rich skills are visible) +} + +export interface SessionSkillRow { + name: string; + description: string; + scope: "global" | "project"; + enabled: boolean; // false = muted for this session only +} + +export interface SkillUploadPreview { + ok: boolean; + error?: string; + token?: string; + name?: string; + description?: string; + instructions?: string; + files?: string[]; +} + +const skillUrl = (path = "") => `${httpBase()}/v1/skills${path}`; +const jsonPost = (body: unknown, method = "POST") => ({ + method, + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), +}); + +export async function listSkills(workspace?: string): Promise { + const qs = workspace ? `?workspace=${encodeURIComponent(workspace)}` : ""; + const res = await fetch(skillUrl(qs)); + return (await res.json()).skills ?? []; +} + +export async function createSkill(body: { + name: string; + description: string; + instructions: string; + scope?: "global" | "project"; + workspace?: string; +}): Promise<{ ok: boolean; error?: string }> { + const res = await fetch(skillUrl(), jsonPost(body)); + return res.json(); +} + +export async function updateSkill( + name: string, + patch: { description?: string; instructions?: string; enabled?: boolean; workspace?: string }, +): Promise<{ ok: boolean; error?: string }> { + const res = await fetch(skillUrl(`/${encodeURIComponent(name)}`), jsonPost(patch, "PATCH")); + return res.json(); +} + +export async function revealSkill(name: string): Promise<{ ok: boolean; error?: string }> { + // §6 "Show folder": the backend opens the skill's folder in the OS file manager. + const res = await fetch(skillUrl(`/${encodeURIComponent(name)}/reveal`), jsonPost({})); + return res.json(); +} + +export async function deleteSkill( + name: string, + workspace?: string, +): Promise<{ ok: boolean; error?: string }> { + const qs = workspace ? `?workspace=${encodeURIComponent(workspace)}` : ""; + const res = await fetch(skillUrl(`/${encodeURIComponent(name)}${qs}`), { method: "DELETE" }); + return res.json(); +} + +export async function moveSkill( + name: string, + scope: "global" | "project", + workspace?: string, +): Promise<{ ok: boolean; error?: string }> { + const res = await fetch(skillUrl(`/${encodeURIComponent(name)}/move`), jsonPost({ scope, workspace })); + return res.json(); +} + +export async function stageSkillUpload( + dataB64: string, + filename = "", +): Promise { + const res = await fetch(skillUrl("/upload"), jsonPost({ data_b64: dataB64, filename })); + return res.json(); +} + +export async function confirmSkillUpload( + token: string, + scope: "global" | "project" = "global", + workspace?: string, +): Promise<{ ok: boolean; error?: string }> { + const res = await fetch(skillUrl("/upload/confirm"), jsonPost({ token, scope, workspace })); + return res.json(); +} + + +export async function sessionSkills( + sessionId: string, + workspace?: string, +): Promise { + const qs = workspace ? `?workspace=${encodeURIComponent(workspace)}` : ""; + const res = await fetch( + `${httpBase()}/v1/sessions/${encodeURIComponent(sessionId)}/skills${qs}`, + ); + return (await res.json()).skills ?? []; +} + +export async function setSessionSkill( + sessionId: string, + skill: string, + enabled: boolean, + opts: { clear?: boolean; workspace?: string } = {}, +): Promise<{ skills?: SessionSkillRow[]; ok?: boolean; error?: string }> { + const res = await fetch( + `${httpBase()}/v1/sessions/${encodeURIComponent(sessionId)}/skills`, + jsonPost({ + skill, + enabled, + ...(opts.clear ? { clear: true } : {}), + ...(opts.workspace ? { workspace: opts.workspace } : {}), + }), + ); + return res.json(); +} + // -- Inbox + Unattended ------------------------------------------------------- export interface InboxItem { id: string; @@ -1846,12 +1983,15 @@ export class Session { * exactly what the user sees — immune to set_model races across reconnects (a new cowork * session always reconnects once to adopt its scratch dir, which could drop a queued * set_model and leave the engine on a stale/resumed model; found 2026-07-04). */ - userMessage(text: string, attachments?: unknown[], model?: string) { + userMessage(text: string, attachments?: unknown[], model?: string, skill?: string) { this.send({ type: "user_message", text, ...(model ? { model } : {}), ...(attachments?.length ? { attachments } : {}), + // Force-run (SKILLS-SPEC §4.1): the composer's /skill pick rides as its own field; + // the server validates it against the session's effective menu and frames the turn. + ...(skill ? { skill } : {}), }); } diff --git a/surfaces/gui/src/components/AccessSection.tsx b/surfaces/gui/src/components/AccessSection.tsx index 5648852a..15faa6db 100644 --- a/surfaces/gui/src/components/AccessSection.tsx +++ b/surfaces/gui/src/components/AccessSection.tsx @@ -221,7 +221,7 @@ export function AccessSection({ : roots.length > 0 ? `${roots.length} folder${roots.length === 1 ? "" : "s"}` : null; - const summary = folderPart ? `${sourcesPart} · ${folderPart}` : sourcesPart; + const summary = [sourcesPart, folderPart].filter(Boolean).join(" · "); return (
@@ -383,6 +383,14 @@ export function AccessSection({ + Add a source… )} + {/* Lives with its list (tester ask 2026-07-26): each group's manage link sits + directly under that group, not pooled at the section's bottom. */} + {recommended.length > 0 && ( @@ -456,13 +464,6 @@ export function AccessSection({ )} {rootsError &&
{rootsError}
} - - )} diff --git a/surfaces/gui/src/components/ApprovalCard.test.tsx b/surfaces/gui/src/components/ApprovalCard.test.tsx index c267f1e8..7f4570b5 100644 --- a/surfaces/gui/src/components/ApprovalCard.test.tsx +++ b/surfaces/gui/src/components/ApprovalCard.test.tsx @@ -110,7 +110,7 @@ describe("ApprovalCard — §35 shapes", () => { expect(onApprove).toHaveBeenCalledWith("once"); }); - it("send_file gets the full external card: destination title, file chip, leaves-the-Mac note", () => { + it("send_file gets the full external card: destination title, file chip, leaves-the-computer note", () => { render( { />, ); expect(screen.getByText(/Send a file to/).textContent).toContain("C9"); - expect(screen.getByText(/leaves this Mac → Slack/)).toBeTruthy(); + expect(screen.getByText(/leaves this computer → Slack/)).toBeTruthy(); expect(screen.getByText(/report\.pdf/)).toBeTruthy(); expect(screen.getByText(/here you go/)).toBeTruthy(); expect(screen.getByText("Allow once")).toBeTruthy(); @@ -159,7 +159,7 @@ describe("ApprovalCard — §35 shapes", () => { ); expect(screen.getByText(/Run a command — fetch semiconductor stock data/)).toBeTruthy(); expect(screen.getByText(/python3 fetch\.py/)).toBeTruthy(); - expect(screen.getByText(/stays on this Mac/)).toBeTruthy(); + expect(screen.getByText(/stays on this computer/)).toBeTruthy(); expect(screen.getByText("Always allow this command")).toBeTruthy(); }); }); @@ -213,10 +213,94 @@ describe("InboxItemCard — Allow every time on parked run approvals", () => { expect(screen.getByText("fetch_data.py")).toBeTruthy(); expect(screen.queryByText("Run `send_message`?")).toBeNull(); expect(screen.getByText(/import json/)).toBeTruthy(); - expect(screen.getByText(/stays on this Mac/)).toBeTruthy(); + expect(screen.getByText(/stays on this computer/)).toBeTruthy(); // §35 labels; resolution vocabulary unchanged (works on every approver path). fireEvent.click(screen.getByText("Allow once")); expect(onResolve).toHaveBeenCalledWith("i1", "allow"); // Old rows without tool data keep the legacy treatment (covered above). }); }); + +describe("ApprovalCard — save_skill (SKILLS-SPEC §5.2)", () => { + const skillApproval = (extra: Partial = {}): ApprovalItem => + sendApproval({ + name: "save_skill", + category: "skills", + args: { + name: "weekly-github-report", + description: "Create a concise Monday status report from GitHub activity.", + instructions: "1. Fetch PRs\n2. Write the report", + files: ["fetch_prs.py", "sub/example-report.md"], + }, + standingTarget: undefined, + ...extra, + }); + + it("shows name-first title, description, instructions, and every bundled file", () => { + render(); + expect(screen.getByText("weekly-github-report")).toBeTruthy(); // bold obj in the title + expect(screen.getAllByText(/to your skills/).length).toBeGreaterThan(0); // title + footer + // The corner answers WHERE; the footer answers what approving means (§5.2 review round). + expect(screen.getByText("saves to Settings ▸ Skills")).toBeTruthy(); + expect(screen.getByText(/usable in every conversation from\s+then on/)).toBeTruthy(); + expect( + screen.getByText("Create a concise Monday status report from GitHub activity."), + ).toBeTruthy(); + expect(screen.getByText(/Fetch PRs/)).toBeTruthy(); + const chips = screen.getByTestId("skill-bundle-files"); + expect(chips.textContent).toContain("fetch_prs.py"); + expect(chips.textContent).toContain("example-report.md"); // basename, not the path + }); + + it("uses the §7 button copy and never offers a session-wide always", () => { + const onApprove = vi.fn(); + render(); + expect(screen.queryByText("Always allow")).toBeNull(); // every proposal gets its own review + expect(screen.queryByText("Deny")).toBeNull(); + fireEvent.click(screen.getByText("Add to my skills")); + expect(onApprove).toHaveBeenCalledWith("once"); + fireEvent.click(screen.getByText("Not now")); + expect(onApprove).toHaveBeenCalledWith("deny"); + }); +}); + +describe("InboxItemCard — parked save_skill proposals (SKILLS-SPEC §5.2)", () => { + const parked = (): InboxItem => ({ + id: "i9", + session_id: "s1", + kind: "approval", + title: "Run `save_skill`?", + body: "", + state: "pending", + resolution: null, + inbox: "default", + created_at: "", + resolved_at: null, + data: { + tool: "save_skill", + arguments: { + name: "weekly-github-report", + description: "Create a concise Monday status report from GitHub activity.", + instructions: "1. Fetch PRs\n2. Write the report", + files: ["fetch_prs.py"], + }, + }, + }); + + it("wears the same review surface and button copy as the live card", () => { + const onResolve = vi.fn(); + render(); + expect(screen.getByText("saves to Settings ▸ Skills")).toBeTruthy(); + expect( + screen.getByText("Create a concise Monday status report from GitHub activity."), + ).toBeTruthy(); + expect(screen.getByText(/Fetch PRs/)).toBeTruthy(); + expect(screen.getByTestId("skill-bundle-files").textContent).toContain("fetch_prs.py"); + expect(screen.getByText(/usable in every conversation/)).toBeTruthy(); + expect(screen.queryByText("Allow once")).toBeNull(); + fireEvent.click(screen.getByText("Add to my skills")); + expect(onResolve).toHaveBeenCalledWith("i9", "allow"); + fireEvent.click(screen.getByText("Not now")); + expect(onResolve).toHaveBeenCalledWith("i9", "deny"); + }); +}); diff --git a/surfaces/gui/src/components/ApprovalCard.tsx b/surfaces/gui/src/components/ApprovalCard.tsx index b3a3ba77..8f2f2541 100644 --- a/surfaces/gui/src/components/ApprovalCard.tsx +++ b/surfaces/gui/src/components/ApprovalCard.tsx @@ -32,6 +32,43 @@ const EXTERNAL = new Set(["send_message", "send_file"]); type ApprovalItem = Extract; +// Per-tool button copy (§7): a skill proposal is an "add", not an "allow". Shared with the +// parked Inbox card so both dialects match. +export function approvalActionLabels(name?: string): { allow: string; deny: string } { + return name === "save_skill" + ? { allow: "Add to my skills", deny: "Not now" } + : { allow: "Allow once", deny: "Deny" }; +} + +// save_skill's review surface (SKILLS-SPEC §5.2): description, the full instructions +// (clamped, expandable, scrollable), every bundled file, and the guaranteed footer that +// answers "added WHERE, available WHEN". Shared verbatim with the parked Inbox card — +// one decision, one dialect. +export function SaveSkillPreview({ args }: { args: any }) { + return ( + <> + {args?.description &&
{String(args.description)}
} + {args?.instructions && } + {Array.isArray(args?.files) && args.files.length > 0 && ( +
+ {args.files.map((f: unknown, i: number) => ( + + + + + {String(f).split(/[\\/]/).pop() || String(f)} + + ))} +
+ )} +
+ Approving adds it to your skills on this computer — usable in every conversation from + then on. +
+ + ); +} + // A `permissions` proposal on the create_scheduled_task consent card (§25): reads are // disclosure lines, writes are the standing grants the approval mints. interface PermissionLine { @@ -65,14 +102,17 @@ export function scopeNote( args: any, category?: string, ): { text: string; external: boolean } { + // save_skill's corner answers WHERE (SKILLS-SPEC §5.2): the exact place to find, edit, + // or turn off the skill afterwards. + if (name === "save_skill") return { text: "saves to Settings ▸ Skills", external: false }; if (category === "connector") return { text: "acts on a connected service", external: true }; if (EXTERNAL.has(name)) { const platform = String(args?.target ?? "").split(":")[0]; const names: Record = { slack: "Slack", telegram: "Telegram" }; - return { text: `leaves this Mac → ${names[platform] || platform || "a connected chat"}`, external: true }; + return { text: `leaves this computer → ${names[platform] || platform || "a connected chat"}`, external: true }; } const overwrite = name === "write_file" && args?.overwrite; - return { text: "stays on this Mac" + (overwrite ? " · overwrites the existing file" : ""), external: false }; + return { text: "stays on this computer" + (overwrite ? " · overwrites the existing file" : ""), external: false }; } // The proposed content/command, straight from the tool call's ARGS — the file/action @@ -125,11 +165,13 @@ function Buttons({ onApprove, runTask, primaryLabel, + denyLabel = "Deny", }: { item: ApprovalItem; onApprove: (decision: ApprovalDecision) => void; runTask?: { id: string; title: string } | null; primaryLabel: string; + denyLabel?: string; }) { const connector = item.category === "connector"; const offerStanding = !!(runTask && item.standingTarget); @@ -152,7 +194,9 @@ function Buttons({ exactly the scope distinction §25 exists to draw. Same rule for run_shell: the command-scoped button below is the specific (safer) grant, so the tool-wide one stays out of the card. */} - {!connector && !offerStanding && item.name !== "run_shell" && ( + {/* save_skill: no session-wide "always" — every skill proposal gets its own review + (SKILLS-SPEC §5: one gate, always). */} + {!connector && !offerStanding && item.name !== "run_shell" && item.name !== "save_skill" && ( ); @@ -252,6 +296,8 @@ export function ApprovalCard({ {item.name === "send_message" && item.args?.text && ( )} + {/* save_skill (SKILLS-SPEC §5.2): the arguments ARE the review surface. */} + {item.name === "save_skill" && } {grants.length > 0 && (
@@ -272,7 +318,7 @@ export function ApprovalCard({ )} {/* Long-tail tools: no bespoke preview — fall back to the compact args line. */} {!FILE_WRITES.has(item.name) && - !["run_shell", "send_message", "send_file"].includes(item.name) && + !["run_shell", "send_message", "send_file", "save_skill"].includes(item.name) && !grants.length && shortArgs(item.args) &&
{shortArgs(item.args)}
} {reason &&
{reason}
} @@ -280,7 +326,13 @@ export function ApprovalCard({ {item.resolved ? (
Approved: {item.resolved.replace("_", " ")}
) : ( - + )}
); diff --git a/surfaces/gui/src/components/AutomationQuickstart.tsx b/surfaces/gui/src/components/AutomationQuickstart.tsx index e7ac533e..06184fe4 100644 --- a/surfaces/gui/src/components/AutomationQuickstart.tsx +++ b/surfaces/gui/src/components/AutomationQuickstart.tsx @@ -433,7 +433,7 @@ export function AutomationQuickstart({ One sign-in unlocks every one-click connection - Connections are brokered by OpenWorker Cloud — your tokens stay on this Mac. + Connections are brokered by OpenWorker Cloud — your tokens stay on this computer.
{signinPhase ? ( <> diff --git a/surfaces/gui/src/components/Composer.skills.test.tsx b/surfaces/gui/src/components/Composer.skills.test.tsx new file mode 100644 index 00000000..9d1250be --- /dev/null +++ b/surfaces/gui/src/components/Composer.skills.test.tsx @@ -0,0 +1,150 @@ +// SKILLS-SPEC §4.6 GUI — the composer's "/" force-run popup: opens only for a leading +// slash, lists only the session's effective (enabled) menu, filters while typing, and the +// picked skill rides onSend as its own field — never as message text. +import { afterEach, describe, expect, it, vi } from "vitest"; +import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { Composer } from "./Composer"; + +const MENU = { + skills: [ + { name: "weekly-report", description: "Monday status report", scope: "global", enabled: true }, + { name: "greet", description: "says hello", scope: "project", enabled: true }, + { name: "muted-one", description: "muted here", scope: "global", enabled: false }, + ], +}; + +function stubFetch() { + const calls: { url: string; method: string }[] = []; + vi.stubGlobal( + "fetch", + vi.fn(async (url: string, init?: RequestInit) => { + calls.push({ url, method: (init?.method || "GET").toUpperCase() }); + if (url.includes("/skills")) return { ok: true, json: async () => MENU } as Response; + return { ok: true, json: async () => ({}) } as Response; + }), + ); + return calls; +} + +const props = (extra: Partial[0]> = {}) => ({ + mode: "interactive", + model: "gpt-5.6-sol", + running: false, + connected: true, + sessionId: "s1", + onSend: vi.fn(), + onInterrupt: vi.fn(), + onModeChange: vi.fn(), + onModelChange: vi.fn(), + ...extra, +}); + +const box = () => screen.getByPlaceholderText(/Ask the coworker/); + +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); +}); + +describe("Composer / skills popup", () => { + it("opens on a leading '/' and lists only enabled skills from the effective menu", async () => { + stubFetch(); + render(); + fireEvent.change(box(), { target: { value: "/" } }); + await screen.findByTestId("skill-popup"); + expect(await screen.findByText("/weekly-report")).toBeTruthy(); + expect(screen.getByText("/greet")).toBeTruthy(); + expect(screen.queryByText("/muted-one")).toBeNull(); // muted → not offered + expect(screen.getByText("project")).toBeTruthy(); // scope badge + }); + + it("filters as you type", async () => { + stubFetch(); + render(); + fireEvent.change(box(), { target: { value: "/" } }); + await screen.findByText("/weekly-report"); + fireEvent.change(box(), { target: { value: "/wee" } }); + expect(screen.getByText("/weekly-report")).toBeTruthy(); + expect(screen.queryByText("/greet")).toBeNull(); + }); + + it("does NOT open for a mid-text slash", async () => { + stubFetch(); + render(); + fireEvent.change(box(), { target: { value: "rate 5/10 please" } }); + expect(screen.queryByTestId("skill-popup")).toBeNull(); + }); + + it("selecting inserts /name inline; the send strips the prefix and carries the skill field", async () => { + stubFetch(); + const p = props(); + render(); + fireEvent.change(box(), { target: { value: "/gr" } }); + fireEvent.click(await screen.findByRole("option", { name: /greet/ })); + expect((box() as HTMLTextAreaElement).value).toBe("/greet "); // inline, no chip + fireEvent.change(box(), { target: { value: "/greet say hi to the team" } }); + fireEvent.keyDown(box(), { key: "Enter" }); + await waitFor(() => expect(p.onSend).toHaveBeenCalled()); + expect(p.onSend).toHaveBeenCalledWith("say hi to the team", [], "greet"); + }); + + it("a skill-only send works and Enter inside the popup never sends the query text", async () => { + stubFetch(); + const p = props(); + render(); + fireEvent.change(box(), { target: { value: "/wee" } }); + await screen.findByText("/weekly-report"); + fireEvent.keyDown(box(), { key: "Enter" }); // selects, does not send + expect(p.onSend).not.toHaveBeenCalled(); + expect((box() as HTMLTextAreaElement).value).toBe("/weekly-report "); + fireEvent.keyDown(box(), { key: "Enter" }); // now sends, skill-only + await waitFor(() => expect(p.onSend).toHaveBeenCalledWith("", [], "weekly-report")); + }); + + it("editing the /name prefix away un-picks the skill", async () => { + stubFetch(); + const p = props(); + render(); + fireEvent.change(box(), { target: { value: "/gr" } }); + fireEvent.click(await screen.findByRole("option", { name: /greet/ })); + fireEvent.change(box(), { target: { value: "hello plain" } }); // prefix gone + fireEvent.keyDown(box(), { key: "Enter" }); + await waitFor(() => expect(p.onSend).toHaveBeenCalledWith("hello plain", [], undefined)); + }); + + it("Escape closes the popup and no popup ever opens without a sessionId", async () => { + stubFetch(); + render(); + fireEvent.change(box(), { target: { value: "/gr" } }); + await screen.findByTestId("skill-popup"); + fireEvent.keyDown(box(), { key: "Escape" }); + expect(screen.queryByTestId("skill-popup")).toBeNull(); + cleanup(); + stubFetch(); + render(); + fireEvent.change(box(), { target: { value: "/" } }); + expect(screen.queryByTestId("skill-popup")).toBeNull(); + }); +}); + +describe("Composer — the doorway prefill (SKILLS-SPEC §5.2)", () => { + it("a prefill arriving together with a session switch survives the draft clear", async () => { + stubFetch(); + const { rerender } = render(); + // The doorway does both in one render: new session (resetKey) + prefill. The clear + // effect must run BEFORE the prefill effect or the prefill is wiped (regression). + rerender( + , + ); + await waitFor(() => { + expect((box() as HTMLTextAreaElement).value).toBe( + "Build a new skill for me: release procedure", + ); + }); + }); +}); diff --git a/surfaces/gui/src/components/Composer.tsx b/surfaces/gui/src/components/Composer.tsx index 53324818..532c437d 100644 --- a/surfaces/gui/src/components/Composer.tsx +++ b/surfaces/gui/src/components/Composer.tsx @@ -1,7 +1,7 @@ import { useEffect, useLayoutEffect, useRef, useState, type ReactNode } from "react"; import type { Attachment, SessionUsage } from "../types"; import { isPdfFile, readFile } from "../attach"; -import { getSettings, inspectPdf } from "../api"; +import { getSettings, inspectPdf, sessionSkills, type SessionSkillRow } from "../api"; import { formatTokens, totalTokens } from "../usage"; import { Dropdown, type Option } from "./Dropdown"; import { Icon } from "./Icon"; @@ -59,7 +59,10 @@ interface Props { modelReady?: boolean; onConnectModel?: () => void; onConfigureVoiceInput?: () => void; - onSend: (text: string, attachments?: Attachment[]) => void; + onSend: (text: string, attachments?: Attachment[], skill?: string) => void; + // Feeds the "/" force-run popup (SKILLS-SPEC §4.1 #3): the popup lists this session's + // effective skill menu. Absent (e.g. tests without sessions) → the popup never opens. + sessionId?: string; onInterrupt: () => void; onModeChange: (mode: string) => void; onModelChange: (model: string) => void; @@ -91,6 +94,46 @@ interface Props { export function Composer(props: Props) { const [text, setText] = useState(""); const [attachments, setAttachments] = useState([]); + // "/" force-run (SKILLS-SPEC §4.1 #3). The popup derives from the draft: it is open while + // the text is a bare "/query" (no whitespace yet) and no skill is picked. Selecting a row + // inserts "/name " INLINE in the box (Claude-Code style — the slash text IS the state); + // the user keeps typing after it, and on send the prefix is stripped while the skill name + // rides the user_message as its own field. Editing the prefix away un-picks the skill. + const [pendingSkill, setPendingSkill] = useState(null); + const [slashSkills, setSlashSkills] = useState(null); + const [slashIndex, setSlashIndex] = useState(0); + const prefixIntact = + pendingSkill !== null && + (text === `/${pendingSkill.name}` || text.startsWith(`/${pendingSkill.name} `)); + useEffect(() => { + if (pendingSkill && !prefixIntact) setPendingSkill(null); + }, [pendingSkill, prefixIntact]); + const slashQuery = + !prefixIntact && props.sessionId && text.startsWith("/") && !/\s/.test(text.slice(1)) + ? text.slice(1).toLowerCase() + : null; + const slashMatches = (slashSkills ?? []).filter((s) => + s.name.toLowerCase().includes(slashQuery ?? ""), + ); + useEffect(() => { + // Fetch on each popup open (fresh menu); drop when closed. + if (slashQuery === null) { + setSlashSkills(null); + setSlashIndex(0); + return; + } + if (slashSkills === null && props.sessionId) { + sessionSkills(props.sessionId, props.workspace) + .then((all) => setSlashSkills(all.filter((s) => s.enabled))) + .catch(() => setSlashSkills([])); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [slashQuery === null]); + const pickSkill = (s: SessionSkillRow) => { + setPendingSkill(s); + setText(`/${s.name} `); + textareaRef.current?.focus(); + }; const [dragging, setDragging] = useState(false); const [attachMenuOpen, setAttachMenuOpen] = useState(false); const [dictation, setDictation] = useState(null); @@ -119,6 +162,17 @@ export function Composer(props: Props) { el.style.overflowY = el.scrollHeight > max ? "auto" : "hidden"; }, [text]); + // Clear the draft when the conversation changes, so a half-typed message / picked file doesn't + // bleed from one session into another. Declared BEFORE the prefill effect: when both fire in + // the same render (the Skills doorway starts a new session AND prefills it), effects run in + // declaration order — clear first, then the prefill lands on the fresh session. + useEffect(() => { + setText(""); + setAttachments([]); + setPendingSkill(null); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [props.resetKey]); + // Apply a prefill (text + attachments) pushed from outside, then focus the composer. Applied at // most once per nonce (a ref guards against StrictMode/re-render double-fires), and attachments // are de-duplicated so the same file never lands twice. @@ -133,14 +187,6 @@ export function Composer(props: Props) { // eslint-disable-next-line react-hooks/exhaustive-deps }, [props.prefill?.nonce]); - // Clear the draft when the conversation changes, so a half-typed message / picked file doesn't - // bleed from one session into another. - useEffect(() => { - setText(""); - setAttachments([]); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [props.resetKey]); - // Dictation is intentionally native-only: the browser/dev build remains a local server client // and never turns on the browser microphone or ships audio anywhere. useEffect(() => { @@ -264,19 +310,54 @@ export function Composer(props: Props) { const needsModel = props.modelReady === false; const submit = () => { - const t = text.trim(); - if ((!t && attachments.length === 0) || props.running || dictation?.recording || dictationBusy) return; + // While the "/" popup is open the draft is a query, not a message — never send it. + if (slashQuery !== null) return; + // The visible "/name " prefix is UI state, not message text — strip it for the send; + // the skill rides as its own field. + const skill = prefixIntact ? pendingSkill!.name : undefined; + const t = (skill ? text.slice(skill.length + 1) : text).trim(); + if ( + (!t && attachments.length === 0 && !skill) || + props.running || + dictation?.recording || + dictationBusy + ) + return; // No model connected: keep the draft (don't drop it) and send the user to setup instead. if (needsModel) { props.onConnectModel?.(); return; } - props.onSend(t, attachments); + props.onSend(t, attachments, skill); setText(""); setAttachments([]); + setPendingSkill(null); }; const onKey = (e: React.KeyboardEvent) => { + if (slashQuery !== null) { + if (e.key === "ArrowDown") { + e.preventDefault(); + setSlashIndex((i) => Math.min(i + 1, Math.max(slashMatches.length - 1, 0))); + return; + } + if (e.key === "ArrowUp") { + e.preventDefault(); + setSlashIndex((i) => Math.max(i - 1, 0)); + return; + } + if (e.key === "Escape") { + e.preventDefault(); + setText(""); + return; + } + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + const chosen = slashMatches[slashIndex]; + if (chosen) pickSkill(chosen); + return; + } + } if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); submit(); @@ -342,7 +423,9 @@ export function Composer(props: Props) { // The send button is accent only when there's something to send — subtle grey otherwise, so the // composer isn't carrying a constant blue dot. - const hasContent = text.trim().length > 0 || attachments.length > 0; + // A pinned /skill is sendable content on its own (tester catch 2026-07-26: the arrow + // stayed grey after picking a skill, reading as "stuck"). + const hasContent = text.trim().length > 0 || attachments.length > 0 || !!pendingSkill; return (
@@ -396,6 +479,37 @@ export function Composer(props: Props) { if (e.dataTransfer.files.length) addFiles(e.dataTransfer.files); }} > + {/* "/" force-run popup — in-flow above the textarea; rows are the session's + effective menu only (muted/disabled skills never appear). */} + {slashQuery !== null && ( +
+ {slashSkills === null ? ( +
Loading skills…
+ ) : slashMatches.length === 0 ? ( +
No matching skills.
+ ) : ( + slashMatches.map((s, i) => ( + + )) + )} +
+ )}