mirror of
https://github.com/andrewyng/openworker.git
synced 2026-09-04 16:42:35 +00:00
Slack installer joins the workspace allow-list on managed connect; MCP interactive oauth only from explicit connects. Run-started toast over a new app-wide /ws/events socket; Automations e2e locators scoped to the account menu.
3456 lines
153 KiB
Python
3456 lines
153 KiB
Python
"""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 ..conversations import ConversationStore, title_from
|
|
from ..engine import ApprovalOutcome, Approver, TurnEngine
|
|
from ..roots import RootDir
|
|
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,
|
|
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 MemoryStore, Scope, SQLiteMemoryStore
|
|
from ..permissions import Mode
|
|
from ..agents import list_agents as _list_agents
|
|
from ..providers import (
|
|
ProviderClient,
|
|
ProviderRouter,
|
|
get_descriptor,
|
|
provider_descriptors,
|
|
verify_provider_key,
|
|
)
|
|
from ..secrets import SecretStore, state_dir
|
|
from ..sessions import SessionRecord
|
|
from ..skills import SkillLoader
|
|
|
|
_SCOPES = {s.value for s in Scope}
|
|
|
|
logger = logging.getLogger("coworker.manager")
|
|
|
|
|
|
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")
|
|
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.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] = {}
|
|
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"
|
|
)
|
|
# 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)}
|
|
|
|
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_store=self.memory_store,
|
|
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),
|
|
)
|
|
# 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
|
|
)
|
|
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]:
|
|
question = str(args.get("question", "")).strip()
|
|
if not question:
|
|
return {"answer": "", "error": "no question"}
|
|
inbox_name = self.inbox_routing.route_for(session_id, agent)
|
|
item = self.inbox.add_question(
|
|
session_id,
|
|
title=question,
|
|
inbox=inbox_name,
|
|
options=list(args.get("options") or []),
|
|
allow_text=bool(args.get("allow_text", True)),
|
|
multi=bool(args.get("multi", False)),
|
|
tool_call_id=tool_call_id,
|
|
)
|
|
if (
|
|
item.state != "pending"
|
|
): # durable resume re-raised an already-answered prompt
|
|
return {"answer": item.resolution or ""}
|
|
self.persist_session(session_id) # the pending tool call is now on disk
|
|
await self.mirror_inbox_item(item)
|
|
answer = await self.inbox.wait(item.id)
|
|
return {"answer": answer}
|
|
|
|
return 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):
|
|
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)
|
|
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 / unreachable url — skip, don't break the session
|
|
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 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"
|
|
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,
|
|
"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."""
|
|
for server in load_mcp_servers(self.default_workspace, secrets=self.secrets):
|
|
if server.name != name:
|
|
continue
|
|
self._mcp_authorizing.add(name)
|
|
self._mcp_errors.pop(name, None)
|
|
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:
|
|
self._mcp_errors[name] = str(exc) or exc.__class__.__name__
|
|
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)
|
|
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):
|
|
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 [])
|
|
}
|
|
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 [])
|
|
}
|
|
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",
|
|
}
|
|
for path in root.rglob("*"):
|
|
try:
|
|
rel = path.relative_to(root)
|
|
if any(
|
|
part.startswith(".")
|
|
or part in {"node_modules", "target", "dist", "__pycache__"}
|
|
for part in rel.parts
|
|
):
|
|
continue
|
|
if not path.is_file() or path.suffix.lower() not in suffixes:
|
|
continue
|
|
st = path.stat()
|
|
out.append(
|
|
{
|
|
"path": str(rel),
|
|
# Absolute path for "Copy path" — the relative one is useless outside
|
|
# the app (tester catch 2026-07-12: it copied just the filename).
|
|
"abs_path": str(path),
|
|
"name": path.name,
|
|
"kind": _artifact_kind(path),
|
|
"size": st.st_size,
|
|
"modified_at": st.st_mtime,
|
|
}
|
|
)
|
|
except OSError:
|
|
continue
|
|
out.sort(key=lambda a: a["modified_at"], reverse=True)
|
|
return out[:80]
|
|
|
|
MAX_BINARY_PREVIEW = 25 * 1024 * 1024 # base64-over-JSON gets heavy past this
|
|
|
|
def _artifact_target(
|
|
self, session_id: str, path: str
|
|
) -> 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 not target.is_file():
|
|
return None, "not found"
|
|
return target, None
|
|
|
|
def read_artifact(self, session_id: str, path: str) -> dict[str, Any]:
|
|
target, err = self._artifact_target(session_id, path)
|
|
if target is None:
|
|
return {"ok": False, "error": err}
|
|
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)
|
|
if target is None:
|
|
return {"ok": False, "error": err}
|
|
try:
|
|
if sys.platform == "darwin":
|
|
args = (
|
|
["open", "-R", str(target)]
|
|
if mode == "reveal"
|
|
else ["open", str(target)]
|
|
)
|
|
subprocess.Popen(
|
|
args, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL
|
|
)
|
|
elif sys.platform == "win32":
|
|
if mode == "reveal":
|
|
# 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" 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.
|
|
"""
|
|
import os
|
|
|
|
out: list[dict[str, Any]] = []
|
|
for d in provider_descriptors():
|
|
profile = self.secrets.get(f"provider:{d.name}") or {}
|
|
if d.needs_key:
|
|
configured = bool(profile.get("api_key")) or bool(
|
|
d.env_key and os.environ.get(d.env_key)
|
|
)
|
|
else:
|
|
configured = True # keyless (Ollama) — usable out of the box
|
|
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 the stored/env key when the form left
|
|
the key 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 {}
|
|
api_key = (fields.get("api_key") or profile.get("api_key") or "").strip()
|
|
if not api_key and d.env_key:
|
|
api_key = os.environ.get(d.env_key, "").strip()
|
|
base_url = (fields.get("base_url") or profile.get("base_url") or "").strip()
|
|
if d.needs_key and not api_key:
|
|
return {"ok": False, "error": "Enter an API key to test."}
|
|
return verify_provider_key(name, api_key=api_key, base_url=base_url)
|
|
|
|
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
|
|
if not d.needs_key:
|
|
return True # keyless (Ollama)
|
|
profile = self.secrets.get(f"provider:{name}") or {}
|
|
return bool(profile.get("api_key")) or bool(
|
|
d.env_key and os.environ.get(d.env_key)
|
|
)
|
|
|
|
# -- 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_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).
|
|
selectable = [
|
|
m
|
|
for m in self._curated_models()
|
|
if self._provider_configured(self._model_provider(m))
|
|
]
|
|
if self.model not in selectable:
|
|
selectable.insert(0, self.model)
|
|
from ..providers.matrix import 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(),
|
|
"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(),
|
|
"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(),
|
|
}
|
|
|
|
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()}
|
|
|
|
# -- 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 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]:
|
|
return self._set_allowed(name, user_id, team_id=team_id, add=False)
|
|
|
|
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,
|
|
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),
|
|
)
|
|
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
|
|
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[ocw:{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)
|
|
already = item is not None and item.state != "pending"
|
|
await self.resolve_inbox(item_id, resolution)
|
|
who = getattr(event, "user_name", None) or "someone"
|
|
title = item.title if item is not None else "Prompt"
|
|
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 `[ocw:<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 ""
|
|
return resolve_from_reply(text, self.inbox.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 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).
|
|
"""
|
|
if self.is_running(session_id):
|
|
engine = self._engines.get(session_id)
|
|
if engine is not None:
|
|
engine.queue_steering(message, source)
|
|
return
|
|
engine = self.get_engine(session_id)
|
|
if engine is None:
|
|
return
|
|
self.mark_running(session_id)
|
|
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:
|
|
"""@ocw 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),
|
|
)
|
|
)
|
|
|
|
@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:
|
|
engine.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 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()
|
|
|
|
def list_skills(self) -> list[dict[str, Any]]:
|
|
loader = SkillLoader([state_dir() / "skills"])
|
|
return loader.catalog()
|
|
|
|
def list_memory(self) -> list[dict[str, Any]]:
|
|
return [
|
|
{"id": m.id, "scope": m.scope.value, "content": m.content}
|
|
for m in self.memory_store.list()
|
|
]
|
|
|
|
def add_memory(
|
|
self, content: str, scope: str = "workspace", workspace: Optional[str] = None
|
|
) -> dict[str, Any]:
|
|
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 _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
|