diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 5ccf5cc2..6b3e96d2 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -43,11 +43,13 @@ jobs:
fail-fast: false
matrix:
include:
- # No Intel macOS target: macos-13 (the last Intel runner image) is deprecated and
- # its queue waits run to hours, which blocks the release job. Intel Macs are
- # 2020-and-earlier hardware — revisit only if beta users actually ask.
- os: macos-latest # Apple Silicon
slug: macos-arm64
+ # Intel macOS: macos-13 retired in Dec 2025; macos-15-intel replaced it and is
+ # the LAST x86_64 image Actions will offer (available until Aug 2027). Builds
+ # natively — the sidecar is a PyInstaller freeze, which cannot cross-compile.
+ - os: macos-15-intel
+ slug: macos-x64
- os: windows-latest
slug: windows
runs-on: ${{ matrix.os }}
diff --git a/.gitignore b/.gitignore
index df96f790..f7166ef0 100644
--- a/.gitignore
+++ b/.gitignore
@@ -6,3 +6,6 @@ __pycache__/
build/
dist/
.coverage
+
+# Local secrets (live-smoke BYO keys) — never committed
+.env
diff --git a/coworker/agent.py b/coworker/agent.py
index b8d8f069..063f0946 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
@@ -36,7 +36,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
@@ -134,6 +134,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:
@@ -183,6 +215,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:
@@ -341,10 +375,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).
@@ -378,6 +424,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:
@@ -393,6 +443,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(
@@ -425,6 +495,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/compaction.py b/coworker/compaction.py
new file mode 100644
index 00000000..678682a5
--- /dev/null
+++ b/coworker/compaction.py
@@ -0,0 +1,561 @@
+"""Auto-compaction of long session histories (OPE-27).
+
+When the outbound history approaches the model's context limit, the older portion of the
+*outbound* view is replaced with (a) an LLM-written structured summary and (b) mechanically
+extracted state — the recent turns and all user messages survive. The persisted transcript
+is never modified; only what is sent to the model. Full design: ocw-context
+docs/auto-compaction-spec.md (approved 2026-07-28).
+
+This module is pure functions + one dataclass; the engine owns *when* (its run loop) and
+*with what* (its provider/model), both injected here. That split keeps the engine.py
+footprint to a few lines and makes every policy testable without a provider.
+"""
+
+from __future__ import annotations
+
+import json
+import time
+from dataclasses import dataclass, field
+from typing import Any, Optional
+
+# Trigger: min(threshold_pct × context_window, cap_tokens). The cap exists so 1M-context
+# models compact early — quality and latency degrade well before the nominal limit.
+DEFAULT_THRESHOLD_PCT = 0.8
+DEFAULT_CAP_TOKENS = 250_000
+# Models without a verified context_window entry in the matrix.
+DEFAULT_CONTEXT_WINDOW = 128_000
+# The newest slice kept verbatim, as a fraction of the trigger (a token budget, not a
+# turn count — one huge tool loop shouldn't starve the working set).
+KEEP_RECENT_FRACTION = 0.25
+# The summarizer call itself: tools off, modest ceiling.
+SUMMARY_MAX_TOKENS = 3_000
+# Per-message clip when rendering the span for the summarizer; tool results are the
+# first casualty (huge and mostly stale — a file read 40 turns ago is better re-read).
+_SPAN_TOOL_RESULT_CLIP = 400
+_SPAN_BUDGET_CHARS = 400_000
+# User messages preserved mechanically in the compacted block ("trimmed of pasted bulk").
+# The list is capped to the newest N across repeated compactions — otherwise it appends
+# forever and the block slowly reclaims the window it freed. Dropped ones stay counted
+# (their intent lives in the summary, which is asked to list user messages too).
+_USER_MESSAGE_CLIP = 600
+_USER_MESSAGES_MAX = 40
+_TRIM_FRACTION = 0.10
+
+
+# -- token math ---------------------------------------------------------------
+
+
+def estimate_tokens(messages: list[dict[str, Any]]) -> int:
+ """chars/4 over the serialized messages — the fallback signal for providers that
+ never report usage (documented in the metering code)."""
+ total = 0
+ for msg in messages:
+ try:
+ total += len(json.dumps(msg, default=str))
+ except (TypeError, ValueError):
+ total += len(str(msg))
+ return total // 4
+
+
+def trigger_tokens(
+ context_window: Optional[int],
+ *,
+ threshold_pct: float = DEFAULT_THRESHOLD_PCT,
+ cap_tokens: int = DEFAULT_CAP_TOKENS,
+) -> int:
+ window = context_window or DEFAULT_CONTEXT_WINDOW
+ return min(int(threshold_pct * window), int(cap_tokens))
+
+
+def should_compact(
+ signal: int,
+ context_window: Optional[int],
+ *,
+ threshold_pct: float = DEFAULT_THRESHOLD_PCT,
+ cap_tokens: int = DEFAULT_CAP_TOKENS,
+) -> bool:
+ return signal >= trigger_tokens(
+ context_window, threshold_pct=threshold_pct, cap_tokens=cap_tokens
+ )
+
+
+# -- state --------------------------------------------------------------------
+
+
+@dataclass
+class CompactionState:
+ """One compaction point. `boundary_index` is an index into the CANONICAL message list:
+ messages before it are represented by the compacted block in the outbound view; messages
+ from it on are sent verbatim. Persisted with the session so reloads keep the view."""
+
+ boundary_index: int
+ summary_text: str
+ working_state: str
+ user_messages: list[str] = field(default_factory=list)
+ # How many older user messages were dropped by the _USER_MESSAGES_MAX cap, across
+ # all compactions of this session — keeps the block's "N earlier omitted" honest.
+ user_messages_dropped: int = 0
+ created_at: float = 0.0
+ model_used: str = ""
+ trimmed: bool = False # True when this state came from the no-summary trim fallback
+
+ def as_dict(self) -> dict[str, Any]:
+ return {
+ "boundary_index": self.boundary_index,
+ "summary_text": self.summary_text,
+ "working_state": self.working_state,
+ "user_messages": list(self.user_messages),
+ "user_messages_dropped": self.user_messages_dropped,
+ "created_at": self.created_at,
+ "model_used": self.model_used,
+ "trimmed": self.trimmed,
+ }
+
+ @classmethod
+ def from_dict(cls, raw: Any) -> Optional["CompactionState"]:
+ if not isinstance(raw, dict) or "boundary_index" not in raw:
+ return None
+ return cls(
+ boundary_index=int(raw.get("boundary_index", 0)),
+ summary_text=str(raw.get("summary_text", "")),
+ working_state=str(raw.get("working_state", "")),
+ user_messages=[str(u) for u in raw.get("user_messages") or []],
+ user_messages_dropped=int(raw.get("user_messages_dropped", 0)),
+ created_at=float(raw.get("created_at", 0.0)),
+ model_used=str(raw.get("model_used", "")),
+ trimmed=bool(raw.get("trimmed", False)),
+ )
+
+
+# -- boundary -----------------------------------------------------------------
+
+
+def _turn_starts(messages: list[dict[str, Any]], *, start: int) -> tuple[list[int], list[int]]:
+ """Candidate boundary indexes past `start`: user-message indexes (turn starts,
+ preferred) and assistant indexes (iteration starts — legal suffix heads; a `tool`
+ message must never head the outbound view)."""
+ users, assistants = [], []
+ for i in range(start, len(messages)):
+ role = messages[i].get("role")
+ if role == "user":
+ users.append(i)
+ elif role == "assistant":
+ assistants.append(i)
+ return users, assistants
+
+
+def pick_boundary(messages: list[dict[str, Any]], *, keep_tokens: int) -> Optional[int]:
+ """The canonical index where the verbatim tail begins: the earliest turn start whose
+ suffix fits the keep budget. Prefers user-message boundaries; falls back to iteration
+ (assistant) boundaries when the newest turn alone exceeds the budget (a giant tool
+ loop). None when there is nothing meaningful to summarize."""
+ start = 1 if messages and messages[0].get("role") == "system" else 0
+ users, assistants = _turn_starts(messages, start=start)
+
+ def _fit(candidates: list[int]) -> Optional[int]:
+ for i in candidates: # earliest-first: keep as much verbatim as fits
+ if estimate_tokens(messages[i:]) <= keep_tokens:
+ return i
+ return None
+
+ boundary = _fit(users)
+ if boundary is None and users:
+ # The newest user turn alone blows the budget — cut inside it at an iteration
+ # boundary, keeping at least the most recent assistant step.
+ inside = [i for i in assistants if i > users[-1]]
+ boundary = _fit(inside)
+ if boundary is None:
+ boundary = inside[-1] if inside else users[-1]
+ if boundary is None:
+ boundary = _fit(assistants) or (assistants[-1] if assistants else None)
+ # A boundary at (or before) the first real message summarizes nothing — skip.
+ if boundary is None or boundary <= start:
+ return None
+ return boundary
+
+
+# -- mechanical extraction (no LLM — zero hallucination risk) -----------------
+
+_WRITE_HINTS = ("write", "edit", "append", "save", "create", "patch")
+_ARTIFACT_HINTS = ("artifact", "publish", "deploy")
+
+
+def _iter_tool_calls(span: list[dict[str, Any]]):
+ """(name, args, result_content) for every tool call in the span, in order."""
+ results = {
+ m.get("tool_call_id"): m.get("content")
+ for m in span
+ if m.get("role") == "tool"
+ }
+ for msg in span:
+ if msg.get("role") != "assistant":
+ continue
+ for tc in msg.get("tool_calls") or []:
+ fn = tc.get("function") or {}
+ try:
+ args = json.loads(fn.get("arguments") or "{}")
+ except (ValueError, TypeError):
+ args = {}
+ yield str(fn.get("name") or ""), args, results.get(tc.get("id"))
+
+
+def _result_status(result: Any) -> str:
+ if not isinstance(result, str):
+ return ""
+ try:
+ parsed = json.loads(result)
+ except (ValueError, TypeError):
+ return ""
+ if not isinstance(parsed, dict):
+ return ""
+ if parsed.get("error"):
+ return "error"
+ if "exit_code" in parsed:
+ code = parsed.get("exit_code")
+ return "ok" if code in (0, "0") else f"exit {code}"
+ return ""
+
+
+def extract_working_state(span: list[dict[str, Any]]) -> str:
+ """The mechanical block appended to the summary by CODE, from the span's tool-call
+ records: files written, recent commands (+ exit status), artifacts, tools used."""
+ files: list[str] = []
+ commands: list[str] = []
+ artifacts: list[str] = []
+ tools: list[str] = []
+ for name, args, result in _iter_tool_calls(span):
+ if name and name not in tools:
+ tools.append(name)
+ lowered = name.lower()
+ path = args.get("path") or args.get("file_path")
+ if path and any(h in lowered for h in _WRITE_HINTS):
+ files.append(str(path))
+ if lowered == "run_shell" and args.get("command"):
+ status = _result_status(result)
+ line = " ".join(str(args["command"]).split())[:160]
+ commands.append(f"{line}" + (f" [{status}]" if status else ""))
+ if any(h in lowered for h in _ARTIFACT_HINTS):
+ location = args.get("url") or args.get("path") or args.get("title")
+ if location:
+ artifacts.append(str(location))
+
+ def _dedupe_recent_first(items: list[str], limit: int) -> list[str]:
+ seen: list[str] = []
+ for item in reversed(items): # most recent first
+ if item not in seen:
+ seen.append(item)
+ if len(seen) >= limit:
+ break
+ return seen
+
+ lines = ["## Working state (extracted mechanically from tool records)"]
+ written = _dedupe_recent_first(files, 20)
+ if written:
+ lines.append("Files written/edited (most recent first):")
+ lines += [f"- {p}" for p in written]
+ recent_cmds = commands[-10:]
+ if recent_cmds:
+ lines.append("Recent shell commands:")
+ lines += [f"- {c}" for c in recent_cmds]
+ made = _dedupe_recent_first(artifacts, 10)
+ if made:
+ lines.append("Artifacts produced:")
+ lines += [f"- {a}" for a in made]
+ if tools:
+ lines.append("Tools used in the summarized span: " + ", ".join(sorted(tools)))
+ return "\n".join(lines) if len(lines) > 1 else ""
+
+
+def _text_of(content: Any) -> str:
+ """A message's text, whether plain or content-parts (images become a placeholder)."""
+ if isinstance(content, str):
+ return content
+ if isinstance(content, list):
+ parts = []
+ for p in content:
+ if isinstance(p, dict) and p.get("type") == "text":
+ parts.append(str(p.get("text", "")))
+ elif isinstance(p, dict) and p.get("type") == "image_url":
+ parts.append("[image]")
+ return "\n".join(parts)
+ return "" if content is None else str(content)
+
+
+def extract_user_messages(
+ span: list[dict[str, Any]], *, clip: int = _USER_MESSAGE_CLIP
+) -> list[str]:
+ """Every user message in the span, chronological, trimmed of pasted bulk. Preserved
+ mechanically — the summarizer is also asked to list them, but user words are the
+ ground truth of intent and must not depend on an LLM remembering to include them."""
+ out: list[str] = []
+ for msg in span:
+ if msg.get("role") != "user":
+ continue
+ text = " ".join(_text_of(msg.get("content")).split())
+ if not text:
+ continue
+ out.append(text[: clip - 1] + "…" if len(text) > clip else text)
+ return out
+
+
+def _cap_user_messages(
+ messages: list[str], *, prior_dropped: int, limit: int = _USER_MESSAGES_MAX
+) -> tuple[list[str], int]:
+ """Newest-`limit` slice plus the running total of everything ever dropped."""
+ if len(messages) <= limit:
+ return messages, prior_dropped
+ return messages[-limit:], prior_dropped + (len(messages) - limit)
+
+
+# -- summarizer ---------------------------------------------------------------
+
+SUMMARY_SYSTEM_PROMPT = """You are compacting an AI coworker's session history so the coworker can continue working in a smaller context. Write a structured summary of the conversation below. It is the coworker's ONLY memory of these turns, so preserve everything load-bearing.
+
+Produce ALL of the following sections, in this order, each as a markdown heading:
+
+1. **Primary request and intent** — what the user is trying to get done, in their terms, including standing constraints stated at any point (e.g. "never send without my approval"). Constraints outlive the turns they were stated in.
+2. **Key concepts and decisions** — domain facts, technical choices, and rationale established so far. Include the WHY, not just the what — a decision without its reason gets relitigated.
+3. **Artifacts and files** — every file/deliverable created, modified, or read that still matters: path, its role, and a short excerpt of load-bearing content only.
+4. **Errors and fixes** — problems hit and how they were resolved, including user corrections ("no, do it this way") — those are feedback with lasting force.
+5. **All user messages** — a chronological list of every user message (trimmed of pasted bulk). This is the intent audit-trail.
+6. **Pending tasks** — explicitly incomplete items, promised follow-ups, things the user said "later" about.
+7. **Current work** — precisely what was in progress at this point: which step, which file, what state.
+8. **Next step** — the immediate next action, justified by the user's request.
+
+Rules:
+- Do NOT carry full file contents as truth. Note THAT a file was read/edited; the coworker re-reads if it needs the content again. Stale memory of a file is worse than no memory.
+- Be concrete: paths, names, commands, ids — not vague references.
+- Output only the summary sections, no preamble."""
+
+CONTINUATION_CONTRACT = (
+ "Continue where you left off: pick up the current work and next step exactly as "
+ "described. Do not re-ask answered questions, do not recap, do not mention that the "
+ "context was compacted. If you need the contents of a file noted above, re-read it."
+)
+
+
+def _render_span(span: list[dict[str, Any]], *, budget_chars: int = _SPAN_BUDGET_CHARS) -> str:
+ """The summarized span as compact text for the summarizer. Tool results are clipped
+ hard (first casualty); if the whole render still exceeds the budget, oldest lines are
+ dropped — the newest context is the most load-bearing."""
+ lines: list[str] = []
+ for msg in span:
+ role = msg.get("role")
+ if role == "system":
+ continue
+ if role == "notice":
+ continue
+ if role == "tool":
+ text = _text_of(msg.get("content"))
+ text = " ".join(text.split())
+ if len(text) > _SPAN_TOOL_RESULT_CLIP:
+ text = text[: _SPAN_TOOL_RESULT_CLIP - 1] + "…"
+ lines.append(f"[tool result] {text}")
+ continue
+ text = _text_of(msg.get("content"))
+ if role == "assistant":
+ for tc in msg.get("tool_calls") or []:
+ fn = tc.get("function") or {}
+ args = " ".join(str(fn.get("arguments", "")).split())
+ if len(args) > 200:
+ args = args[:199] + "…"
+ lines.append(f"[assistant → {fn.get('name')}] {args}")
+ if text:
+ lines.append(f"[assistant] {text}")
+ elif role == "user":
+ lines.append(f"[user] {text}")
+ rendered = "\n".join(lines)
+ if len(rendered) > budget_chars:
+ rendered = "(…oldest turns elided…)\n" + rendered[-budget_chars:]
+ return rendered
+
+
+def summarizer_messages(
+ span: list[dict[str, Any]], *, prior_summary: str = ""
+) -> list[dict[str, Any]]:
+ """The provider-ready messages for the summarizer call. On repeated compaction the
+ previous summary is message zero of the new span — summarized along with the turns
+ since."""
+ body = _render_span(span)
+ if prior_summary:
+ body = (
+ "[previous compaction summary — fold its still-relevant content into the new "
+ "summary]\n" + prior_summary + "\n\n[conversation since]\n" + body
+ )
+ return [
+ {"role": "system", "content": SUMMARY_SYSTEM_PROMPT},
+ {"role": "user", "content": body},
+ ]
+
+
+def summarize_span(
+ provider: Any,
+ model: str,
+ span: list[dict[str, Any]],
+ *,
+ prior_summary: str = "",
+ max_tokens: int = SUMMARY_MAX_TOKENS,
+) -> str:
+ """One summarizer round-trip (blocking — the engine runs it off-loop). Tools are
+ disabled; the Settings model override is just a different `model` id. Raises on
+ provider failure or an empty summary — the caller owns the retry/trim policy."""
+ turn = provider.complete(
+ model=model,
+ messages=summarizer_messages(span, prior_summary=prior_summary),
+ tools=None,
+ max_tokens=max_tokens,
+ )
+ text = (getattr(turn, "text", None) or "").strip()
+ if not text:
+ raise RuntimeError("summarizer returned an empty summary")
+ return text
+
+
+# -- building + applying a compaction -----------------------------------------
+
+
+def build_state(
+ messages: list[dict[str, Any]],
+ *,
+ provider: Any,
+ model: str,
+ keep_tokens: int,
+ prior: Optional[CompactionState] = None,
+) -> Optional[CompactionState]:
+ """Summarize everything older than the picked boundary into a new CompactionState.
+ On repeated compaction the prior summary heads the new span. Returns None when there
+ is nothing to compact; raises when the summarizer fails (caller applies policy)."""
+ boundary = pick_boundary(messages, keep_tokens=keep_tokens)
+ if boundary is None or (prior is not None and boundary <= prior.boundary_index):
+ return None
+ span_start = prior.boundary_index if prior is not None else 0
+ span = messages[span_start:boundary]
+ prior_users = list(prior.user_messages) if prior is not None else []
+ summary = summarize_span(
+ provider,
+ model,
+ span,
+ prior_summary=prior.summary_text if prior is not None else "",
+ )
+ users, dropped = _cap_user_messages(
+ prior_users + extract_user_messages(span),
+ prior_dropped=prior.user_messages_dropped if prior is not None else 0,
+ )
+ return CompactionState(
+ boundary_index=boundary,
+ summary_text=summary,
+ working_state=extract_working_state(span),
+ user_messages=users,
+ user_messages_dropped=dropped,
+ created_at=time.time(),
+ model_used=model,
+ )
+
+
+def trim_state(
+ messages: list[dict[str, Any]],
+ *,
+ prior: Optional[CompactionState] = None,
+ fraction: float = _TRIM_FRACTION,
+) -> Optional[CompactionState]:
+ """The no-LLM fallback: advance the boundary past ~`fraction` of the outbound
+ messages. No summary — but the mechanical block and the user-message list (never
+ trimmed away, per spec) are free, so the model still gets deterministic state."""
+ start = prior.boundary_index if prior is not None else 0
+ remaining = len(messages) - start
+ if remaining <= 2:
+ return None
+ step = max(1, int(remaining * fraction))
+ target = start + step
+ # Land on a legal suffix head at or after the target (never a tool message).
+ boundary = None
+ for i in range(target, len(messages)):
+ if messages[i].get("role") in ("user", "assistant"):
+ boundary = i
+ break
+ if boundary is None or boundary <= start or boundary >= len(messages):
+ return None
+ span = messages[start:boundary]
+ prior_users = list(prior.user_messages) if prior is not None else []
+ summary = (
+ (prior.summary_text + "\n\n" if prior is not None and prior.summary_text else "")
+ + "(Older turns were trimmed to fit the context window; no summary is available "
+ "for them. Re-read files and re-run commands if earlier results are needed.)"
+ )
+ users, dropped = _cap_user_messages(
+ prior_users + extract_user_messages(span),
+ prior_dropped=prior.user_messages_dropped if prior is not None else 0,
+ )
+ return CompactionState(
+ boundary_index=boundary,
+ summary_text=summary,
+ working_state=extract_working_state(span),
+ user_messages=users,
+ user_messages_dropped=dropped,
+ created_at=time.time(),
+ model_used="",
+ trimmed=True,
+ )
+
+
+def compacted_block(state: CompactionState) -> str:
+ """The single outbound message standing in for everything before the boundary."""
+ parts = [
+ "",
+ "Earlier turns of this session were compacted. The summary below is your memory "
+ "of them.",
+ "",
+ state.summary_text,
+ ]
+ if state.working_state:
+ parts += ["", state.working_state]
+ if state.user_messages:
+ parts += ["", "## User messages in the compacted span (verbatim, chronological)"]
+ if state.user_messages_dropped:
+ parts += [
+ f"({state.user_messages_dropped} earlier user messages omitted — "
+ "their intent is covered by the summary above)"
+ ]
+ parts += [f"- {u}" for u in state.user_messages]
+ parts += ["", CONTINUATION_CONTRACT, ""]
+ return "\n".join(parts)
+
+
+def apply_to_outbound(
+ messages: list[dict[str, Any]], state: Optional[CompactionState]
+) -> list[dict[str, Any]]:
+ """The outbound view: [system?] + the compacted block (as a user message) + the
+ verbatim tail. Canonical history is untouched; provider-private sidecars in the
+ summarized span vanish with it (replay chains legally restart after a compaction
+ point). No-op when state is absent or stale."""
+ if state is None:
+ return messages
+ boundary = state.boundary_index
+ if boundary <= 0 or boundary >= len(messages):
+ return messages
+ head: list[dict[str, Any]] = []
+ if messages and messages[0].get("role") == "system":
+ head.append(messages[0])
+ head.append({"role": "user", "content": compacted_block(state)})
+ return head + messages[boundary:]
+
+
+# -- overflow detection -------------------------------------------------------
+
+_OVERFLOW_MARKERS = (
+ "context_length_exceeded",
+ "maximum context length",
+ "context window",
+ "prompt is too long",
+ "input is too long",
+ "too many tokens",
+ "input length and `max_tokens` exceed",
+ "exceeds the maximum number of tokens",
+)
+
+
+def is_context_overflow(exc: BaseException) -> bool:
+ """A raw context-overflow 400 from the main model (compaction mispredicted, e.g. the
+ estimate path) — routed into the compaction policy instead of surfacing."""
+ text = str(exc).lower()
+ return any(marker in text for marker in _OVERFLOW_MARKERS)
diff --git a/coworker/connectors/browser_automation.py b/coworker/connectors/browser_automation.py
index e1e22e0c..357cb005 100644
--- a/coworker/connectors/browser_automation.py
+++ b/coworker/connectors/browser_automation.py
@@ -17,6 +17,8 @@ from typing import Any, Callable, Optional
import aisuite as ai
+from ..web.guard import check_url
+
def _meta(
name: str, *, approval: bool = False, capabilities: Optional[list[str]] = None
@@ -332,6 +334,12 @@ def make_browser_automation_tools() -> list[Callable[..., Any]]:
) -> dict[str, Any]:
if not url.lower().startswith(("http://", "https://")):
return {"error": "url must start with http:// or https://"}
+ # Same address guard as web_fetch. This is approval gated, so it is defense in
+ # depth, not the primary control. It checks the initial model supplied URL only;
+ # redirects that the browser follows internally are not hop checked here.
+ blocked = check_url(url)
+ if blocked:
+ return {"error": blocked}
return _BROWSER.call(
"open_url",
lambda page: (
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/connectors/integration_tools.py b/coworker/connectors/integration_tools.py
index 7588136d..f0f18d0b 100644
--- a/coworker/connectors/integration_tools.py
+++ b/coworker/connectors/integration_tools.py
@@ -19,6 +19,7 @@ from urllib.parse import quote
import aisuite as ai
from ..secrets import SecretStore
+from ..web.guard import get_checked
from .browser_automation import make_browser_automation_tools
from .email_tools import make_email_tools
from .tool_defs import approval_for_tool, connector_for_tool
@@ -305,15 +306,39 @@ def _gmail_is_hidden(
def _request(
- method: str, url: str, *, headers=None, params=None, json=None, auth=None
+ method: str,
+ url: str,
+ *,
+ headers=None,
+ params=None,
+ json=None,
+ auth=None,
+ check_addresses: bool = False,
) -> dict[str, Any]:
+ """HTTP for the connectors.
+
+ `check_addresses` is for URLs the *model* supplies (browser_read_url). It turns off
+ automatic redirects and walks the chain through the address guard instead, so a public
+ URL cannot 302 into loopback or the metadata endpoint. The vendor endpoints everything
+ else in this module calls are hardcoded, so they skip the guard and its DNS lookup.
+ """
try:
import httpx
- with httpx.Client(timeout=30.0, follow_redirects=True) as client:
- resp = client.request(
- method, url, headers=headers, params=params, json=json, auth=auth
- )
+ with httpx.Client(
+ timeout=30.0, follow_redirects=not check_addresses
+ ) as client:
+ if check_addresses:
+ if method.upper() != "GET":
+ return {"error": "address-checked requests must be GET"}
+ try:
+ resp = get_checked(client, url)
+ except PermissionError as exc:
+ return {"error": str(exc)}
+ else:
+ resp = client.request(
+ method, url, headers=headers, params=params, json=json, auth=auth
+ )
ctype = resp.headers.get("content-type", "")
data: Any = resp.json() if "json" in ctype.lower() else resp.text
if resp.status_code >= 400:
@@ -533,7 +558,13 @@ def make_integration_tools(
def browser_read_url(url: str, max_chars: int = 20000) -> dict[str, Any]:
if not url.lower().startswith(("http://", "https://")):
return {"error": "url must start with http:// or https://"}
- out = _request("GET", url, headers={"User-Agent": "coworker/0.1 (+connector)"})
+ # Model-supplied URL: address-check every hop, same guard as web_fetch.
+ out = _request(
+ "GET",
+ url,
+ headers={"User-Agent": "coworker/0.1 (+connector)"},
+ check_addresses=True,
+ )
if "error" in out:
return out
data = out["data"]
diff --git a/coworker/conversations.py b/coworker/conversations.py
index fd2131bf..e67ef2fb 100644
--- a/coworker/conversations.py
+++ b/coworker/conversations.py
@@ -95,6 +95,7 @@ class ConversationStore:
"ALTER TABLE sessions ADD COLUMN auto_title TEXT",
"ALTER TABLE sessions ADD COLUMN renamed INTEGER DEFAULT 0",
"ALTER TABLE sessions ADD COLUMN grants TEXT",
+ "ALTER TABLE sessions ADD COLUMN compaction TEXT",
):
try:
self._conn.execute(ddl)
@@ -191,13 +192,14 @@ class ConversationStore:
title = record.title or title_from(record.messages)
self._conn.execute(
"""
- INSERT INTO sessions (session_id, workspace, model, mode, title, agent, n_msgs, messages, extra_roots, grants, updated_at)
- VALUES (?, ?, ?, ?, ?, ?, ?, NULL, ?, ?, CURRENT_TIMESTAMP)
+ INSERT INTO sessions (session_id, workspace, model, mode, title, agent, n_msgs, messages, extra_roots, grants, compaction, updated_at)
+ VALUES (?, ?, ?, ?, ?, ?, ?, NULL, ?, ?, ?, CURRENT_TIMESTAMP)
ON CONFLICT(session_id) DO UPDATE SET
workspace = excluded.workspace, model = excluded.model, mode = excluded.mode,
title = COALESCE(sessions.title, excluded.title), agent = excluded.agent,
n_msgs = excluded.n_msgs, messages = NULL, extra_roots = excluded.extra_roots,
- grants = excluded.grants, updated_at = CURRENT_TIMESTAMP
+ grants = excluded.grants, compaction = excluded.compaction,
+ updated_at = CURRENT_TIMESTAMP
""",
(
sid,
@@ -209,6 +211,7 @@ class ConversationStore:
len(record.messages),
json.dumps(record.extra_roots or []),
json.dumps(record.grants or {}),
+ json.dumps(record.compaction or {}),
),
)
self._conn.commit()
@@ -241,6 +244,10 @@ class ConversationStore:
row["extra_roots"] if "extra_roots" in row.keys() else None
),
grants=_load_grants(row["grants"] if "grants" in row.keys() else None),
+ # Auto-compaction state (OPE-27) — same defensive parse as grants.
+ compaction=_load_grants(
+ row["compaction"] if "compaction" in row.keys() else None
+ ),
pinned=bool(row["pinned"]),
archived=bool(row["archived"]),
origin=row["origin"],
diff --git a/coworker/engine.py b/coworker/engine.py
index 3ce77d1e..9e07d712 100644
--- a/coworker/engine.py
+++ b/coworker/engine.py
@@ -19,6 +19,7 @@ from dataclasses import dataclass
from enum import Enum
from typing import Any, AsyncIterator, Awaitable, Callable, Optional
+from . import compaction as _compaction
from .events import Event, EventType
from .permissions import Mode, PermissionEngine
from .providers import AssistantTurn, ProviderClient, ToolCall
@@ -103,6 +104,14 @@ class TurnEngine:
# (answerable inline in a live session or from the Inbox when unattended). None on surfaces
# that can't ask (the tool then no-ops).
self.question_asker = question_asker
+ # Auto-compaction (OPE-27) — set post-construction by the surface/manager so the
+ # constructor footprint stays put. `compaction_settings` is a live getter (Settings
+ # changes apply without a rebuild); `is_attended` gates the failure prompt (None →
+ # treat as unattended: never park a background run on internal bookkeeping).
+ self.compaction_state: Optional[_compaction.CompactionState] = None
+ self.compaction_settings: Optional[Callable[[], dict[str, Any]]] = None
+ self.is_attended: Optional[Callable[[], bool]] = None
+ self._last_context_tokens: Optional[int] = None
self.audit_context: dict[str, Any] = {}
if instructions and not (
self.messages and self.messages[0].get("role") == "system"
@@ -154,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,
@@ -168,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
@@ -302,6 +322,18 @@ class TurnEngine:
return
iterations += 1
+ # Auto-compaction checkpoint (OPE-27): between tool turns and before a new
+ # turn's first call. Deliberately no "wrap up" warning to the model. The
+ # COMPACTING signal precedes the (multi-second) summarizer call so surfaces
+ # can show progress instead of a silent stall.
+ notice = None
+ if self._compaction_due():
+ yield Event(EventType.COMPACTING, {})
+ notice = await self._compact_now()
+ if notice:
+ self._append_notice("compacted", notice)
+ yield Event(EventType.COMPACTED, {"text": notice})
+
turn: Optional[AssistantTurn] = None
streamed: list[str] = []
streamed_reasoning: list[str] = []
@@ -329,6 +361,17 @@ class TurnEngine:
if chunk.turn is not None:
turn = chunk.turn
except Exception as exc: # provider failure
+ # A raw context-overflow 400 (compaction mispredicted, e.g. the estimate
+ # path) routes into the compaction policy instead of surfacing. The retry
+ # is progress-guarded: each pass moves the boundary forward or gives up,
+ # so a model that keeps overflowing still terminates in the error path.
+ if _compaction.is_context_overflow(exc) and not self._cancel.is_set():
+ yield Event(EventType.COMPACTING, {})
+ notice = await self._compact_now(force=True)
+ if notice:
+ self._append_notice("compacted", notice)
+ yield Event(EventType.COMPACTED, {"text": notice})
+ continue
# Same contract as the stop path below: the partial the user watched
# arrive survives the failure.
if streamed or streamed_reasoning:
@@ -352,6 +395,10 @@ class TurnEngine:
return
if turn is None:
turn = AssistantTurn()
+ if turn.usage is not None:
+ # The trigger signal: the prompt-side total that actually occupied the
+ # window on this round-trip (estimate fallback when never reported).
+ self._last_context_tokens = turn.usage.context_tokens
self.messages.append(_assistant_message(turn, model=self.model))
payload: dict[str, Any] = {
@@ -386,6 +433,104 @@ class TurnEngine:
if self._steering:
self._inject_steering()
+ # -- auto-compaction (OPE-27) ------------------------------------------------
+ def _compaction_config(self) -> dict[str, Any]:
+ cfg = dict(self.compaction_settings() or {}) if self.compaction_settings else {}
+ if not cfg.get("context_window"):
+ from .providers.matrix import model_context_windows
+
+ cfg["context_window"] = model_context_windows().get(self.model)
+ cfg.setdefault("threshold_pct", _compaction.DEFAULT_THRESHOLD_PCT)
+ cfg.setdefault("cap_tokens", _compaction.DEFAULT_CAP_TOKENS)
+ return cfg
+
+ def _compaction_due(self) -> bool:
+ """The trigger check alone — cheap and side-effect free, so the loop can emit
+ the COMPACTING signal before committing to the (slow) summarizer call."""
+ cfg = self._compaction_config()
+ if cfg.get("enabled") is False:
+ return False
+ signal = self._last_context_tokens or _compaction.estimate_tokens(
+ self._outbound_messages()
+ )
+ return _compaction.should_compact(
+ signal,
+ cfg.get("context_window"),
+ threshold_pct=float(cfg["threshold_pct"]),
+ cap_tokens=int(cfg["cap_tokens"]),
+ )
+
+ async def _compact_now(self, *, force: bool = False) -> Optional[str]:
+ """Run the compaction policy. Callers gate on `_compaction_due()` (or `force`,
+ the overflow path). Returns the user-facing notice text when the outbound view
+ changed, else None. Failure policy per spec: retry once (both modes); attended →
+ Retry / Trim prompt; unattended → auto-trim and continue (never park a run on
+ bookkeeping)."""
+ cfg = self._compaction_config()
+ pct = float(cfg["threshold_pct"])
+ cap = int(cfg["cap_tokens"])
+ window = cfg.get("context_window")
+ keep = int(
+ _compaction.KEEP_RECENT_FRACTION
+ * _compaction.trigger_tokens(window, threshold_pct=pct, cap_tokens=cap)
+ )
+ model = str(cfg.get("model") or "") or self.model
+
+ def _build() -> Optional[_compaction.CompactionState]:
+ return _compaction.build_state(
+ self.messages,
+ provider=self.provider,
+ model=model,
+ keep_tokens=keep,
+ prior=self.compaction_state,
+ )
+
+ state: Optional[_compaction.CompactionState] = None
+ failed = False
+ for _attempt in range(2): # first try + the unconditional single retry
+ try:
+ state = await asyncio.to_thread(_build)
+ failed = False
+ break
+ except Exception:
+ failed = True
+ if failed and self.question_asker is not None and self.is_attended and self.is_attended():
+ while True:
+ answer = await self._interruptible(
+ self.question_asker(
+ {
+ "question": (
+ "Context compaction failed — the summarizer couldn't "
+ "condense this session's history. How should I proceed?"
+ ),
+ "options": ["Retry", "Trim oldest 10%"],
+ "allow_text": False,
+ "header": "Compaction",
+ },
+ None,
+ ),
+ interrupted=None,
+ )
+ if not answer or answer.get("answer") != "Retry":
+ break
+ try:
+ state = await asyncio.to_thread(_build)
+ failed = False
+ break
+ except Exception:
+ continue
+ if state is not None:
+ self.compaction_state = state
+ self._last_context_tokens = None # stale once the outbound view shrank
+ return "Context compacted — earlier turns were summarized"
+ if failed or force:
+ trimmed = _compaction.trim_state(self.messages, prior=self.compaction_state)
+ if trimmed is not None:
+ self.compaction_state = trimmed
+ self._last_context_tokens = None
+ return "Context trimmed — oldest turns dropped (summary unavailable)"
+ return None
+
# -- helpers ----------------------------------------------------------------
async def _astream(self):
"""Bridge the provider's blocking stream generator to the async loop via a
@@ -826,6 +971,13 @@ class TurnEngine:
from the Inbox when unattended), and return it as the tool result."""
args = tool_call.arguments or {}
question = str(args.get("question", "")).strip()
+ # Grouped form (OPE-51): `questions` alone is a valid call — the singular field may be
+ # empty. The asker normalizes/validates the entries; here only "is anything asked?".
+ if not question:
+ for entry in args.get("questions") or []:
+ if isinstance(entry, dict) and str(entry.get("question", "")).strip():
+ question = str(entry["question"]).strip()
+ break
if self.question_asker is None or not question:
result: dict[str, Any] = {
"answer": "",
@@ -847,7 +999,7 @@ class TurnEngine:
"error": "no response",
}
- status = "ok" if result.get("answer") else "denied"
+ status = "ok" if (result.get("answer") or result.get("answers")) else "denied"
self.messages.append(_tool_result_message(tool_call, result))
self._audit(
tool_call,
@@ -893,13 +1045,19 @@ class TurnEngine:
# one. Whole `notice` messages (error/interrupted/model-switch markers) are
# display-only too: dropped entirely.
_SIDECARS = ("source", "_display", "ts", "reasoning", "usage")
+ # Auto-compaction (OPE-27): everything before the boundary is represented by the
+ # compacted block. Outbound-only — the canonical history stays intact — and the
+ # block+tail are byte-stable between turns, so prompt caching keeps working.
+ source_messages = _compaction.apply_to_outbound(
+ self.messages, self.compaction_state
+ )
out = [
(
{k: v for k, v in msg.items() if k not in _SIDECARS}
if any(s in msg for s in _SIDECARS)
else msg
)
- for msg in self.messages
+ for msg in source_messages
if msg.get("role") != "notice"
]
# PDF attachments (stored as `file` parts) are adapted to the ACTIVE model right
diff --git a/coworker/events.py b/coworker/events.py
index 4cdfc192..cdb9fe00 100644
--- a/coworker/events.py
+++ b/coworker/events.py
@@ -31,6 +31,8 @@ class EventType(str, Enum):
TURN_END = "turn_end"
ERROR = "error"
INTERRUPTED = "interrupted"
+ COMPACTING = "compacting" # compaction started — surfaces show a transient signal
+ COMPACTED = "compacted" # outbound history was compacted (summary or trim)
@dataclass
diff --git a/coworker/inbox.py b/coworker/inbox.py
index 924e707a..2354db47 100644
--- a/coworker/inbox.py
+++ b/coworker/inbox.py
@@ -78,11 +78,19 @@ class InboxItem:
tool_call_id: Optional[str] = None
# Question metadata (ask_user): optional quick-reply choices + a free-text escape, mirroring
# the structured-but-always-answerable shape of Claude Code's AskUserQuestion.
- options: list[str] = field(default_factory=list)
+ # An option is a plain string OR a rich {label, description, recommended, preview} object
+ # (OPE-51); old persisted items hold strings and stay valid.
+ options: list = field(default_factory=list)
allow_text: bool = (
True # accept a typed answer even when options exist (the "Other" escape)
)
multi: bool = False # allow choosing more than one option
+ header: str = "" # short chip label for the card ("Region")
+ # Grouped form (OPE-51): up to 4 {question, header, options, allow_text, multi} entries
+ # rendered as a stepper. When non-empty the singular title/options fields above still hold
+ # the FIRST question (so old surfaces and channel mirrors degrade to something sensible),
+ # and the resolution is a JSON object string keyed by header-or-question.
+ questions: list[dict] = field(default_factory=list)
# Kind-specific payload (directory: suggested path/writable; plan: the plan text; …).
data: dict[str, Any] = field(default_factory=dict)
@@ -126,6 +134,8 @@ class InboxStore:
options=None,
allow_text: bool = True,
multi: bool = False,
+ header: str = "",
+ questions=None,
tool_call_id: Optional[str] = None,
) -> InboxItem:
# Idempotent by (session_id, tool_call_id): a durable resume re-raises the same prompt, and
@@ -146,6 +156,8 @@ class InboxStore:
options=list(options or []),
allow_text=bool(allow_text),
multi=bool(multi),
+ header=str(header or ""),
+ questions=list(questions or []),
tool_call_id=tool_call_id,
)
with self._lock:
@@ -194,6 +206,8 @@ class InboxStore:
options=None,
allow_text=True,
multi=False,
+ header="",
+ questions=None,
tool_call_id=None,
) -> InboxItem:
return self.add(
@@ -206,6 +220,8 @@ class InboxStore:
options=options,
allow_text=allow_text,
multi=multi,
+ header=header,
+ questions=questions,
tool_call_id=tool_call_id,
)
diff --git a/coworker/inbox_routing.py b/coworker/inbox_routing.py
index ef115e95..f3efc9ed 100644
--- a/coworker/inbox_routing.py
+++ b/coworker/inbox_routing.py
@@ -23,6 +23,9 @@ DEFAULT_INBOX = "default"
# to OpenWorker (2026-07-22); the legacy [ocw:…] spelling stays parseable so replies to
# messages sent before the rename still resolve.
_ID_TOKEN = re.compile(r"\[o(?:c)?w:([0-9a-f]{6,})\]")
+# Whole words only — substring matching resolved "disallow" as allow and "note" as deny.
+_ALLOW_WORDS = re.compile(r"\b(?:approve|approved|allow|allowed|yes)\b")
+_DENY_WORDS = re.compile(r"\b(?:deny|denied|reject|rejected|no)\b")
@dataclass
@@ -130,9 +133,9 @@ def resolve_from_reply(
return None
item_id = m.group(1)
lowered = reply.lower()
- if any(w in lowered for w in ("approve", "allow", "yes", "👍", "✅")):
+ if _ALLOW_WORDS.search(lowered) or "👍" in reply or "✅" in reply:
resolution = "allow"
- elif any(w in lowered for w in ("deny", "reject", "no", "👎", "❌")):
+ elif _DENY_WORDS.search(lowered) or "👎" in reply or "❌" in reply:
resolution = "deny"
else:
resolution = _ID_TOKEN.sub("", reply).strip() # free-text answer to a question
diff --git a/coworker/interactions.py b/coworker/interactions.py
index b8a93960..f40943b8 100644
--- a/coworker/interactions.py
+++ b/coworker/interactions.py
@@ -17,6 +17,7 @@ from dataclasses import dataclass
from typing import Optional
from .inbox import KIND_APPROVAL, KIND_QUESTION
+from .tools.ask import option_label
@dataclass
@@ -48,7 +49,15 @@ def buttons_for(item) -> list[Button]:
Button("Approve", encode(item.id, "allow")),
Button("Deny", encode(item.id, "deny")),
]
+ if item.kind == KIND_QUESTION and getattr(item, "questions", None):
+ # Grouped questions (OPE-51): one button row can't answer 2+ questions — send plain text
+ # with the open-the-app hint instead.
+ return []
if item.kind == KIND_QUESTION and getattr(item, "options", None):
- # One button per option; the resolution IS the chosen option text (what the agent gets).
- return [Button(opt, encode(item.id, opt)) for opt in item.options]
+ # One button per option; the resolution IS the chosen option's label (what the agent
+ # gets). Rich {label, description, …} options button as their label.
+ return [
+ Button(option_label(opt), encode(item.id, option_label(opt)))
+ for opt in item.options
+ ]
return []
diff --git a/coworker/mcp/config.py b/coworker/mcp/config.py
index ae04a0ad..8bdbeadc 100644
--- a/coworker/mcp/config.py
+++ b/coworker/mcp/config.py
@@ -1,7 +1,9 @@
"""MCP server config — the standard `mcpServers` JSON, layered global + workspace.
Global: ~/.config/coworker/mcp.json
-Workspace: /.coworker/mcp.json (overrides global on name clash)
+Workspace: /.coworker/mcp.json (overrides global on name clash,
+ but only after the user trusts that workspace — same gate as
+ repository `allowed_commands`)
Paste-compatible with Claude Desktop / Cursor / Codex. `${VAR}` refs in command/args/env/
url/headers are resolved at load time via the SecretStore (env + local `.env`). REST edits
@@ -50,9 +52,15 @@ def _read(path: Path) -> dict[str, Any]:
return {}
-def _config_paths(workspace: Optional[str | Path]) -> list[Path]:
+def _config_paths(
+ workspace: Optional[str | Path], *, workspace_trusted: bool
+) -> list[Path]:
+ """Config files to merge. Workspace MCP is executable provenance (stdio spawn),
+ so an untrusted repo's `.coworker/mcp.json` is never read — cloning alone must
+ not be enough to define processes that run at session open.
+ """
paths = [global_mcp_path()]
- if workspace:
+ if workspace and workspace_trusted:
paths.append(Path(workspace).expanduser() / ".coworker" / "mcp.json")
return paths
@@ -79,15 +87,25 @@ def _parse(name: str, raw: dict[str, Any], secrets: SecretStore) -> MCPServerDef
def load_mcp_servers(
- workspace: Optional[str | Path] = None, *, secrets: Optional[SecretStore] = None
+ workspace: Optional[str | Path] = None,
+ *,
+ secrets: Optional[SecretStore] = None,
+ workspace_trusted: bool = False,
) -> list[MCPServerDef]:
- """Merge global + workspace `mcpServers` (workspace wins) into parsed server defs."""
+ """Merge global + (when trusted) workspace `mcpServers` into parsed server defs.
+
+ Only trusted workspaces contribute — the same consent boundary as repository
+ ``allowed_commands`` — and **global wins on name clash**, so even a trusted repo
+ cannot silently redefine a global server by reusing its name. ``${VAR}`` refs in
+ a workspace def are resolved from the user's env, which is acceptable only because
+ the workspace is trusted; untrusted workspaces are never read.
+ """
secrets = secrets or SecretStore()
merged: dict[str, dict[str, Any]] = {}
- for path in _config_paths(workspace):
+ for path in _config_paths(workspace, workspace_trusted=workspace_trusted):
for name, raw in (_read(path).get("mcpServers") or {}).items():
if isinstance(raw, dict):
- merged[name] = raw
+ merged.setdefault(name, raw) # global first → global wins on clash
return [_parse(name, raw, secrets) for name, raw in merged.items()]
diff --git a/coworker/providers/__init__.py b/coworker/providers/__init__.py
index 6c35ef95..5245a15a 100644
--- a/coworker/providers/__init__.py
+++ b/coworker/providers/__init__.py
@@ -10,6 +10,7 @@ from .base import (
from .capabilities import capabilities_for
from .gemini_provider import GeminiProvider
from .openai_provider import OpenAIProvider, resolve_api_key
+from .openai_responses import OpenAIResponsesProvider
from .registry import (
ProviderDescriptor,
ProviderField,
@@ -34,6 +35,7 @@ __all__ = [
"BedrockProvider",
"GeminiProvider",
"OpenAIProvider",
+ "OpenAIResponsesProvider",
"VertexProvider",
"resolve_api_key",
"capabilities_for",
diff --git a/coworker/providers/base.py b/coworker/providers/base.py
index 412ab313..93ec660b 100644
--- a/coworker/providers/base.py
+++ b/coworker/providers/base.py
@@ -1,8 +1,9 @@
"""Provider-agnostic model access layer.
The runtime never imports a provider SDK directly — it talks to a `ProviderClient`.
-v1 ships `OpenAIProvider` (OpenAI SDK, `chat.completions` only); an `AISuiteProvider`
-slots in later (P12) without touching the engine, since aisuite is OpenAI-API-shaped.
+Implementations: `OpenAIResponsesProvider` (native OpenAI via `/v1/responses`),
+`OpenAIProvider` (Chat Completions — the compat world), and the native
+Anthropic/Gemini/Bedrock/Vertex providers, all selected by the registry/router.
"""
from __future__ import annotations
diff --git a/coworker/providers/matrix.py b/coworker/providers/matrix.py
index 9c3e0d79..bf6a878c 100644
--- a/coworker/providers/matrix.py
+++ b/coworker/providers/matrix.py
@@ -112,7 +112,15 @@ MATRIX: dict[str, ModelEntry] = {
# -- resellers (their model namespaces, verbatim) -----------------------------
"together:thinkingmachines/Inkling": ModelEntry("Inkling · via Together"),
"together:zai-org/GLM-5.2": ModelEntry("GLM-5.2 · via Together", _AGENTIC, 128_000),
- # Kimi K3 (2026-07-16) is not on Together yet — weights land ~07-27; revisit then.
+ # Kimi K3 on Together (landed late July 2026): 1M window, native vision; PDFs
+ # unverified over the compat surface (falls back via pdf_support.py, like Muse Spark).
+ "together:moonshotai/Kimi-K3": ModelEntry(
+ "Kimi K3 · via Together",
+ ModelCapabilities(
+ tools=True, vision=True, parallel_tool_calls=True, streaming=True
+ ),
+ 1_000_000,
+ ),
"together:moonshotai/Kimi-K2.7-Code": ModelEntry(
"Kimi K2.7 Code · via Together", _AGENTIC, 256_000
),
diff --git a/coworker/providers/openai_provider.py b/coworker/providers/openai_provider.py
index 5e480e7c..fd733f93 100644
--- a/coworker/providers/openai_provider.py
+++ b/coworker/providers/openai_provider.py
@@ -1,7 +1,11 @@
-"""OpenAI provider — the v1 model access implementation.
+"""OpenAI Chat Completions provider — the compat workhorse.
-Uses the OpenAI Python SDK `chat.completions` API only (no Responses/Assistants), so
-the later swap to aisuite (OpenAI-API-shaped) stays a near drop-in.
+Uses the OpenAI Python SDK `chat.completions` API only, which is what the entire
+OpenAI-compatible world implements: the compat vendors (DeepSeek, Z AI, Kimi, …),
+resellers, Ollama, custom endpoints (Azure OpenAI, vLLM), and the Bedrock/Vertex MaaS
+paths. Native OpenAI models (the `openai` provider with no custom endpoint) route to
+`openai_responses.OpenAIResponsesProvider` instead — Chat Completions rejects function
+tools combined with reasoning on GPT-5.6+, so reasoning + tools needs `/v1/responses`.
"""
from __future__ import annotations
@@ -40,10 +44,12 @@ def resolve_api_key(secrets: Any = None) -> Optional[str]:
# GPT-5.6 (2026-07) defaults reasoning_effort to "medium" server-side, and
# /v1/chat/completions rejects function tools combined with any effort other than
-# "none" ("use /v1/responses"). Until we grow a Responses API path, pin effort to
-# none whenever tools ride along on these models — and when the API rejects a call
-# with that exact complaint anyway (a future generation, an alias we didn't list),
-# retry once at effort none so the user gets a working turn instead of a 400.
+# "none" ("use /v1/responses"). Native OpenAI now routes to the Responses provider,
+# but GPT-5.6 can still land here through a custom endpoint (Azure OpenAI serves the
+# same wire), so keep pinning effort to none whenever tools ride along on these
+# models — and when the API rejects a call with that exact complaint anyway (a future
+# generation, an alias we didn't list), retry once at effort none so the user gets a
+# working turn instead of a 400.
_EFFORT_ERROR = "function tools with reasoning_effort are not supported"
diff --git a/coworker/providers/openai_responses.py b/coworker/providers/openai_responses.py
new file mode 100644
index 00000000..566c620b
--- /dev/null
+++ b/coworker/providers/openai_responses.py
@@ -0,0 +1,414 @@
+"""OpenAI Responses provider — native OpenAI models via `/v1/responses`.
+
+Chat Completions rejects function tools combined with any `reasoning_effort` other than
+`none` on GPT-5.6+ ("use /v1/responses"), which had reasoning pinned OFF for native OpenAI
+models (see `openai_provider._pin_reasoning_effort`). This provider is the Responses path:
+reasoning + tools at real effort levels, streamed reasoning summaries (→ the same
+`reasoning_delta` / `AssistantTurn.reasoning` plumbing the GUI already renders), and
+chain-of-thought continuity across tool round-trips via `store: false` +
+`include: ["reasoning.encrypted_content"]` — nothing retained server-side.
+
+Routing: the `openai` provider entry with NO custom base_url builds this class; a custom
+endpoint (Azure, vLLM, any OpenAI-compatible gateway) and every compat vendor keep the
+Chat Completions `OpenAIProvider` (registry.py).
+
+Like the other native providers, this is mostly a pair of pure converters from the
+canonical OpenAI-chat-shaped history to Responses `input` items. What the converters
+must absorb:
+
+- The system prompt is the `instructions` request field, not a message role.
+- Assistant tool calls are top-level `function_call` items; tool results are
+ `function_call_output` items paired by `call_id` (ids only need to pair up, so foreign
+ `toolu_…` ids from a mid-conversation provider switch are fine).
+- Tool schemas are FLAT (`{"type": "function", "name", …}` — no nested `function` key).
+- Reasoning continuity: the raw output items (reasoning item with `encrypted_content`,
+ `function_call` items with their ids) ride the canonical assistant message as the
+ `_openai` sidecar (see providers/base.py). Present → replayed verbatim for exact CoT
+ continuity; absent (history from another provider) → items are synthesized from the
+ canonical fields. Reasoning items WITHOUT `encrypted_content` never enter the sidecar:
+ with `store: false` the server can't resolve them and would reject the replay.
+"""
+
+from __future__ import annotations
+
+import json
+import re
+from typing import Any, Optional
+
+from .base import (
+ AssistantTurn,
+ ModelCapabilities,
+ ProviderClient,
+ StreamChunk,
+ ToolCall,
+)
+from .capabilities import capabilities_for
+from .openai_provider import resolve_api_key
+
+# Request params passed through from model settings; everything else (frequency_penalty,
+# reasoning_effort — no effort knob in v1, the server default rides) is dropped.
+_SETTINGS_WHITELIST = {
+ "temperature",
+ "top_p",
+ "max_output_tokens",
+ "tool_choice",
+ "parallel_tool_calls",
+}
+
+# "Unsupported parameter: 'temperature' is not supported with this model." — reasoning
+# models reject sampling params; non-reasoning models reject `reasoning`/`include`. The
+# server names exactly one offender per error, so each retry drops exactly that.
+_UNSUPPORTED_PARAM = re.compile(r"unsupported (?:parameter|value)s?:?\s*'([^']+)'")
+
+
+def _param_fix_retry(kwargs: dict[str, Any], exc: Exception) -> dict[str, Any]:
+ """Kwargs for the one retry an unsupported-parameter error earns, or re-raise.
+
+ Same contract as the Chat Completions retries: fix exactly what the server named.
+ A dotted name (`reasoning.summary`) drops its top-level param.
+ """
+ match = _UNSUPPORTED_PARAM.search(str(exc).lower())
+ if match:
+ param = match.group(1).split(".", 1)[0].split("[", 1)[0]
+ if param in kwargs and param not in ("model", "input"):
+ fixed = dict(kwargs)
+ del fixed[param]
+ return fixed
+ raise exc
+
+
+def _user_content(content: Any) -> Any:
+ """User content (str or OpenAI chat parts) → Responses content (str or input parts)."""
+ if isinstance(content, str):
+ return content
+ parts: list[dict[str, Any]] = []
+ for part in content or []:
+ kind = part.get("type") if isinstance(part, dict) else None
+ if kind == "text":
+ parts.append({"type": "input_text", "text": part.get("text") or ""})
+ elif kind == "image_url":
+ url = (part.get("image_url") or {}).get("url") or ""
+ parts.append({"type": "input_image", "image_url": url})
+ elif kind == "file":
+ file = part.get("file") or {}
+ entry: dict[str, Any] = {"type": "input_file"}
+ if file.get("filename"):
+ entry["filename"] = file["filename"]
+ if file.get("file_data"):
+ entry["file_data"] = file["file_data"]
+ parts.append(entry)
+ return parts
+
+
+def _synthesized_items(message: dict[str, Any]) -> list[dict[str, Any]]:
+ """An assistant message WITHOUT a usable `_openai` sidecar (history produced by another
+ provider before a switch) → items rebuilt from the canonical fields."""
+ items: list[dict[str, Any]] = []
+ text = message.get("content")
+ if isinstance(text, str) and text:
+ items.append({"role": "assistant", "content": text})
+ for call in message.get("tool_calls") or []:
+ function = call.get("function") or {}
+ arguments = function.get("arguments")
+ if not isinstance(arguments, str):
+ arguments = json.dumps(arguments or {})
+ items.append(
+ {
+ "type": "function_call",
+ "call_id": call.get("id") or "",
+ "name": function.get("name") or "",
+ "arguments": arguments,
+ }
+ )
+ return items
+
+
+def convert_messages(
+ messages: list[dict[str, Any]],
+) -> tuple[Optional[str], list[dict[str, Any]]]:
+ """Canonical OpenAI-chat history → (`instructions`, Responses `input` items).
+
+ Leading system messages join into `instructions`; a stray mid-thread system message
+ rides as a system message item. Assistant messages replay their `_openai` sidecar
+ verbatim when present (exact CoT continuity), else synthesize from canonical fields.
+ """
+ system_parts: list[str] = []
+ index = 0
+ while index < len(messages) and messages[index].get("role") == "system":
+ content = messages[index].get("content")
+ if isinstance(content, str) and content:
+ system_parts.append(content)
+ index += 1
+
+ items: list[dict[str, Any]] = []
+ for message in messages[index:]:
+ role = message.get("role")
+ if role == "system":
+ text = message.get("content") or ""
+ if text:
+ items.append({"role": "system", "content": text})
+ elif role == "user":
+ content = _user_content(message.get("content"))
+ if content:
+ items.append({"role": "user", "content": content})
+ elif role == "assistant":
+ sidecar = message.get("_openai") or {}
+ replay = sidecar.get("items") or []
+ if replay:
+ items.extend(replay)
+ else:
+ items.extend(_synthesized_items(message))
+ elif role == "tool":
+ content = message.get("content")
+ items.append(
+ {
+ "type": "function_call_output",
+ "call_id": message.get("tool_call_id") or "",
+ "output": content if isinstance(content, str) else str(content or ""),
+ }
+ )
+
+ return ("\n\n".join(system_parts) or None), items
+
+
+def convert_tools(tools: Optional[list[dict[str, Any]]]) -> list[dict[str, Any]]:
+ """OpenAI chat function schemas → Responses FLAT tool entries (no nested `function`)."""
+ converted: list[dict[str, Any]] = []
+ for tool in tools or []:
+ function = (tool or {}).get("function") or {}
+ name = function.get("name")
+ if not name:
+ continue
+ entry: dict[str, Any] = {"type": "function", "name": name}
+ if function.get("description"):
+ entry["description"] = function["description"]
+ if function.get("parameters") is not None:
+ entry["parameters"] = function["parameters"]
+ converted.append(entry)
+ return converted
+
+
+def _dump(value: Any) -> Any:
+ """An output item (SDK model, dict, or test namespace) → plain jsonl-safe data."""
+ if isinstance(value, dict):
+ return {k: _dump(v) for k, v in value.items() if v is not None}
+ if isinstance(value, (list, tuple)):
+ return [_dump(v) for v in value]
+ dump = getattr(value, "model_dump", None)
+ if callable(dump):
+ return dump(exclude_none=True)
+ if hasattr(value, "__dict__"): # SimpleNamespace fakes in tests
+ return {k: _dump(v) for k, v in vars(value).items() if v is not None}
+ return value
+
+
+def _parse_arguments(raw: Any) -> dict[str, Any]:
+ if isinstance(raw, dict):
+ return raw
+ if not raw:
+ return {}
+ try:
+ parsed = json.loads(raw)
+ return parsed if isinstance(parsed, dict) else {"_raw": raw}
+ except (TypeError, json.JSONDecodeError):
+ # Surface unparseable arguments rather than dropping the call; the engine
+ # can return a tool-error so the model corrects itself.
+ return {"_raw": raw}
+
+
+def _sidecar_extras(items: list[dict[str, Any]]) -> dict[str, Any]:
+ """Output items → the `_openai` sidecar, or {} when replay would add nothing.
+
+ Reasoning items without `encrypted_content` are dropped: under `store: false` the
+ server can't resolve them by id and rejects the replay. The sidecar is only worth
+ persisting when something beyond plain answer text needs continuity.
+ """
+ kept = [
+ item
+ for item in items
+ if item.get("type") != "reasoning" or item.get("encrypted_content")
+ ]
+ if any(item.get("type") in ("reasoning", "function_call") for item in kept):
+ return {"_openai": {"items": kept}}
+ return {}
+
+
+def _parse_response(response: Any) -> AssistantTurn:
+ """One Responses result → an AssistantTurn (+ `_openai` extras)."""
+ items = [_dump(item) for item in getattr(response, "output", None) or []]
+ texts: list[str] = []
+ summaries: list[str] = []
+ tool_calls: list[ToolCall] = []
+ for item in items:
+ kind = item.get("type")
+ if kind == "message" or (kind is None and "content" in item):
+ content = item.get("content")
+ if isinstance(content, str):
+ texts.append(content)
+ else:
+ for part in content or []:
+ if part.get("type") == "output_text" and part.get("text"):
+ texts.append(part["text"])
+ elif kind == "reasoning":
+ for part in item.get("summary") or []:
+ text = part.get("text") if isinstance(part, dict) else part
+ if text:
+ summaries.append(text)
+ elif kind == "function_call":
+ tool_calls.append(
+ ToolCall(
+ id=item.get("call_id") or item.get("id") or "",
+ name=item.get("name") or "",
+ arguments=_parse_arguments(item.get("arguments")),
+ )
+ )
+
+ incomplete = _dump(getattr(response, "incomplete_details", None)) or {}
+ if tool_calls:
+ finish = "tool_calls"
+ elif incomplete.get("reason") == "max_output_tokens":
+ finish = "length"
+ else:
+ finish = "stop"
+
+ return AssistantTurn(
+ text="".join(texts) or None,
+ tool_calls=tool_calls,
+ finish_reason=finish,
+ raw=response,
+ reasoning="".join(summaries) or None,
+ extras=_sidecar_extras(items),
+ )
+
+
+class OpenAIResponsesProvider(ProviderClient):
+ def __init__(
+ self,
+ client: Any = None,
+ *,
+ default_model: str = "gpt-5.6-sol",
+ api_key: Optional[str] = None,
+ secrets: Any = None,
+ ):
+ # Same deferred-client contract as OpenAIProvider: built lazily so an engine can be
+ # assembled before any key exists; key resolves at call time (explicit → env →
+ # SecretStore). Tests inject a `client` directly. No base_url — a custom endpoint
+ # routes to the Chat Completions provider instead (registry.py).
+ self._client = client
+ self._api_key = api_key
+ self._secrets = secrets
+ self.default_model = default_model
+
+ def _ensure_client(self) -> Any:
+ if self._client is None:
+ # Lazy import so the SDK is only required when actually talking to OpenAI.
+ from openai import OpenAI
+
+ key = self._api_key or resolve_api_key(self._secrets)
+ if not key:
+ raise RuntimeError(
+ "No model API key configured. Set OPENAI_API_KEY in the environment, "
+ "or add your key in Manage → Settings."
+ )
+ self._client = OpenAI(api_key=key)
+ return self._client
+
+ def _request_kwargs(
+ self,
+ *,
+ model: str,
+ messages: list[dict[str, Any]],
+ tools: Optional[list[dict[str, Any]]],
+ settings: dict[str, Any],
+ ) -> dict[str, Any]:
+ instructions, items = convert_messages(messages)
+ if "max_tokens" in settings and "max_output_tokens" not in settings:
+ settings = {**settings, "max_output_tokens": settings["max_tokens"]}
+ kwargs: dict[str, Any] = {
+ "model": model,
+ "input": items,
+ # Stateless: nothing retained server-side; the encrypted reasoning rides the
+ # `_openai` sidecar instead, and summaries feed the GUI's thinking display.
+ "store": False,
+ "include": ["reasoning.encrypted_content"],
+ "reasoning": {"summary": "auto"},
+ **{k: v for k, v in settings.items() if k in _SETTINGS_WHITELIST},
+ }
+ if instructions:
+ kwargs["instructions"] = instructions
+ if tools:
+ converted = convert_tools(tools)
+ if converted:
+ kwargs["tools"] = converted
+ return kwargs
+
+ def _create(self, client: Any, kwargs: dict[str, Any]) -> Any:
+ # Up to three param-fix retries: sampling params, `reasoning`, and `include` can
+ # each need dropping depending on the model (reasoning vs not).
+ for _ in range(3):
+ try:
+ return client.responses.create(**kwargs)
+ except Exception as exc:
+ kwargs = _param_fix_retry(kwargs, exc)
+ return client.responses.create(**kwargs)
+
+ def complete(
+ self,
+ *,
+ model: str,
+ messages: list[dict[str, Any]],
+ tools: Optional[list[dict[str, Any]]] = None,
+ **settings: Any,
+ ) -> AssistantTurn:
+ kwargs = self._request_kwargs(
+ model=model, messages=messages, tools=tools, settings=settings
+ )
+ response = self._create(self._ensure_client(), kwargs)
+ return _parse_response(response)
+
+ def capabilities(self, model: str) -> ModelCapabilities:
+ return capabilities_for(model)
+
+ def stream(
+ self,
+ *,
+ model: str,
+ messages: list[dict[str, Any]],
+ tools: Optional[list[dict[str, Any]]] = None,
+ **settings: Any,
+ ):
+ kwargs = self._request_kwargs(
+ model=model, messages=messages, tools=tools, settings=settings
+ )
+ kwargs["stream"] = True
+ events = self._create(self._ensure_client(), kwargs)
+
+ text_parts: list[str] = []
+ reasoning_parts: list[str] = []
+ final: Optional[Any] = None
+ for event in events:
+ kind = getattr(event, "type", None)
+ if kind == "response.output_text.delta":
+ delta = getattr(event, "delta", None)
+ if delta:
+ text_parts.append(delta)
+ yield StreamChunk(text_delta=delta)
+ elif kind == "response.reasoning_summary_text.delta":
+ delta = getattr(event, "delta", None)
+ if delta:
+ reasoning_parts.append(delta)
+ yield StreamChunk(reasoning_delta=delta)
+ elif kind in ("response.completed", "response.incomplete", "response.failed"):
+ final = getattr(event, "response", None)
+
+ if final is not None:
+ # The terminal event carries the full response — parse it whole so tool
+ # calls, finish reason, and the `_openai` sidecar come from one place.
+ yield StreamChunk(turn=_parse_response(final))
+ else:
+ yield StreamChunk(
+ turn=AssistantTurn(
+ text="".join(text_parts) or None,
+ reasoning="".join(reasoning_parts) or None,
+ )
+ )
diff --git a/coworker/providers/registry.py b/coworker/providers/registry.py
index a07ec316..6c147705 100644
--- a/coworker/providers/registry.py
+++ b/coworker/providers/registry.py
@@ -6,8 +6,9 @@ GUI, same `to_dict()` shape connectors use) and a `build(profile, secrets)` fact
a `ProviderClient`. The `ProviderRouter` selects a descriptor by the `provider:` prefix of a
model string and builds (and caches) its client from the matching SecretStore profile.
-Today: `openai` (the default, with an optional custom endpoint that covers Azure OpenAI's
-`/openai/v1` and any OpenAI-compliant gateway), `anthropic` (native Messages API via
+Today: `openai` (the default — native models via the Responses API; an optional custom
+endpoint covering Azure OpenAI's `/openai/v1` and any OpenAI-compliant gateway keeps the
+Chat Completions path), `anthropic` (native Messages API via
`AnthropicProvider`), `gemini` (native Google GenAI API via `GeminiProvider`), `bedrock`
(models in the user's own AWS account — Claude natively, everything else via Converse),
`vertex` (the user's own GCP project — Gemini and Claude natively, open-weight via the
@@ -25,6 +26,7 @@ from .base import ProviderClient
from .bedrock_provider import BedrockProvider
from .gemini_provider import GeminiProvider
from .openai_provider import OpenAIProvider
+from .openai_responses import OpenAIResponsesProvider
from .vertex_provider import VertexProvider
DEFAULT_OLLAMA_URL = "http://localhost:11434"
@@ -111,11 +113,15 @@ def _normalize_ollama_url(url: Optional[str]) -> str:
def _build_openai(profile: dict[str, Any], secrets: Any) -> ProviderClient:
- # Key resolution stays in OpenAIProvider/resolve_api_key (explicit → env → SecretStore),
- # so we just hand it the SecretStore. An optional custom endpoint (Azure OpenAI /openai/v1,
- # OpenRouter, vLLM, …) comes from the stored profile.
+ # Key resolution stays in resolve_api_key (explicit → env → SecretStore), so we just
+ # hand over the SecretStore. Stock OpenAI (no custom endpoint) speaks the Responses
+ # API — the only wire with reasoning + tools on GPT-5.6+. A custom endpoint (Azure
+ # OpenAI /openai/v1, vLLM, any OpenAI-compliant gateway) keeps Chat Completions,
+ # which is what compat servers implement.
base_url = ((profile or {}).get("base_url") or "").strip() or None
- return OpenAIProvider(secrets=secrets, base_url=base_url)
+ if base_url:
+ return OpenAIProvider(secrets=secrets, base_url=base_url)
+ return OpenAIResponsesProvider(secrets=secrets)
def _build_anthropic(profile: dict[str, Any], secrets: Any) -> ProviderClient:
diff --git a/coworker/server/app.py b/coworker/server/app.py
index f50fb507..e8b97638 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]:
@@ -1393,6 +1455,11 @@ def create_app(manager: SessionManager) -> FastAPI:
# Sidebar: sessions shown per group before "Show more" (owner ask, 2026-07-03).
return manager.set_sessions_peek((body or {}).get("sessions_peek", 5))
+ @app.post("/v1/settings/context-bar")
+ def settings_set_context_bar(body: dict) -> dict[str, Any]:
+ # Composer: show the context-window fill bar, or just the popover (owner ask).
+ return manager.set_context_bar((body or {}).get("context_bar", True))
+
@app.post("/v1/settings/pdf")
def settings_set_pdf(body: dict) -> dict[str, Any]:
# Token savings (owner ask, 2026-07-17): fallback mode for models without native
@@ -1404,6 +1471,17 @@ def create_app(manager: SessionManager) -> FastAPI:
max_mb=b.get("pdf_max_mb"),
)
+ @app.post("/v1/settings/compaction")
+ def settings_set_compaction(body: dict) -> dict[str, Any]:
+ # Auto-compaction overrides (OPE-27): threshold % of the context window, the
+ # absolute token cap, and the summarizer-model pin ("" → session's own model).
+ b = body or {}
+ return manager.set_compaction_settings(
+ threshold_pct=b.get("compaction_threshold_pct"),
+ cap_tokens=b.get("compaction_cap_tokens"),
+ model=b.get("compaction_model"),
+ )
+
@app.post("/v1/attachments/inspect-pdf")
def attachments_inspect_pdf(body: dict) -> dict[str, Any]:
# Attach-time page/size probe for the composer's threshold check. Local only.
@@ -1551,15 +1629,17 @@ def create_app(manager: SessionManager) -> FastAPI:
async def question_asker(args: dict, tool_call_id=None) -> dict:
# ask_user (engine does NOT emit the event — we do, only when attended).
+ from ..tools.ask import answer_result, question_item_fields
+
+ fields = question_item_fields(args)
+ if fields is None: # engine guards too; belt-and-braces
+ return {"answer": "", "error": "no question"}
item = manager.inbox.add_question(
session_id,
- str(args.get("question", "")),
inbox=_route(),
visibility=_visibility(),
- options=list(args.get("options") or []),
- allow_text=bool(args.get("allow_text", True)),
- multi=bool(args.get("multi", False)),
tool_call_id=tool_call_id,
+ **fields,
)
if item.state == "pending":
manager.persist_session(session_id)
@@ -1574,11 +1654,12 @@ def create_app(manager: SessionManager) -> FastAPI:
"options": item.options,
"allow_text": item.allow_text,
"multi": item.multi,
- "header": str(args.get("header", "")),
+ "header": item.header,
+ "questions": item.questions,
},
}
)
- return {"answer": await manager.inbox.wait(item.id)}
+ return answer_result(item.questions, await manager.inbox.wait(item.id))
async def directory_requester(args: dict, tool_call_id=None) -> dict:
# The engine has already emitted DIRECTORY_REQUESTED. Park, await, then apply the grant.
@@ -1703,6 +1784,9 @@ def create_app(manager: SessionManager) -> FastAPI:
)
await ws.close()
return
+ # Auto-compaction failure prompt (OPE-27): only an ATTENDED session may be asked
+ # Retry/Trim — unattended runs auto-trim (the policy in engine._compact_now).
+ engine.is_attended = lambda: _visibility() == VIS_INLINE
await ws.send_json(
{
"type": "ready",
@@ -1736,11 +1820,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.
@@ -1766,13 +1854,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:
@@ -1925,10 +2013,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 c838ea39..66a51ecc 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}
@@ -228,6 +233,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")
@@ -279,6 +289,14 @@ class SessionManager:
"required": bool(commands and not trusted),
}
+ def _mcp_workspace_trusted(self, workspace: Optional[str | Path]) -> bool:
+ """Whether workspace `.coworker/mcp.json` may be loaded (#213).
+
+ Same consent boundary as repository ``allowed_commands``: an untrusted
+ clone must not define stdio processes that spawn at session open.
+ """
+ return bool(workspace and self.workspace_trust.is_trusted(workspace))
+
def set_workspace_trust(
self, path: str | Path, *, trusted: bool
) -> dict[str, Any]:
@@ -460,6 +478,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.
@@ -474,6 +495,13 @@ class SessionManager:
)
if record is not None and record.grants:
self._apply_grants(engine, record.grants)
+ # Auto-compaction (OPE-27): restore the persisted view boundary and wire the live
+ # Settings getter — post-construction, so build_engine's signature stays put.
+ if record is not None and record.compaction:
+ from ..compaction import CompactionState
+
+ engine.compaction_state = CompactionState.from_dict(record.compaction)
+ engine.compaction_settings = self.compaction_settings
self._engines[session_id] = engine
if is_new_session:
self._emit_session_created(session_id, agent_name)
@@ -737,27 +765,26 @@ class SessionManager:
async def ask(
args: dict[str, Any], tool_call_id: Optional[str] = None
) -> dict[str, Any]:
- question = str(args.get("question", "")).strip()
- if not question:
+ from ..tools.ask import answer_result, question_item_fields
+
+ fields = question_item_fields(args)
+ if fields is None:
return {"answer": "", "error": "no question"}
inbox_name = self.inbox_routing.route_for(session_id, agent)
item = self.inbox.add_question(
session_id,
- title=question,
inbox=inbox_name,
- options=list(args.get("options") or []),
- allow_text=bool(args.get("allow_text", True)),
- multi=bool(args.get("multi", False)),
tool_call_id=tool_call_id,
+ **fields,
)
if (
item.state != "pending"
): # durable resume re-raised an already-answered prompt
- return {"answer": item.resolution or ""}
+ return answer_result(item.questions, item.resolution)
self.persist_session(session_id) # the pending tool call is now on disk
await self.mirror_inbox_item(item)
answer = await self.inbox.wait(item.id)
- return {"answer": answer}
+ return answer_result(item.questions, answer)
return ask
@@ -895,7 +922,11 @@ class SessionManager:
loop = asyncio.get_running_loop()
effective: Optional[set[str]] = None # computed lazily, once
out: list[Any] = []
- for server in load_mcp_servers(ws, secrets=self.secrets):
+ for server in load_mcp_servers(
+ ws,
+ secrets=self.secrets,
+ workspace_trusted=self._mcp_workspace_trusted(ws),
+ ):
if not server.enabled:
continue
if server.auth == "oauth" and not mcp_oauth.has_tokens(
@@ -1011,7 +1042,11 @@ class SessionManager:
"""Connect one server NOW — for OAuth servers this may open the browser and wait
for the loopback callback, so callers run it as a background task and watch
list_mcp for the status flip."""
- for server in load_mcp_servers(self.default_workspace, secrets=self.secrets):
+ for server in load_mcp_servers(
+ self.default_workspace,
+ secrets=self.secrets,
+ workspace_trusted=self._mcp_workspace_trusted(self.default_workspace),
+ ):
if server.name != name:
continue
self._mcp_authorizing.add(name)
@@ -1089,7 +1124,11 @@ class SessionManager:
async def mcp_tools(self, name: str) -> dict[str, Any]:
"""Connect one server and list its tools (name + description)."""
- for server in load_mcp_servers(self.default_workspace, secrets=self.secrets):
+ for server in load_mcp_servers(
+ self.default_workspace,
+ secrets=self.secrets,
+ workspace_trusted=self._mcp_workspace_trusted(self.default_workspace),
+ ):
if server.name == name:
try:
conn = await self.mcp.ensure(server)
@@ -1237,39 +1276,47 @@ class SessionManager:
".doc",
".docm",
}
- for path in root.rglob("*"):
- try:
- rel = path.relative_to(root)
- if any(
- part.startswith(".")
- or part in {"node_modules", "target", "dist", "__pycache__"}
- for part in rel.parts
- ):
+ # os.walk with in-place pruning, NOT rglob: rglob descends first and filters after,
+ # so a home-directory workspace walked into ~/Library and tripped the macOS App Data
+ # TCC prompt ("OpenWorker would like to access data from other apps") on every turn.
+ # Pruning here means those directories are never entered at all.
+ from ..tools.search import OS_DATA_DIRS
+
+ skip = {"node_modules", "target", "dist", "__pycache__"} | OS_DATA_DIRS
+ for dirpath, dirs, files in os.walk(root):
+ dirs[:] = [d for d in dirs if not d.startswith(".") and d not in skip]
+ for name in files:
+ if name.startswith("."):
continue
- if not path.is_file() or path.suffix.lower() not in suffixes:
+ path = Path(dirpath) / name
+ if path.suffix.lower() not in suffixes:
+ continue
+ try:
+ st = path.stat()
+ if not path.is_file():
+ continue
+ out.append(
+ {
+ "path": str(path.relative_to(root)),
+ # Absolute path for "Copy path" — the relative one is useless
+ # outside the app (tester catch 2026-07-12: it copied just the
+ # filename).
+ "abs_path": str(path),
+ "name": path.name,
+ "kind": _artifact_kind(path),
+ "size": st.st_size,
+ "modified_at": st.st_mtime,
+ }
+ )
+ except OSError:
continue
- st = path.stat()
- out.append(
- {
- "path": str(rel),
- # Absolute path for "Copy path" — the relative one is useless outside
- # the app (tester catch 2026-07-12: it copied just the filename).
- "abs_path": str(path),
- "name": path.name,
- "kind": _artifact_kind(path),
- "size": st.st_size,
- "modified_at": st.st_mtime,
- }
- )
- except OSError:
- continue
out.sort(key=lambda a: a["modified_at"], reverse=True)
return out[:80]
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)
@@ -1282,14 +1329,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
@@ -1343,27 +1412,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,
@@ -1792,12 +1863,14 @@ class SessionManager:
"surfaces": self._surfaces(),
"nav_layout": self._nav_layout(),
"sessions_peek": self.sessions_peek(),
+ "context_bar": self.context_bar(),
"scratch_base": self._prefs.get("scratch_base")
or self.DEFAULT_SCRATCH_BASE,
# Real on-disk secrets location, so the UI shows the OS-native path instead of a
# hardcoded POSIX one (Windows -> %APPDATA%\coworker, macOS/Linux -> ~/.config).
"secrets_path": str(self.secrets.path),
**self.pdf_settings(),
+ **self.compaction_settings_payload(),
}
def _surfaces(self) -> dict[str, bool]:
@@ -1850,6 +1923,16 @@ class SessionManager:
self._save_prefs()
return {"ok": True, "sessions_peek": self.sessions_peek()}
+ def context_bar(self) -> bool:
+ """Whether the composer shows the context-window fill bar. OFF by default (owner
+ ask): the chip then states the session total, and the popover keeps both numbers."""
+ return bool(self._prefs.get("context_bar", False))
+
+ def set_context_bar(self, shown: Any) -> dict[str, Any]:
+ self._prefs["context_bar"] = bool(shown)
+ self._save_prefs()
+ return {"ok": True, "context_bar": self.context_bar()}
+
# -- PDF attachments / token savings (owner ask, 2026-07-17) ----------------
DEFAULT_PDF_MAX_PAGES = 20
DEFAULT_PDF_MAX_MB = 10
@@ -1874,6 +1957,65 @@ class SessionManager:
"pdf_max_mb": max(1, min(mb, 10)),
}
+ def compaction_settings(self) -> dict[str, Any]:
+ """The live auto-compaction knobs (OPE-27) — read by every engine per check, so a
+ Settings change applies without a rebuild. Only the two spec'd overrides plus the
+ summarizer-model pin; absent keys fall back to compaction.py defaults."""
+ from ..compaction import DEFAULT_CAP_TOKENS, DEFAULT_THRESHOLD_PCT
+
+ return {
+ "threshold_pct": float(
+ self._prefs.get("compaction_threshold_pct") or DEFAULT_THRESHOLD_PCT
+ ),
+ "cap_tokens": int(
+ self._prefs.get("compaction_cap_tokens") or DEFAULT_CAP_TOKENS
+ ),
+ # "" → the session's own model (engine falls back to self.model).
+ "model": str(self._prefs.get("compaction_model") or ""),
+ }
+
+ def compaction_settings_payload(self) -> dict[str, Any]:
+ """The same knobs under REST-facing names (prefixed to keep /v1/settings flat)."""
+ settings = self.compaction_settings()
+ return {
+ "compaction_threshold_pct": settings["threshold_pct"],
+ "compaction_cap_tokens": settings["cap_tokens"],
+ "compaction_model": settings["model"],
+ }
+
+ def set_compaction_settings(
+ self,
+ threshold_pct: Any = None,
+ cap_tokens: Any = None,
+ model: Any = None,
+ ) -> dict[str, Any]:
+ """Persist the auto-compaction overrides (OPE-27). Threshold is a percentage of
+ the model's context window (10–95); the cap is an absolute token ceiling; model
+ pins the summarizer ('' → the session's own model). Engines read these live via
+ `compaction_settings()`, so changes apply to running sessions immediately."""
+ if threshold_pct is not None:
+ try:
+ pct = float(threshold_pct)
+ except (TypeError, ValueError):
+ return {"ok": False, "error": "compaction_threshold_pct must be a number"}
+ if not 0.10 <= pct <= 0.95:
+ return {
+ "ok": False,
+ "error": "compaction_threshold_pct must be between 0.10 and 0.95",
+ }
+ self._prefs["compaction_threshold_pct"] = pct
+ if cap_tokens is not None:
+ try:
+ self._prefs["compaction_cap_tokens"] = max(
+ 10_000, min(int(cap_tokens), 2_000_000)
+ )
+ except (TypeError, ValueError):
+ return {"ok": False, "error": "compaction_cap_tokens must be a number"}
+ if model is not None:
+ self._prefs["compaction_model"] = str(model)
+ self._save_prefs()
+ return {"ok": True, **self.compaction_settings()}
+
def set_pdf_settings(
self,
fallback: Any = None,
@@ -2614,6 +2756,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
@@ -3269,6 +3414,11 @@ class SessionManager:
agent=getattr(engine, "agent_name", "code"),
extra_roots=self._extra_roots_of(engine),
grants=_grants_of(engine),
+ compaction=(
+ engine.compaction_state.as_dict()
+ if getattr(engine, "compaction_state", None)
+ else {}
+ ),
)
)
@@ -3592,6 +3742,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)
@@ -3667,9 +3819,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 _memory_saved_notifier(self, session_id: str):
"""MEMORY-SPEC §5.1: push the memory_saved event that powers the GUI's save
diff --git a/coworker/sessions.py b/coworker/sessions.py
index cc6c4cf5..4bd857c7 100644
--- a/coworker/sessions.py
+++ b/coworker/sessions.py
@@ -34,3 +34,6 @@ class SessionRecord:
# (e.g. origin="slack", origin_label="#general · T0ABCD"). Set once at spawn.
origin: Optional[str] = None
origin_label: Optional[str] = None
+ # Auto-compaction state (OPE-27): CompactionState.as_dict(), {} when never compacted.
+ # Persisted so a reloaded session keeps its compacted outbound view.
+ compaction: dict[str, Any] = field(default_factory=dict)
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/coworker/tools/ask.py b/coworker/tools/ask.py
index 14deebfa..3268109d 100644
--- a/coworker/tools/ask.py
+++ b/coworker/tools/ask.py
@@ -6,36 +6,136 @@ plus `multi` for choose-several. Like `request_directory`, it's intercepted by t
question becomes an Inbox item (answerable inline in the live session, or from the Inbox when the
session runs unattended), the agent suspends until it's resolved, and the answer comes back as the
tool result. The callable here is only a schema carrier + a safe fallback.
+
+OPE-51 upgrades: options may be rich objects ({label, description, recommended, preview}) instead
+of plain strings, and `questions` groups up to 4 questions into ONE call (rendered as a stepper —
+one agent round-trip instead of several). Plain-string options and the singular `question` form
+stay valid: old sessions and simple asks render exactly as before.
"""
from __future__ import annotations
+import json
+
from aisuite.agents import ToolMetadata, tool
+# How many questions one grouped call may carry (stepper chips get unreadable past this).
+MAX_GROUPED_QUESTIONS = 4
+
+# An option is a plain string OR a rich object. `label` is what the user picks (and what comes
+# back as the answer); `description` renders under it; `recommended` adds the green tag (put the
+# recommended option first); `preview` is monospace text shown in the side pane (code, config,
+# ASCII mockups, SQL — any text; when ≥1 option has one the card switches to two-pane layout).
+_OPTION_SCHEMA = {
+ "anyOf": [
+ {"type": "string"},
+ {
+ "type": "object",
+ "properties": {
+ "label": {"type": "string"},
+ "description": {"type": "string"},
+ "recommended": {"type": "boolean"},
+ "preview": {"type": "string"},
+ },
+ "required": ["label"],
+ },
+ ]
+}
+
+# Explicit schema (same pattern as todo.py): the string-or-object option union and the nested
+# `questions` array can't be auto-generated from the signature reliably.
+_ASK_SCHEMA = {
+ "type": "function",
+ "function": {
+ "name": "ask_user",
+ "description": (
+ "Ask the user one or more questions and wait for their answer. Use for decisions or "
+ "information only the user can provide. Group related questions (up to "
+ f"{MAX_GROUPED_QUESTIONS}) into one call via `questions` instead of asking serially."
+ ),
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "question": {
+ "type": "string",
+ "description": "The full question, in plain language (single-question form).",
+ },
+ "options": {
+ "type": "array",
+ "items": _OPTION_SCHEMA,
+ "description": (
+ "Optional quick-reply choices: plain strings, or objects with `label` "
+ "(required — this is the answer value), `description` (why/when to pick "
+ "it), `recommended` (green tag; list that option first), and `preview` "
+ "(monospace text — code, config, a mockup — shown in a side pane)."
+ ),
+ },
+ "allow_text": {
+ "type": "boolean",
+ "description": (
+ "Keep a free-text answer available even when options exist (default true; "
+ "the \"Other / type your own\" escape). Set false only when the options "
+ "are exhaustive."
+ ),
+ },
+ "multi": {
+ "type": "boolean",
+ "description": "Allow the user to pick more than one option.",
+ },
+ "header": {
+ "type": "string",
+ "description": "Short (≤ ~12 char) chip label for the card, e.g. \"Region\".",
+ },
+ "questions": {
+ "type": "array",
+ "maxItems": MAX_GROUPED_QUESTIONS,
+ "items": {
+ "type": "object",
+ "properties": {
+ "question": {"type": "string"},
+ "header": {
+ "type": "string",
+ "description": (
+ "Short (≤ ~12 char) label — names this step in the stepper "
+ "chips and keys its answer in the result."
+ ),
+ },
+ "options": {"type": "array", "items": _OPTION_SCHEMA},
+ "allow_text": {"type": "boolean"},
+ "multi": {"type": "boolean"},
+ },
+ "required": ["question"],
+ },
+ "description": (
+ f"Grouped form: up to {MAX_GROUPED_QUESTIONS} questions asked in ONE "
+ "round-trip, rendered as a stepper. When set, the singular "
+ "question/options fields are ignored."
+ ),
+ },
+ },
+ "required": [],
+ },
+ },
+}
+
def ask_user_tool() -> object:
def ask_user(
- question: str,
- options: list[str] | None = None,
+ question: str = "",
+ options: list | None = None,
allow_text: bool = True,
multi: bool = False,
header: str = "",
+ questions: list | None = None,
) -> dict:
"""Ask the user a question and wait for their answer — use when you genuinely need a human
decision or information you can't infer (a preference, a missing fact, a choice between real
alternatives). Prefer this over guessing or stalling.
- - `question`: the full question, in plain language.
- - `options`: optional quick-reply choices. Offer them when the answer is one of a few
- discrete alternatives; leave empty for an open-ended question.
- - `allow_text`: keep a free-text answer available even when you give options (the default;
- this is the "Other / type your own" escape). Set False only when the options are
- exhaustive and a typed answer would be meaningless.
- - `multi`: allow the user to pick more than one option.
- - `header`: a short (≤ ~12 char) label for the Inbox card chip, e.g. "Region".
-
- Returns `{"answer": "..."}` — the chosen option(s) or the typed text. Don't ask what you can
- reasonably decide yourself; reserve this for choices that are actually the user's to make.
+ Single form returns `{"answer": "..."}` — the chosen option label(s) or the typed text.
+ Grouped form (`questions`) returns `{"answers": {"": "..."}}` — one
+ entry per question. Don't ask what you can reasonably decide yourself; reserve this for
+ choices that are actually the user's to make.
"""
# Real handling lives in the engine (it needs the out-of-band Inbox round-trip). This body
# only runs if no question_asker is wired (e.g. a headless surface).
@@ -44,7 +144,7 @@ def ask_user_tool() -> object:
"error": "asking the user isn't available in this surface",
}
- return tool(
+ wrapped = tool(
ask_user,
metadata=ToolMetadata(
category="interaction",
@@ -56,3 +156,100 @@ def ask_user_tool() -> object:
),
),
)
+ wrapped.__coworker_schema__ = _ASK_SCHEMA
+ return wrapped
+
+
+def normalize_option(opt) -> dict:
+ """One option in canonical dict form: {label, description, recommended, preview}. Plain
+ strings become {label: str, ...empty}. The label doubles as the answer value everywhere
+ (buttons, pills, resolutions), so it is always a non-empty-able str."""
+ if isinstance(opt, dict):
+ return {
+ "label": str(opt.get("label", "")),
+ "description": str(opt.get("description", "")),
+ "recommended": bool(opt.get("recommended", False)),
+ "preview": str(opt.get("preview", "")),
+ }
+ return {"label": str(opt), "description": "", "recommended": False, "preview": ""}
+
+
+def option_label(opt) -> str:
+ """The answer value / button text for a str-or-dict option."""
+ return str(opt.get("label", "")) if isinstance(opt, dict) else str(opt)
+
+
+def normalize_questions(raw) -> list[dict]:
+ """The grouped `questions` arg in canonical form (capped, blanks dropped). Each entry:
+ {question, header, options: [canonical option], allow_text, multi}."""
+ out: list[dict] = []
+ for entry in list(raw or [])[:MAX_GROUPED_QUESTIONS]:
+ if not isinstance(entry, dict):
+ continue
+ q = str(entry.get("question", "")).strip()
+ if not q:
+ continue
+ out.append(
+ {
+ "question": q,
+ "header": str(entry.get("header", "")),
+ "options": [normalize_option(o) for o in entry.get("options") or []],
+ "allow_text": bool(entry.get("allow_text", True)),
+ "multi": bool(entry.get("multi", False)),
+ }
+ )
+ return out
+
+
+def question_item_fields(args: dict) -> dict | None:
+ """`InboxStore.add_question` kwargs from raw ask_user args, or None when nothing was asked.
+ A grouped call surfaces its FIRST question as title/options too, so legacy surfaces (channel
+ mirrors, old persisted-item readers) degrade to a sensible single question."""
+ grouped = normalize_questions(args.get("questions"))
+ if grouped:
+ first = grouped[0]
+ return {
+ "title": first["question"],
+ "options": first["options"],
+ "allow_text": first["allow_text"],
+ "multi": first["multi"],
+ "header": first["header"],
+ "questions": grouped,
+ }
+ question = str(args.get("question", "")).strip()
+ if not question:
+ return None
+ return {
+ "title": question,
+ # Strings pass through untouched (simple asks keep rendering as today's pills);
+ # rich objects are canonicalized so downstream never meets a half-filled dict.
+ "options": [
+ o if isinstance(o, str) else normalize_option(o)
+ for o in args.get("options") or []
+ ],
+ "allow_text": bool(args.get("allow_text", True)),
+ "multi": bool(args.get("multi", False)),
+ "header": str(args.get("header", "")),
+ "questions": [],
+ }
+
+
+def answer_result(item_questions: list, resolution: str | None) -> dict:
+ """Shape the ask_user tool result from an Inbox item's resolution string. Grouped items
+ resolve with a JSON object string keyed by header-or-question → `{"answers": {...}}`;
+ everything else returns the plain `{"answer": str}` shape."""
+ if item_questions:
+ try:
+ parsed = json.loads(resolution or "")
+ except (ValueError, TypeError):
+ parsed = None
+ if isinstance(parsed, dict):
+ return {"answers": {str(k): str(v) for k, v in parsed.items()}}
+ if resolution:
+ # Answered from a text-only surface (e.g. a mirrored channel): attribute the lone
+ # answer to the first question rather than losing it.
+ first = item_questions[0] if isinstance(item_questions[0], dict) else {}
+ key = str(first.get("header") or first.get("question") or "answer")
+ return {"answers": {key: str(resolution)}}
+ return {"answer": ""}
+ return {"answer": resolution or ""}
diff --git a/coworker/tools/search.py b/coworker/tools/search.py
index ad489e1f..3ff7fc3e 100644
--- a/coworker/tools/search.py
+++ b/coworker/tools/search.py
@@ -16,6 +16,18 @@ from typing import Any, Optional
import aisuite as ai
+# Per-OS application data directories. These are not build noise: on macOS 14+ merely
+# *descending* into ~/Library/Application Support (other apps' containers) trips the App
+# Data TCC protection and macOS shows "would like to access data from other apps" — an
+# alarming prompt the user never asked for, reachable whenever the workspace is a home
+# directory. Never traversed; a workspace under one of these is still searched normally,
+# because the guard matches directory NAMES encountered during a walk.
+OS_DATA_DIRS = {
+ "Library", # macOS
+ "AppData", # Windows
+ "Application Data", # Windows (legacy junction)
+}
+
_IGNORE_DIRS = {
".git",
"node_modules",
@@ -30,7 +42,7 @@ _IGNORE_DIRS = {
".pytest_cache",
".ruff_cache",
".idea",
-}
+} | OS_DATA_DIRS
_SCHEMA = {
"type": "function",
diff --git a/coworker/web/fetch.py b/coworker/web/fetch.py
index 9d273d03..f58291da 100644
--- a/coworker/web/fetch.py
+++ b/coworker/web/fetch.py
@@ -13,6 +13,8 @@ from typing import Any, Callable
import aisuite as ai
+from .guard import get_checked
+
_MAX = 20000 # default chars returned
_SCHEMA = {
@@ -84,16 +86,20 @@ def make_web_fetch_tool() -> Callable[..., Any]:
try:
import httpx
+ # follow_redirects=False: guard.get_checked walks the chain so every hop is
+ # address-checked, not just the URL the model first supplied.
with httpx.Client(
- follow_redirects=True,
+ follow_redirects=False,
timeout=20.0,
headers={"User-Agent": "coworker/0.1 (+desktop)"},
) as client:
- resp = client.get(url)
+ resp = get_checked(client, url)
resp.raise_for_status()
ctype = resp.headers.get("content-type", "")
body = resp.text
final_url = str(resp.url)
+ except PermissionError as exc: # blocked address (loopback, private, metadata)
+ return {"error": str(exc)}
except Exception as exc: # network / HTTP / TLS
return {"error": f"fetch failed: {exc}"}
text = _html_to_text(body) if "html" in ctype.lower() else body
diff --git a/coworker/web/guard.py b/coworker/web/guard.py
new file mode 100644
index 00000000..549d7841
--- /dev/null
+++ b/coworker/web/guard.py
@@ -0,0 +1,116 @@
+"""Address guard for URLs the model chooses.
+
+`web_fetch` and `browser_read_url` take a URL straight from the model, and the model's
+input is untrusted by design — it reads web pages, email and Slack messages, all of which
+are documented as "data, not instructions". A page that talks the agent into fetching
+`http://169.254.169.254/` or `http://127.0.0.1:11434/` turns a read-only research tool into
+a probe of the machine's own network position, and `web_fetch` is `requires_approval=False`,
+so no prompt ever appears.
+
+This blocks the ranges that are only reachable *because* OpenWorker runs on the user's
+machine: loopback, RFC1918 and other private space, link-local (which covers the cloud
+metadata endpoint at 169.254.169.254), and the reserved/multicast blocks.
+
+Every hop is checked, not just the first: `follow_redirects=True` otherwise lets a public
+URL 302 straight to loopback, which is the standard way this filter is bypassed.
+
+Not covered: DNS rebinding. The name is resolved here and resolved again by the client when
+it connects, so a record with a ~0 TTL can change between the two. Closing that needs
+connection-level IP pinning; the hop check is the cheap 90% and is stated as such.
+"""
+
+from __future__ import annotations
+
+import ipaddress
+import socket
+from typing import Optional
+from urllib.parse import urlsplit
+
+MAX_REDIRECTS = 5
+
+# RFC 6598 shared address space. Python's is_private misses it, but it is carrier grade
+# NAT space and Tailscale hands out internal hosts here (100.64.0.0/10), so a fetch to it
+# is the same "reach the machine's network position" class as RFC1918.
+_CGNAT = ipaddress.ip_network("100.64.0.0/10")
+
+
+def _blocked_reason(ip: ipaddress._BaseAddress) -> Optional[str]:
+ if ip.is_loopback:
+ return "loopback"
+ if ip.is_link_local:
+ return "link-local (includes the cloud metadata endpoint)"
+ if ip.is_private:
+ return "a private network"
+ if ip.version == 4 and ip in _CGNAT:
+ return "shared address space (CGNAT / RFC 6598)"
+ if ip.is_multicast:
+ return "multicast"
+ if ip.is_reserved or ip.is_unspecified:
+ return "a reserved range"
+ return None
+
+
+def check_url(url: str) -> Optional[str]:
+ """None if the URL may be fetched, else a human-readable refusal reason.
+
+ Resolves the host and rejects when *any* answer lands in a blocked range, so a name
+ with both a public and a private A record cannot be used to slip through.
+ """
+ parts = urlsplit(url)
+ if parts.scheme not in ("http", "https"):
+ return "url must start with http:// or https://"
+ host = parts.hostname
+ if not host:
+ return "url has no host"
+
+ # A literal address needs no lookup.
+ try:
+ literal = ipaddress.ip_address(host)
+ except ValueError:
+ literal = None
+ if literal is not None:
+ reason = _blocked_reason(literal)
+ return f"refusing to fetch {host}: {reason}" if reason else None
+
+ try:
+ infos = socket.getaddrinfo(host, parts.port or (443 if parts.scheme == "https" else 80),
+ proto=socket.IPPROTO_TCP)
+ except OSError as exc:
+ return f"could not resolve {host}: {exc}"
+
+ for info in infos:
+ raw = info[4][0]
+ try:
+ ip = ipaddress.ip_address(raw)
+ except ValueError:
+ continue
+ # ::ffff:127.0.0.1 and friends must be judged as the v4 address they carry.
+ mapped = getattr(ip, "ipv4_mapped", None)
+ if mapped is not None:
+ ip = mapped
+ reason = _blocked_reason(ip)
+ if reason:
+ return f"refusing to fetch {host} ({ip}): {reason}"
+ return None
+
+
+def get_checked(client, url: str, *, max_redirects: int = MAX_REDIRECTS):
+ """GET `url`, validating the address before every hop.
+
+ `client` must be built with `follow_redirects=False`; redirects are walked here so each
+ Location is checked. Returns the final response. Raises `PermissionError` when a hop is
+ refused, `RuntimeError` when the redirect budget is exhausted.
+ """
+ seen = url
+ for _ in range(max_redirects + 1):
+ reason = check_url(seen)
+ if reason:
+ raise PermissionError(reason)
+ resp = client.get(seen)
+ if resp.status_code not in (301, 302, 303, 307, 308):
+ return resp
+ location = resp.headers.get("location")
+ if not location:
+ return resp
+ seen = str(resp.url.join(location))
+ raise RuntimeError(f"too many redirects (>{max_redirects})")
diff --git a/packaging/make_update_manifest.py b/packaging/make_update_manifest.py
index 513f8223..84c4f295 100644
--- a/packaging/make_update_manifest.py
+++ b/packaging/make_update_manifest.py
@@ -10,6 +10,7 @@ Looks for the updater artifacts by their STABLE names (the same names release.ym
uploads):
OpenWorker-macos-arm64.app.tar.gz(.sig) -> platforms["darwin-aarch64"]
+ OpenWorker-macos-x64.app.tar.gz(.sig) -> platforms["darwin-x86_64"]
OpenWorker-windows-setup.exe(.sig) -> platforms["windows-x86_64"]
URLs point at the TAG-pinned GitHub download path (releases/download//),
@@ -34,6 +35,7 @@ import sys
# stable asset name -> Tauri platform key
ARTIFACTS = {
"OpenWorker-macos-arm64.app.tar.gz": "darwin-aarch64",
+ "OpenWorker-macos-x64.app.tar.gz": "darwin-x86_64",
"OpenWorker-windows-setup.exe": "windows-x86_64",
}
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/ask-upgrades.spec.ts b/surfaces/gui/e2e/ask-upgrades.spec.ts
new file mode 100644
index 00000000..018450a5
--- /dev/null
+++ b/surfaces/gui/e2e/ask-upgrades.spec.ts
@@ -0,0 +1,153 @@
+import type { Page } from "@playwright/test";
+import { test, expect } from "./fixtures";
+
+// OPE-51 — ask_user upgrades: rich options (descriptions, the Recommended tag, monospace
+// previews with the two-pane layout) and grouped questions (the stepper). Seeded via a per-test
+// inbox route override (later routes match first) so the base fixtures' counts — which
+// inbox.spec.ts pins — stay untouched.
+
+const BASE = {
+ body: "",
+ state: "pending",
+ resolution: null as string | null,
+ inbox: "default",
+ created_at: "2026-07-29 08:00:00",
+ resolved_at: null as string | null,
+ session_title: "Investigate alerts",
+ session_agent: "ops",
+ session_workspace: "",
+ session_exists: true,
+};
+
+const RICH_ITEM = {
+ ...BASE,
+ id: "inb-question-rich",
+ session_id: "ops-1",
+ kind: "question",
+ title: "How should I format the report?",
+ header: "Format",
+ options: [
+ {
+ label: "Markdown table",
+ description: "Compact and renders in the app",
+ recommended: true,
+ preview: "| env | status |\n| --- | --- |\n| staging | ok |",
+ },
+ {
+ label: "Plain text",
+ description: "Safest for email forwarding",
+ preview: "env: staging\nstatus: ok",
+ },
+ ],
+ allow_text: true,
+ multi: false,
+ questions: [],
+};
+
+const GROUPED_ITEM = {
+ ...BASE,
+ id: "inb-question-grouped",
+ session_id: "ops-1",
+ kind: "question",
+ // The first question doubles as title/options (legacy-surface degradation, server parity).
+ title: "Chart style?",
+ header: "Chart style",
+ options: ["Bar", "Line"],
+ allow_text: false,
+ multi: false,
+ questions: [
+ { question: "Chart style?", header: "Chart style", options: ["Bar", "Line"], allow_text: false, multi: false },
+ { question: "Which distribution?", header: "Distribution", options: ["Stacked", "Grouped"], allow_text: true, multi: false },
+ ],
+};
+
+/** Replace the Inbox's seeded items for this test (resolve mutates the local copy). */
+async function seedInbox(page: Page, items: Record[]) {
+ const inbox = items.map((i) => ({ ...i }));
+ const json = (body: unknown) => ({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify(body),
+ });
+ await page.route(/\/v1\/inbox\/[^/]+\/resolve$/, (route) => {
+ const path = new URL(route.request().url()).pathname;
+ const id = decodeURIComponent(path.split("/").slice(-2)[0]);
+ const it = inbox.find((x) => x.id === id);
+ if (it) {
+ it.state = "resolved";
+ it.resolution = route.request().postDataJSON().resolution;
+ }
+ return route.fulfill(json({ ok: true }));
+ });
+ await page.route(/\/v1\/inbox(\?.*)?$/, (route) =>
+ route.fulfill(json({ items: inbox.filter((i) => i.state === "pending") })),
+ );
+ return inbox;
+}
+
+async function openInbox(page: Page, expectTitle: string) {
+ await page.goto("/");
+ await page.getByTestId("inbox-chip").click();
+ await expect(page.getByText(expectTitle)).toBeVisible();
+}
+
+test("rich options render descriptions + Recommended; the preview pane follows hover", async ({
+ page,
+}) => {
+ await seedInbox(page, [RICH_ITEM]);
+ await openInbox(page, "How should I format the report?");
+
+ await expect(page.getByText("Compact and renders in the app")).toBeVisible();
+ await expect(page.getByText("Recommended")).toBeVisible();
+
+ // The pane opens on the first option holding a preview…
+ const pane = page.getByTestId("question-preview");
+ await expect(pane).toContainText("| env | status |");
+ // …and follows hover to the other option.
+ await page.getByRole("button", { name: /Plain text/ }).hover();
+ await expect(pane).toContainText("env: staging");
+
+ // Single-select still resolves on click, with the option's LABEL as the resolution.
+ const resolved = page.waitForRequest(
+ (r) => r.url().includes("/resolve") && r.method() === "POST",
+ );
+ await page.getByRole("button", { name: /Markdown table/ }).click();
+ expect((await resolved).postDataJSON().resolution).toBe("Markdown table");
+ await expect(page.getByText("How should I format the report?")).not.toBeVisible();
+});
+
+test("grouped questions step through the header chips and resolve as one answer map", async ({
+ page,
+}) => {
+ await seedInbox(page, [GROUPED_ITEM]);
+ await openInbox(page, "Chart style?");
+
+ // Step 1: "Chart style · 1 of 2 · Distribution ›" — and no free-text row (allow_text: false).
+ const stepper = page.getByTestId("question-stepper");
+ await expect(stepper).toContainText("Chart style");
+ await expect(stepper).toContainText("1 of 2");
+ await expect(stepper).toContainText("Distribution ›");
+ await expect(page.getByPlaceholder("Or type your own answer…")).not.toBeVisible();
+
+ // Answering advances to step 2 (its free-text escape is back — allow_text: true).
+ await page.getByRole("button", { name: "Bar", exact: true }).click();
+ await expect(stepper).toContainText("2 of 2");
+ await expect(page.getByText("Which distribution?")).toBeVisible();
+ await expect(page.getByPlaceholder("Or type your own answer…")).toBeVisible();
+
+ // ‹ steps back with the first answer re-askable; answer forward again.
+ await page.getByRole("button", { name: "Previous question" }).click();
+ await expect(stepper).toContainText("1 of 2");
+ await page.getByRole("button", { name: "Bar", exact: true }).click();
+ await expect(stepper).toContainText("2 of 2");
+
+ // The final answer resolves the whole card with a JSON map keyed by header.
+ const resolved = page.waitForRequest(
+ (r) => r.url().includes("/resolve") && r.method() === "POST",
+ );
+ await page.getByRole("button", { name: "Stacked", exact: true }).click();
+ expect((await resolved).postDataJSON().resolution).toBe(
+ JSON.stringify({ "Chart style": "Bar", Distribution: "Stacked" }),
+ );
+ await expect(page.getByText("Nothing pending.")).toBeVisible();
+});
diff --git a/surfaces/gui/e2e/chat.spec.ts b/surfaces/gui/e2e/chat.spec.ts
index b7b42d61..b40345a8 100644
--- a/surfaces/gui/e2e/chat.spec.ts
+++ b/surfaces/gui/e2e/chat.spec.ts
@@ -57,3 +57,37 @@ test("approval: Deny skips the tool and the agent says so", async ({ page }) =>
await page.getByRole("button", { name: "Deny" }).last().click();
await expect(page.getByText("Understood — skipped the command.")).toBeVisible();
});
+
+test("long user pastes clamp with a more…/less… toggle", async ({ page }) => {
+ await page.goto("/");
+ const box = page.getByPlaceholder(/Ask the coworker/);
+ await expect(box).toBeVisible();
+
+ const tail = "END-OF-PASTE-MARKER";
+ const paste =
+ "reply OK. " + "lorem ipsum dolor sit amet consectetur ".repeat(60) + tail; // ~2.4k chars
+ await box.fill(paste);
+ await page.getByRole("button", { name: "Send" }).click();
+
+ // Clamped: the bubble shows the head but not the tail, plus the toggle.
+ const more = page.getByRole("button", { name: "more…" });
+ await expect(more).toBeVisible();
+ const bubble = page.locator(".bubble-user").last();
+ await expect(bubble).toContainText("reply OK.");
+ await expect(bubble).not.toContainText(tail);
+
+ // Expand → full text + "less…"; collapse → clamped again.
+ await more.click();
+ await expect(bubble).toContainText(tail);
+ const less = page.getByRole("button", { name: "less…" });
+ await expect(less).toBeVisible();
+ await less.click();
+ await expect(bubble).not.toContainText(tail);
+
+ // Short messages never show the control.
+ await expect(page.getByText("Echo:").first()).toBeVisible();
+ await box.fill("short follow-up");
+ await page.getByRole("button", { name: "Send" }).click();
+ await expect(page.getByText("short follow-up", { exact: true }).first()).toBeVisible();
+ await expect(page.getByRole("button", { name: "more…" })).toHaveCount(1); // still only the paste's
+});
diff --git a/surfaces/gui/e2e/compaction.spec.ts b/surfaces/gui/e2e/compaction.spec.ts
new file mode 100644
index 00000000..c5fa0b61
--- /dev/null
+++ b/surfaces/gui/e2e/compaction.spec.ts
@@ -0,0 +1,80 @@
+// OPE-27 — auto-compaction GUI: the Settings card's two overrides + summarizer-model
+// pin POST through, and the "context compacted" divider renders inline mid-session
+// (driven by the fixtures' scripted `compacted` event) without touching the transcript.
+import { expect } from "@playwright/test";
+import { test } from "./fixtures";
+
+test("Settings: Context compaction card edits threshold, cap, and summarizer model", 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: "Models", exact: true }).click();
+
+ const card = page.getByTestId("compaction-card");
+ await expect(card).toBeVisible();
+ await expect(card.getByText("Context compaction")).toBeVisible();
+
+ // Defaults render when the backend doesn't send the fields (older-backend robustness).
+ await expect(card.getByTestId("compaction-threshold")).toHaveValue("80");
+ await expect(card.getByTestId("compaction-cap")).toHaveValue("250000");
+ await expect(card.getByTestId("compaction-model")).toHaveValue("");
+
+ // Threshold edits POST as a fraction, clamped to 10–95%.
+ const [req] = await Promise.all([
+ page.waitForRequest(
+ (r) => r.url().endsWith("/v1/settings/compaction") && r.method() === "POST",
+ ),
+ card.getByTestId("compaction-threshold").fill("70"),
+ ]);
+ expect(req.postDataJSON()).toEqual({ compaction_threshold_pct: 0.7 });
+
+ const [req2] = await Promise.all([
+ page.waitForRequest(
+ (r) => r.url().endsWith("/v1/settings/compaction") && r.method() === "POST",
+ ),
+ card.getByTestId("compaction-cap").fill("100000"),
+ ]);
+ expect(req2.postDataJSON()).toEqual({ compaction_cap_tokens: 100000 });
+
+ // Summarizer pin: the picker offers the session-default plus the configured models.
+ const [req3] = await Promise.all([
+ page.waitForRequest(
+ (r) => r.url().endsWith("/v1/settings/compaction") && r.method() === "POST",
+ ),
+ card.getByTestId("compaction-model").selectOption("gpt-4o-mini"),
+ ]);
+ expect(req3.postDataJSON()).toEqual({ compaction_model: "gpt-4o-mini" });
+});
+
+test("the compacted divider renders mid-session and the transcript stays intact", async ({
+ page,
+}) => {
+ await page.goto("/");
+ await page.getByText("Draft the launch note").first().click();
+ const box = page.getByPlaceholder(/Ask the coworker/);
+
+ // An earlier exchange that must survive the compaction marker (transcript intact).
+ await box.fill("remember the launch date");
+ await box.press("Enter");
+ await expect(page.getByText("Echo: remember the launch date").first()).toBeVisible({
+ timeout: 10_000,
+ });
+
+ await box.fill("compact the context");
+ await box.press("Enter");
+ // The transient signal shows while the summarizer runs, then yields to the divider.
+ await expect(page.getByText("Compacting context…").first()).toBeVisible({
+ timeout: 10_000,
+ });
+ await expect(
+ page.getByText("Context compacted — earlier turns were summarized").first(),
+ ).toBeVisible({ timeout: 10_000 });
+ await expect(page.getByText("Compacting context…")).toHaveCount(0);
+ await expect(
+ page.getByText("Still on it — continuing where I left off.").first(),
+ ).toBeVisible();
+ // Outbound-only: everything before the divider is still on screen.
+ await expect(page.getByText("Echo: remember the launch date").first()).toBeVisible();
+});
diff --git a/surfaces/gui/e2e/fixtures.ts b/surfaces/gui/e2e/fixtures.ts
index 4224bff7..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" } });
@@ -681,6 +694,18 @@ export async function mockApi(page: import("@playwright/test").Page) {
}, 120);
return;
}
+ // Auto-compaction (OPE-27): the server signals `compacting` (the transient
+ // spinner label), summarizes for a beat, then emits the marker and the turn
+ // continues normally — the divider must render inline.
+ if (/compact the context/i.test(msg.text)) {
+ send("compacting", {});
+ setTimeout(() => {
+ send("compacted", { text: "Context compacted — earlier turns were summarized" });
+ send("assistant_message", { text: "Still on it — continuing where I left off." });
+ send("turn_done");
+ }, 400);
+ return;
+ }
// A turn that dies on a provider error; the follow-up {type:"retry"} recovers.
if (/fail the turn/i.test(msg.text)) {
send("error", { error: "model unreachable" });
@@ -709,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,
@@ -824,8 +850,79 @@ 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") {
+ Object.assign(SETTINGS, req.postDataJSON());
+ return json({ ok: true, context_bar: SETTINGS.context_bar });
+ }
if (p.endsWith("/v1/settings/pdf") && m === "POST") {
Object.assign(SETTINGS, req.postDataJSON());
return json({
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/e2e/usage-chip.spec.ts b/surfaces/gui/e2e/usage-chip.spec.ts
index bfb98750..92a62ca2 100644
--- a/surfaces/gui/e2e/usage-chip.spec.ts
+++ b/surfaces/gui/e2e/usage-chip.spec.ts
@@ -20,7 +20,8 @@ test("usage chip appears after a turn and opens the breakdown popover", async ({
timeout: 10_000,
});
- // Chip shows the session total (1k + 200 + 8k + 800 = 10k).
+ // Default: no bar (owner ask 2026-07-30) — the chip states the session total
+ // (1k + 200 + 8k + 800 = 10k). The bar is opt-in via Settings.
const chip = page.getByTestId("usage-chip");
await expect(chip).toContainText("10k");
@@ -58,9 +59,41 @@ test("usage resets on a new session", async ({ page }) => {
const box = page.getByPlaceholder(/Ask the coworker/);
await box.fill("hello");
await box.press("Enter");
- await expect(page.getByTestId("usage-chip")).toContainText("10k", { timeout: 10_000 });
+ await expect(page.getByTestId("usage-chip")).toBeVisible({ timeout: 10_000 });
// "+ New session" wipes the transcript — and the usage accumulation with it.
await page.getByRole("button", { name: /New session/ }).first().click();
await expect(page.getByTestId("usage-chip")).toHaveCount(0);
});
+
+test("Settings toggle turns the context bar on; default is the session total", async ({ page }) => {
+ await page.goto("/");
+ await page.getByText("Draft the launch note").first().click();
+ const box = page.getByPlaceholder(/Ask the coworker/);
+ await box.fill("hello");
+ await box.press("Enter");
+ const chip = page.getByTestId("usage-chip");
+ await expect(chip).toContainText("10k", { timeout: 10_000 }); // default: total, no bar
+
+ // Turn the bar ON in Settings -> General.
+ await page.getByTestId("account-row").click();
+ await page.getByRole("button", { name: "Settings", exact: true }).click();
+ await expect(page.getByTestId("context-bar-toggle")).not.toBeChecked();
+ const [req] = await Promise.all([
+ page.waitForRequest(
+ (r) => r.url().endsWith("/v1/settings/context-bar") && r.method() === "POST",
+ ),
+ page.getByTestId("context-bar-toggle").check(),
+ ]);
+ expect(req.postDataJSON()).toEqual({ context_bar: true });
+
+ // Reload so the app re-reads settings: the chip is now the fill bar, not a number.
+ await page.goto("/");
+ await page.getByText("Draft the launch note").first().click();
+ await page.getByPlaceholder(/Ask the coworker/).fill("hello");
+ await page.getByPlaceholder(/Ask the coworker/).press("Enter");
+ const bar = page.getByTestId("usage-chip");
+ await expect(bar).toBeVisible({ timeout: 10_000 });
+ await expect(bar).not.toContainText("10k");
+ await expect(bar).toHaveAttribute("title", /Context window 5% full/);
+});
diff --git a/surfaces/gui/src-tauri/tauri.conf.json b/surfaces/gui/src-tauri/tauri.conf.json
index 95aa77cd..ca15a297 100644
--- a/surfaces/gui/src-tauri/tauri.conf.json
+++ b/surfaces/gui/src-tauri/tauri.conf.json
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "OpenWorker",
- "version": "0.1.6",
+ "version": "0.1.7",
"identifier": "com.openworker.desktop",
"build": {
"frontendDist": "../dist",
diff --git a/surfaces/gui/src/App.tsx b/surfaces/gui/src/App.tsx
index ecca5bf7..972abd9a 100644
--- a/surfaces/gui/src/App.tsx
+++ b/surfaces/gui/src/App.tsx
@@ -168,6 +168,9 @@ export function App() {
// {full model id → context window in tokens} from the curated matrix (verified only);
// drives the composer usage chip's context-fill meter.
const [modelContextWindows, setModelContextWindows] = useState>({});
+ // Settings: show the composer's context-window fill bar. OFF by default (owner ask),
+ // so an older backend without the field also shows the session total.
+ const [contextBar, setContextBar] = useState(false);
// Per-session token usage (OPE-42): rebuilt from the transcript on session load,
// accumulated live from assistant_message events, reset with the transcript.
const [usage, setUsage] = useState(emptyUsage());
@@ -175,6 +178,10 @@ export function App() {
const [mode, setMode] = useState("interactive");
const [connected, setConnected] = useState(false);
const [running, setRunning] = useState(false);
+ // Transient "Compacting context…" indicator (OPE-27): set by the `compacting` event,
+ // cleared by whatever the engine emits next — the summarizer call is otherwise a
+ // multi-second silent stall mid-turn.
+ const [compacting, setCompacting] = useState(false);
const [items, setItems] = useState([]);
const [streaming, setStreamingState] = useState("");
// Ref mirror of `streaming`: the WS handler closure is built once per socket and can't read
@@ -207,10 +214,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" | "memory" | "personas">(
- "appearance",
- );
- const openSettings = (tab: "appearance" | "models" | "voice" | "memory" | "personas" = "appearance") => {
+ const [settingsTab, setSettingsTab] = useState<
+ "appearance" | "models" | "skills" | "voice" | "memory" | "personas"
+ >("appearance");
+ const openSettings = (
+ tab: "appearance" | "models" | "skills" | "voice" | "memory" | "personas" = "appearance",
+ ) => {
setSettingsTab(tab);
setSurface("settings");
};
@@ -505,6 +514,7 @@ export function App() {
setModels(s.models || []);
setModelLabels(s.model_labels || {});
setModelContextWindows(s.model_context_windows || {});
+ setContextBar(s.context_bar === true);
setModelReady(s.model_ready);
if (s.surfaces) setSurfaces(s.surfaces);
})
@@ -579,6 +589,9 @@ export function App() {
},
]);
};
+ // Any engine event after `compacting` means the summarizer finished (compacted /
+ // silent no-op / failure prompt) — the transient must never outlive it.
+ if (ev.type !== "compacting") setCompacting(false);
switch (ev.type) {
case "ready":
setConnected(true);
@@ -605,11 +618,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;
@@ -682,6 +698,8 @@ export function App() {
options: d.options || [],
allow_text: d.allow_text !== false,
multi: !!d.multi,
+ header: d.header || "",
+ questions: d.questions || [],
},
]);
break;
@@ -730,6 +748,14 @@ export function App() {
]);
announceMemoryChanged(); // Settings ▸ Memory, if open, is now stale
break;
+ case "compacting":
+ setCompacting(true);
+ break;
+ case "compacted":
+ // Auto-compaction marker (OPE-27): outbound-only — the transcript stays intact,
+ // this divider just shows where the model's memory was summarized.
+ setItems((p) => [...p, { kind: "notice", tone: "info", text: d.text || "Context compacted" }]);
+ break;
case "interrupted":
flushPartialStream();
setItems((p) => [...p, { kind: "notice", tone: "warn", text: "Interrupted." }]);
@@ -863,10 +889,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
@@ -1363,6 +1392,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" ? (
@@ -1548,7 +1588,11 @@ export function App() {
)}
+ {/* Compaction runs between provider turns (nothing streams during it), so
+ the transient takes over the waiting slot with a specific label. */}
+ {running && compacting && }
{running &&
+ !compacting &&
!reasoningStream &&
(!streaming || streamMode(streaming, items, running) === "hold") &&
!lastItemIsAssistant(items) && }
@@ -1595,6 +1639,7 @@ export function App() {
onInterrupt={interrupt}
onModeChange={changeMode}
onModelChange={changeModel}
+ sessionId={sessionId}
workspace={needsWorkspace(agent) ? workspace || "" : undefined}
unattended={unattended}
onUnattendedChange={agent !== "chat" ? toggleUnattended : undefined}
@@ -1602,6 +1647,7 @@ export function App() {
resetKey={sessionId}
usage={usage}
contextWindow={modelContextWindows[model]}
+ contextBar={contextBar}
placeholder={
agent === "code"
? "Ask the coder to build, fix, or explain… (drop or paste files)"
@@ -1635,6 +1681,8 @@ export function App() {
options: pendingQuestion.options,
allow_text: pendingQuestion.allow_text,
multi: pendingQuestion.multi,
+ header: pendingQuestion.header,
+ questions: pendingQuestion.questions,
}}
onResolve={(_id, answer) => answerQuestion(answer)}
compact
@@ -1714,12 +1762,12 @@ function lastItemIsAssistant(items: Item[]): boolean {
return false;
}
-function WaitingForAgent() {
+function WaitingForAgent({ label }: { label?: string }) {
return (
- Waiting for agent...
+ {label || "Waiting for agent..."}
);
diff --git a/surfaces/gui/src/api.ts b/surfaces/gui/src/api.ts
index 9b40d3ee..13cca52e 100644
--- a/surfaces/gui/src/api.ts
+++ b/surfaces/gui/src/api.ts
@@ -1,4 +1,4 @@
-import type { SessionInfo, WsEvent } from "./types";
+import type { GroupedQuestion, QuestionOption, SessionInfo, WsEvent } from "./types";
declare const __COWORKER_DEV_TOKEN__: string;
@@ -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 {
@@ -692,6 +694,9 @@ export interface ModelSettings {
nav_layout?: "flat" | "grouped";
// Sidebar: sessions shown per group before "Show more" (default 5, 1–50).
sessions_peek?: number;
+ // Composer: show the context-window fill bar (default FALSE; absent → the chip shows
+ // the session total). The usage popover keeps both numbers regardless.
+ context_bar?: boolean;
// Curated-matrix display names ({full id → "GLM-5.2 · via Together"}); custom models absent.
model_labels?: Record;
// {full id → context window in tokens}, verified matrix entries only — drives the
@@ -702,6 +707,12 @@ export interface ModelSettings {
pdf_fallback?: "text" | "images";
pdf_max_pages?: number; // default 20, 1–100
pdf_max_mb?: number; // default 10, 1–10
+ // Auto-compaction of long histories (OPE-27): trigger = min(threshold% × context
+ // window, cap tokens); model pins the summarizer ("" → the session's own model).
+ // Optional so the GUI is robust to an older backend.
+ compaction_threshold_pct?: number; // default 0.8, 0.10–0.95
+ compaction_cap_tokens?: number; // default 250000
+ compaction_model?: string;
}
export interface PdfSettings {
@@ -722,6 +733,24 @@ export async function setPdfSettings(
return res.json();
}
+export interface CompactionSettings {
+ compaction_threshold_pct: number;
+ compaction_cap_tokens: number;
+ compaction_model: string;
+}
+
+/** Persist the auto-compaction overrides (threshold %, token cap, summarizer model). */
+export async function setCompactionSettings(
+ patch: Partial,
+): Promise<{ ok: boolean; error?: string }> {
+ const res = await fetch(`${httpBase()}/v1/settings/compaction`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(patch),
+ });
+ return res.json();
+}
+
/** Local page/size probe for a PDF data URL — the composer's attach-time threshold check. */
export async function inspectPdf(
dataUrl: string,
@@ -734,6 +763,18 @@ export async function inspectPdf(
return res.json();
}
+/** Persist whether the composer shows the context-window fill bar. */
+export async function setContextBar(
+ shown: boolean,
+): Promise<{ ok: boolean; context_bar?: boolean; error?: string }> {
+ const res = await fetch(`${httpBase()}/v1/settings/context-bar`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ context_bar: shown }),
+ });
+ return res.json();
+}
+
/** Persist how many sessions a sidebar group shows before "Show more". */
export async function setSessionsPeek(
n: number,
@@ -1050,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;
@@ -1063,10 +1239,14 @@ export interface InboxItem {
created_at: string;
resolved_at: string | null;
visibility?: "inline" | "inbox";
- // Question metadata (ask_user): quick-reply choices + a free-text escape.
- options?: string[];
+ // Question metadata (ask_user): quick-reply choices + a free-text escape. Options may be rich
+ // {label, description, recommended, preview} objects (OPE-51); `questions` is the grouped form
+ // (stepper), whose resolution is a JSON object string keyed by header-or-question.
+ options?: QuestionOption[];
allow_text?: boolean;
multi?: boolean;
+ header?: string;
+ questions?: GroupedQuestion[];
// Kind-specific payload (directory: {path, writable}; …).
data?: Record;
// Originating-session context (server-joined) so the Inbox is self-contained.
@@ -1874,12 +2054,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 &&
);
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 5d638ee1..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;
@@ -84,11 +87,53 @@ interface Props {
// Context-window size (tokens) of the ACTIVE model, from the curated matrix;
// undefined hides the fill meter (unverified/custom models) but keeps the counts.
contextWindow?: number;
+ // Settings toggle (default off): true shows the fill bar instead of the session total.
+ contextBar?: boolean;
}
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);
@@ -117,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.
@@ -131,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(() => {
@@ -262,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();
@@ -340,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 (
@@ -394,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 && (
+
{/* Task-persistent standing grant (§25) — present only when the approval was
raised inside an automation run AND the call can carry a tool+target rule.
@@ -120,46 +364,11 @@ export function InboxItemCard({
className={item.data?.tool ? BTN_QUIET : BTN_BORDERED}
onClick={() => onResolve(item.id, "deny")}
>
- Deny
+ {item.data?.tool ? approvalActionLabels(item.data.tool).deny : "Deny"}
diff --git a/surfaces/gui/src/components/SettingsView.tsx b/surfaces/gui/src/components/SettingsView.tsx
index 5221b72a..ac2ef488 100644
--- a/surfaces/gui/src/components/SettingsView.tsx
+++ b/surfaces/gui/src/components/SettingsView.tsx
@@ -2,11 +2,14 @@ import { useEffect, useState } from "react";
import {
getSettings,
getTrustedWorkspaces,
+ setCompactionSettings,
+ setContextBar,
setOnboarded,
setPdfSettings,
setScratchBase,
setSessionsPeek,
setWorkspaceTrusted,
+ type CompactionSettings,
type ModelSettings,
type PdfSettings,
type WorkspaceCommandTrust,
@@ -39,6 +42,7 @@ import { ModelsTab } from "./ManageTabs";
import { MemorySection } from "./MemorySection";
import { GalleryModal } from "./GalleryModal";
import { PersonasTab } from "./PersonasTab";
+import { SkillsTab } from "./SkillsTab";
import { showPersonas } from "../flags";
// Settings, restructured (Option 2) into a full-page surface that mirrors IntegrationsView's shell:
@@ -48,7 +52,7 @@ import { showPersonas } from "../flags";
// Models + Personas host the existing tab components inside the page shell (field re-skin to follow).
// "appearance" is the General tab's stable key — callers deep-link with it, so the
// rename (UX-021) changed only the label. "files" folded into General as a card.
-type SetTab = "appearance" | "models" | "voice" | "memory" | "personas";
+type SetTab = "appearance" | "models" | "skills" | "voice" | "memory" | "personas";
const CARD = "rounded-xl2 border border-line bg-panel";
const FIELD_LABEL = "text-[12.5px] font-medium text-ink";
@@ -59,9 +63,14 @@ const BTN_ACCENT = "text-[12.5px] px-3 py-2 rounded-lg bg-accent text-white shri
const BTN_BORDERED =
"text-[12.5px] px-3 py-2 rounded-lg border border-line bg-paper hover:border-lineStrong shrink-0";
-const SET_TABS: { key: SetTab; label: string; icon: "sliders" | "code" | "mic" | "archive" | "sparkle" }[] = [
+const SET_TABS: {
+ key: SetTab;
+ label: string;
+ icon: "sliders" | "code" | "mic" | "archive" | "sparkle" | "book";
+}[] = [
{ key: "appearance", label: "General", icon: "sliders" },
{ key: "models", label: "Models", icon: "code" },
+ { key: "skills", label: "Skills", icon: "book" },
{ key: "voice", label: "Voice input", icon: "mic" },
{ key: "memory", label: "Memory", icon: "archive" },
{ key: "personas", label: "Personas", icon: "sparkle" },
@@ -70,9 +79,13 @@ const SET_TABS: { key: SetTab; label: string; icon: "sliders" | "code" | "mic" |
export function SettingsView({
initialTab,
onOpenPersona,
+ onCreateSkill,
}: {
initialTab?: SetTab;
onOpenPersona?: (id: string) => void;
+ // Skills doorway (SKILLS-SPEC §5.2): start a new conversation with the description
+ // prefilled — the worker builds the skill and proposes it via save_skill.
+ onCreateSkill?: (description: string) => void;
}) {
// Personas is flag-gated (hidden for launch) — filter the tab AND coerce a stale
// deep-link to it (openSettings("personas") callers) so the page never opens on a
@@ -120,8 +133,11 @@ export function SettingsView({
not under General. */}
+
+ ) : tab === "skills" ? (
+
) : tab === "voice" ? (
) : tab === "memory" ? (
@@ -429,6 +445,8 @@ function AppearanceSection() {
+
+
@@ -588,9 +606,9 @@ function UpdateInline() {
// -- Sidebar density -------------------------------------------------------------
// -- Token savings (PDF attachments; owner ask, 2026-07-17) ---------------------
// Attachments replay with EVERY turn, so a big PDF quietly multiplies token spend.
-// Auto-compaction of long histories is a planned follow-up (punchlist §7) — until
-// then this card is the user's dial: attach thresholds + the fallback for models
-// without native PDF support.
+// This card is the attachment dial: attach thresholds + the fallback for models
+// without native PDF support. (Long-history spend is handled by auto-compaction —
+// the CompactionCard below, OPE-27.)
function TokenSavingsCard() {
const [pdf, setPdf] = useState(null);
@@ -676,6 +694,163 @@ function TokenSavingsCard() {
);
}
+// -- Context compaction (OPE-27) ------------------------------------------------
+// Long sessions are summarized automatically when they approach the model's context
+// limit, so work continues instead of hitting a raw provider error. Two spec'd
+// overrides (trigger % + token cap) and the summarizer-model pin — nothing more.
+function CompactionCard() {
+ const [cfg, setCfg] = useState(null);
+ const [models, setModels] = useState([]);
+ const [labels, setLabels] = useState>({});
+
+ useEffect(() => {
+ getSettings()
+ .then((s) => {
+ setCfg({
+ compaction_threshold_pct: s.compaction_threshold_pct ?? 0.8,
+ compaction_cap_tokens: s.compaction_cap_tokens ?? 250_000,
+ compaction_model: s.compaction_model ?? "",
+ });
+ setModels(s.models || []);
+ setLabels(s.model_labels || {});
+ })
+ .catch(() =>
+ setCfg({
+ compaction_threshold_pct: 0.8,
+ compaction_cap_tokens: 250_000,
+ compaction_model: "",
+ }),
+ );
+ }, []);
+
+ const save = async (patch: Partial) => {
+ setCfg((p) => (p ? { ...p, ...patch } : p));
+ await setCompactionSettings(patch);
+ };
+
+ if (!cfg) return null;
+ const modelLabel = (id: string) => labels[id]?.split(" · ")[0] || id;
+ return (
+
+
Context compaction
+
+ Long sessions are compacted automatically: older turns are summarized so the
+ coworker keeps working instead of running out of context. Your visible transcript
+ is never changed — a small marker shows where compaction happened.
+
+
+
+
+
+
+
+ The cap makes very-large-context models compact early — quality and speed degrade
+ well before their nominal limit.
+
+
+
+ Summarizer model
+
+
+
+ The summary is written by this model. The default follows whatever model the
+ session is using.
+
+
+ );
+}
+
+// -- Composer: context-window bar (owner ask 2026-07-30) ------------------------
+// The chip's bar is context-window occupancy; the session total (unbounded) lives in
+// the popover. Some people would rather not watch a meter at all, hence the toggle.
+function ContextBarCard() {
+ const [shown, setShown] = useState(null);
+
+ useEffect(() => {
+ getSettings()
+ .then((s) => setShown(s.context_bar === true))
+ .catch(() => setShown(false));
+ }, []);
+
+ const save = async (next: boolean) => {
+ setShown(next);
+ await setContextBar(next);
+ };
+
+ if (shown === null) return null;
+ return (
+
+
Composer
+
+
+ );
+}
+
function SidebarCard() {
const [peek, setPeek] = useState(null);
diff --git a/surfaces/gui/src/components/SkillsTab.test.tsx b/surfaces/gui/src/components/SkillsTab.test.tsx
new file mode 100644
index 00000000..97fc42b5
--- /dev/null
+++ b/surfaces/gui/src/components/SkillsTab.test.tsx
@@ -0,0 +1,286 @@
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
+import { SkillsTab } from "./SkillsTab";
+
+// SKILLS-SPEC §5/§6 GUI — Settings ▸ Skills: list + badges + rich-skill file counts, form
+// validation, the doors (write form / upload-with-preview / doorway-to-conversation).
+
+type Call = { url: string; method: string; body: any };
+
+function stubFetch(routes: { match: string; method?: string; json: any }[]) {
+ const calls: Call[] = [];
+ const fn = vi.fn(async (url: string, init?: RequestInit) => {
+ const method = (init?.method || "GET").toUpperCase();
+ calls.push({ url, method, body: init?.body ? JSON.parse(String(init.body)) : undefined });
+ for (const r of routes) {
+ if (url.includes(r.match) && (!r.method || r.method === method)) {
+ return { ok: true, json: async () => r.json } as Response;
+ }
+ }
+ return { ok: true, json: async () => ({}) } as Response;
+ });
+ vi.stubGlobal("fetch", fn);
+ return calls;
+}
+
+const ROW = {
+ name: "weekly-report",
+ description: "Monday status report",
+ instructions: "1. Collect updates\n2. Write it up",
+ scope: "global",
+ source: "local",
+ enabled: true,
+ path: "/skills/weekly-report",
+};
+
+const UPLOADED_ROW = {
+ ...ROW,
+ name: "greet",
+ description: "says hello",
+ source: "uploaded",
+ enabled: false,
+};
+
+const LIST = { skills: [ROW, UPLOADED_ROW] };
+
+afterEach(() => {
+ cleanup();
+ vi.unstubAllGlobals();
+});
+
+// The single add-action: open the "Add skill" menu, pick a door (SKILLS-SPEC §5).
+const openWriteForm = async () => {
+ fireEvent.click(await screen.findByRole("button", { name: /Add skill/ }));
+ fireEvent.click(screen.getByText("Write it myself"));
+};
+
+describe("SkillsTab", () => {
+ it("renders rows with provenance badges and dims disabled skills", async () => {
+ stubFetch([{ match: "/v1/skills", method: "GET", json: LIST }]);
+ render();
+ expect(await screen.findByText("weekly-report")).toBeTruthy();
+ expect(screen.getByText("Monday status report")).toBeTruthy();
+ expect(screen.queryByText("global")).toBeNull(); // no scope badges — global-only (§4.7)
+ expect(screen.getByText("uploaded")).toBeTruthy(); // provenance badge stays
+ const toggles = screen.getAllByRole("switch");
+ expect((toggles[0] as HTMLInputElement).checked).toBe(true);
+ expect((toggles[1] as HTMLInputElement).checked).toBe(false);
+ });
+
+ it("blocks Save until name and instructions are filled", async () => {
+ stubFetch([{ match: "/v1/skills", method: "GET", json: { skills: [] } }]);
+ render();
+ await openWriteForm();
+ const save = screen.getByText("Save skill") as HTMLButtonElement;
+ expect(save.disabled).toBe(true);
+ fireEvent.change(screen.getByLabelText("Name"), { target: { value: "greet" } });
+ expect(save.disabled).toBe(true); // instructions still empty
+ fireEvent.change(screen.getByLabelText("Instructions"), {
+ target: { value: "Say hello." },
+ });
+ expect(save.disabled).toBe(false);
+ });
+
+ it("creates a skill (global, no scope field) and refreshes the list", async () => {
+ const calls = stubFetch([
+ { match: "/v1/skills", method: "GET", json: { skills: [] } },
+ { match: "/v1/skills", method: "POST", json: { ok: true } },
+ ]);
+ render();
+ await openWriteForm();
+ fireEvent.change(screen.getByLabelText("Name"), { target: { value: "greet" } });
+ fireEvent.change(screen.getByLabelText("Instructions"), {
+ target: { value: "Say hello." },
+ });
+ fireEvent.click(screen.getByText("Save skill"));
+ await waitFor(() => {
+ const post = calls.find((c) => c.method === "POST" && c.url.endsWith("/v1/skills"));
+ expect(post?.body).toMatchObject({ name: "greet", instructions: "Say hello." });
+ expect(post?.body.workspace).toBeUndefined(); // global-only: no scope/workspace sent
+ });
+ // list re-fetched after save
+ expect(calls.filter((c) => c.method === "GET" && c.url.includes("/v1/skills")).length).toBeGreaterThan(1);
+ });
+
+ it("edit prefills the form (name locked, body loaded) and PATCHes on save", async () => {
+ const calls = stubFetch([
+ { match: "/v1/skills", method: "GET", json: LIST },
+ { match: "/v1/skills/weekly-report", method: "PATCH", json: { ok: true } },
+ ]);
+ render();
+ await screen.findByText("weekly-report");
+ fireEvent.click(screen.getAllByTitle("Edit")[0]);
+ const name = screen.getByLabelText("Name") as HTMLInputElement;
+ expect(name.value).toBe("weekly-report");
+ expect(name.disabled).toBe(true);
+ const body = screen.getByLabelText("Instructions") as HTMLTextAreaElement;
+ expect(body.value).toContain("Collect updates");
+ fireEvent.change(body, { target: { value: "New steps" } });
+ fireEvent.click(screen.getByText("Save skill"));
+ await waitFor(() => {
+ const patch = calls.find((c) => c.method === "PATCH");
+ expect(patch?.url).toContain("/v1/skills/weekly-report");
+ expect(patch?.body.instructions).toBe("New steps");
+ });
+ });
+
+ it("delete is two-step: arm, then DELETE on confirm", async () => {
+ const calls = stubFetch([
+ { match: "/v1/skills", method: "GET", json: LIST },
+ { match: "/v1/skills/weekly-report", method: "DELETE", json: { ok: true } },
+ ]);
+ render();
+ await screen.findByText("weekly-report");
+ // arm via the trash button (renders "Confirm delete" once armed)
+ fireEvent.click(screen.getByLabelText("Delete weekly-report"));
+ expect(calls.some((c) => c.method === "DELETE")).toBe(false);
+ const confirm = await screen.findByText("Confirm delete");
+ fireEvent.click(confirm);
+ await waitFor(() => {
+ expect(calls.some((c) => c.method === "DELETE" && c.url.includes("weekly-report"))).toBe(true);
+ });
+ });
+
+ it("the enabled switch PATCHes {enabled} and teaches the off rule + physics footnote", async () => {
+ const calls = stubFetch([
+ { match: "/v1/skills", method: "GET", json: LIST },
+ { match: "/v1/skills/weekly-report", method: "PATCH", json: { ok: true } },
+ ]);
+ render();
+ await screen.findByText("weekly-report");
+ fireEvent.click(screen.getByLabelText("weekly-report enabled"));
+ await waitFor(() => {
+ const patch = calls.find((c) => c.method === "PATCH");
+ expect(patch?.body).toMatchObject({ enabled: false });
+ });
+ const status = await screen.findByRole("status");
+ expect(status.textContent).toContain("weekly-report"); // name-first — WHICH skill
+ expect(status.textContent).toContain("turned off everywhere");
+ expect(status.textContent).toContain("clean slate"); // the guaranteed remedy, in place
+ });
+
+ it("upload shows the parsed preview and installs nothing until confirmed", async () => {
+ const calls = stubFetch([
+ { match: "/v1/skills/upload/confirm", method: "POST", json: { ok: true } },
+ {
+ match: "/v1/skills/upload",
+ method: "POST",
+ json: {
+ ok: true,
+ token: "t1",
+ name: "greet",
+ description: "says hello",
+ instructions: "Say hello warmly.",
+ files: ["notes.txt"],
+ },
+ },
+ { match: "/v1/skills", method: "GET", json: { skills: [] } },
+ ]);
+ render();
+ const input = (await screen.findByLabelText("Upload a skill archive")) as HTMLInputElement;
+ const file = new File([new Uint8Array([80, 75, 3, 4])], "greet.zip", { type: "application/zip" });
+ fireEvent.change(input, { target: { files: [file] } });
+ await screen.findByText("Review before installing");
+ expect(screen.getByText("Say hello warmly.")).toBeTruthy();
+ expect(screen.getByText(/notes\.txt/)).toBeTruthy();
+ expect(calls.some((c) => c.url.includes("/upload/confirm"))).toBe(false); // preview ≠ install
+ fireEvent.click(screen.getByText("Install skill"));
+ await waitFor(() => {
+ const confirm = calls.find((c) => c.url.includes("/upload/confirm"));
+ expect(confirm?.body).toMatchObject({ token: "t1" });
+ });
+ });
+
+ it("Add skill menu: three doors; Create with OpenWorker hands off to a conversation", async () => {
+ const calls = stubFetch([{ match: "/v1/skills", method: "GET", json: { skills: [] } }]);
+ const onCreateSkill = vi.fn();
+ render();
+ fireEvent.click(await screen.findByRole("button", { name: /Add skill/ }));
+ // The three doors (§5), each with its teaching subtitle.
+ expect(screen.getByText("Write it myself")).toBeTruthy();
+ expect(screen.getByText("Import a file")).toBeTruthy();
+ expect(screen.getByText(/you review before it installs/)).toBeTruthy();
+ expect(screen.getByText(/asks before adding it to\s+your skills/)).toBeTruthy();
+ fireEvent.click(screen.getByText("Create with OpenWorker"));
+ // Straight to the conversation — the composer is where you describe it (§5.2).
+ expect(onCreateSkill).toHaveBeenCalledWith("");
+ // Settings never drafts: no POST of any kind happened.
+ expect(calls.some((c) => c.method === "POST")).toBe(false);
+ });
+
+ it("offers no scope UI at all — skills are global (§4.7)", async () => {
+ stubFetch([{ match: "/v1/skills", method: "GET", json: { skills: [] } }]);
+ render();
+ await openWriteForm();
+ expect(screen.queryByText("Available in")).toBeNull();
+ expect(screen.queryByLabelText("Everywhere")).toBeNull();
+ expect(screen.queryByLabelText("Only one project")).toBeNull();
+ expect(screen.queryByText(/Move to/)).toBeNull();
+ });
+
+ it("shows the new-session confirmation line after creating a skill", async () => {
+ stubFetch([
+ { match: "/v1/skills", method: "GET", json: { skills: [] } },
+ { match: "/v1/skills", method: "POST", json: { ok: true } },
+ ]);
+ render();
+ await openWriteForm();
+ fireEvent.change(screen.getByLabelText("Name"), { target: { value: "greet" } });
+ fireEvent.change(screen.getByLabelText("Instructions"), { target: { value: "x" } });
+ fireEvent.click(screen.getByText("Save skill"));
+ const status = await screen.findByRole("status");
+ expect(status.textContent).toContain("greet"); // name-first — WHICH skill
+ expect(status.textContent).toContain("can now use it in every conversation");
+ });
+
+ it("the list is the page: no standing add-surfaces, no drafting remnants", async () => {
+ stubFetch([{ match: "/v1/skills", method: "GET", json: { skills: [] } }]);
+ render();
+ await screen.findByRole("button", { name: /Add skill/ });
+ // No permanently-open description box or draft-era UI (§5.2/§9) — adding is menu-only.
+ expect(screen.queryByLabelText("Describe the skill")).toBeNull();
+ expect(screen.queryByText("Start a conversation")).toBeNull();
+ expect(screen.queryByText("Ask OpenWorker to revise")).toBeNull();
+ expect(screen.queryByText(/Not a chat/)).toBeNull();
+ // The menu closes after picking a door.
+ await openWriteForm();
+ expect(screen.queryByText("Write it myself")).toBeNull();
+ expect(screen.getByText("Save skill")).toBeTruthy();
+ });
+
+ it("surfaces server-side validation errors", async () => {
+ stubFetch([
+ { match: "/v1/skills", method: "GET", json: { skills: [] } },
+ { match: "/v1/skills", method: "POST", json: { ok: false, error: "A skill named 'x' already exists in that scope." } },
+ ]);
+ render();
+ await openWriteForm();
+ fireEvent.change(screen.getByLabelText("Name"), { target: { value: "x" } });
+ fireEvent.change(screen.getByLabelText("Instructions"), { target: { value: "y" } });
+ fireEvent.click(screen.getByText("Save skill"));
+ expect(await screen.findByRole("alert")).toBeTruthy();
+ expect(screen.getByText(/already exists/)).toBeTruthy();
+ });
+});
+
+describe("SkillsTab — rich-skill disclosure (§6)", () => {
+ it("shows a file count only when a skill bundles resources", async () => {
+ stubFetch([
+ {
+ match: "/v1/skills",
+ method: "GET",
+ json: {
+ skills: [
+ { name: "plain", description: "d", instructions: "i", scope: "global", source: "local", enabled: true, path: "/p", files: 0 },
+ { name: "rich", description: "d", instructions: "i", scope: "global", source: "uploaded", enabled: true, path: "/r", files: 3 },
+ ],
+ },
+ },
+ ]);
+ render();
+ const note = await screen.findByTitle("Show folder");
+ expect(note.textContent).toContain("3 files");
+ // The one-file skill carries no count at all — only rich skills are marked.
+ expect(screen.getAllByTitle("Show folder")).toHaveLength(1);
+ });
+});
diff --git a/surfaces/gui/src/components/SkillsTab.tsx b/surfaces/gui/src/components/SkillsTab.tsx
new file mode 100644
index 00000000..6849ffcf
--- /dev/null
+++ b/surfaces/gui/src/components/SkillsTab.tsx
@@ -0,0 +1,440 @@
+import { useRef, useState } from "react";
+import { useEffect } from "react";
+import {
+ createSkill,
+ deleteSkill,
+ listSkills,
+ revealSkill,
+ stageSkillUpload,
+ confirmSkillUpload,
+ updateSkill,
+ type SkillRow,
+ type SkillUploadPreview,
+} from "../api";
+import { Icon } from "./Icon";
+
+// Settings ▸ Skills (SKILLS-SPEC §5/§6) — the management home: the LIST is the page; every
+// add-surface appears only when summoned from the single "Add skill" menu (the three doors:
+// write form / import / start-a-conversation). Everything a user creates here is GLOBAL —
+// "skills are things your worker knows everywhere". Creation-by-AI is a CONVERSATION (the
+// menu's third door starts one; the worker proposes via save_skill) — there is no
+// in-Settings drafting and no description box: the composer is where you describe it.
+// Persona-bundled skills arrive with personas (§10), managed on the persona page, not here.
+
+const CARD = "rounded-xl2 border border-line bg-panel";
+const FIELD_LABEL = "text-[12.5px] font-medium text-ink";
+const INPUT =
+ "w-full min-w-0 px-3 py-2 rounded-lg border border-line bg-paper text-[13px] text-ink outline-none focus:border-accent";
+const BTN_ACCENT =
+ "text-[12.5px] px-3 py-2 rounded-lg bg-accent text-white shrink-0 disabled:opacity-40";
+const BTN_BORDERED =
+ "text-[12.5px] px-3 py-2 rounded-lg border border-line bg-paper hover:border-lineStrong shrink-0";
+const BADGE =
+ "text-[11px] px-2 py-0.5 rounded-full border border-line bg-paper text-muted shrink-0";
+
+type Editor = {
+ mode: "new" | "edit";
+ name: string;
+ description: string;
+ instructions: string;
+};
+
+const emptyEditor = (): Editor => ({
+ mode: "new",
+ name: "",
+ description: "",
+ instructions: "",
+});
+
+async function fileToB64(file: File): Promise {
+ // FileReader fallback: File.arrayBuffer is missing in some webviews (and jsdom).
+ const buf =
+ typeof file.arrayBuffer === "function"
+ ? await file.arrayBuffer()
+ : await new Promise((resolve, reject) => {
+ const r = new FileReader();
+ r.onload = () => resolve(r.result as ArrayBuffer);
+ r.onerror = () => reject(r.error);
+ r.readAsArrayBuffer(file);
+ });
+ const bytes = new Uint8Array(buf);
+ let bin = "";
+ const CHUNK = 0x8000;
+ for (let i = 0; i < bytes.length; i += CHUNK) {
+ bin += String.fromCharCode(...bytes.subarray(i, i + CHUNK));
+ }
+ return btoa(bin);
+}
+
+export function SkillsTab({
+ onCreateSkill,
+}: {
+ // The doorway (SKILLS-SPEC §5.2): starts a new conversation with the description
+ // prefilled in the composer — the worker builds the skill and proposes it via save_skill.
+ onCreateSkill?: (description: string) => void;
+}) {
+ const [rows, setRows] = useState([]);
+ const [editor, setEditor] = useState(null);
+ const [upload, setUpload] = useState(null);
+ const [addOpen, setAddOpen] = useState(false);
+ const [armedDelete, setArmedDelete] = useState(null);
+ const [error, setError] = useState("");
+ // The state-change callout (SKILLS-SPEC §4.1 #2): name-first so the user knows WHICH
+ // skill, and visually distinct so it can't be skimmed past (tester ask 2026-07-27).
+ const [notice, setNotice] = useState<{ name: string; text: string; tone: "ok" | "warn" } | null>(
+ null,
+ );
+ const fileInput = useRef(null);
+
+ // Confirmation copy (SKILLS-SPEC §4.1 #2): name-first, outcome + remedy only, in words a
+ // person already owns — now / everywhere / off / start a new one. Never mechanism ("the
+ // model will be told…") or engineering timing ("from the next message") — owner-driver
+ // review rounds, 2026-07-27. The engine countermands disabled-but-loaded skills silently;
+ // the copy promises only the guaranteed part.
+ const CONFIRMATION = "— the worker can now use it in every conversation.";
+ const OFF_NOTE =
+ "turned off everywhere. If a conversation already used it, start a new one for a completely clean slate.";
+ const DELETE_NOTE =
+ "removed. If a conversation already used it, start a new one for a completely clean slate.";
+
+ const refresh = () => listSkills().then(setRows);
+ useEffect(() => {
+ refresh();
+ }, []);
+
+ const fail = (res: { ok?: boolean; error?: string }) => {
+ setNotice(null);
+ if (res.ok === false) {
+ setError(res.error || "Something went wrong.");
+ return true;
+ }
+ setError("");
+ return false;
+ };
+
+ const save = async () => {
+ if (!editor) return;
+ const res =
+ editor.mode === "new"
+ ? await createSkill({
+ name: editor.name.trim(),
+ description: editor.description.trim(),
+ instructions: editor.instructions,
+ })
+ : await updateSkill(editor.name, {
+ description: editor.description.trim(),
+ instructions: editor.instructions,
+ });
+ if (fail(res)) return;
+ setEditor(null);
+ if (editor.mode === "new")
+ setNotice({ name: editor.name.trim(), text: CONFIRMATION, tone: "ok" });
+ refresh();
+ };
+
+ const onPickFile = async (file: File | undefined) => {
+ if (!file) return;
+ const res = await stageSkillUpload(await fileToB64(file), file.name);
+ if (fail(res)) return;
+ setUpload(res);
+ };
+
+ const confirmUpload = async () => {
+ if (!upload?.token) return;
+ const res = await confirmSkillUpload(upload.token);
+ if (fail(res)) return;
+ setUpload(null);
+ setNotice({ name: upload.name || "Skill", text: CONFIRMATION, tone: "ok" });
+ refresh();
+ };
+
+ const remove = async (row: SkillRow) => {
+ if (armedDelete !== row.name) {
+ setArmedDelete(row.name);
+ return;
+ }
+ setArmedDelete(null);
+ const res = await deleteSkill(row.name);
+ if (fail(res)) return;
+ setNotice({ name: row.name, text: DELETE_NOTE, tone: "warn" });
+ refresh();
+ };
+
+ return (
+
+
+
+
Skills
+
+ Reusable instructions the worker can follow in every conversation. Off here means
+ off everywhere.
+
+
+ {/* One add-action, three doors behind it (SKILLS-SPEC §5): the list is the page. */}
+
+ No skills yet — Add skill teaches your worker its first one, like
+ “prepare my Monday status report”.
+
+ ) : null}
+ {rows.map((row) => (
+
+
+
+
+ {row.name}
+
+ {row.source !== "local" ? {row.source} : null}
+ {/* §6: a rich skill must not look identical to a one-file one. Styled as a
+ chip with a folder icon so it READS as clickable (live drive: plain
+ text hid the affordance). */}
+ {row.files ? (
+
+ ) : null}
+
+ {/* Full description, wrapping — a skill's one-liner is its menu entry; cutting
+ it mid-word hid what the skill does (live drive). */}
+
{row.description}
+
+
+
+
+
+ ))}
+
+
+
+ );
+}
diff --git a/surfaces/gui/src/components/Transcript.tsx b/surfaces/gui/src/components/Transcript.tsx
index 15c0878f..cd6168bc 100644
--- a/surfaces/gui/src/components/Transcript.tsx
+++ b/surfaces/gui/src/components/Transcript.tsx
@@ -6,6 +6,28 @@ import { Markdown } from "./Markdown";
import { ConnectorMessageCard } from "./ConnectorMessageCard";
import { Icon } from "./Icon";
+// Long user pastes swallow the transcript (owner ask 2026-07-30): clamp past a generous
+// threshold with a more…/less… toggle. Normal typed messages never see the control; the
+// full text still drives copy (BubbleMeta) and is what the model received.
+const USER_CLAMP_CHARS = 1200;
+
+function ClampedUserText({ text }: { text: string }) {
+ const [open, setOpen] = useState(false);
+ if (text.length <= USER_CLAMP_CHARS) return <>{text}>;
+ return (
+ <>
+ {open ? text : text.slice(0, USER_CLAMP_CHARS).trimEnd() + "…"}
+
+ >
+ );
+}
+
// Hover affordances for a message bubble (FB-005): copy the raw text + the message's time.
// Lives in a ZERO-HEIGHT strip under the bubble (absolute, inside the transcript's 20px gap)
// so revealing it on group-hover never shifts the layout. `ts` is unix seconds — canonical
@@ -165,7 +187,15 @@ function StepRow({ tool, approval }: { tool: ToolItem; approval?: ApprovalItem }
{running ? : "●"}
-
+
{approval && approvalChip(approval.resolved)}
{!!tool.standingRule && (
)}
- {item.text}
+
diff --git a/surfaces/gui/src/humanize.skills.test.ts b/surfaces/gui/src/humanize.skills.test.ts
new file mode 100644
index 00000000..49cbda3e
--- /dev/null
+++ b/surfaces/gui/src/humanize.skills.test.ts
@@ -0,0 +1,17 @@
+// SKILLS-SPEC §4.6 GUI — the transcript trust line: a load_skill tool call always renders
+// as a human-readable "Used skill: X" step, whether model-invoked or forced via /skill.
+import { describe, expect, it } from "vitest";
+import { humanizeTool } from "./humanize";
+
+describe("humanizeTool(load_skill)", () => {
+ it("renders the Used-skill line with the skill name", () => {
+ const line = humanizeTool("load_skill", { name: "incident-summary" });
+ expect(line.pre).toBe("Used skill: ");
+ expect(line.obj).toBe("incident-summary");
+ });
+
+ it("stays safe on null/missing args", () => {
+ expect(humanizeTool("load_skill", null).obj).toBe("");
+ expect(humanizeTool("load_skill", {}).obj).toBe("");
+ });
+});
diff --git a/surfaces/gui/src/humanize.ts b/surfaces/gui/src/humanize.ts
index ad4c751c..f24c7f31 100644
--- a/surfaces/gui/src/humanize.ts
+++ b/surfaces/gui/src/humanize.ts
@@ -88,6 +88,10 @@ export function humanizeTool(name: string, args: any): HumanLine {
}
case "explore":
return { pre: "Sent a sub-agent to explore — ", obj: `“${trunc(String(a.task ?? a.prompt ?? ""), 60)}”` };
+ case "load_skill":
+ // SKILLS-SPEC §4.1 #4 — the trust line: the transcript always shows the moment a
+ // skill's instructions were picked up, model-invoked or forced via /skill.
+ return { pre: "Used skill: ", obj: String(a.name ?? "") };
case "ask_user":
return { pre: "Asked you a question" };
case "propose_plan":
@@ -131,6 +135,11 @@ export function humanizeApprovalTitle(name: string, args: any): HumanLine {
return a.title
? { pre: "Create the automation ", obj: `“${trunc(String(a.title), 60)}”` }
: { pre: "Create an automation" };
+ case "save_skill":
+ // SKILLS-SPEC §5.2/§7: "Add", never "install"; destination is "your skills".
+ return a.name
+ ? { pre: "Add skill ", obj: String(a.name), post: " to your skills" }
+ : { pre: "Add a skill to your skills" };
default:
return { pre: `Use ${name}` };
}
diff --git a/surfaces/gui/src/itemsFromMessages.test.ts b/surfaces/gui/src/itemsFromMessages.test.ts
index ad025475..b87b8717 100644
--- a/surfaces/gui/src/itemsFromMessages.test.ts
+++ b/surfaces/gui/src/itemsFromMessages.test.ts
@@ -83,6 +83,20 @@ describe("itemsFromMessages model switch", () => {
});
});
+describe("itemsFromMessages compaction", () => {
+ it("replays the persisted compacted marker as an info notice (the divider)", () => {
+ const items = itemsFromMessages([
+ { role: "user", content: "hi" },
+ { role: "notice", kind: "compacted", text: "Context compacted — earlier turns were summarized" },
+ ] as any);
+ expect(items[1]).toEqual({
+ kind: "notice",
+ tone: "info",
+ text: "Context compacted — earlier turns were summarized",
+ });
+ });
+});
+
describe("itemsFromMessages reasoning", () => {
it("attaches the reasoning sidecar to assistant items; thinking-only messages still render", () => {
const items = itemsFromMessages([
diff --git a/surfaces/gui/src/itemsFromMessages.ts b/surfaces/gui/src/itemsFromMessages.ts
index b13b81e2..54cc3364 100644
--- a/surfaces/gui/src/itemsFromMessages.ts
+++ b/surfaces/gui/src/itemsFromMessages.ts
@@ -33,6 +33,9 @@ export function itemsFromMessages(messages: ConversationMessage[]): Item[] {
continue;
}
const user = userItemFromContent(m.content);
+ // Force-run (`/skill …`): `_display` holds the user's literal line; `content` carries
+ // the model-facing framing. Render what the user typed — one truthful bubble.
+ if (typeof m._display === "string" && m._display) user.text = m._display;
// `ts` (unix seconds) is the server's canonical-message stamp; older sessions have none.
if (typeof m.ts === "number") user.ts = m.ts;
if (user.text || user.attachments?.length) items.push(user);
@@ -72,7 +75,10 @@ export function itemsFromMessages(messages: ConversationMessage[]): Item[] {
? { kind: "notice", tone: "warn", text: "Interrupted." }
: m.kind === "model_switch"
? { kind: "notice", tone: "info", text: m.text || "Model switched" }
- : { kind: "notice", tone: "warn", text: "Error: " + (m.text || "unknown"), retriable: true },
+ : m.kind === "compacted"
+ ? // The subtle "compacted here" divider (OPE-27) — the transcript itself is intact.
+ { kind: "notice", tone: "info", text: m.text || "Context compacted" }
+ : { kind: "notice", tone: "warn", text: "Error: " + (m.text || "unknown"), retriable: true },
);
}
// system messages are omitted; tool-result messages are folded into the tool row above
diff --git a/surfaces/gui/src/providers/ProviderSetup.tsx b/surfaces/gui/src/providers/ProviderSetup.tsx
index b2f962d4..1d8dc881 100644
--- a/surfaces/gui/src/providers/ProviderSetup.tsx
+++ b/surfaces/gui/src/providers/ProviderSetup.tsx
@@ -499,7 +499,7 @@ export function ProviderForm({
)}
{info && !info.needs_key && (
- No API key needed — Ollama runs models on this Mac.{" "}
+ No API key needed — Ollama runs models on this computer.{" "}