Files
openworker/coworker/server/manager.py
T
Rohit C Prasad 5ebaa376d7 Add MCP server flow: Remote URL + JSON tabs, Test connection
Explicit connect reports stderr tails; a 401 on an anonymous http probe becomes needs-sign-in with a one-click OAuth switch.
Test button probes any enabled server row without opening a session.
2026-08-20 13:50:13 -07:00

4233 lines
188 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Session manager — owns engines (one per session), stores, and the provider.
Each session is bound to a workspace folder (Code requires one). Storage is a single DB
under a data dir (global for the real server, per-workspace for tests), so recents and
sessions span folders.
"""
from __future__ import annotations
import asyncio
import json
import logging
import os
import re
import shutil
import subprocess
import time
from pathlib import Path
from typing import Any, Optional
from ..agent import build_engine
from ..agents import get_agent
from ..connections import (
PersonaConnectionStore,
SessionConnectionStore,
effective as effective_connections,
)
from ..inbox import InboxStore, args_preview
from ..inbox_routing import InboxRouting
from ..personas import PersonaRegistry
from ..personas.registry import set_registry as set_persona_registry
from ..selfwake import WakeStore
from ..mentions import MentionSessionStore
from ..subscriptions import ChannelBuffer, SubscriptionStore
from ..unrouted import UnroutedStore
from ..unattended import UnattendedRegistry
from ..audit import AuditStore
from ..config import load_config, workspace_allowed_commands
from ..conversations import ConversationStore, title_from
from ..engine import ApprovalOutcome, Approver, TurnEngine
from ..roots import RootDir
from ..workspace_trust import WorkspaceTrustStore
from ..automation import Schedule, ScheduledTask, Scheduler, TaskRun, TaskStore
from ..connectors import (
Gateway,
MessageSource,
connect_connector,
connector_list,
disconnect_connector,
experimental_enabled,
load_settings,
make_adapter,
set_experimental_enabled,
slack_split,
update_connector_tools,
)
from ..connectors.browser_automation import (
browser_close_session,
browser_state,
browser_take_screenshot,
)
from ..connectors.parked import ParkedStore
from ..mcp import (
MCPManager,
build_callables,
delete_global_server,
load_mcp_servers,
patch_global_server,
put_global_server,
read_global,
)
from ..memory import MemorySettingsStore, MemoryStore, Scope, SQLiteMemoryStore
from ..permissions import Mode
from ..agents import list_agents as _list_agents
from ..providers import (
ProviderClient,
ProviderRouter,
descriptor_configured,
get_descriptor,
provider_descriptors,
verify_provider_key,
)
from ..secrets import SecretStore, state_dir
from ..sessions import SessionRecord
from ..skills import (
SessionSkillStore,
SkillLoader,
SkillStore,
effective_skills,
)
_SCOPES = {s.value for s in Scope}
logger = logging.getLogger("coworker.manager")
def _grants_of(engine) -> dict[str, Any]:
"""The engine's session-scoped "Always allow" approvals, in persistable shape."""
tools = sorted(getattr(engine.permissions, "session_allow_tools", None) or ())
commands = sorted(getattr(engine.permissions, "session_allow_commands", None) or ())
return {"tools": tools, "commands": commands} if (tools or commands) else {}
def _approval_body(request) -> str:
"""Approval card body: the tool's reason (if any) plus a compact preview of its args, so a
mirrored 'Run `write_file`?' shows the path/content rather than just the tool name.
"""
reason = (getattr(request, "reason", "") or "").strip()
preview = args_preview(getattr(request, "arguments", None))
return "\n".join(p for p in (reason, preview) if p)
class SessionManager:
def __init__(
self,
*,
workspace: Optional[str | Path] = None, # default/seed workspace (e.g. --cwd)
data_dir: Optional[str | Path] = None,
model: str = "gpt-5.6-sol",
mode: Mode = Mode.INTERACTIVE,
provider: Optional[ProviderClient] = None,
) -> None:
self.default_workspace = (
str(Path(workspace).expanduser().resolve()) if workspace else None
)
self.model = model
self.mode = mode
self.provider = provider
if data_dir is not None:
base = Path(data_dir).expanduser()
elif self.default_workspace is not None:
base = Path(self.default_workspace) / ".coworker"
else:
base = state_dir()
base.mkdir(parents=True, exist_ok=True)
self.memory_store: MemoryStore = SQLiteMemoryStore(base / "coworker.db")
# MEMORY-SPEC §4.3/§6: the on/off switch + the user's standing rules. Settings-
# level, outside the memory table; read at engine build time.
self.memory_settings = MemorySettingsStore(base / "memory-settings.json")
self.audit_store = AuditStore(base / "coworker.db")
self.session_store = ConversationStore(base)
self.session_store.canonicalize_workspaces() # collapse /tmp vs /private/tmp etc.
if self.default_workspace:
self.session_store.touch_workspace(self.default_workspace)
self._engines: dict[str, TurnEngine] = {}
self._running_sessions: set[str] = (
set()
) # sessions with an in-flight turn (busy)
# Sessions with an auto-title LLM call in flight (FB-010) — one call at a time.
self._autotitle_inflight: set[str] = set()
self._autotitle_tasks: set[asyncio.Task] = set()
self._autotitle_attempts: dict[str, int] = {}
self.workspace_trust = WorkspaceTrustStore()
self.secrets = SecretStore()
# No explicit provider injected → route by the model's `provider:` prefix (OpenAI default,
# Ollama, …). Tests inject a provider directly and bypass the router. The same router is
# shared by every engine and the `/v1/chat/completions` proxy.
if self.provider is None:
self.provider = ProviderRouter(
self.secrets, default_provider="openai", on_use=self._note_provider_use
)
self.mcp = MCPManager(secrets=self.secrets)
# OAuth MCP servers with a sign-in in flight / their last connect error —
# feeds list_mcp's status so the GUI can show "authorizing…" and failures.
self._mcp_authorizing: set[str] = set()
self._mcp_errors: dict[str, str] = {}
# http servers whose anonymous connect came back 401/403 — the failure is
# "needs sign-in", so the GUI offers the OAuth switch instead of a raw error.
self._mcp_auth_hints: set[str] = set()
# Servers that failed to connect while preparing a session's tools —
# drained once by the WS handler to append a transcript notice.
self._mcp_session_failures: dict[str, list[str]] = {}
self.gateway: Optional[Gateway] = None
self._data_base = base
# Desktop/UI prefs (default model, onboarding state) — not secrets; a plain JSON file.
self._prefs = self._load_prefs()
if self._prefs.get("default_model"):
self.model = self._prefs["default_model"]
# Seed the PDF-fallback module global from prefs so engines see the user's
# choice from the first turn (set_pdf_settings keeps it in sync after).
from ..pdf_support import set_fallback_mode
set_fallback_mode(self.pdf_settings()["pdf_fallback"])
# Per-session live-view registry: every socket open on a session id gets the turn's events,
# whoever drives the turn (foreground user_message, channel delivery, self-wake, resume).
# Delivery itself is socket-independent — this only governs *live visibility*.
self._session_clients: dict[str, set[Any]] = {}
# App-wide event sockets (/ws/events): session-independent pushes — today the
# automation-run-started toast (UX-026); badges could ride it later.
self._event_clients: set[Any] = set()
# Automation: scheduled tasks store + the tick scheduler (started in the lifespan).
# The scheduler also resumes self-wake'd sessions each tick (extra_tick).
self.task_store = TaskStore(base / "automation.db")
self.scheduler = Scheduler(
self.task_store, self._run_scheduled_task, extra_tick=self.resume_due_wakes
)
# Personas: registry + lifecycle state under this manager's data dir. Installed as the
# process singleton so agents.get_agent resolves persona ids (incl. third-party) here.
self.personas = PersonaRegistry(state_path=base / "personas.json")
set_persona_registry(self.personas)
# Inbox (cross-session human-attention queue), routing (named inboxes + Slack/Telegram
# bindings), the Unattended toggle, and self-wake records.
self.inbox = InboxStore(base / "inbox.json")
self.inbox_routing = InboxRouting(base / "inbox_routing.json")
self.unattended = UnattendedRegistry(base / "unattended.json")
self.wakes = WakeStore(base / "wakes.json")
# Channel subscriptions (inbound): persisted (session_id, channel) records + a ring buffer
# of recently-seen channel messages for get_channel_messages.
self.subscriptions = SubscriptionStore(base / "subscriptions.json")
self.channel_buffer = ChannelBuffer(state_path=base / "channels.json")
# Mention router (§31): thread target → the session that owns that Slack thread.
# Also the durable source of the thread's standing send_message grant (re-seeded
# onto the engine in get_engine).
self.mention_sessions = MentionSessionStore(base / "mention_threads.json")
# Unauthorized inbound messages, parked instead of dropped (one-step allow-and-deliver).
self.parked = ParkedStore(base / "parked.json")
# People directory: "platform:user_id" → display name, noted from every inbound
# (authorized or parked) so allow-list chips read "Rohit Prsad", not "U07JK…".
self._people_path = base / "people.json"
try:
self._people: dict[str, str] = json.loads(self._people_path.read_text())
except (OSError, ValueError):
self._people = {}
# Seed from already-parked messages (they carry resolved names) so an allow made from
# an old parked item still gets a named chip.
for it in self.parked.list():
if it.get("user_name"):
self._people.setdefault(
f"{it['platform']}:{it['user_id']}", it["user_name"]
)
# Connection hierarchy (UI-REFRESH §4): per-persona default connector on/off (seeded from the
# manifest, then user-editable) + per-session overrides. Resolved into the session's effective
# connector set, which gates inbound delivery and the engine's connector tools.
self.persona_connections = PersonaConnectionStore(
base / "persona_connections.json"
)
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")
# -- workspaces -------------------------------------------------------------
def open_workspace(self, path: str, *, create: bool = False) -> dict[str, Any]:
resolved = Path(path).expanduser()
if resolved.exists() and not resolved.is_dir():
return {"path": str(resolved), "ok": False, "error": "not a directory"}
if not resolved.exists():
if not create:
return {
"path": str(resolved),
"ok": False,
"error": "folder does not exist",
}
try:
resolved.mkdir(parents=True, exist_ok=True)
except OSError as exc:
return {"path": str(resolved), "ok": False, "error": str(exc)}
resolved = resolved.resolve()
self.session_store.touch_workspace(str(resolved))
return {
"path": str(resolved),
"ok": True,
"git_branch": _git_branch(resolved),
"command_trust": self.workspace_command_trust(resolved),
}
def workspace_command_trust(self, path: str | Path) -> dict[str, Any]:
if not str(path).strip():
return {
"workspace": "",
"requested_commands": [],
"trusted": False,
"required": False,
}
canonical = WorkspaceTrustStore.canonical(path)
commands = (
workspace_allowed_commands(canonical)
if Path(canonical).is_dir()
else []
)
trusted = self.workspace_trust.is_trusted(canonical)
return {
"workspace": canonical,
"requested_commands": commands,
"trusted": trusted,
"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]:
if not str(path).strip():
return {"ok": False, "error": "workspace path is required"}
candidate = Path(path).expanduser()
if trusted and not candidate.is_dir():
return {"ok": False, "error": "workspace is not a directory"}
canonical = self.workspace_trust.set_trusted(candidate, trusted)
effective = load_config(
canonical, workspace_trusted=trusted
).allowed_commands
# Apply trust/revocation immediately to live sessions rooted at this exact path.
for engine in self._engines.values():
engine_workspace = str(
(getattr(engine, "audit_context", {}) or {}).get("workspace", "")
)
if engine_workspace and WorkspaceTrustStore.canonical(
engine_workspace
) == canonical:
engine.permissions.allowed_commands = list(effective)
return {
"ok": True,
**self.workspace_command_trust(canonical),
}
def trusted_workspaces(self) -> list[dict[str, Any]]:
return [
{
**self.workspace_command_trust(path),
"exists": Path(path).is_dir(),
}
for path in self.workspace_trust.list()
]
def recent_workspaces(self) -> list[dict[str, Any]]:
"""Recent real projects for the folder gate. Per-conversation scratch dirs are
excluded — they're workspaces to the session store, but never something a user
should re-open as a 'project'."""
scratch = self.scratch_base().resolve()
out = []
for path in self.session_store.recent_workspaces():
p = Path(path)
try:
if p.resolve().is_relative_to(scratch):
continue
except OSError:
pass
out.append({"path": path, "name": p.name, "exists": p.is_dir()})
return out
DEFAULT_SCRATCH_BASE = "~/OpenWorker"
def scratch_base(self) -> Path:
"""Common area for per-conversation scratch directories. Configurable via prefs."""
base = self._prefs.get("scratch_base") or self.DEFAULT_SCRATCH_BASE
return Path(base).expanduser()
def _provision_scratch(self, session_id: str) -> str:
"""Create (idempotently) and return this conversation's scratch directory."""
d = self.scratch_base() / session_id
d.mkdir(parents=True, exist_ok=True)
return str(d.resolve())
def resolve_workspace(self, requested: Optional[str]) -> Optional[str]:
if requested:
p = Path(requested).expanduser()
if p.is_dir():
return str(p.resolve())
return None
return self.default_workspace
# -- engines ----------------------------------------------------------------
def engine_workspace(
self, session_id: str, *, workspace: Optional[str] = None, agent: str = "code"
) -> Optional[str]:
"""The workspace `get_engine` would bind — for prepping MCP tools beforehand."""
record = self.session_store.load(session_id)
if record:
return record.workspace or None
ag = get_agent(agent or "code")
return self.resolve_workspace(workspace) if ag.needs_workspace else None
def get_engine(
self,
session_id: str,
*,
workspace: Optional[str] = None,
agent: str = "code",
approver: Optional[Approver] = None,
extra_tools: Optional[list[Any]] = None,
directory_requester: Optional[Any] = None,
plan_approver: Optional[Any] = None,
question_asker: Optional[Any] = None,
) -> Optional[TurnEngine]:
engine = self._engines.get(session_id)
if engine is not None:
if approver is not None:
engine.approver = approver
if directory_requester is not None:
engine.directory_requester = directory_requester
if plan_approver is not None:
engine.plan_approver = plan_approver
if question_asker is not None:
engine.question_asker = question_asker
return engine
record = self.session_store.load(session_id)
is_new_session = record is None
agent_name = (record.agent if record else agent) or "code"
ag = get_agent(agent_name)
if record:
ws = record.workspace or None
model, mode, messages = record.model, Mode(record.mode), record.messages
else:
ws = self.resolve_workspace(workspace) if ag.needs_workspace else None
model, mode, messages = self.model, self.mode, None
if ag.needs_workspace and (not ws or not Path(ws).is_dir()):
# Knowledge surfaces (Cowork, Ops, …) start "orphan": no folder picked →
# auto-provision a per-conversation scratch directory (generalizes MyHelper's
# auto-workspace). Code-family surfaces still require a real repo; Chat needs none.
if ag.family == "knowledge":
ws = self._provision_scratch(session_id)
else:
return None
if ws:
self.session_store.touch_workspace(ws)
# Orphan surfaces are multi-root: the scratch (ws) is the primary writable root, plus any
# folders the user added (persisted per session). Code/Chat stay single-root (roots=None).
roots = None
if ag.family == "knowledge" and ws:
extra = [
r
for r in ((record.extra_roots if record else []) or [])
if Path(str(r.get("path", ""))).is_dir()
]
roots = [{"path": ws, "writable": True, "label": "scratch"}, *extra]
engine = build_engine(
agent=ag,
workspace=ws,
model=model,
mode=mode,
provider=self.provider,
# Memory off (§4.3) = stop LEARNING, not amnesia: saved facts still inject
# and stay usable, only the write tools go. Read at build time; running
# sessions finish under the mode they started with.
memory_store=self.memory_store,
memory_off=not self.memory_settings.enabled,
# LIVE, not a snapshot: turning saving off mid-conversation must take
# effect at once (owner-hit 2026-07-28 — a running session kept saving).
memory_saving_enabled=lambda: self.memory_settings.enabled,
# Callable, not a snapshot: editing your instructions in Settings applies
# to conversations already open (same reason as the saving switch).
user_rules=lambda: self.memory_settings.user_rules,
on_memory_saved=self._memory_saved_notifier(session_id),
messages=messages,
extra_tools=extra_tools,
secrets=self.secrets,
task_store=self.task_store,
wake_store=self.wakes,
session_id=session_id,
audit_sink=self.audit_store.append,
roots=roots,
# WS sessions pass mode-aware callbacks (attended → live prompt, unattended → Inbox).
# Background / self-wake / durable-resume runs have no live socket → default to the
# Inbox-based callbacks so a rebuilt engine can still get approvals/answers (and, on
# resume, the already-resolved item returns immediately).
approver=approver or self.inbox_approver(session_id, agent),
directory_requester=directory_requester
or self.inbox_directory_requester(session_id, agent),
plan_approver=plan_approver or self.inbox_plan_approver(session_id, agent),
question_asker=question_asker
or self.inbox_question_asker(session_id, agent),
subscription_store=self.subscriptions,
channel_buffer=self.channel_buffer,
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.
owning_task = self.task_store.task_for_run_session(session_id)
if owning_task is not None:
self._seed_task_permissions(engine, owning_task)
# A mention-spawned session (§31) keeps its in-thread reply pre-approved across
# rebuilds/restarts — the grant is re-derived from the durable thread map.
for thread_target in self.mention_sessions.targets_for(session_id):
engine.permissions.task_rules.setdefault("send_message", set()).add(
thread_target
)
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)
return engine
def _emit_session_created(self, session_id: str, persona_id: str) -> None:
"""Phase 5 telemetry, fired once per brand-new session on a background thread
(never blocks session start). cloud.emit_session_created is a hard no-op when
signed out or opted out, and sends only content-free facts."""
import threading
from .. import cloud
from ..config import load_config
entry = self.personas.get(persona_id)
family = entry.family if entry else ""
workspace_kind = entry.workspace if entry else ""
def _send() -> None:
try:
cloud.emit_session_created(
self.secrets,
load_config(),
session_id=session_id,
persona_id=persona_id,
persona_family=family,
workspace_kind=workspace_kind,
)
except Exception:
pass # telemetry must never surface as a session error
threading.Thread(target=_send, daemon=True).start()
def _routing_targets(self, session_id: str, agent: str) -> list[str]:
"""The channel address(es) this session's Inbox routes OUT to — used to warn when a
subscription (inbound) collides with Inbox routing (outbound) on the same channel.
"""
binding = self.inbox_routing.binding_for(
self.inbox_routing.route_for(session_id, agent)
)
return [f"{binding.channel}:{binding.target}"] if binding.channel else []
# -- connection hierarchy (UI-REFRESH §4) -----------------------------------
def _persona_of(self, session_id: str, persona_id: Optional[str] = None) -> str:
if persona_id:
return persona_id
record = self.session_store.load(session_id)
return (record.agent if record else None) or self.personas.default_id()
def effective_connectors(
self, session_id: str, persona_id: Optional[str] = None
) -> set[str]:
"""The connectors effectively enabled for this session (§4.1): connected AND not muted by
the session override / persona default. Drives the engine's connector-tool gating; seeds the
persona defaults from the manifest on first read using the full connected set.
"""
persona = self._persona_of(session_id, persona_id)
connected = {c["name"] for c in connector_list(self.secrets) if c["connected"]}
entry = self.personas.get(persona)
manifest = entry.manifest if entry else None
persona_defaults = self.persona_connections.defaults_for(
persona, manifest, connected=connected
)
session_overrides = self.session_connections.get(session_id)
return set(
effective_connections(
connected=connected,
persona_defaults=persona_defaults,
session_overrides=session_overrides,
)
)
def _inbound_connector_allowed(self, session_id: str, connector: str) -> bool:
"""Whether an inbound message on `connector` should be DELIVERED to `session_id` (§4.3).
Uses the SAME effective set as the engine's connector-tool gating so the inbound gate and the
tool gate can never disagree (a muted connector is muted both ways, from the first message).
"""
return connector in self.effective_connectors(session_id)
# -- persona + session connection surfaces (UI-REFRESH §5/§6) ----------------
@staticmethod
def _workspace_kind(entry) -> str:
"""The persona's workspace requirement as a stable string for the GUI. Manifest-backed
personas carry it verbatim (git|deliverable|none); builtins (which have no manifest) map
family/needs_workspace into the SAME vocabulary so the frontend reads one enum:
code-family → git, knowledge-family with a workspace → deliverable, none → none.
"""
if entry.manifest is not None:
return entry.manifest.workspace
if not entry.needs_workspace:
return "none"
return "git" if entry.family == "code" else "deliverable"
def _connected_connectors(self) -> set[str]:
"""The account-connected connector names (the first layer of the §4 hierarchy)."""
return {c["name"] for c in connector_list(self.secrets) if c["connected"]}
def _persona_default_connections(
self, persona_id: str, manifest, connected: set[str]
) -> list[dict[str, Any]]:
"""The persona's default connector map (seeded from the manifest's connector recommends on
first read, then user-editable) as a list, each annotated with account-connectedness.
"""
defaults = self.persona_connections.defaults_for(
persona_id, manifest, connected=connected
)
return [
{"connector": c, "enabled": bool(enabled), "connected": c in connected}
for c, enabled in defaults.items()
]
def persona_detail(self, persona_id: str) -> Optional[dict[str, Any]]:
"""Identity + capabilities + recommends(+connected) + default connections for one persona
(UI-REFRESH §5). Returns None for an unknown id (the route maps that to an error).
"""
entry = self.personas.get(persona_id)
if entry is None:
return None
manifest = entry.manifest
connected = self._connected_connectors()
recommends = [
{
"kind": rec.kind,
"ref": rec.ref,
"reason": rec.reason,
"tier": rec.tier,
"connected": rec.ref in connected,
}
for rec in (manifest.recommends if manifest else [])
]
return {
"id": entry.id,
"name": entry.name,
"icon": entry.icon,
"tagline": entry.tagline,
"description": manifest.description if manifest else "",
"enabled": self.personas.is_enabled(entry.id),
"tools": list(entry.tools),
"recommended_models": list(manifest.recommended_models) if manifest else [],
"default_permission_mode": (
manifest.default_permission_mode if manifest else "interactive"
),
"workspace": self._workspace_kind(entry),
"recommends": recommends,
"default_connections": self._persona_default_connections(
persona_id, manifest, connected
),
}
def set_persona_connection(
self, persona_id: str, connector: str, enabled: bool
) -> dict[str, Any]:
"""Set a persona-default connector on/off (UI-REFRESH §5). Seeds the manifest defaults
first so the stored row stays complete (the edit overlays the full seed rather than
collapsing the row to this one connector), then returns the refreshed default_connections
so the client can re-render without a second GET."""
entry = self.personas.get(persona_id)
if entry is None:
return {"ok": False, "error": f"unknown persona: {persona_id}"}
manifest = entry.manifest
connected = self._connected_connectors()
self.persona_connections.defaults_for(persona_id, manifest, connected=connected)
self.persona_connections.set(persona_id, connector, bool(enabled))
return {
"ok": True,
"default_connections": self._persona_default_connections(
persona_id, manifest, connected
),
}
def set_persona_enabled(self, persona_id: str, enabled: bool) -> dict[str, Any]:
"""Flip a persona's enabled flag. Disabling also archives its real (unarchived,
non-internal) sessions — disable means "put this coworker and its history away", so
the persona's sidebar section disappears with it (owner call, 2026-07-04). Re-enabling
never unarchives: that would overwrite the user's archive state; history returns one
click at a time via the Show-archived disclosure. Raises KeyError for unknown ids.
"""
self.personas.set_enabled(persona_id, enabled)
archived = 0
if not enabled:
for r in self.session_store.list():
if (
r.agent == persona_id
and not r.archived
and not r.session_id.startswith("__")
):
self.session_store.set_flags(r.session_id, archived=True)
archived += 1
return {"ok": True, "archived_sessions": archived}
def _connection_detail(
self, session_id: str, connector: str, info: Optional[dict[str, Any]]
) -> str:
"""A short human description of WHY a connector is live for a session: the chat ids it's
subscribed to on that platform, plus "DMs" if this is the designated DM session. Channel
*names* would need the live adapter's resolve cache (not cheap here), so we show the chat
ids; with no subscription/DM tie we fall back to the connector's title."""
prefix = f"{connector}:"
parts = [
s.channel.split(":", 1)[1]
for s in self.subscriptions.for_session(session_id)
if s.channel.startswith(prefix)
]
if self.dm_session() == session_id:
parts.append("DMs")
if parts:
return " · ".join(parts)
return (info or {}).get("title") or connector
def session_connections_view(
self, session_id: str, persona_id: Optional[str] = None
) -> dict[str, Any]:
"""The per-session connections drawer payload (UI-REFRESH §6): every account-connected
connector with its effective on/off state (muted ones stay VISIBLE as off — a §4.2 toggle
must never make a row vanish), the persona's connector recommends that aren't yet
account-connected, and the attention count (= those unconnected recommends).
``persona_id`` is the caller's hint (the GUI knows the active persona). It matters for a
brand-new session: no SessionRecord exists until the first turn persists, so without the
hint the view would resolve to the DEFAULT persona and show its defaults/recommends —
the owner's 2026-07-03 finding (a fresh Project Manager session rendered cowork's view).
"""
persona = self._persona_of(session_id, persona_id)
entry = self.personas.get(persona)
manifest = entry.manifest if entry else None
connectors = connector_list(self.secrets)
by_name = {c["name"]: c for c in connectors}
connected_names = {c["name"] for c in connectors if c["connected"]}
effective = self.effective_connectors(session_id, persona)
connected = [
{
"connector": name,
"enabled": name in effective,
"detail": self._connection_detail(session_id, name, by_name.get(name)),
}
for name in sorted(connected_names)
]
recommended = [
{
"connector": rec.ref,
"reason": rec.reason,
"tier": rec.tier,
"connected": False,
}
for rec in (manifest.recommends if manifest else [])
if rec.kind == "connector" and rec.ref not in connected_names
]
return {
"connected": connected,
"recommended": recommended,
"attention": sum(1 for r in recommended if not r["connected"]),
}
def inbox_question_asker(self, session_id: str, agent: str):
"""The Unattended `ask_user` handler: turn the agent's question into an Inbox item and
suspend until a human answers it (from the Inbox, or inline when they open the session).
Also the default for background/self-wake runs (no live socket). Mirrors to a bound channel
like the approver does."""
async def ask(
args: dict[str, Any], tool_call_id: Optional[str] = None
) -> dict[str, Any]:
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,
inbox=inbox_name,
tool_call_id=tool_call_id,
**fields,
)
if (
item.state != "pending"
): # durable resume re-raised an already-answered prompt
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_result(item.questions, answer)
return ask
def inbox_approver(self, session_id: str, agent: str):
"""Inbox-based approver — the default for no-socket runs (background, self-wake, durable
resume). On resume the item already exists + is resolved, so wait returns at once.
"""
async def approve(request):
item = self.inbox.add_approval(
session_id,
f"Run `{request.tool_name}`?",
body=_approval_body(request),
inbox=self.inbox_routing.route_for(session_id, agent),
tool_call_id=getattr(request, "tool_call_id", None),
data=self.approval_prompt_data(session_id, request),
)
if item.state == "pending":
self.persist_session(session_id)
await self.mirror_inbox_item(item)
resolution = await self.inbox.wait(item.id)
return self.approval_outcome(resolution, request, session_id)
return approve
def inbox_directory_requester(self, session_id: str, agent: str):
async def request(args, tool_call_id=None):
item = self.inbox.add_directory(
session_id,
"Grant access to a folder?",
body=str(args.get("reason", "")),
inbox=self.inbox_routing.route_for(session_id, agent),
data={
"path": str(args.get("path", "")),
"writable": bool(args.get("writable", False)),
},
tool_call_id=tool_call_id,
)
if item.state == "pending":
self.persist_session(session_id)
await self.mirror_inbox_item(item)
resp = _parse_inbox_json(await self.inbox.wait(item.id))
if not resp.get("granted"):
return {"granted": False, "reason": "the user declined the request"}
path = (resp.get("path") or args.get("path") or "").strip()
if not path:
return {"granted": False, "error": "no directory was provided"}
writable = bool(resp.get("writable", args.get("writable", False)))
res = self.add_root(session_id, path, writable)
if not res.get("ok"):
return {
"granted": False,
"error": res.get("error", "could not grant access"),
}
return {"granted": True, "path": path, "writable": writable}
return request
def inbox_plan_approver(self, session_id: str, agent: str):
async def approve(args, tool_call_id=None):
item = self.inbox.add_plan(
session_id,
"Approve the plan?",
body=str(args.get("plan", "")),
inbox=self.inbox_routing.route_for(session_id, agent),
tool_call_id=tool_call_id,
)
if item.state == "pending":
self.persist_session(session_id)
await self.mirror_inbox_item(item)
resp = _parse_inbox_json(await self.inbox.wait(item.id))
if not resp.get("approved"):
return {
"approved": False,
"feedback": resp.get("feedback") or "the user rejected the plan",
}
return {"approved": True, "mode": resp.get("mode") or "interactive"}
return approve
def persist_session(self, session_id: str) -> None:
"""Save the cached engine's thread (so a prompt's pending tool call survives a crash)."""
engine = self._engines.get(session_id)
if engine is not None:
self.save(session_id, engine)
async def resolve_inbox(self, item_id: str, resolution: str) -> bool:
"""Resolve an Inbox item from any surface (REST / Slack button / channel reply). If the
asking agent is still suspended live, that await handles it. Otherwise the process restarted
(or the engine was evicted) while blocked → durably resume: rebuild the engine from the
saved thread and continue the turn."""
item = self.inbox.get(item_id)
ok = self.inbox.resolve(item_id, resolution)
if not ok or item is None:
return ok
if not self.is_running(item.session_id):
await self._durable_resume(item)
return ok
async def _durable_resume(self, item) -> None:
if not getattr(item, "tool_call_id", None):
return # nothing to reconstruct (legacy item) — best-effort: leave it
engine = self.get_engine(item.session_id)
if engine is None or not hasattr(engine, "resume"):
return
self.mark_running(item.session_id)
try:
async for _event in engine.resume():
pass
self.save(item.session_id, engine)
finally:
self.mark_idle(item.session_id)
# -- MCP --------------------------------------------------------------------
async def prepare_mcp_tools(
self, session_id: str, *, workspace: Optional[str] = None, agent: str = "code"
) -> list[Any]:
"""Connect enabled MCP servers (global + workspace) and return their tool callables.
Called from the async WS handler before `get_engine`; no-op if the engine is already
built (its MCP tools are attached). Servers that fail to connect are skipped.
"""
if session_id in self._engines:
return []
from ..connectors.descriptors import get_descriptor
from ..connectors.tool_defs import (
approval_for_tool,
mcp_tool_defs,
tool_enabled,
)
from ..mcp import oauth as mcp_oauth
ws = self.engine_workspace(session_id, workspace=workspace, agent=agent)
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,
workspace_trusted=self._mcp_workspace_trusted(ws),
):
if not server.enabled:
continue
if server.auth == "oauth" and not mcp_oauth.has_tokens(
server.name, self.secrets
):
# NEVER start an interactive OAuth flow from a turn: a token-less
# server here would open a browser and block every session for the
# full flow timeout (owner-hit 2026-07-20 — a failed one-click's
# leftover config froze all new sessions). Flows start only from an
# explicit connect in Settings/Connectors.
continue
descriptor = get_descriptor(server.name)
backed = descriptor is not None and bool(descriptor.mcp_url)
if backed:
# Connector-backed server: obey the same gates as connector tools —
# the session's effective connector set and the per-tool toggles.
# The descriptor's PIN is authoritative over whatever the config
# file says (drift can only ever shrink the surface).
if effective is None:
effective = self.effective_connectors(session_id, agent)
if server.name not in effective:
continue
prefix = f"mcp__{server.name}__"
server.include_tools = [
t.name.removeprefix(prefix)
for t in mcp_tool_defs(server.name)
if tool_enabled(self.secrets, server.name, t.name)
]
try:
conn = await self.mcp.ensure(server)
self._mcp_errors.pop(server.name, None)
except Exception as exc:
if mcp_oauth.is_auth_required(exc):
# Stored tokens no longer refresh (vendor rotated/expired
# them) — the non-interactive connect refused to open a
# browser. Record it so the MCP page shows WHY the server is
# dark; the session just runs without its tools.
self._mcp_errors[server.name] = (
"sign-in required — reconnect this server from its page"
)
logger.info(
"mcp %s needs re-auth; skipped for this session", server.name
)
else:
# Bad command / crashed child / unreachable url — the session
# still runs without the tools, but the failure must not be
# silent (three-for-three silent failures in the 2026-08-20
# drill): record it for the MCP page and the session notice.
msg = str(exc) or exc.__class__.__name__
tail = self.mcp.last_stderr(server.name)
if tail:
msg = f"{msg}{tail}"
self._mcp_errors[server.name] = msg[:500]
logger.warning(
"mcp %s failed to connect: %s", server.name, msg[:500]
)
self._mcp_session_failures.setdefault(session_id, []).append(
server.name
)
continue
callables = build_callables(
server,
conn.tools,
lambda tool, args, name=server.name: self.mcp.call(name, tool, args),
loop,
)
if backed:
# Per-tool approval from the pinned read/write classification
# (server-level requires_approval is off for backed servers);
# anything unclassified stays approval-gated — fail closed.
for fn in callables:
fn.__aisuite_tool_metadata__.requires_approval = approval_for_tool(
fn.__aisuite_tool_metadata__.name, default=True
)
out.extend(callables)
return out
def pop_mcp_failures(self, session_id: str) -> list[tuple[str, Optional[str]]]:
"""Drain (name, error) for servers that failed while preparing this session's
tools — consumed once by the WS handler to append a transcript notice."""
names = self._mcp_session_failures.pop(session_id, [])
return [(n, self._mcp_errors.get(n)) for n in names]
def list_mcp(self) -> list[dict[str, Any]]:
"""Servers from the global config + connection status (does not connect)."""
from ..mcp import oauth as mcp_oauth
from ..connectors.descriptors import get_descriptor
out = []
for name, raw in read_global().items():
d = get_descriptor(name)
if d is not None and d.mcp_url:
# Connector-backed server: surfaced on the Connectors page (its
# connect/disconnect lifecycle lives there), not in the MCP tab.
continue
connected = name in self.mcp._conns
is_oauth = str(raw.get("auth", "")).lower() == "oauth"
if connected:
status = "connected"
elif not raw.get("enabled", True):
status = "disabled"
elif name in self._mcp_authorizing:
status = "authorizing"
elif is_oauth and not mcp_oauth.has_tokens(name, self.secrets):
status = "needs_auth"
elif name in self._mcp_errors and not is_oauth:
# Startup/connection failure (stdio crash, unreachable url) — the
# drill class. OAuth servers keep their softer statuses: acquiring
# tokens supersedes a stale sign-in error (the GUI still prints
# last_error under the row either way).
status = "error"
else:
status = "configured"
out.append(
{
"name": name,
"enabled": bool(raw.get("enabled", True)),
"transport": (
"http"
if (
raw.get("url")
or str(raw.get("type", "")).lower()
in {"http", "sse", "streamable-http"}
)
else "stdio"
),
"requires_approval": bool(raw.get("requires_approval", True)),
"auth": "oauth" if is_oauth else None,
"status": status,
"auth_hint": name in self._mcp_auth_hints,
"last_error": self._mcp_errors.get(name),
"tool_count": (
len(self.mcp._conns[name].tools) if connected else None
),
"config": _redact(raw),
}
)
return out
async def connect_mcp(self, name: str) -> dict[str, Any]:
"""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."""
from ..mcp import oauth as mcp_oauth
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)
self._mcp_errors.pop(name, None)
self._mcp_auth_hints.discard(name)
try:
# The ONE place a browser sign-in may start: an explicit connect.
conn = await self.mcp.ensure(server, interactive=True)
return {"ok": True, "tools": len(conn.tools)}
except Exception as exc:
if (
server.transport == "http"
and server.auth != "oauth"
and mcp_oauth.is_http_auth_error(exc)
):
# Anonymous probe of a guarded server (the add-by-URL flow):
# the answer is sign-in, not a raw 401 dump.
self._mcp_auth_hints.add(name)
msg = "authentication required — sign in to connect"
else:
msg = str(exc) or exc.__class__.__name__
tail = self.mcp.last_stderr(name)
if tail:
msg = f"{msg}{tail}"
self._mcp_errors[name] = msg[:500]
return {"ok": False, "error": self._mcp_errors[name]}
finally:
self._mcp_authorizing.discard(name)
return {"ok": False, "error": f"unknown MCP server: {name}"}
async def mcp_connect_connector(self, name: str) -> dict[str, Any]:
"""One-click connect for an MCP-BACKED connector (descriptor.mcp_url): seed
the global server entry pinned to the curated allowlist, run the browser
OAuth flow, and mark the connector profile `mode: "mcp"` on success."""
from ..connectors.descriptors import get_descriptor
from ..connectors.tool_defs import mcp_pinned_tools
d = get_descriptor(name)
if d is None or not d.mcp_url:
return {"ok": False, "error": f"{name} has no MCP connect path"}
put_global_server(
name,
{
"url": d.mcp_url,
"auth": "oauth",
# Server-level approval off: writes gate per-tool via the pinned
# read/write classification (prepare_mcp_tools); unknown vendor
# tools never load at all (include_tools).
"requires_approval": False,
"include_tools": mcp_pinned_tools(name),
"enabled": True,
},
)
result = await self.connect_mcp(name)
if result.get("ok"):
profile = self.secrets.get(f"{name}:default") or {}
self.secrets.put(
f"{name}:default", {**profile, "mode": "mcp", "enabled": True}
)
else:
# A failed connect must take its seeded config with it: an enabled
# oauth entry with no tokens lingers forever (nothing owns it once
# the descriptor's mcp_url is gone) and re-arms at every session
# start — the owner-hit asana leftover, 2026-07-20.
delete_global_server(name)
return result
async def signout_mcp(self, name: str) -> dict[str, Any]:
"""Drop the live connection (if any) and forget the stored OAuth tokens."""
from ..mcp import oauth as mcp_oauth
conn = self.mcp._conns.get(name)
if conn is not None:
conn.shutdown.set()
self._mcp_errors.pop(name, None)
removed = mcp_oauth.sign_out(name, self.secrets)
return {"ok": True, "had_tokens": removed}
def add_mcp(self, name: str, config: dict[str, Any]) -> dict[str, Any]:
put_global_server(name, config)
return {"ok": True, "name": name}
def patch_mcp(self, name: str, changes: dict[str, Any]) -> dict[str, Any]:
ok = patch_global_server(name, changes)
return {"ok": ok, "name": name}
def delete_mcp(self, name: str) -> dict[str, Any]:
ok = delete_global_server(name)
if ok:
# A later re-add under the same name starts clean, not pre-failed.
self._mcp_errors.pop(name, None)
self._mcp_auth_hints.discard(name)
return {"ok": ok, "name": name}
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,
workspace_trusted=self._mcp_workspace_trusted(self.default_workspace),
):
if server.name == name:
try:
conn = await self.mcp.ensure(server)
except Exception as exc:
return {"name": name, "ok": False, "error": str(exc), "tools": []}
return {
"name": name,
"ok": True,
"tools": [
{"name": t.name, "description": getattr(t, "description", "")}
for t in conn.tools
],
}
return {"name": name, "ok": False, "error": "unknown server", "tools": []}
async def reload_mcp(self) -> dict[str, Any]:
"""Drop live MCP connections so new sessions reconnect with fresh config."""
await self.mcp.aclose()
return {"ok": True}
# -- connectors -------------------------------------------------------------
def list_connectors(self) -> list[dict[str, Any]]:
# Enrich two-way connectors with the live gateway's recently-seen senders, so the Connectors
# tab can manage the allow-list inline (each recent sender flagged authorized or not).
connectors = connector_list(self.secrets)
for c in connectors:
if not (c.get("two_way") and c.get("connected")):
continue
allowed = set(c.get("allowed_users") or [])
# Per-workspace allow-lists (managed relay) — a sender is judged against
# ITS workspace's list; the flat list only governs team-less (socket) events.
team_allowed = {
w["team_id"]: set(w.get("allowed_users") or [])
for w in (c.get("workspaces") or [])
}
recent = self.gateway.recent_senders(c["name"]) if self.gateway else []
for r in recent:
team = r.get("team_id")
pool = team_allowed.get(team, set()) if team else allowed
r["authorized"] = r.get("user_id") in pool
# Backfill from the people directory (an event may predate name scopes).
r["user_name"] = r.get("user_name") or self._people.get(
f"{c['name']}:{r.get('user_id')}"
)
c["recent"] = recent
# Parked unauthorized messages (§19) — the connector page resolves them inline.
c["unauthorized"] = self.parked.list(c["name"])
# Allow-list display names from the people directory (ids stay the source of truth).
c["allowed_user_names"] = {
u: self._people.get(f"{c['name']}:{u}")
for u in (c.get("allowed_users") or [])
}
c["approval_owner_names"] = {
u: self._people.get(f"{c['name']}:{u}")
for u in (c.get("approval_owner_ids") or [])
}
for w in c.get("workspaces") or []:
w["allowed_user_names"] = {
u: self._people.get(f"{c['name']}:{u}")
for u in (w.get("allowed_users") or [])
}
w["approval_owner_names"] = {
u: self._people.get(f"{c['name']}:{u}")
for u in (w.get("approval_owner_ids") or [])
}
return connectors
def connect_connector(
self, name: str, fields: dict[str, Any], *, acknowledged: bool = False
) -> dict[str, Any]:
# validates the token by a live API call (sync httpx) — run off the event loop
return connect_connector(self.secrets, name, fields, acknowledged=acknowledged)
def set_experimental_connectors(self, value: bool) -> dict[str, Any]:
return set_experimental_enabled(self.secrets, value)
def disconnect_connector(self, name: str) -> dict[str, Any]:
# MCP-backed profile: drop the live server connection before the tokens go.
conn = self.mcp._conns.get(name)
if conn is not None:
conn.shutdown.set()
return disconnect_connector(self.secrets, name)
def update_connector_tools(
self, name: str, enabled: dict[str, Any]
) -> dict[str, Any]:
return update_connector_tools(self.secrets, name, enabled)
def list_audit(
self,
*,
limit: int = 100,
session_id: Optional[str] = None,
connector: Optional[str] = None,
tool: Optional[str] = None,
) -> list[dict[str, Any]]:
return self.audit_store.list(
limit=limit, session_id=session_id, connector=connector, tool=tool
)
def browser_state(self) -> dict[str, Any]:
return browser_state()
def browser_screenshot(self) -> dict[str, Any]:
return browser_take_screenshot()
def browser_close(self) -> dict[str, Any]:
return browser_close_session()
def list_artifacts(self, session_id: str) -> list[dict[str, Any]]:
record = self.session_store.load(session_id)
workspace = record.workspace if record else self.default_workspace
if not workspace:
return []
root = Path(workspace).expanduser().resolve()
if not root.is_dir():
return []
out: list[dict[str, Any]] = []
suffixes = {
".md",
".markdown",
".html",
".htm",
".txt",
".json",
".csv",
".tsv",
".py",
".js",
".ts",
".tsx",
".css",
".png",
".jpg",
".jpeg",
".webp",
".gif",
".pdf",
".xlsx",
".xls",
".pptx",
".ppt",
".pptm",
".docx",
".doc",
".docm",
}
# 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
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
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, *, 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)
workspace = record.workspace if record else self.default_workspace
if not workspace:
return None, "no workspace"
root = Path(workspace).expanduser().resolve()
target = (root / path).expanduser().resolve()
try:
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, (
"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]:
# 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
# "Open in default app" instead of trying to render them.
return {"ok": True, "path": path, "kind": "office"}
if kind in ("image", "pdf", "sheet"):
import base64
if target.stat().st_size > self.MAX_BINARY_PREVIEW:
return {
"ok": False,
"error": "file too large to preview — use Reveal to open it",
}
mime = {
".png": "image/png",
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".webp": "image/webp",
".gif": "image/gif",
".pdf": "application/pdf",
".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
".xls": "application/vnd.ms-excel",
}.get(target.suffix.lower(), "application/octet-stream")
data = base64.b64encode(target.read_bytes()).decode("ascii")
return {
"ok": True,
"path": path,
"kind": kind,
"data_url": f"data:{mime};base64,{data}",
}
try:
text = target.read_text(encoding="utf-8")
except UnicodeDecodeError:
return {"ok": False, "error": "binary file cannot be previewed"}
return {
"ok": True,
"path": path,
"kind": kind,
"content": text[:500000],
"truncated": len(text) > 500000,
}
def reveal_artifact(
self, session_id: str, path: str, mode: str = "reveal"
) -> dict[str, Any]:
"""Show the file in the OS file manager (`reveal`) or open it with its default app
(`open`). The server runs on the user's machine in both desktop and browser builds, so
this is local. Cross-platform: macOS `open`, Windows Explorer/ShellExecute, Linux
`xdg-open`."""
import os
import subprocess
import sys
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" and not is_dir
else ["open", str(target)]
)
subprocess.Popen(
args, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL
)
elif sys.platform == "win32":
if mode == "reveal" and not is_dir:
# Explorer wants the path glued to the switch: /select,<path>
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" and not is_dir else str(target)
subprocess.Popen(
["xdg-open", tgt],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
except OSError as exc:
return {"ok": False, "error": str(exc)}
return {"ok": True}
# -- web search -------------------------------------------------------------
def get_web_search(self) -> dict[str, Any]:
from ..config import load_config
from ..web import provider_names
profile = self.secrets.get("web_search:default") or {}
provider = (
profile.get("provider") or load_config().web_search_provider or "duckduckgo"
)
return {
"provider": provider,
"has_key": bool(profile.get("api_key")),
"providers": provider_names(),
}
def set_web_search(
self, provider: str, api_key: Optional[str] = None
) -> dict[str, Any]:
from ..web import provider_names
if provider not in provider_names():
return {"ok": False, "error": f"unknown provider: {provider}"}
profile: dict[str, Any] = {"provider": provider}
if api_key:
profile["api_key"] = api_key
self.secrets.put("web_search:default", profile)
return {"ok": True, "provider": provider}
# -- model providers (OpenAI, Ollama, …) ------------------------------------
def get_providers(self) -> list[dict[str, Any]]:
"""Descriptor + per-provider status for the Settings UI. Never returns secret values;
non-secret field values (e.g. the Ollama base URL) ARE returned so the form can prefill.
"""
out: list[dict[str, Any]] = []
for d in provider_descriptors():
profile = self.secrets.get(f"provider:{d.name}") or {}
configured = descriptor_configured(d, profile)
values = {
f.key: profile.get(f.key)
for f in d.fields
if not f.secret and profile.get(f.key)
}
out.append(
{
**d.to_dict(),
"configured": configured,
"values": values,
"suggested_models": self._suggested_models(d.name),
# Key hygiene for the Settings pane: when the key was saved (date, stamped
# by set_provider) and when the provider last served a completion (epoch,
# stamped by the router's on_use hook). Absent for env-only config.
"key_set_at": profile.get("key_set_at"),
"last_used_at": (self._prefs.get("provider_last_used") or {}).get(
d.name
),
}
)
return out
def pick_native_folder(self) -> dict[str, Any]:
"""Open the OS folder picker FROM THE SIDECAR — the browser GUI can't obtain absolute
paths from web file dialogs, but the sidecar is local and can (the desktop shell uses
Tauri's own picker instead). Blocking until pick/cancel; callers run it off-thread.
"""
import subprocess
import sys
if sys.platform == "darwin":
cmd = [
"osascript",
"-e",
'tell application "System Events" to activate',
"-e",
'POSIX path of (choose folder with prompt "Give the coworker access to a folder")',
]
elif sys.platform == "win32":
# WinForms folder dialog via PowerShell — no extra deps. -STA is required
# (the dialog silently fails in the default MTA apartment).
ps = (
"Add-Type -AssemblyName System.Windows.Forms; "
"$f = New-Object System.Windows.Forms.FolderBrowserDialog; "
"$f.Description = 'Give the coworker access to a folder'; "
"if ($f.ShowDialog() -eq [System.Windows.Forms.DialogResult]::OK) "
"{ [Console]::Out.Write($f.SelectedPath) }"
)
cmd = ["powershell.exe", "-NoProfile", "-STA", "-Command", ps]
else:
# Linux: zenity when present; otherwise the GUI's paste-a-path input remains.
cmd = ["zenity", "--file-selection", "--directory"]
try:
out = subprocess.run(cmd, capture_output=True, text=True, timeout=300)
except (OSError, subprocess.TimeoutExpired):
return {"ok": False, "error": "no native folder picker available"}
path = (out.stdout or "").strip()
if out.returncode != 0 or not path:
return {"ok": False, "canceled": True}
return {"ok": True, "path": path}
def _note_provider_use(self, name: str) -> None:
"""Router on_use hook: remember when a provider last served a completion. Persisted
THROTTLED (once per provider per minute) — this fires on every model call, from engine
threads, and prefs.json isn't a place for a write-per-token-of-work."""
import time
now = time.time()
used = self._prefs.setdefault("provider_last_used", {})
if now - float(used.get(name) or 0) < 60:
return
used[name] = now
try:
self._save_prefs()
except OSError:
pass
# Suggestions for the OpenAI-compatible vendor providers (checked against vendor docs
# 2026-07-04; refresh alongside `recommended_model` in providers/registry.py).
COMPAT_MODELS = {
"zai": ["glm-5.2", "glm-4.6"],
"deepseek": ["deepseek-v4-flash", "deepseek-v4-pro"],
"kimi": ["kimi-k2.6", "kimi-k2.5"],
"minimax": ["MiniMax-M2.5", "MiniMax-M2.5-highspeed", "MiniMax-M3"],
"qwen": ["qwen3-max", "qwen3-coder-plus", "qwen-plus"],
"xai": ["grok-4.3", "grok-4"],
"mistral": ["mistral-large-latest", "mistral-small-latest"],
}
def _suggested_models(self, name: str) -> list[str]:
"""Bare model-name suggestions for the 'add model' form (datalist), per provider.
Ollama → live `/api/tags` (best-effort); everyone else → the curated matrix,
topped up with the compat-vendor extras the matrix doesn't vouch for."""
if name == "ollama":
return [m.split(":", 1)[-1] for m in self._ollama_models()]
from ..providers.matrix import models_for_provider
return list(
dict.fromkeys(
[*models_for_provider(name), *self.COMPAT_MODELS.get(name, [])]
)
)
def set_provider(
self, name: str, fields: Optional[dict[str, Any]]
) -> dict[str, Any]:
"""Store a provider's config in its `provider:<name>` SecretStore profile and rebuild
its cached client. Merges provided fields into any existing profile."""
d = get_descriptor(name)
if d is None:
return {"ok": False, "error": f"unknown provider: {name}"}
fields = fields or {}
profile = dict(self.secrets.get(f"provider:{name}") or {})
for f in d.fields:
if f.key not in fields:
continue
val = fields.get(f.key)
if isinstance(val, str):
val = val.strip()
if val:
profile[f.key] = val
elif not f.required:
profile.pop(f.key, None)
missing = [f.label for f in d.fields if f.required and not profile.get(f.key)]
if missing:
return {"ok": False, "error": "missing: " + ", ".join(missing)}
# A (re)pasted key stamps its save date — Settings shows "key added <date>" so stale
# keys are visible. Endpoint-only saves keep the original stamp.
if isinstance(fields.get("api_key"), str) and fields["api_key"].strip():
from datetime import date
profile["key_set_at"] = date.today().isoformat()
self.secrets.put(f"provider:{name}", profile)
self._refresh_provider(name)
# Convenience: if the provider recommends a model and it's actually available, add it to
# the curated list so it shows up in the composer right after configuring the provider.
rec = d.recommended_model
added: Optional[str] = None
if rec and rec in self._suggested_models(name):
# OpenAI models stay bare (the router's default); others carry their prefix.
added = rec if name == "openai" else f"{name}:{rec}"
self.add_model(added)
# First working provider wins the default: if the current default model belongs to a
# provider with no usable config (the fresh-install gpt-5.6-sol case), switch the default to
# this provider's model. A default that already works is never stolen.
if added and not self._provider_configured(self._model_provider(self.model)):
self.set_default_model(added)
return {"ok": True, "provider": name, "recommended_model": rec}
def remove_provider(self, name: str) -> dict[str, Any]:
"""Forget a provider's stored config (Settings ▸ Models "Remove key"). The whole
`provider:<name>` profile goes — key, endpoint, key_set_at — so the provider reads
as never configured. Curated models stay; they just gray out until a new key."""
d = get_descriptor(name)
if d is None:
return {"ok": False, "error": f"unknown provider: {name}"}
self.secrets.delete(f"provider:{name}")
self._refresh_provider(name)
return {"ok": True, "provider": name}
def verify_provider(
self, name: str, fields: Optional[dict[str, Any]]
) -> dict[str, Any]:
"""Test a provider's credentials with a live read-only call, WITHOUT persisting them, so
onboarding can offer a "Test" button. Falls back to stored/env values when the form left
a field blank (e.g. testing an already-configured provider)."""
import os
d = get_descriptor(name)
if d is None:
return {"ok": False, "error": f"unknown provider: {name}"}
fields = fields or {}
profile = self.secrets.get(f"provider:{name}") or {}
merged = {}
for f in d.fields:
val = fields.get(f.key) or profile.get(f.key) or ""
if isinstance(val, str):
val = val.strip()
if val:
merged[f.key] = val
api_key = merged.get("api_key", "")
if not api_key and d.env_key:
api_key = os.environ.get(d.env_key, "").strip()
has_key_field = any(f.key == "api_key" for f in d.fields)
if d.needs_key and has_key_field and not api_key:
return {"ok": False, "error": "Enter an API key to test."}
if d.needs_key and not has_key_field:
# Multi-field cloud providers (Bedrock): required fields must be present;
# actual credentials may be ambient (~/.aws, env) and are checked by the call.
missing = [f.label for f in d.fields if f.required and not merged.get(f.key)]
if missing:
return {"ok": False, "error": "missing: " + ", ".join(missing)}
return verify_provider_key(
name, api_key=api_key, base_url=merged.get("base_url", ""), fields=merged
)
def _model_provider(self, model: str) -> str:
"""The provider a model string routes to (known `prefix:` or the OpenAI default)."""
if ":" in (model or ""):
prefix = model.split(":", 1)[0]
if get_descriptor(prefix) is not None:
return prefix
return "openai"
def _provider_configured(self, name: str) -> bool:
d = get_descriptor(name)
if d is None:
return False
return descriptor_configured(d, self.secrets.get(f"provider:{name}") or {})
# -- settings / prefs (model API key, default model, onboarding) -------------
def _prefs_path(self) -> Path:
return self._data_base / "prefs.json"
def _load_prefs(self) -> dict[str, Any]:
try:
return json.loads(self._prefs_path().read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return {}
def _save_prefs(self) -> None:
self._prefs_path().write_text(
json.dumps(self._prefs, indent=2), encoding="utf-8"
)
# -- direct-message routing -------------------------------------------------
def dm_session(self) -> Optional[str]:
"""The session a DM to the bot is routed to (user-designated). None → DMs are parked."""
sid = self._prefs.get("dm_session")
return sid or None
def set_dm_session(self, session_id: Optional[str]) -> dict[str, Any]:
"""Designate (or clear, with a falsy id) the session that handles incoming DMs."""
sid = (session_id or "").strip()
if sid:
self._prefs["dm_session"] = sid
else:
self._prefs.pop("dm_session", None)
self._save_prefs()
return {"ok": True, "dm_session": self.dm_session()}
def _ollama_alive(self) -> bool:
"""Best-effort local-Ollama liveness, cached 30s (get_settings runs on every GUI
fetch — no 2s probe inline). Keyless is not the same as PRESENT: `ollama:*` picker
entries render only when an Ollama actually answers, so a machine with no Ollama
never shows phantom local models (e.g. a stray pasted string saved as a model id,
caught 2026-07-21)."""
import time
now = time.monotonic()
cached = getattr(self, "_ollama_alive_cache", None)
if cached and now - cached[0] < 30:
return cached[1]
profile = self.secrets.get("provider:ollama") or {}
base = (profile.get("base_url") or "http://localhost:11434").strip().rstrip("/")
if base.endswith("/v1"):
base = base[: -len("/v1")]
try:
import httpx
alive = httpx.get(base + "/api/tags", timeout=0.8).status_code == 200
except Exception:
alive = False
self._ollama_alive_cache = (now, alive)
return alive
def _ollama_models(self) -> list[str]:
"""Live list of models pulled into the configured Ollama server (via its native
`/api/tags`), as `ollama:<name>` so they're directly selectable. Empty if Ollama isn't
configured or unreachable — best-effort, never raises."""
profile = self.secrets.get("provider:ollama")
if not profile:
return []
base = (profile.get("base_url") or "http://localhost:11434").strip().rstrip("/")
if base.endswith("/v1"):
base = base[: -len("/v1")]
try:
import httpx
data = httpx.get(base + "/api/tags", timeout=2.0).json()
return [
f"ollama:{m['name']}" for m in data.get("models", []) if m.get("name")
]
except Exception:
return []
def _curated_models(self) -> list[str]:
"""The models offered in the composer's selector: every curated-matrix model
(`get_settings` culls the ones whose provider has no key) plus custom ids the user
added, minus matrix models they removed. Deliberately NO built-in seed list — a
fresh install offers nothing until a provider key exists, and then exactly that
provider's matrix models appear. The active default is always kept selectable.
"""
from ..providers.matrix import MATRIX
user = self._prefs.get("models")
user = user if isinstance(user, list) else []
hidden = set(self._prefs.get("hidden_models") or [])
models = [m for m in [*MATRIX, *user] if m not in hidden]
return list(dict.fromkeys([self.model, *models]))
def add_model(self, model: str) -> dict[str, Any]:
"""Add a model id (e.g. `gpt-4o`, `ollama:qwen2.5-coder:32b`) to the picker.
Custom ids persist in prefs; a previously removed matrix model is just unhidden
(storing it too would shadow future matrix updates)."""
from ..providers.matrix import MATRIX
model = (model or "").strip()
if not model:
return {"ok": False, "error": "empty model"}
hidden = [m for m in self._prefs.get("hidden_models") or [] if m != model]
if hidden:
self._prefs["hidden_models"] = hidden
else:
self._prefs.pop("hidden_models", None)
models = self._prefs.get("models")
models = models if isinstance(models, list) else []
if model not in models and model not in MATRIX:
models.append(model)
self._prefs["models"] = models
self._save_prefs()
return {"ok": True, **self.get_settings()}
def remove_model(self, model: str) -> dict[str, Any]:
"""Remove a model id from the picker. Custom ids are dropped; matrix models are
hidden by id (the matrix is derived, not stored, so a bare drop would resurrect
them on the next read)."""
from ..providers.matrix import MATRIX
models = self._prefs.get("models")
models = models if isinstance(models, list) else []
self._prefs["models"] = [m for m in models if m != model]
if model in MATRIX:
hidden = self._prefs.get("hidden_models") or []
if model not in hidden:
self._prefs["hidden_models"] = [*hidden, model]
self._save_prefs()
return {"ok": True, **self.get_settings()}
def get_settings(self) -> dict[str, Any]:
"""Model-access + UI status. Never returns the key; `source` says where it comes from."""
import os
env_key = bool(os.environ.get("OPENAI_API_KEY"))
stored = bool((self.secrets.get("provider:openai") or {}).get("api_key"))
# Only surface models whose provider is actually configured — the composer picker
# reflects exactly what's connected. The active default is always kept selectable
# (it's hidden behind the "No model" state until a provider is connected anyway).
# Ollama is keyless, so "configured" is meaningless there — its models show only
# while a local Ollama answers (cached liveness probe).
def _selectable(m: str) -> bool:
provider = self._model_provider(m)
if provider == "ollama":
return self._ollama_alive()
return self._provider_configured(provider)
selectable = [m for m in self._curated_models() if _selectable(m)]
if self.model not in selectable:
selectable.insert(0, self.model)
from ..providers.matrix import model_context_windows, model_labels
return {
"provider": "openai",
"model": self.model,
"models": selectable,
# Curated-matrix display names ({full id → "GLM-5.2 · via Together"}) so every
# picker shows human labels; custom models absent here render their raw id.
"model_labels": model_labels(),
# {full id → context window in tokens}, verified matrix entries only —
# drives the composer's context-fill meter (absent id → meter hides).
"model_context_windows": model_context_windows(),
"has_key": env_key or stored,
# Provider-agnostic "can this default model actually run?" — true when the default
# model's provider is configured (any provider, not just OpenAI). Drives the GUI's
# "No model connected" composer chip and the onboarding Skip warning.
"model_ready": self._provider_configured(self._model_provider(self.model)),
"source": "env" if env_key else ("store" if stored else None),
"onboarded": bool(self._prefs.get("onboarded")),
"experimental_connectors": experimental_enabled(self.secrets),
"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]:
"""Which session surfaces are shown in the sidebar. Cowork is always on; Chat and Code
are opt-in (default off) so a new user sees Cowork only."""
return {
"cowork": True,
"chat": bool(self._prefs.get("show_chat", False)),
"code": bool(self._prefs.get("show_code", False)),
}
def set_surfaces(
self, chat: Optional[bool] = None, code: Optional[bool] = None
) -> dict[str, Any]:
"""Toggle Chat/Code visibility (Cowork is always shown). Persisted in prefs."""
if chat is not None:
self._prefs["show_chat"] = bool(chat)
if code is not None:
self._prefs["show_code"] = bool(code)
self._save_prefs()
return {"ok": True, "surfaces": self._surfaces()}
def _nav_layout(self) -> str:
"""Sidebar layout: ``"flat"`` (default) or ``"grouped"`` (by persona). Persisted in
prefs (UI-REFRESH §7)."""
return "grouped" if self._prefs.get("nav_layout") == "grouped" else "flat"
def set_nav_layout(self, nav_layout: str) -> dict[str, Any]:
"""Set + persist the sidebar layout. Unknown values fall back to ``"flat"``."""
value = "grouped" if (nav_layout or "").strip() == "grouped" else "flat"
self._prefs["nav_layout"] = value
self._save_prefs()
return {"ok": True, "nav_layout": value}
DEFAULT_SESSIONS_PEEK = 5
def sessions_peek(self) -> int:
"""How many sessions a sidebar group shows before "Show more" (owner ask, 2026-07-03)."""
try:
n = int(self._prefs.get("sessions_peek", self.DEFAULT_SESSIONS_PEEK))
except (TypeError, ValueError):
n = self.DEFAULT_SESSIONS_PEEK
return max(1, min(n, 50))
def set_sessions_peek(self, n: int) -> dict[str, Any]:
try:
self._prefs["sessions_peek"] = max(1, min(int(n), 50))
except (TypeError, ValueError):
return {"ok": False, "error": "sessions_peek must be a number"}
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
def pdf_settings(self) -> dict[str, Any]:
"""Fallback mode for models without native PDF support + the attach-time
thresholds (Settings → Token savings: big PDFs quietly eat tokens)."""
from ..pdf_support import FALLBACK_MODES
mode = self._prefs.get("pdf_fallback")
try:
pages = int(self._prefs.get("pdf_max_pages", self.DEFAULT_PDF_MAX_PAGES))
except (TypeError, ValueError):
pages = self.DEFAULT_PDF_MAX_PAGES
try:
mb = int(self._prefs.get("pdf_max_mb", self.DEFAULT_PDF_MAX_MB))
except (TypeError, ValueError):
mb = self.DEFAULT_PDF_MAX_MB
return {
"pdf_fallback": mode if mode in FALLBACK_MODES else "text",
"pdf_max_pages": max(1, min(pages, 100)),
"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 (1095); 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,
max_pages: Any = None,
max_mb: Any = None,
) -> dict[str, Any]:
from ..pdf_support import FALLBACK_MODES, set_fallback_mode
if fallback is not None:
if fallback not in FALLBACK_MODES:
return {"ok": False, "error": "pdf_fallback must be 'text' or 'images'"}
self._prefs["pdf_fallback"] = fallback
for key, value, ceiling in (
("pdf_max_pages", max_pages, 100),
("pdf_max_mb", max_mb, 10),
):
if value is None:
continue
try:
self._prefs[key] = max(1, min(int(value), ceiling))
except (TypeError, ValueError):
return {"ok": False, "error": f"{key} must be a number"}
self._save_prefs()
settings = self.pdf_settings()
set_fallback_mode(settings["pdf_fallback"]) # engines read the module global
return {"ok": True, **settings}
def set_model_key(self, api_key: str) -> dict[str, Any]:
"""Persist the model API key to the SecretStore (0600). The new provider client is
built lazily on the next turn, so it picks the key up without a restart."""
api_key = (api_key or "").strip()
if not api_key:
return {"ok": False, "error": "empty api key"}
# Merge, don't replace: the profile may also hold a custom endpoint (base_url).
profile = dict(self.secrets.get("provider:openai") or {})
profile.update({"type": "api_key", "api_key": api_key})
self.secrets.put("provider:openai", profile)
self._refresh_provider("openai") # rebuild the OpenAI client with the new key
return {"ok": True, **self.get_settings()}
def set_default_model(self, model: str) -> dict[str, Any]:
"""Set + persist the default model for new sessions (the UI pre-selects it)."""
model = (model or "").strip()
if not model:
return {"ok": False, "error": "empty model"}
self.model = model
self._prefs["default_model"] = model
self._save_prefs()
return {"ok": True, **self.get_settings()}
def set_onboarded(self, value: bool = True) -> dict[str, Any]:
"""Record that first-run setup is complete (so it isn't shown again)."""
self._prefs["onboarded"] = bool(value)
self._save_prefs()
return {"ok": True, "onboarded": bool(value)}
def set_scratch_base(self, path: str) -> dict[str, Any]:
"""Set + persist the common area where each Cowork conversation's scratch directory is
created (default ~/OpenWorker). The raw value is stored so the UI shows it as entered;
new conversations use it immediately (existing ones keep their provisioned dir).
"""
path = (path or "").strip()
if not path:
return {"ok": False, "error": "empty path"}
try:
Path(path).expanduser().mkdir(parents=True, exist_ok=True)
except OSError as exc:
return {"ok": False, "error": str(exc)}
self._prefs["scratch_base"] = path
self._save_prefs()
return {"ok": True, **self.get_settings()}
# -- gateway + connector allow-list (inbound messaging) ---------------------
def allow_user(
self,
name: str,
user_id: str,
team_id: Optional[str] = None,
*,
display_name: str = "",
) -> dict[str, Any]:
out = self._set_allowed(name, user_id, team_id=team_id, add=True)
# Directory picks arrive with the name in hand — record it so the chip
# is readable immediately (message-driven allows learn it on arrival).
if out.get("ok") and display_name:
self._note_person(name, user_id, display_name)
return out
def disallow_user(
self, name: str, user_id: str, team_id: Optional[str] = None
) -> dict[str, Any]:
if name == "slack" and user_id in self.slack_approval_owner_ids(team_id):
return {
"ok": False,
"error": "Remove this person as an approval owner first.",
}
return self._set_allowed(name, user_id, team_id=team_id, add=False)
def slack_approval_owner_ids(self, team_id: Optional[str] = None) -> set[str]:
"""Stable Slack user ids allowed to resolve consequential Inbox prompts.
Managed relay installs are installer-owned. Manual Socket Mode has no
human OAuth identity, so its owners are selected explicitly.
"""
key = f"slack:team:{team_id}" if team_id else "slack:default"
profile = self.secrets.get(key) or {}
if team_id:
installer = str(profile.get("slack_user_id") or "").strip()
return {installer} if installer else set()
if profile.get("mode") == "relay":
return set()
return {
str(user_id).strip()
for user_id in (profile.get("approval_owner_ids") or [])
if str(user_id).strip()
}
def set_slack_approval_owner(
self, user_id: str, *, add: bool, display_name: str = ""
) -> dict[str, Any]:
"""Edit Manual Socket Mode approval owners.
Owner status implies inbound permission. Relay ownership is derived from
the OAuth installer and is intentionally not editable here.
"""
user_id = str(user_id).strip()
if not user_id:
return {"ok": False, "error": "user_id required"}
profile = self.secrets.get("slack:default")
if not profile:
return {"ok": False, "error": "Slack is not connected in Manual mode."}
if profile.get("mode") == "relay" or profile.get("managed"):
return {
"ok": False,
"error": "Relay approval ownership is set by the Slack installer.",
}
owners = self.slack_approval_owner_ids()
if add:
owners.add(user_id)
else:
owners.discard(user_id)
if not owners and self._has_manual_slack_inbox_binding():
return {
"ok": False,
"error": (
"Choose another approval owner before removing the last one "
"while Slack Inbox routing is active."
),
}
profile["approval_owner_ids"] = sorted(owners)
if add:
allowed = set(profile.get("allowed_users") or [])
allowed.add(user_id)
profile["allowed_users"] = sorted(allowed)
self.secrets.put("slack:default", profile)
if display_name:
self._note_person("slack", user_id, display_name)
if self.gateway is not None and "slack" in self.gateway.settings:
self.gateway.settings["slack"].allowed_users = set(
profile.get("allowed_users") or []
)
return {
"ok": True,
"approval_owner_ids": sorted(owners),
"allowed_users": list(profile.get("allowed_users") or []),
}
def _has_manual_slack_inbox_binding(self) -> bool:
for raw in self.inbox_routing.bindings():
if raw.get("channel") != "slack":
continue
team_id, _ = slack_split(str(raw.get("target") or ""))
if team_id is None:
return True
return False
def _slack_actor_owns_item(
self,
item,
*,
actor_id: str,
chat_id: str,
team_id: Optional[str],
) -> bool:
"""Authorize a Slack resolution against both its owner and delivery binding."""
event_team, event_channel = slack_split(chat_id)
event_team = team_id or event_team
binding = self.inbox_routing.binding_for(item.inbox)
owner_team = event_team
if binding.channel == "slack":
owner_team, bound_channel = slack_split(binding.target)
if owner_team != event_team or bound_channel != event_channel:
return False
return bool(actor_id) and actor_id in self.slack_approval_owner_ids(owner_team)
def set_inbox_binding(
self, name: str, *, channel: Optional[str], target: str
) -> dict[str, Any]:
"""Persist an Inbox transport after validating its approval identity."""
channel = str(channel or "").strip() or None
target = str(target or "").strip()
if channel and not target:
return {"ok": False, "error": "Choose a destination channel."}
if channel == "slack":
settings = load_settings(self.secrets).get("slack")
if settings is None or not settings.enabled:
return {"ok": False, "error": "Slack is not connected."}
team_id, destination = slack_split(target)
if not destination:
return {"ok": False, "error": "Choose a destination channel."}
key = f"slack:team:{team_id}" if team_id else "slack:default"
if not self.secrets.get(key):
return {
"ok": False,
"error": "That Slack workspace is not connected.",
}
if not self.slack_approval_owner_ids(team_id):
return {
"ok": False,
"error": (
"Choose at least one approval owner in Slack settings before "
"routing Inbox requests there."
),
}
self.inbox_routing.set_binding(name, channel=channel, target=target)
return {"ok": True, "bindings": self.inbox_routing.bindings()}
def _set_allowed(
self, name: str, user_id: str, *, team_id: Optional[str] = None, add: bool
) -> dict[str, Any]:
"""Add/remove a sender on the allow-list. With `team_id` the edit targets that
scope's profile — a workspace's `slack:team:<id>`, or a GitHub App
installation's `github:install:<id>` (the same per-tenant pattern);
without, the flat `<name>:default` list (manual single-workspace mode)."""
user_id = str(user_id).strip()
if not user_id:
return {"ok": False, "error": "user_id required"}
scope = "install" if name == "github" else "team"
profile_key = f"{name}:{scope}:{team_id}" if team_id else f"{name}:default"
profile = self.secrets.get(profile_key)
if not profile:
return {
"ok": False,
"error": (
"workspace not connected" if team_id else "connector not connected"
),
}
allowed = set(profile.get("allowed_users") or [])
allowed.add(user_id) if add else allowed.discard(user_id)
profile["allowed_users"] = sorted(allowed)
self.secrets.put(profile_key, profile)
# reflect into the live gateway so it takes effect without a restart
if self.gateway is not None and name in self.gateway.settings:
if team_id:
from ..connectors import TeamAuth
teams = self.gateway.settings[name].teams
team = teams.setdefault(team_id, TeamAuth())
team.allowed_users = set(allowed)
else:
self.gateway.settings[name].allowed_users = set(allowed)
return {"ok": True, "allowed_users": sorted(allowed), "team_id": team_id}
async def disconnect_slack_workspace(self, team_id: str) -> dict[str, Any]:
"""Stop relaying ONE workspace: delete the cloud routing row (best-effort),
drop the local per-team token, and hot-reload the gateway. Removing the last
workspace also clears relay mode on slack:default so the connector reads
disconnected (the manual Socket Mode fields, if any, are left untouched)."""
team_id = str(team_id).strip()
profile_key = f"slack:team:{team_id}"
if not team_id or not self.secrets.get(profile_key):
return {"ok": False, "error": "workspace not connected"}
from .. import cloud
from ..config import load_config
await asyncio.to_thread(
lambda: cloud.slack_disconnect_workspace(
self.secrets, load_config(), team_id
)
)
self.secrets.delete(profile_key)
remaining = [
m["profile"]
for m in self.secrets.status()
if m.get("profile", "").startswith("slack:team:")
]
if not remaining:
default = self.secrets.get("slack:default") or {}
if default.get("mode") == "relay":
default.pop("mode", None)
default.pop("managed", None)
if default.get("bot_token"):
# Manual Socket Mode creds predating the relay switch: keep them
# stored but DISABLED — removing the last workspace must never
# silently start listening with old tokens.
default["type"] = "token"
default["enabled"] = False
self.secrets.put("slack:default", default)
else:
default.pop("type", None)
default.pop("enabled", None)
if default: # e.g. a flat allow-list worth keeping
self.secrets.put("slack:default", default)
else:
self.secrets.delete("slack:default")
await self.refresh_gateway()
return {"ok": True, "remaining_workspaces": len(remaining)}
def slack_status(self) -> dict[str, Any]:
"""Slack connection health in three honest layers (UX-DECISIONS §21):
the desktop↔relay socket, the cloud sign-in that authorizes it, and each
workspace's bot token. The desktop can't see the Slack↔cloud leg, so no
layer here ever claims it — event silence ≠ outage."""
from .. import cloud
default = self.secrets.get("slack:default") or {}
mode = default.get("mode") or ""
signin = cloud.status(self.secrets)
relay: dict[str, Any] = {
"state": "offline",
"reconnects": 0,
"last_event_at": None,
"last_error": "",
}
teams: dict[str, Any] = {}
adapter = (
self.gateway._adapters.get("slack") if self.gateway is not None else None
)
snapshot = getattr(
adapter, "status", None
) # relay adapter only; Socket Mode has none
if callable(snapshot):
relay = snapshot()
teams = relay.pop("teams", {})
return {
"ok": True,
"mode": mode,
"relay": relay,
"signed_in": bool(signin.get("signed_in")),
"teams": teams,
}
async def disconnect_github_installation(
self, installation_id: str
) -> dict[str, Any]:
"""Stop relaying ONE GitHub installation: delete the cloud routing rows
(best-effort), drop the local profile, hot-reload the gateway. The Slack
per-workspace disconnect, GitHub flavour — a manual PAT stays untouched."""
installation_id = str(installation_id).strip()
from .. import cloud
from ..config import load_config
from ..connectors import github_installs
if not installation_id or not self.secrets.get(
github_installs.PREFIX + installation_id
):
return {"ok": False, "error": "installation not connected"}
await asyncio.to_thread(
lambda: cloud.github_disconnect_installation(
self.secrets, load_config(), installation_id
)
)
result = github_installs.disconnect_install(self.secrets, installation_id)
await self.refresh_gateway()
return result
def github_status(self) -> dict[str, Any]:
"""GitHub relay health, same three honest layers as Slack: the shared
relay socket, the cloud sign-in, and per-installation token health."""
from .. import cloud
default = self.secrets.get("github:default") or {}
signin = cloud.status(self.secrets)
relay: dict[str, Any] = {
"state": "offline",
"reconnects": 0,
"last_event_at": None,
"last_error": "",
}
installs: dict[str, Any] = {}
missed: dict[str, Any] = {}
adapter = (
self.gateway._adapters.get("github") if self.gateway is not None else None
)
snapshot = getattr(adapter, "status", None)
if callable(snapshot):
relay = snapshot()
installs = relay.pop("installs", {})
missed = relay.pop("missed", {})
return {
"ok": True,
"mode": default.get("mode") or "",
"relay": relay,
"signed_in": bool(signin.get("signed_in")),
"installs": installs,
"missed": missed,
}
async def start_gateway(self) -> list[str]:
"""Build the messaging gateway and start enabled listeners. Inbound messages route to
durable sessions: a channel message to its subscribers, a DM to the designated DM session
(else parked). Returns the platforms whose listeners came up."""
self.scheduler.start() # tick scheduler for automations (independent of connectors)
return await self._build_and_start_gateway()
async def refresh_gateway(self) -> list[str]:
"""Hot-reload the messaging listeners with fresh secrets — called after a connector
connect/disconnect so pasting new tokens takes effect immediately. A platform socket
(Slack Socket Mode) authenticates at connect time, so new creds mean reopening that
socket; this replaces the adapters in-process — the sidecar never restarts."""
await self.stop_gateway()
started = await self._build_and_start_gateway()
print(f"[coworker] messaging gateway reloaded: {', '.join(started) or 'none'}")
return started
async def _build_and_start_gateway(self) -> list[str]:
settings = load_settings(self.secrets)
self.gateway = Gateway(
secrets=self.secrets,
settings=settings,
handler=self._dispatch_inbound,
reply_resolver=self._resolve_inbox_reply,
interaction_handler=self._on_interaction,
on_unauthorized=self._park_unauthorized,
)
# Managed Slack relay wiring (only used when a connector picks relay mode):
# the cloud sign-in JWT authorizes the relay WebSocket, and the relay
# endpoint comes from config. Both are lazy — Socket Mode needs neither.
from ..cloud import fresh_access_token
from ..config import load_config
cloud_config = load_config()
def _relay_token() -> str:
return fresh_access_token(self.secrets, cloud_config) or ""
# Every relay-mode platform shares ONE cloud socket; the hub fans frames
# out by provider tag. Built lazily on the first relay adapter.
relay_ws_url = getattr(cloud_config, "cloud_relay_ws_url", "") or None
relay_hub = None
if relay_ws_url:
from ..connectors.relay_client import RelayHub
relay_hub = RelayHub(relay_ws_url, _relay_token)
async def _github_token(installation_id: str) -> str:
from ..cloud import github_installation_token
return await asyncio.to_thread(
github_installation_token, self.secrets, cloud_config, installation_id
)
for platform, st in settings.items():
if not st.enabled:
continue
profile = self.secrets.get(f"{platform}:default") or {}
adapter = make_adapter(
platform,
profile,
secrets=self.secrets,
token_provider=_relay_token,
relay_url=relay_ws_url,
relay_hub=relay_hub,
github_token_client=_github_token,
)
if adapter is not None:
self.gateway.register(adapter)
return await self.gateway.start()
async def stop_gateway(self) -> None:
if self.gateway is not None:
await self.gateway.stop()
self.gateway = None
# -- unauthorized inbound (parked, §19) --------------------------------------
def _note_person(
self, platform: str, user_id: Optional[str], name: Optional[str]
) -> None:
"""Remember a sender's display name (persisted) so ID-keyed surfaces — the allow-list
chips above all — can show who a U07JK… actually is. Best-effort, newest name wins.
"""
if not user_id or not name:
return
key = f"{platform}:{user_id}"
if self._people.get(key) != name:
self._people[key] = name
try:
self._people_path.write_text(json.dumps(self._people))
except OSError:
pass
async def _park_unauthorized(self, event) -> None:
"""Gateway callback: keep what an unallowed sender said (names already resolved by the
adapter, best-effort) so the owner can allow-and-deliver without a re-send."""
s = event.source
self._note_person(s.platform, s.user_id, s.user_name)
self.parked.park(
platform=s.platform,
chat_id=s.chat_id,
chat_name=s.chat_name,
user_id=s.user_id or "?",
user_name=s.user_name,
chat_type=s.chat_type,
thread_id=s.thread_id,
team_id=s.team_id,
text=event.text or "",
)
async def resolve_unauthorized(
self, name: str, item_id: str, action: str
) -> dict[str, Any]:
"""Resolve one parked message: "dismiss" throws it away; "allow" adds the sender to the
allow-list (future messages flow); "allow_deliver" also re-injects the parked message
through the NORMAL inbound path — buffer + subscriptions — as if it just arrived.
"""
item = self.parked.pop(item_id)
if item is None or item.platform != name:
return {"ok": False, "error": "unknown item"}
if action == "dismiss":
return {"ok": True}
if action not in ("allow", "allow_deliver"):
return {"ok": False, "error": f"unknown action: {action}"}
allowed = self._set_allowed(name, item.user_id, team_id=item.team_id, add=True)
if not allowed.get("ok"):
return allowed
if action == "allow_deliver":
from ..connectors import MessageEvent, SessionSource
event = MessageEvent(
text=item.text,
source=SessionSource(
platform=item.platform,
chat_id=item.chat_id,
user_id=item.user_id,
user_name=item.user_name,
chat_name=item.chat_name,
chat_type=item.chat_type,
thread_id=item.thread_id,
team_id=item.team_id,
),
)
await self._dispatch_inbound(event)
return {"ok": True}
# -- per-session live view --------------------------------------------------
def register_event_client(self, send_cb: Any) -> None:
self._event_clients.add(send_cb)
def unregister_event_client(self, send_cb: Any) -> None:
self._event_clients.discard(send_cb)
async def broadcast_event(self, message: dict) -> None:
"""Fan an app-wide event out to every /ws/events socket. Best-effort: a dead
socket is dropped, never fatal to the caller."""
for cb in list(self._event_clients):
try:
await cb(message)
except Exception:
self.unregister_event_client(cb)
def register_session_client(self, session_id: str, send_cb: Any) -> None:
self._session_clients.setdefault(session_id, set()).add(send_cb)
def unregister_session_client(self, session_id: str, send_cb: Any) -> None:
clients = self._session_clients.get(session_id)
if clients is not None:
clients.discard(send_cb)
if not clients:
self._session_clients.pop(session_id, None)
async def broadcast_session(self, session_id: str, message: dict) -> None:
"""Fan a turn event out to every socket viewing this session. Best-effort: a dead socket
is dropped, never fatal to the turn (delivery is socket-independent)."""
for cb in list(self._session_clients.get(session_id, ())):
try:
await cb(message)
except Exception:
self.unregister_session_client(session_id, cb)
async def aclose(self) -> None:
await self.scheduler.stop()
await self.stop_gateway()
await self.mcp.aclose()
self.audit_store.close()
# -- automation (scheduled tasks) -------------------------------------------
def approval_prompt_data(self, session_id: str, request) -> dict[str, Any]:
"""Extra Inbox-item payload for a parked approval. Always carries the tool name +
arguments so the GUI can render the same humanized card (§35) it shows live —
without them a reopened session fell back to the raw 'Run `tool`?' treatment.
Automation runs additionally carry the owning task + (when the call is eligible)
the exact target a standing rule would pin: the GUI offers "Allow every time" only
when both are present — in-app only, never on Slack-mirrored buttons (§25)."""
from ..permissions import standing_rule_candidate
data: dict[str, Any] = {
"tool": request.tool_name,
"arguments": getattr(request, "arguments", None) or {},
}
task = self.task_store.task_for_run_session(session_id)
if task is None:
return data
data.update({"task_id": task.id, "task_title": task.title})
target = standing_rule_candidate(
request.tool_name,
getattr(request, "arguments", None) or {},
getattr(request, "metadata", None),
)
if target:
data["standing_target"] = target
return data
def mint_task_rule(
self, session_id: str, tool_name: str, arguments: Any, metadata: Any = None
) -> bool:
"""Persist a standing rule a human minted via "Allow every time" on a run's
approval card (§25's retrofit path). Server-side validation, not trust in the
card: the session must be an automation run and the call must be rule-eligible
(external risk, declared target argument, non-empty target). Also applies the
rule to the live engine so the run's next call auto-allows."""
from ..permissions import standing_rule_candidate
task = self.task_store.task_for_run_session(session_id)
if task is None:
return False
target = standing_rule_candidate(tool_name, arguments or {}, metadata)
if not target or not task.add_rule(tool_name, target):
return False
self.task_store.save(task)
engine = self._engines.get(session_id)
if engine is not None:
engine.permissions.task_rules.setdefault(tool_name, set()).add(target)
try:
self.audit_store.append(
{
"session_id": session_id,
"tool": tool_name,
"arguments": arguments or {},
"stage": "standing_rule_minted",
"status": "granted",
"reason": f"allow every time: {tool_name}{target} (task {task.id})",
}
)
except Exception:
pass
return True
def approval_outcome(self, resolution: str, request, session_id: str):
"""Map an approval resolution (from any surface) to an ApprovalOutcome, handling
the task-persistent "always_task" vocabulary alongside the session-scoped ones.
"""
from ..engine import ApprovalOutcome
if resolution == "always_task":
self.mint_task_rule(
session_id,
request.tool_name,
getattr(request, "arguments", None),
getattr(request, "metadata", None),
)
return ApprovalOutcome.ONCE
try:
return ApprovalOutcome(resolution)
except ValueError:
pass
if resolution == "allow":
return ApprovalOutcome.ONCE
if resolution == "always":
return ApprovalOutcome.ALWAYS_TOOL
return ApprovalOutcome.DENY
def _scheduled_approver(self, task, session_id: str):
from ..engine import ApprovalOutcome
from ..permissions import WRITE_TOOLS
name_allowed = task.name_allowed_tools()
async def approver(request):
# Unattended: auto-allow the deliverable writes (path-scoped to the task
# workspace) + tools the task allows BY NAME (legacy entries). Target-bound
# rules never reach here — the permission engine matched them already.
if request.tool_name in WRITE_TOOLS or request.tool_name in name_allowed:
return ApprovalOutcome.ONCE
# Anything else parks in the Inbox and suspends the run (§25 graceful
# degradation — an ungranted automation still works, it just asks). The item
# carries the task binding so the in-app card can offer "Allow every time";
# the Slack mirror renders only Approve/Deny buttons.
item = self.inbox.add_approval(
session_id,
f"Run `{request.tool_name}`?",
body=_approval_body(request),
inbox=self.inbox_routing.route_for(session_id, task.agent),
tool_call_id=getattr(request, "tool_call_id", None),
data=self.approval_prompt_data(session_id, request),
)
if item.state == "pending":
self.persist_session(session_id)
await self.mirror_inbox_item(item)
resolution = await self.inbox.wait(item.id)
return self.approval_outcome(resolution, request, session_id)
return approver
def _seed_task_permissions(self, engine: TurnEngine, task) -> None:
"""Apply a task's standing allowances to an engine: target-bound rules feed the
permission engine's matcher (connector tools included — the target binding is the
safety); name-only legacy entries keep their session-allowlist behavior."""
engine.permissions.task_rules = task.standing_rules()
for tool in task.name_allowed_tools():
engine.permissions.allow_tool_for_session(tool)
def _build_task_engine(self, task, *, session_id: str) -> TurnEngine:
ag = get_agent(task.agent)
Path(task.workspace).mkdir(parents=True, exist_ok=True)
engine = build_engine(
agent=ag,
workspace=task.workspace,
model=task.model or self.model,
mode=Mode.INTERACTIVE,
approver=self._scheduled_approver(task, session_id),
provider=self.provider,
memory_store=self.memory_store,
memory_off=not self.memory_settings.enabled,
memory_saving_enabled=lambda: self.memory_settings.enabled,
# Callable, not a snapshot: editing your instructions in Settings applies
# to conversations already open (same reason as the saving switch).
user_rules=lambda: self.memory_settings.user_rules,
on_memory_saved=self._memory_saved_notifier(session_id),
secrets=self.secrets,
# No scheduling tools inside a scheduled run: the executing agent's job is to DO the
# task, and instructions that mention timing ("every day at 5:32pm…") otherwise tempt
# it to create another automation instead of running this one.
task_store=None,
session_id=session_id,
audit_sink=self.audit_store.append,
# 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
# -- mirroring inbox items to a bound channel -------------------------------
async def mirror_inbox_item(self, item) -> None:
"""Mirror an Inbox item to its bound channel. Discrete choices (approve/deny, ask_user
options) render as BUTTONS — the item id rides in each, so a click resolves it
unambiguously. Free-text answers aren't offered over messaging (open the app).
"""
from ..interactions import buttons_for
binding = self.inbox_routing.binding_for(item.inbox)
if not (binding.channel and self.gateway is not None):
return
if binding.channel == "slack":
team_id, _ = slack_split(binding.target)
# Legacy bindings may predate approval ownership. Keep the item
# available in-app, but never mirror it to an ownerless channel.
if not self.slack_approval_owner_ids(team_id):
return
target = f"{binding.channel}:{binding.target}"
body = "\n".join(p for p in (item.title, item.body) if p).strip()
buttons = buttons_for(item)
try:
if buttons:
await self.gateway.deliver_interactive(target, body, buttons)
else:
await self.gateway.deliver(
target,
f"{body}\n(Open the app to respond.)\n[ow:{item.id}]".strip(),
)
except Exception:
pass
# -- interactive prompt buttons (Slack/Telegram) ----------------------------
async def _on_interaction(self, event) -> None:
"""A button click on a mirrored Inbox prompt. The button value carries the item id + the
resolution, so this is unambiguous — resolve the item, then swap the buttons for the
outcome. Resolving releases any agent suspended on it (first-responder-wins)."""
from ..interactions import decode
decoded = decode(getattr(event, "value", "") or "")
if decoded is None:
return
item_id, resolution = decoded
item = self.inbox.get(item_id)
if item is None:
return
protected_kinds = {"approval", "directory", "plan"}
if (
getattr(event, "platform", "") == "slack"
and item.kind in protected_kinds
):
actor_id = str(getattr(event, "user_id", "") or "")
if not self._slack_actor_owns_item(
item,
actor_id=actor_id,
chat_id=getattr(event, "chat_id", "") or "",
team_id=getattr(event, "team_id", None),
):
if self.gateway is not None:
await self.gateway.reject_interaction(event)
return
already = item is not None and item.state != "pending"
resolved = await self.resolve_inbox(item_id, resolution)
if not resolved and not already:
return
who = getattr(event, "user_name", None) or "someone"
title = item.title
outcome = "already resolved" if already else f"“{resolution}” — by {who}"
if self.gateway is not None and getattr(event, "message_id", None):
try:
await self.gateway.update_message(
getattr(event, "platform", "slack"),
getattr(event, "chat_id", ""),
event.message_id,
f"{title}\n{outcome}",
)
except Exception:
pass
# -- inbox replies over messaging connectors --------------------------------
def _resolve_inbox_reply(self, event) -> bool:
"""Try to handle an inbound Slack/Telegram message as an Inbox reply. Returns True if the
message carried an `[ow:<id>]` token (so it's consumed here, not routed as a new turn) —
resolving the item also releases any agent suspended on it."""
from ..inbox_routing import resolve_from_reply
text = getattr(event, "text", "") or ""
def _resolve(item_id: str, resolution: str) -> bool:
item = self.inbox.get(item_id)
if item is None:
return False
if (
getattr(event.source, "platform", "") == "slack"
and item.kind in {"approval", "directory", "plan"}
):
actor_id = str(getattr(event.source, "user_id", "") or "")
if not self._slack_actor_owns_item(
item,
actor_id=actor_id,
chat_id=getattr(event.source, "chat_id", "") or "",
team_id=getattr(event.source, "team_id", None),
):
return False
return self.inbox.resolve(item_id, resolution)
return resolve_from_reply(text, _resolve) is not None
# -- self-wake resumption ---------------------------------------------------
async def resume_due_wakes(self) -> int:
"""Resume sessions whose self-wakes are due (called each scheduler tick). A suspended
agent (it called sleep_for / wake_on / wake_on_event and ended its turn) is re-invoked on
its own session with a wake message so it continues where it left off. Returns the count.
"""
resumed = 0
for wake in self.wakes.due():
try:
await self._resume_wake(wake)
resumed += 1
except Exception:
pass
finally:
self.wakes.mark_fired(wake.id)
return resumed
def mark_running(self, session_id: str) -> None:
self._running_sessions.add(session_id)
def try_mark_running(self, session_id: str) -> bool:
"""Atomically claim an idle session for one turn on the server event loop."""
if session_id in self._running_sessions:
return False
self._running_sessions.add(session_id)
return True
def mark_idle(self, session_id: str) -> None:
self._running_sessions.discard(session_id)
# Every turn path (WS, background delivery, durable resume) marks idle when it
# finishes — the one shared post-turn moment, so auto-titling hooks in here and
# can never add latency to the response itself.
self._maybe_autotitle(session_id)
def is_running(self, session_id: str) -> bool:
return session_id in self._running_sessions
async def _resume_wake(self, wake) -> None:
await self.deliver_to_session(wake.session_id, self._wake_message(wake))
async def deliver_to_session(
self, session_id: str, message: str, *, source: Optional[dict[str, Any]] = None
) -> None:
"""Deliver an out-of-band message to a (durable) session — the agent stays resumable
forever, so this works with no live socket. Busy (mid tool-loop): steer it into the live
turn at its next step (don't start a colliding run). Idle: run a fresh background turn
(results persist; if the session is Unattended, any approvals route to the Inbox). Shared
by self-wake and channel-subscription delivery. `source` is the display-only MessageSource
sidecar for connector messages (framed `message` stays the model-facing text).
"""
engine = self.get_engine(session_id)
if engine is None:
return
if not self.try_mark_running(session_id):
engine.queue_steering(message, source)
return
try:
async for event in engine.run(message, source=source):
# Stream every event to any socket viewing this session, so a background turn
# (channel delivery, self-wake, durable resume) is seen live — not just on reselect.
await self.broadcast_session(
session_id, {"type": event.type.value, "data": event.data}
)
# A background turn has no user watching to read an inline error: a dead model or
# tool failure would otherwise vanish. Log it and park it in the dead-letter store.
if event.type.value == "error":
reason = (event.data or {}).get("error", "unknown error")
logger.warning(
"background turn failed for %s: %s", session_id, reason
)
self.unrouted.record(session_id, "-", message, reason=reason)
self.save(session_id, engine)
except (
Exception
) as exc: # an unexpected raise out of the turn must not be swallowed
logger.warning("background turn crashed for %s: %s", session_id, exc)
self.unrouted.record(session_id, "-", message, reason=str(exc))
await self.broadcast_session(
session_id, {"type": "error", "data": {"error": str(exc)}}
)
finally:
self.mark_idle(session_id)
await self.broadcast_session(session_id, {"type": "turn_done", "data": {}})
# -- channel subscriptions (inbound messaging) ------------------------------
async def _dispatch_inbound(self, event) -> None:
"""Route a non-token inbound message. Channel messages are buffered (for catch-up) and
fanned out to every subscribed session; a DM (or any non-channel) goes to the user-designated
DM session (delivered like any background turn) or, if none is set, is parked as unrouted.
"""
src = event.source
text = getattr(event, "text", "") or ""
who = src.user_name or src.user_id or "?"
channel = f"{src.platform}:{src.chat_id}" # thread-agnostic channel address
self._note_person(src.platform, src.user_id, src.user_name)
# Structured sidecar (display-only) built from the resolved identities on the event — the
# framed text below stays the model-facing `content`; `ms.text` carries the RAW message.
ms = MessageSource(
connector=src.platform,
kind="channel" if src.chat_type in ("channel", "group") else "dm",
channel_id=src.chat_id,
channel_name=src.chat_name or src.chat_id,
sender_id=src.user_id or "",
sender_name=src.user_name or src.user_id or "?",
ts=_inbound_epoch(getattr(event, "message_id", None)),
text=text,
)
if src.chat_type in ("channel", "group"):
self.channel_buffer.record(
channel, who, text, name=src.chat_name
) # buffer all, even unsubscribed
subs = self.subscriptions.for_channel(channel)
# §31 mention router: a direct @-mention of the bot outranks the passive fan-out —
# subscribed sessions must answer it; an unsubscribed channel spawns (or steers)
# the per-thread coworker session.
if getattr(event, "mentions_me", False):
await self._route_mention(event, ms, subs)
return
if subs:
# Chattiness tiers (§31): untagged channel traffic is judgement-only —
# silence is the default; the must-respond framing is the mention path's.
msg = (
f"💬 New message on {src.chat_name or channel} from {who}: {text}\n"
f"(You're subscribed to this channel but were NOT mentioned. Use your "
f"judgement: stay silent unless the message clearly concerns your job and "
f"a reply adds real value — most channel chatter needs no response from "
f'you. If you do reply, use the send_message tool with target "{channel}".)'
)
for sub in subs:
# Per-session connection hierarchy (§4.3): a session that has muted this
# connector skips delivery — the message is still buffered (above) for catch-up.
if not self._inbound_connector_allowed(
sub.session_id, src.platform
):
continue
try:
await self.deliver_to_session(
sub.session_id, msg, source=ms.to_dict()
)
except Exception:
pass
return
return # channel with no subscribers — nobody is listening
# DM (or any non-channel): route to the designated session, else park it for visibility.
dm = self.dm_session()
if dm and self._inbound_connector_allowed(dm, src.platform):
await self.deliver_to_session(dm, event.tagged_text(), source=ms.to_dict())
elif dm:
# Designated, but this session has muted the connector → park rather than deliver.
self.unrouted.record(
src.target, who, text, reason="connector muted for DM session"
)
else:
self.unrouted.record(
src.target, who, text, reason="no DM session designated"
)
# -- mention router (§31) ----------------------------------------------------
async def _route_mention(self, event, ms: MessageSource, subs) -> None:
"""@OpenWorker tagged in a channel. A subscribed (user-connected) coworker owns the channel
and must answer; otherwise the per-thread coworker session handles it — spawned on the
first tag, steered by follow-ups (deduped on the thread target)."""
from ..connectors.base import format_target
src = event.source
# Slack semantics: replying to a top-level message threads on THAT message's ts, so a
# top-level tag (no thread_ts) keys — and is answered — on its own ts.
thread_key = src.thread_id or getattr(event, "message_id", None)
thread_target = format_target(src.platform, src.chat_id, thread_key)
who = src.user_name or src.user_id or "?"
chan = f"#{src.chat_name}" if src.chat_name else src.chat_id
if subs:
# The user connected a coworker to this channel — it answers tags; no spawn.
msg = (
f"🔔 You were tagged by {who} in {chan}: {event.text}\n"
f"(You are subscribed to this channel and were mentioned directly — you must "
f"respond. Reply in the thread with the send_message tool, target "
f'"{thread_target}".)'
)
for sub in subs:
if not self._inbound_connector_allowed(sub.session_id, src.platform):
continue
try:
await self.deliver_to_session(
sub.session_id, msg, source=ms.to_dict()
)
except Exception:
pass
return
sid = self.mention_sessions.get(thread_target)
if sid and self.session_store.load(sid) is not None:
# Follow-up tag in a thread we already own → steer the same session.
msg = (
f"💬 Follow-up in your Slack thread ({chan}) from {who}: {event.text}\n"
f'(Reply in the thread with the send_message tool, target "{thread_target}" '
f"— replies there are pre-approved.)"
)
await self.deliver_to_session(sid, msg, source=ms.to_dict())
return
await self._spawn_mention_session(event, ms, thread_target)
async def _spawn_mention_session(
self, event, ms: MessageSource, thread_target: str
) -> None:
"""First tag in a thread: a NEW visible coworker session that owns the thread. Its
in-thread replies carry a standing grant (§25 shape, exact-target match) so the
conversation never stalls on an approval nobody in Slack can see; everything else
asks as usual (approvals park to the Inbox)."""
import uuid
src = event.source
who = src.user_name or src.user_id or "?"
chan = f"#{src.chat_name}" if src.chat_name else src.chat_id
sid = uuid.uuid4().hex
engine = self.get_engine(sid, agent=self.personas.default_id())
if engine is None:
self.unrouted.record(
src.target, who, event.text, reason="could not spawn mention session"
)
return
# Durable mapping FIRST (a fast follow-up tag mid-turn dedupes into steering),
# then the live grant; get_engine re-derives it from the store on any rebuild.
self.mention_sessions.set(
thread_target, sid, channel=f"{src.platform}:{src.chat_id}"
)
engine.permissions.task_rules.setdefault("send_message", set()).add(
thread_target
)
self.save(sid, engine) # the sessions row must exist before rename/set_origin
# Title = the ASK first, channel last (owner call 2026-07-14): the text is what
# varies between sessions, so it gets the truncation budget; the mention token is
# noise (origin is already told by the From Slack group + icon + origin_label).
ask = re.sub(r"<@[^>]+>", "", event.text or "")
ask = " ".join(ask.split())[:48]
self.session_store.rename(sid, f"{ask}{chan}" if ask else chan)
label = chan + (f" · {src.team_id}" if src.team_id else "")
self.session_store.set_origin(sid, src.platform, label)
# Up to 6 lines of channel context, minus the tag itself (it's the opening line).
recent = self.channel_buffer.recent(f"{src.platform}:{src.chat_id}", 7)[:-1]
context = "\n".join(f"- {m['from']}: {m['text']}" for m in recent)
opening = (
f"🔔 You were mentioned on Slack in {chan} by {who}: {event.text}\n\n"
f"You own this Slack thread. Reply in the thread using the send_message tool "
f'with target "{thread_target}" — replies to this thread are pre-approved and '
f"never prompt the user. Anything else (other channels, files, external "
f"actions) asks for approval as usual. Keep replies concise and "
f"Slack-appropriate."
+ (f"\n\nRecent channel context:\n{context}" if context else "")
)
try:
await self.deliver_to_session(sid, opening, source=ms.to_dict())
except Exception:
logger.exception("mention session %s opening turn failed", sid)
@staticmethod
def _wake_message(wake) -> str:
note = f" (note: {wake.note})" if getattr(wake, "note", "") else ""
if wake.kind == "completion":
return (
f"⏰ Wake — the job `{wake.job_id}` you were waiting on has completed{note}. "
"Continue where you left off."
)
if wake.kind == "event":
return (
f"⏰ Wake — the event `{wake.event_key}` you were waiting on has fired{note}. "
"Continue where you left off."
)
return (
f"⏰ Wake — the timer you set has fired{note}. Continue where you left off."
)
async def _run_scheduled_task(self, task, trigger: str) -> TaskRun:
run = TaskRun(
task_id=task.id, trigger=trigger
) # __post_init__ sets run.session_id
self.task_store.add_run(run) # mark "running"
# UX-026: tell every open app window a SCHEDULED run just started (the 5s
# top-right toast). Manual runs never come through here — the user is
# already watching those live.
await self.broadcast_event(
{
"type": "automation_run_started",
"data": {
"task_id": task.id,
"task_title": task.title,
"session_id": run.session_id,
"workspace": task.workspace,
"agent": task.agent,
"trigger": trigger,
},
}
)
# Each run is a real, persisted conversation thread: it runs the instructions under its
# own session id, then saves the transcript. The user can reopen that session and ask a
# follow-up — the scheduled agent is no longer fire-and-forget.
engine = self._build_task_engine(task, session_id=run.session_id)
# Register the live engine up-front: a parked approval persists the session
# mid-run (durable suspend), and resolving from the Inbox must find this engine.
self._engines[run.session_id] = engine
# The first turn is the task itself. The framing matters: instructions often restate the
# schedule ("every day at 5:32pm…"), so make explicit that the schedule already fired and
# the job now is to execute, not to (re)schedule.
opening = (
f"⏰ Scheduled run — {task.title}\n\n"
"This automation is due now: carry out the task below immediately and produce the "
"result. The schedule already exists — do not create or modify any scheduled tasks.\n\n"
f"{task.instructions}"
)
try:
async for _event in engine.run(opening):
pass
run.result_text = _last_assistant_text(engine.messages)
run.artifacts = _recent_files(task.workspace, since=run.started_at)
run.status = "ok"
if task.notify_on_completion:
await self._notify_task_done(task, run)
except Exception as exc:
run.status, run.error = "error", str(exc)
finally:
run.finished_at = _epoch()
# Persist the run as a continuable session + keep the live engine for an immediate
# follow-up; record the run (now carrying its session_id).
try:
self.save(run.session_id, engine)
self._engines[run.session_id] = engine
except Exception:
pass
self.task_store.add_run(run)
return run
async def _notify_task_done(self, task, run: TaskRun) -> None:
summary = (run.result_text or "").strip()[:280]
# Notify any socket viewing this scheduled run's session (it's a durable session of its own).
await self.broadcast_session(
run.session_id,
{
"type": "task_done",
"data": {
"task": task.title,
"id": task.id,
"text": summary,
"run_id": run.run_id,
},
},
)
if task.notify_target:
from ..connectors.base import parse_target
from ..connectors.senders import DEFAULT_SENDERS
try:
platform, chat_id, thread = parse_target(task.notify_target)
sender = DEFAULT_SENDERS.get(platform)
creds = self.secrets.get(f"{platform}:default") or {}
if sender and creds.get("bot_token"):
await asyncio.to_thread(
sender,
creds["bot_token"],
chat_id,
f"✓ {task.title}\n\n{summary}",
thread,
)
except Exception:
pass
# -- automation REST --------------------------------------------------------
def list_automations(self) -> dict[str, Any]:
# Unseen = runs started after the task's seen mark (UX-023 sidebar badges).
# `unseen_failed` tints the badge when the NEWEST unseen run errored.
tasks = []
for t in self.task_store.list():
unseen = [
r for r in self.task_store.runs(t.id) if r.started_at > t.seen_runs_at
]
tasks.append(
{
**t.public(),
"unseen_runs": len(unseen),
"unseen_failed": bool(unseen) and unseen[0].status == "error",
}
)
return {"tasks": tasks}
def mark_automation_seen(self, task_id: str) -> dict[str, Any]:
task = self.task_store.get(task_id)
if task is None:
return {"ok": False, "error": "not found"}
task.seen_runs_at = time.time()
self.task_store.save(task)
return {"ok": True}
def get_automation(self, task_id: str) -> dict[str, Any]:
task = self.task_store.get(task_id)
if task is None:
return {"error": "not found"}
return {
"task": task.public(),
"runs": [r.to_dict() for r in self.task_store.runs(task_id)],
}
def create_automation(self, payload: dict[str, Any]) -> dict[str, Any]:
"""Create an automation directly from the GUI (the "New automation" / template flow).
Mirrors the agent-facing `create_scheduled_task` validation, but binds the task to a
fresh per-task scratch workspace instead of an origin conversation's folder."""
from croniter import croniter
title = (payload.get("title") or "").strip()
instructions = (payload.get("instructions") or "").strip()
cron = (payload.get("cron") or "").strip() or None
fire_at = (payload.get("fire_at") or "").strip() or None
timezone = (payload.get("timezone") or "").strip() or "local"
if not title:
return {"ok": False, "error": "title is required"}
if not instructions:
return {"ok": False, "error": "instructions are required"}
if not cron and not fire_at:
return {
"ok": False,
"error": "provide a cron (recurring) or a fire_at ISO datetime (one-time)",
}
if cron and not croniter.is_valid(cron):
return {"ok": False, "error": f"invalid cron expression: {cron}"}
schedule = Schedule(
kind="once" if (fire_at and not cron) else "cron",
cron=cron,
fire_at=fire_at,
timezone=timezone,
)
from ..automation.models import grant_entries
task = ScheduledTask(
title=title,
instructions=instructions,
schedule=schedule,
workspace="",
origin_surface="cowork",
agent="cowork",
# Human-driven path (GUI form / onboarding recipes): the creating surface
# rendered the grants, the submit IS the consent. Same validation as the
# agent tool — only target-bound write grants survive.
always_allowed_tools=grant_entries(payload.get("permissions")),
)
task.workspace = self._provision_scratch(task.task_session_id)
self.task_store.save(task)
return {"ok": True, "task": task.public()}
def update_automation(
self, task_id: str, changes: dict[str, Any]
) -> dict[str, Any]:
task = self.task_store.get(task_id)
if task is None:
return {"ok": False, "error": "not found"}
if "enabled" in changes:
task.enabled = bool(changes["enabled"])
if changes.get("instructions") is not None:
task.instructions = changes["instructions"]
if changes.get("title") is not None:
task.title = changes["title"]
if changes.get("cron") is not None:
from croniter import croniter
if not croniter.is_valid(changes["cron"]):
return {"ok": False, "error": "invalid cron"}
task.schedule.cron, task.schedule.kind = changes["cron"], "cron"
if changes.get("revoke"):
# Revocation from the task detail page ("Allowed without asking … · Revoke").
# Human-only, like minting; the agent-facing update tool has no such field.
task.revoke_rule(str(changes["revoke"]))
self.task_store.save(task)
if changes.get("revoke"):
# A live run engine may still hold the revoked rule — reseed from the record.
for sid, engine in self._engines.items():
owner = self.task_store.task_for_run_session(sid)
if owner is not None and owner.id == task.id:
engine.permissions.task_rules = task.standing_rules()
return {"ok": True, "task": task.public()}
def delete_automation(self, task_id: str) -> dict[str, Any]:
return {"ok": self.task_store.delete(task_id), "id": task_id}
def prepare_manual_run(self, task_id: str) -> dict[str, Any]:
"""Create a 'running' manual run and return its session, so the GUI can open it and
drive the task LIVE over the normal session WS (you watch the agent + follow up). The
automatic scheduler path stays headless (`_run_scheduled_task`)."""
task = self.task_store.get(task_id)
if task is None:
return {"ok": False, "error": "not found"}
Path(task.workspace).mkdir(parents=True, exist_ok=True)
run = TaskRun(
task_id=task.id, trigger="manual"
) # status "running", session_id auto
self.task_store.add_run(run)
return {
"ok": True,
"run_id": run.run_id,
"session_id": run.session_id,
"workspace": task.workspace,
"agent": task.agent,
# Same execute-now framing as the headless path — manual runs ride a normal live
# session whose engine DOES have scheduling tools, so be explicit.
"prompt": (
f"⏰ Running automation '{task.title}' now. Carry out these instructions "
"immediately and produce the result. The schedule already exists — do not create "
f"or modify any scheduled tasks.\n\n{task.instructions}"
),
}
def finalize_manual_run(self, task_id: str, run_id: str) -> dict[str, Any]:
"""Mark a manual run complete once its first turn finished (the WS already saved the
session). Pulls result text + artifacts from the persisted transcript/workspace.
"""
run = next(
(r for r in self.task_store.runs(task_id) if r.run_id == run_id), None
)
task = self.task_store.get(task_id)
if run is None or task is None:
return {"ok": False, "error": "not found"}
if run.status == "running":
record = self.session_store.load(run.session_id)
run.result_text = _last_assistant_text(record.messages) if record else None
run.artifacts = _recent_files(task.workspace, since=run.started_at)
run.status = "ok"
run.finished_at = _epoch()
self.task_store.add_run(run)
task.last_run, task.last_status = run.finished_at, "ok"
task.run_count += 1
self.task_store.save(task)
return {"ok": True, "run": run.to_dict()}
def save(self, session_id: str, engine: TurnEngine) -> None:
executor = getattr(engine, "executor", None)
workspace = os.path.realpath(str(executor.cwd)) if executor else ""
self.session_store.save(
SessionRecord(
session_id=session_id,
workspace=workspace,
model=engine.model,
mode=engine.permissions.mode.value,
messages=engine.messages,
title=title_from(engine.messages),
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 {}
),
)
)
@staticmethod
def _apply_grants(engine: TurnEngine, grants: dict[str, Any]) -> None:
"""Re-apply a reloaded session's persisted "Always allow" approvals — they're
session-scoped, and the session outlives the process (owner-hit 2026-07-22)."""
for tool in grants.get("tools") or []:
engine.permissions.allow_tool_for_session(str(tool))
for command in grants.get("commands") or []:
engine.permissions.allow_command_for_session(str(command))
@staticmethod
def _extra_roots_of(engine: TurnEngine) -> list[dict[str, Any]]:
"""Added folders = the engine's roots minus the primary scratch (index 0)."""
roots = getattr(engine, "roots", None) or []
return [
{"path": str(r.path), "writable": bool(r.writable), "label": r.label}
for r in roots[1:]
]
# -- LLM auto-titles (FB-010) -------------------------------------------------
_AUTOTITLE_PROMPT = (
"You title chat sessions. Given the user's opening message(s), reply with ONLY "
"a 4-5 word title for the session — no quotes or punctuation wrapping it. If "
'the opening is merely a greeting or small-talk with no topic ("hey", '
'"how are you", "hi there"), reply with exactly: small-talk'
)
def _maybe_autotitle(self, session_id: str) -> None:
"""Kick off title generation after a turn completes, fire-and-forget. Only while
the session has neither a manual rename nor a generated title, at most twice:
attempt 1 rides turn 1, and the second window exists solely for the small-talk
retry (with both openers). Attempts are counted in memory rather than derived
from the user-message count — steering injections also land as role "user", and
counting them would silently suppress titling on a steered first turn. A restart
forgetting the counter is harmless: renamed/auto_title still gate re-titling."""
if session_id.startswith("__"):
return
engine = self._engines.get(session_id)
if engine is None or session_id in self._autotitle_inflight:
return
if self.task_store.task_for_run_session(session_id) is not None:
return # automation runs are titled by their task
if self._autotitle_attempts.get(session_id, 0) >= 2:
return
users = [m for m in engine.messages if m.get("role") == "user"]
if not users:
return
state = self.session_store.title_state(session_id)
if state is None or state["renamed"] or state["auto_title"]:
return
from ..attachments import content_to_text
openers = [
text
for m in users
if (text := content_to_text(m.get("content"), image_placeholder="").strip())
][:2]
if not openers:
return
self._autotitle_attempts[session_id] = (
self._autotitle_attempts.get(session_id, 0) + 1
)
try:
loop = asyncio.get_running_loop()
except RuntimeError:
return # no loop to ride (sync caller) — skip, never block
self._autotitle_inflight.add(session_id)
# Retain the task: the loop holds only a weak ref, and a GC'd task would both
# kill the title mid-flight and strand the inflight guard.
task = loop.create_task(self._generate_autotitle(session_id, engine, openers))
self._autotitle_tasks.add(task)
task.add_done_callback(self._autotitle_tasks.discard)
async def _generate_autotitle(
self, session_id: str, engine: TurnEngine, openers: list[str]
) -> None:
"""One cheap non-streaming completion on the session's own provider/model. Every
failure (provider error, empty, absurdly long) is swallowed — the title_from
fallback stays; the small-talk sentinel leaves auto_title unset so the turn-2
retry can run."""
try:
turn = await asyncio.to_thread(
engine.provider.complete,
model=engine.model,
messages=[
{"role": "system", "content": self._AUTOTITLE_PROMPT},
{"role": "user", "content": "\n\n".join(openers)},
],
temperature=0.2,
# Reasoning-routed models spend hidden tokens BEFORE emitting text; a
# tight cap plus default effort yields an empty completion and a silent
# no-op. Effort "none" reaches only the OpenAI-compat path (the native
# providers whitelist their settings), and 64 leaves headroom either way.
max_tokens=64,
reasoning_effort="none",
)
raw = (getattr(turn, "text", None) or "").strip()
# Sanitize: surrounding quotes off, whitespace collapsed, capped at 60.
title = " ".join(raw.strip("\"'“”‘’`").split())
# Sentinel tolerance: models riff on the exact token ("Small talk.", quoted,
# trailing period) — normalize before comparing, else the riff becomes the title.
if title.lower().strip(".!,;:'\"").replace(" ", "-").replace("_", "-") in (
"small-talk",
"smalltalk",
):
return
if not title or len(title) > 80:
return
if self.session_store.set_auto_title(session_id, title[:60]):
# Best-effort nudge for any live viewer; the sidebar's poll and
# post-turn refresh pick the new title up regardless.
await self.broadcast_session(
session_id,
{
"type": "session_title",
"data": {"session_id": session_id, "title": title[:60]},
},
)
except Exception:
# A failed title must never surface as a session error — but it must
# not be invisible either (a silent provider 400 hid the max_tokens
# rejection for a whole owner test pass, 2026-07-20).
logger.debug("autotitle failed for %s", session_id, exc_info=True)
finally:
self._autotitle_inflight.discard(session_id)
# -- session roots (orphan Cowork: scratch + added folders) ------------------
def get_roots(self, session_id: str) -> list[dict[str, Any]]:
"""The directories this session can touch: primary scratch first, then added folders.
Reads the live engine when one is running; otherwise reconstructs from persisted state.
"""
engine = self._engines.get(session_id)
if engine is not None and getattr(engine, "roots", None):
return [
{
"path": str(r.path),
"writable": bool(r.writable),
"label": r.label,
"primary": i == 0,
"exists": r.path.is_dir(),
}
for i, r in enumerate(engine.roots)
]
record = self.session_store.load(session_id)
primary = (
record.workspace
if record and record.workspace
else self._provision_scratch(session_id)
)
extra = (record.extra_roots if record else []) or []
out = [
{
"path": primary,
"writable": True,
"label": "scratch",
"primary": True,
"exists": Path(primary).is_dir(),
}
]
for r in extra:
p = str(r.get("path", ""))
out.append(
{
"path": p,
"writable": bool(r.get("writable", False)),
"label": r.get("label") or Path(p).name,
"primary": False,
"exists": Path(p).is_dir(),
}
)
return out
def add_root(
self, session_id: str, path: str, writable: bool = False
) -> dict[str, Any]:
"""Grant the session access to another folder (read-only or read-write). Mutates the live
engine in place when running (file tools + permissions + context see it immediately) and
persists it so a later resume still has it."""
p = Path(path).expanduser()
if not p.is_dir():
return {"ok": False, "error": f"not a directory: {path}"}
resolved = p.resolve()
engine = self._engines.get(session_id)
if engine is not None and getattr(engine, "roots", None) is not None:
if any(r.path == resolved for r in engine.roots):
# already present: just update its access level
for r in engine.roots:
if r.path == resolved:
r.writable = bool(writable)
else:
engine.roots.append(RootDir(path=resolved, writable=bool(writable)))
self.session_store.set_extra_roots(session_id, self._extra_roots_of(engine))
else:
# A brand-new conversation has no record yet (it's only saved after the first turn) —
# create one now so set_extra_roots has a row to update and the folder survives.
if self.session_store.load(session_id) is None:
self.session_store.save(
SessionRecord(
session_id=session_id,
workspace=self._provision_scratch(session_id),
model=self.model,
mode=self.mode.value,
messages=[],
agent="cowork", # folder access is a Cowork affordance
)
)
extra = [r for r in self.get_roots(session_id) if not r["primary"]]
extra = [r for r in extra if Path(r["path"]).resolve() != resolved]
extra.append(
{
"path": str(resolved),
"writable": bool(writable),
"label": resolved.name,
}
)
self.session_store.set_extra_roots(
session_id,
[
{
"path": r["path"],
"writable": r["writable"],
"label": r.get("label", ""),
}
for r in extra
],
)
self.session_store.touch_workspace(str(resolved))
return {"ok": True, "roots": self.get_roots(session_id)}
def remove_root(self, session_id: str, path: str) -> dict[str, Any]:
"""Revoke a previously-added folder. The primary scratch cannot be removed."""
resolved = Path(path).expanduser().resolve()
engine = self._engines.get(session_id)
if engine is not None and getattr(engine, "roots", None):
if engine.roots and engine.roots[0].path == resolved:
return {
"ok": False,
"error": "cannot remove the primary scratch directory",
}
engine.roots[:] = [r for r in engine.roots if r.path != resolved]
self.session_store.set_extra_roots(session_id, self._extra_roots_of(engine))
else:
current = self.get_roots(session_id)
if (
current
and current[0]["primary"]
and Path(current[0]["path"]).resolve() == resolved
):
return {
"ok": False,
"error": "cannot remove the primary scratch directory",
}
extra = [
r
for r in current
if not r["primary"] and Path(r["path"]).resolve() != resolved
]
self.session_store.set_extra_roots(
session_id,
[
{
"path": r["path"],
"writable": r["writable"],
"label": r.get("label", ""),
}
for r in extra
],
)
return {"ok": True, "roots": self.get_roots(session_id)}
def session_messages(self, session_id: str) -> list[dict[str, Any]]:
# A live engine's in-memory thread is authoritative: mid-turn it's ahead of the
# persisted record — which may not even exist yet for a scheduled run's first turn
# (opening a "running" automation showed a blank session; owner report 2026-07-04).
engine = self._engines.get(session_id)
if engine is not None:
return list(engine.messages)
record = self.session_store.load(session_id)
return record.messages if record else []
def rename_session(self, session_id: str, title: str) -> dict[str, Any]:
if session_id.startswith("__"):
return {"ok": False, "error": "internal sessions cannot be renamed"}
ok = self.session_store.rename(session_id, title)
return {
"ok": ok,
"session_id": session_id,
"title": " ".join((title or "").split())[:120],
}
def set_session_flags(
self,
session_id: str,
*,
pinned: Optional[bool] = None,
archived: Optional[bool] = None,
) -> dict[str, Any]:
if session_id.startswith("__"):
return {"ok": False, "error": "internal sessions cannot be modified here"}
ok = self.session_store.set_flags(session_id, pinned=pinned, archived=archived)
return {"ok": ok, "session_id": session_id}
def delete_session(self, session_id: str) -> dict[str, Any]:
if session_id.startswith("__"):
return {"ok": False, "error": "internal sessions cannot be deleted here"}
engine = self._engines.pop(session_id, None)
if engine is not None:
try:
# (was engine.interrupt() — a method that never existed; the AttributeError
# was silently swallowed, so deleting a running session never stopped it.)
engine.request_interrupt()
except Exception:
pass
record = self.session_store.load(session_id)
ok = self.session_store.delete(session_id)
# Deleting a session is the one implicit unsubscribe (otherwise subscriptions are permanent).
self.subscriptions.remove_session(session_id)
# ...and releases any Slack threads it owned (§31): the next tag there spawns fresh.
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)
# ...and its scratch dir. STRICTLY scoped: only a directory inside scratch_base is
# removed — a real project folder the user picked is never touched.
if ok and record and record.workspace:
scratch = self.scratch_base().resolve()
ws = Path(record.workspace)
try:
resolved = ws.resolve()
if (
resolved.is_relative_to(scratch)
and resolved != scratch
and resolved.is_dir()
):
shutil.rmtree(resolved)
except OSError:
pass # a stale/foreign path must not fail the delete
return {"ok": ok, "session_id": session_id}
# -- provider proxy ---------------------------------------------------------
def provider_complete(self, model, messages, tools=None):
return self.provider.complete(model=model, messages=messages, tools=tools)
def _refresh_provider(self, name: Optional[str] = None) -> None:
"""Drop the router's cached client(s) so the next turn rebuilds with fresh config.
No-op for an injected non-router provider (tests)."""
invalidate = getattr(self.provider, "invalidate", None)
if callable(invalidate):
invalidate(name)
# -- read models ------------------------------------------------------------
def list_sessions(self, workspace: Optional[str] = None) -> list[dict[str, Any]]:
ws = self.resolve_workspace(workspace) if workspace else None
return [
{
"session_id": r.session_id,
"title": r.title or "New session",
"workspace": r.workspace,
"agent": r.agent,
"model": r.model,
"mode": r.mode,
"updated_at": r.updated_at,
"messages": r.message_count,
"pinned": r.pinned,
"archived": r.archived,
# §31: non-user origin ("slack") + display label — drives the sidebar's
# "From Slack" group and the row's platform icon.
"origin": r.origin,
"origin_label": r.origin_label,
# Attention = Inbox items awaiting this session (the amber count that bubbles
# session → persona → footer Inbox). Liveness = working (in-flight turn) /
# sleeping (a self-wake is pending) / idle — a count-less dot that never bubbles.
"attention": len(self.inbox.pending(session_id=r.session_id)),
"liveness": self._session_liveness(r.session_id),
# Channels this session listens to (inbound subscriptions) — drives the per-session
# "connections" indicator.
"subscriptions": [
s.channel for s in self.subscriptions.for_session(r.session_id)
],
}
for r in self.session_store.list(workspace=ws)
if not r.session_id.startswith("__") # hide internal threads
]
def _session_liveness(self, session_id: str) -> str:
if self.is_running(session_id):
return "working"
if self.wakes.pending(session_id):
return "sleeping"
return "idle"
def list_agents(self) -> list[dict[str, Any]]:
return _list_agents()
# -- 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
toast ("I'll remember that — … [Undo]"). Best-effort by design: `remember` may
run with no socket attached (background runs) or off the loop thread — a lost
toast never fails the save."""
try:
loop = asyncio.get_running_loop()
except RuntimeError:
loop = None
def notify(item, previous=None) -> None:
if loop is None or not loop.is_running():
return
payload = {
"type": "memory_saved",
"data": {
"id": item.id,
"scope": item.scope.value,
"summary": item.summary or "",
"content": item.content,
# Set when this was an EDIT of an existing memory: the surface says
# "I've updated what I remember" and Undo restores this text.
"previous": previous or "",
},
}
try:
asyncio.run_coroutine_threadsafe(
self.broadcast_session(session_id, payload), loop
)
except RuntimeError:
pass
return notify
def list_memory(self) -> list[dict[str, Any]]:
return [
{
"id": m.id,
"scope": m.scope.value,
"content": m.content,
"summary": m.summary or "",
"created_at": m.created_at or "",
}
for m in self.memory_store.list()
]
def add_memory(
self, content: str, scope: str = "workspace", workspace: Optional[str] = None
) -> dict[str, Any]:
content = (content or "").strip()
if not content:
return {"ok": False, "error": "content required"}
chosen = Scope(scope) if scope in _SCOPES else Scope.WORKSPACE
ws = self.resolve_workspace(workspace) if chosen is Scope.WORKSPACE else None
item = self.memory_store.add(content, scope=chosen, workspace=ws)
return {"id": item.id, "scope": item.scope.value, "content": item.content}
def update_memory(self, item_id: int, content: str) -> dict[str, Any]:
"""Edit-in-place from the memory screen (§5.3). The user rewrote the fact, so
the stale one-line summary is cleared rather than left contradicting it."""
content = (content or "").strip()
if not content:
return {"ok": False, "error": "content required"}
item = self.memory_store.update(item_id, content, summary="")
if item is None:
return {"ok": False, "error": f"no memory with id {item_id}"}
return {"ok": True, "id": item.id, "content": item.content}
def delete_memory(self, item_id: int) -> dict[str, Any]:
"""Row delete on the memory screen — and the toast's Undo (§5.1)."""
if self.memory_store.delete(item_id):
return {"ok": True, "id": item_id}
return {"ok": False, "error": f"no memory with id {item_id}"}
def delete_all_memory(self) -> dict[str, Any]:
return {"ok": True, "deleted": self.memory_store.delete_all()}
def get_memory_settings(self) -> dict[str, Any]:
return self.memory_settings.snapshot()
def set_memory_settings(
self, enabled: Optional[bool] = None, user_rules: Optional[str] = None
) -> dict[str, Any]:
return self.memory_settings.set(enabled=enabled, user_rules=user_rules)
def _parse_inbox_json(s: str) -> dict[str, Any]:
"""Parse a structured Inbox resolution (directory/plan carry their reply as a JSON string)."""
import json as _json
try:
v = _json.loads(s) if s else {}
return v if isinstance(v, dict) else {}
except Exception:
return {}
def _epoch() -> float:
import time
return time.time()
# A Slack message ts looks like "1700000001.000001" (epoch seconds + microseconds). Other
# platforms use opaque/incrementing ids (e.g. a Telegram integer), so only parse the Slack shape.
_SLACK_TS_RE = re.compile(r"^\d+\.\d+$")
def _inbound_epoch(message_id: Optional[str]) -> float:
"""Best-effort epoch-seconds for a MessageSource: a Slack-style ts, else wall-clock now."""
if message_id and _SLACK_TS_RE.match(str(message_id)):
try:
return float(message_id)
except ValueError:
pass
return time.time()
def _last_assistant_text(messages: list[dict[str, Any]]) -> Optional[str]:
for msg in reversed(messages or []):
if msg.get("role") == "assistant" and msg.get("content"):
return msg["content"]
return None
def _recent_files(workspace: str, *, since: float, limit: int = 20) -> list[str]:
"""Files in the task workspace modified during the run — the run's artifacts."""
out: list[str] = []
root = Path(workspace)
if not root.is_dir():
return out
for path in root.rglob("*"):
if any(part.startswith(".") for part in path.relative_to(root).parts):
continue
try:
if path.is_file() and path.stat().st_mtime >= since - 1:
out.append(str(path.relative_to(root)))
except OSError:
continue
if len(out) >= limit:
break
return out
def _artifact_kind(path: Path) -> str:
suffix = path.suffix.lower()
if suffix in {".md", ".markdown"}:
return "markdown"
if suffix in {".html", ".htm"}:
return "html"
if suffix in {".png", ".jpg", ".jpeg", ".webp", ".gif"}:
return "image"
if suffix == ".pdf":
return "pdf"
if suffix in {".xlsx", ".xls"}:
return "sheet"
if suffix in {".pptx", ".ppt", ".pptm", ".docx", ".doc", ".docm"}:
return "office"
if suffix in {".csv", ".tsv"}:
return "csv"
if suffix in {".py", ".js", ".ts", ".tsx", ".css", ".json"}:
return "code"
return "text"
def _redact(raw: dict[str, Any]) -> dict[str, Any]:
"""Copy of a server config safe to return over REST — env/header values masked."""
out = dict(raw)
for key in ("env", "headers"):
if isinstance(out.get(key), dict):
out[key] = {k: ("***" if v else v) for k, v in out[key].items()}
return out
def _git_branch(path: Path) -> Optional[str]:
try:
result = subprocess.run(
["git", "rev-parse", "--abbrev-ref", "HEAD"],
cwd=path,
capture_output=True,
text=True,
timeout=3,
)
branch = result.stdout.strip()
return branch or None
except (OSError, subprocess.SubprocessError):
return None