跳转至

本页暂时只有英文版

Subsystem Details

Deep-dive architecture for cross-cutting subsystems. See also DESIGN.md.

All-Stores Scheduling

Cross-store scheduling. Three recurring task patterns:

Pattern Store binding phase_mode Tab Example
Store-specific One store (store_id set) forced single Scheduled "Check Store A listings daily"
All-stores (fan-out) All stores (store_id=NULL) fanout Scheduled "Check inactive listings for every store"
All-stores (single) All stores (store_id=NULL) single Scheduled "Poll shared IMAP mailbox every 5 min"
  • Fan-out / single / two-phase runners: app/scheduler/fanout.py — the Schedule.phase_mode column controls orchestration for all-stores schedules. The choice is made at APScheduler registration time in cron.add_schedule_job():
  • phase_mode='fanout' (back-compat default): _run_fanout_job creates one per-store task fired in parallel, no prerequisite.
  • phase_mode='single': _run_single_job creates exactly one store_id=None task per tick. Intended for shared work (IMAP sweeps, account health checks, housekeeping) where per-store fanout is wasteful or semantically wrong.
  • phase_mode='two_phase' (system-only, currently only the catalog-sync schedule; never client-selectable): Phase 1 creates a single no-store prerequisite task (e.g. global L2 catalog) and awaits completion, then Phase 2 fans out one per-store task (e.g. L3 catalogs).
  • Finalize / parent-reduce step (the join, mirror of two_phase's prerequisite): an all-stores fanout schedule may set Schedule.finalize_description. When set, app/scheduler/finalize_reaper.py (a periodic single-writer reaper, mirrors plan_reaper/stall_reaper) fires one no-store task (Task.is_finalize=True) after every per-store child of a batch is terminal — handing it a batch_results.json (per child: status, result, error, store_slug, task_dir). The framework decides nothing past fire-once-after-all-terminal; the prompt (finalize_description) drives retry/summarize/publish. It's agent-authored at plan time via the vibe_seller_register_finalize MCP tool and shown in the plan-review panel. finalize_description is only valid for all-stores fanout schedules (create/update + the MCP endpoint reject it otherwise). is_finalize (not "store_id IS NULL") disambiguates the finalize task from a two_phase L2 prereq task, which is also no-store.
  • One store at a time (Schedule.fanout_serial, opt-in, all-stores fanout only): a batch's per-store children run one after another, in creation (store) order, instead of all at once. A cron time cannot stagger a fanout, and every store shares one anti-detect client, so two stores driving the same portal in the same instant starve each other at the launch lock. TaskQueueScheduler._serial_sibling_ahead holds a child while any sibling is RUNNING/DESIGNING (or dispatched and not yet RUNNING) — earlier or later, so a child that comes back after a wake or a retry waits for the one running — or while an earlier sibling is still PENDING/QUEUED/PLANNED. FAILED and WAITING release the line (a child asking the user a question may wait a month and holds no agent). The gate is derived from the batch's rows on every tick, so a restart needs no recovery. Create/update reject it with 400 on store-bound and single-phase schedules, where it would silently do nothing; the create and edit modals show the checkbox only on an all-stores fanout.
  • Mode resolution on create: store-bound → forced single; else client-supplied value (rejects two_phase); else AppSettings.default_schedule_phase_mode; else fanout. phase_mode and store_id are immutable after creation — ScheduleUpdate uses extra='forbid' so mutation attempts return 422.
  • No-store tasks: store_id=None. They go through TaskQueueScheduler but bypass the per-store browser config, session tracking, and store CDP proxy. L2 catalog tasks are pure file operations; general orchestrator tasks additionally get the store-less web browser wrapper (bin/_web, lazy-started) for neutral public web work — see docs/browser.md.
  • Task grouping: Task.batch_id UUID groups tasks from same fan-out trigger
  • Plan authoring (plan-mode schedules): Plan is authored at schedule creation via a dedicated is_plan_only Task, stored on Schedule.plan, and gated by plan_status. Every fire copies the frozen plan into its child task and skips the design phase. See Plan-at-creation lifecycle below.
  • No new task status: fan-out tasks use standard lifecycle (pending → queued → designing → planned → running → completed/failed)
  • Session reuse: existing per-store task queue serializes fan-out tasks; browser session persists between queued tasks
  • Frontend: all-stores schedules shown with "All Stores" badge; child tasks grouped by store (collapsed by default)
  • Task archiving: terminal tasks (completed/failed) >7 days hidden by default, toggle to show
  • Timezone resolution: every schedule stores an IANA timezone column. When a client omits timezone on create, the router resolves it in order: (1) explicit body.timezone, (2) AppSettings['default_schedule_timezone'], (3) get_server_timezone() in app/scheduler/cron.py (wraps tzlocal.get_localzone_name(), UTC on failure). build_trigger() feeds the stored string into ZoneInfo so APScheduler fires in that zone. PUT /api/schedules/:id re-registers the APScheduler job so timezone edits take effect immediately.

Schedule AI profile resolution

Every schedule carries Schedule.ai_profile_id with two meanings: 'default'/NULL = inherit (follow the owner's current User.default_profile_id), any concrete value = explicit pin (always that profile). resolve_schedule_profile() (app/ai/profiles.py) runs live at every fire — cron (scheduler/cron.py), all-stores fan-out (scheduler/fanout.py), and finalize (scheduler/finalize_reaper.py) — and the resolved id is stamped onto the fired Task.ai_profile_id as the run's receipt. Switching the default profile therefore moves every unpinned schedule on its next fire with no schedule edits; pinned schedules are immune by design.

Opt-in sync on default change: the per-user pref User.sync_profile_to_schedules (default off, toggle in Settings → AI profiles) makes PATCH /api/profiles/{id}/set-default additionally re-pin the caller's own schedules that hold a concrete ai_profile_id to the new default (UPDATE ... WHERE created_by = me AND ai_profile_id NOT IN (NULL, 'default')). Inherit rows and other users' schedules are untouched; the endpoint response reports how many rows moved in schedules_synced.

Plan-at-creation lifecycle

Plan-mode schedules (Schedule.plan_mode=True) author their plan once at creation, not on every fire. The plan lives on Schedule.plan and is governed by a lifecycle column plan_status (PlanStatus enum in app/plan_states.py):

           (no plan_mode)
 create ─────────────────── ready
    │
    │ (plan_mode=True)
    ▼
  planning ──(user /replan)──┐
    │       ▲                │
    │ ok    │ fail           │
    ▼       │                │
  ready ◄───┴── failed       │
    │                        │
    │ (prompt edited)        │
    ▼                        │
  stale ───(user /replan)────┘
Status Meaning Fire-gate
none No plan authored (plan_mode=False, or never planned) pass (plan_mode=False only)
planning An is_plan_only Task is authoring the plan block
ready Plan committed; schedule eligible to fire pass
stale Prompt was edited; stored plan is no longer valid block
failed Planner task failed or timed out block

Fire-gate

Enforced inside both app/scheduler/cron.py:_run_task_job and app/scheduler/fanout.py:run_fanout_job / run_single_job:

fire eligible iff
    is_system == True
 OR plan_mode == False
 OR plan_status == 'ready'

Non-ready plan-mode schedules are skipped with a schedule_skipped SSE event. Manual POST /api/schedules/{id}/trigger and /resume mirror the gate with a 409.

Cancel-forward on next fire

If a prior run for (schedule, store) is still in waiting when the next fire lands, the scheduler cancels the waiter (FAILED with error_category='superseded_by_next_fire') and emits schedule_waiting_cancelled before creating the new run. Silently skipping would stall a daily schedule for up to 30 days on one forgotten user question.

Plan-only Task flow

A plan-only Task (Task.is_plan_only=True) is a one-shot planner that terminates at completed without executing. auto_approve_plan is forced to False for these tasks (narrowed gate in app/task_runner_auto.py), so the agent pauses at ExitPlanMode and waits for human review via the standard review-plan UI.

On approval, _commit_plan_only_approval in app/ai/claude_backend_hooks.py runs inside a single DB transaction:

  1. Task DESIGNING/PLANNED → COMPLETED.
  2. Schedule.plan = <plan_text>, plan_status='ready', plan_version += 1, plan_error=None, current_planning_task_id=None.
  3. Sends a deny ControlResponse so the agent halts instead of entering bypass mode.
  4. Emits task_update + schedule_plan_ready only after commit.

Invalidation on prompt edit

PUT /api/schedules/{id}:

  • plan_mode is immutable after creation — attempts return 400.
  • plan_version acts as an If-Match optimistic-lock token — stale values return 412. Bumped on every successful plan commit.
  • description changes are normalized (strip + collapse whitespace) before comparison so cosmetic edits don't spuriously invalidate.
  • Prompt change → abort any in-flight planner (stop session, FAIL the Task with error_category='superseded_by_edit') and set plan_status='stale'. The old plan text is kept so the UI can show a diff until the user re-plans.
  • Cron/timezone/store changes do not invalidate.

/replan (idempotent)

POST /api/schedules/{id}/replan spawns a new plan-only Task. If a non-terminal planning Task already exists for the schedule, the endpoint returns the current response without spawning a duplicate. Non-plan-mode schedules return 400.

Stuck-planning reaper

app/scheduler/plan_reaper.py:reap_stuck_planning_tasks runs every 5 minutes (registered in cron.py:_register_plan_reaper). For each Schedule with plan_status='planning' whose planner Task is neither WAITING nor younger than 30 min, it:

  1. Calls agent_manager.stop(task.id).
  2. Transitions the Task to FAILED with error_category='planning_timeout'.
  3. Sets plan_status='failed', plan_error='Planning timed out', and clears current_planning_task_id.
  4. After commit, emits schedule_plan_timeout.

WAITING is explicitly preserved — it is legitimate user-input wait, handled by waiting.py's 30-day timeout, not this reaper.

Key file map

Component File
Plan state enum app/plan_states.py
Schedule model (plan columns) app/models/schedule.py
Task model (is_plan_only, plan_version) app/models/task.py
Router endpoints (create/update/replan/trigger/resume) app/routers/schedules.py
Fire-gate + cancel-forward app/scheduler/cron.py, app/scheduler/fanout.py
Approval hook + plan commit app/ai/claude_backend_hooks.py:_commit_plan_only_approval
Reaper app/scheduler/plan_reaper.py
auto_approve_plan narrowing app/task_runner_auto.py
Prompt block for plan-only tasks app/task_runner.py:build_system_extra

Skill Prerequisite Enforcement

Skills can declare requires: [<prereq-skill-name>] in their YAML frontmatter. The PreToolUse hook enforces the order: a Skill(<dependent>) call is denied until <prereq> has been loaded in the same session — same mechanism shape as Claude Code's built-in Read-before-Write rule.

Why

Several skills are layered: e.g. noon-ads needs noon-shared's login flow and URL map before any ad work makes sense. A prose > **PREREQUISITE:** Read ../noon-shared/SKILL.md line in the dependent's SKILL.md is the spec, but non-Claude models (and Claude under load) sometimes skip it and run with a partial context. The hook turns that prose into a mechanism.

Each e-commerce platform ships as one such family: amazon-shared + amazon-{ads,listing}, noon-shared + noon-{ads,…}, and — for Taobao/Tmall — qianniu-shared + qianniu-{listing,ads}. Qianniu differs from the Amazon/Noon (Ziniao) families in its browser layer: it runs on the chrome backend with a human-in-the-loop login (QR scan or slider-captcha + SMS — no auto-captcha), a wrong-account hard gate, and a promo/公告 popup-close loop; its exports are GB18030-encoded CSV. All writes (Excel 批量导入, ad-plan changes) are surfaced for human review, never auto-executed.

Frontmatter format

---
name: noon-ads
description: "Noon Ad Manager — campaigns, tuning audits, ..."
requires: [noon-shared]
---

Only the inline-list form is parsed (parse_skill_requires in app/ai/claude_backend_utils.py). Multiple prereqs are allowed: requires: [a, b]. Skills with no requires: (and unknown skills not shipped by us) pass through unchanged.

Hook flow

Skill(noon-ads) tool_use
        ↓
Claude Code → control_request hook_callback (PreToolUse, tool_name="Skill")
        ↓
_handle_hook_callback (claude_backend_hooks.py)
        ↓
_check_skill_prereqs("noon-ads"):
   • find_skill_md(workspace, "noon-ads") → task_dir/.claude/skills/noon-ads/SKILL.md
   • parse_skill_requires() → ["noon-shared"]
   • missing = [r for r in requires if r not in self._loaded_skills]
        ↓
missing? → control_response {permissionDecision: "deny",
                             permissionDecisionReason: "Skill 'noon-ads' requires 'noon-shared'
                                                        to be loaded first. Call ..."}
not missing? → allow, then self._loaded_skills.add("noon-ads")

The deny lands back at Claude Code as an is_error: true tool_result, which the agent reads and reacts to in the same turn — typically by calling Skill(noon-shared) next, then retrying Skill(noon-ads).

One load path: the Skill tool

A skill is loaded — added to _loaded_skills, durably bound via record_skill_load, and its gates: armed — only by a Skill(<name>) call, the same single path Claude Code itself uses (addInvokedSkill / registerSkillHooks). Reading a SKILL.md as a file (Read, cat, sed -n, …) loads nothing; the hook allows it and, if the skill isn't loaded, attaches additionalContext saying that a lookup can carry on but following the procedure needs Skill(<name>) (skill_gate_utils.unloaded_skill_read_hint). Reads used to bind too, and that guess bound the wrong gates: an ad audit sed-ed 22 lines of review-collect for a metadata shape, review-collect's gate refused the ad report, and the run went collecting product reviews.

Per-session state

self._loaded_skills: set[str] lives on AgentSession. It resets when the session resets (e.g. a fresh restart of a failed task) — which is correct, because the restarted agent's context window doesn't have the prior skill body. The hook then re-enforces the order against the fresh session.

Key files

Concern File
Frontmatter requires: parser app/ai/claude_backend_utils.py:parse_skill_requires
SKILL.md path resolver (task workspace → global fallback) app/ai/claude_backend_utils.py:find_skill_md
Hook deny path app/ai/claude_backend_hooks.py:_check_skill_prereqs
Per-session loaded set app/ai/claude_backend.py:AgentSession._loaded_skills
E2E proof of full deny → retry → load cycle tests/e2e/test_skill_prereq_hook.py

Email System

Persistent per-account email storage with background sync and SMTP send capability.

  • EmailAccount model: Stores IMAP + SMTP credentials with Fernet-encrypted passwords (key from JWT_SECRET via SHA-256). SMTP columns: smtp_host, smtp_port, smtp_use_tls
  • StoreEmailLink: Many-to-many junction linking email accounts to stores
  • Per-account SQLite DBs: Each email account gets its own SQLite database at ~/.vibe-seller/data/email_dbs/email_{account_id}.db (managed by app/email/db.py). Stores messages with full headers, body, and metadata
  • Background sync: app/scheduler/email_sync.py syncs all accounts every 5 minutes (concurrent with semaphore to limit load). Replaces the old channel-based polling
  • Auto-discovery: app/channels/email_discovery.py provides both IMAP and SMTP auto-discovery for top providers (163, Gmail, QQ, Outlook, Yahoo), with heuristic fallback
  • SMTP send: app/email/sender.py — send emails via SMTP through any configured account. Exposed via POST /api/email-accounts/{id}/send
  • Agent access: Agents query emails via sqlite3 CLI against the local DB files. Store context (_build_store_context) injects DB paths and schema info. MCP tools vibe_seller_email_info (DB paths + schema) and vibe_seller_send_email (send via SMTP) replace the old vibe_seller_poll_emails
  • Waiting task checker: Queries the local SQLite DB for keyword matches instead of live IMAP polling

Chrome Persistent Profiles & Bookmarks

  • Persistent profiles: Managed by the browser-use (0.13 browser_harness) daemon per session. Chrome stores use the BU_NAME={slug} env var (0.13; was --session {slug}) for profile isolation. Cookies, localStorage, and login sessions survive across tasks.
  • Ziniao aux sessions: Ziniao stores also get an auxiliary Chrome session ({slug}-aux) for non-seller-center URLs (Google, logistics sites, etc.). The aux session starts lazily on first use — zero overhead if unused.
  • Proxy config: Only relevant for Chrome stores in UI.
  • Bookmarks: read_bookmarks(slug) reads Default/Bookmarks JSON from the profile dir. Auto-injected into agent context when knowledge files are sparse.

Browser & Task Concurrency

Concurrent per-store execution: Multiple tasks for the same store can run concurrently (up to the proxy's max_clients connection limit, default 16 per CDPMuxProxy instance) when they share the same platform/country. The CDPMuxProxy provides per-task isolation within a single browser session via request ID rewriting and session-based event routing. Tasks that share the same platform but target different countries are queued since Ziniao needs to switch country; all other combinations run concurrently.

Dispatch: All store-task launch paths (create, retry, continue, execute-plan) route through _schedule_or_run() which submits to TaskQueueScheduler when it's running. No-store tasks launch directly. The scheduler uses can_schedule() to gate by platform/country compatibility (RUN, RUN_IN_NEW_TAB, or QUEUE).

Per-task daemon sessions: Each task gets its own browser-use daemon session, named via the BU_NAME env var (0.13): {slug}-{VIBE_TASK_ID[:8]}. CDPMuxProxy is the primary isolation mechanism — each task connects with a unique client ID (/client-{task_id}, injected as BU_CDP_WS) and receives isolated CDP sessions via request ID rewriting and session-based event routing. The wrapper validates the session name via regex (^{slug}(-aux|(-{8hex})?(-w{N})?)$).

Parallel workers within one task: a task's subagents share its process and therefore its VIBE_TASK_ID, so two of them driving the browser at once would land on one daemon and one tab. browser-use --worker N gives each concurrent driver its own daemon ({slug}-{task[:8]}-wN) and its own mux client (client-{task}-wN) on the same logged-in browser. The bound is VIBE_BROWSER_WORKER_SLOTS (default 3, 0 disables); the parent agent assigns the numbers. See browser.md § Parallel workers.

These bounds multiply. One store's proxy serves MAX_AGENT_CONCURRENCY × (1 + VIBE_BROWSER_WORKER_SLOTS) + 1 aux clients — 9 at the defaults, against a cap of 16. max_agent_concurrency is user-settable up to 10 from Settings, and nothing recomputes the cap, so a high setting combined with worker slots can exhaust it on a single store and get clients refused with close code 4029.

Ziniao guard (account conflict): Only one Ziniao account can be active per machine (one Ziniao process). Multiple profiles (different browserOauth) on the same account work fine — each gets a unique debuggingPort and CDP proxy. But if store A uses Ziniao account #1 and store B uses account #2, store B's task will fail with a clear error: "Store(s) [Store A] are using a different Ziniao account. Stop the browser session first." Chrome stores have no such account restriction (but still use CDPMuxProxy for shared browser and cookie persistence).

Ziniao dual-browser: Ziniao stores get dual-session support in their wrapper — a main session ({slug}, routed via CDP proxy to Ziniao) and an aux session ({slug}-aux, Chrome for non-seller-center URLs). The agent uses AI judgment to route: seller center → Ziniao session, everything else → Chrome aux session. Routing rules defined in app/prompts/dual_browser.md. Users can override via stores/{slug}/browser-routing.md.

System Scheduler Jobs

start_scheduler() in app/scheduler/cron.py registers a fixed set of system-only APScheduler jobs (none of these correspond to a row in the schedules table, so they don't appear in the UI):

Job id Trigger Source Purpose
waiting_checker every 15 min app/scheduler/waiting.py Check WAITING tasks for email/condition triggers; time out stale waits
email_sync every 5 min app/scheduler/email_sync.py Sync configured email accounts into per-account SQLite DBs
plan_reaper every 5 min app/scheduler/plan_reaper.py Fail schedules stuck in plan_status='planning'
stall_reaper every 1 min app/scheduler/stall_reaper.py Terminalize RUNNING tasks whose agent can no longer progress
task_cleanup daily 03:30 server-tz app/scheduler/task_cleanup.py Auto-delete terminal leaf tasks past AppSettings.task_retention_days (see docs/tasks.md § Task deletion + auto-cleanup)

stall_reaper's authority split — who may declare "this request is dead": while the agent process is alive and input-capable, the CLI owns the in-flight request, and every spawn env arms the CLI's own dead-stream machinery (ProfileManager.get_env_for_profile → ensure_stream_liveness_env): CLAUDE_ENABLE_STREAM_WATCHDOG=1 + CLAUDE_STREAM_IDLE_TIMEOUT_MS=420000 (7 min) make the CLI abort a stream with no chunk for 7 min (TTFB counts, every chunk resets it) and fall into its non-streaming fallback, with each failed attempt announced on stdout as system/api_retry; CLAUDE_CODE_INCLUDE_PARTIAL_MESSAGES=1 forwards every raw SSE part (message_start / deltas / pings) as a stdout line, so a healthy compose bumps updated_at per part instead of going silent. The reaper only fires after true stdout silence has outlasted that watchdog bound + recovery headroom (~200 s retry backoff + 60 s poll + 60 s heartbeat throttle) — 13 min with presets — instead of the earlier API_TIMEOUT_MS + 2 min (~52 min), which only bounded the headers phase. A slow first token is structurally inside the bound: a healthy agent composing a long report after a large tool_result can be silent for minutes (observed 3–4 min live), and updated_at staleness alone can't distinguish it from a dropped stream. When there is no session, the process has exited, or the process is past its turn terminator (stdin closed — the post-close kill escalation in app/ai/claude_backend_turns.py disposes of it), nothing can bump updated_at any more and the flat 3-min silence rule applies. Pending AskUserQuestions are always skipped (the operator may take hours).

Ziniao on WSL

WSL cannot auto-launch Ziniao because Electron's Node.js V8 rejects unknown -- flags before the app code runs. The workflow is:

  1. User downloads ziniao_webdriver.bat from GET /api/ziniao/launcher (or uses the copy in the repo root)
  2. User double-clicks the .bat on Windows — it auto-finds the exe, kills any existing Ziniao, launches in WebDriver mode, and verifies the HTTP API
  3. Once Ziniao is running, WSL connects via the gateway IP (auto-detected by _get_ziniao_host() from ip route)

If Ziniao is already running with the correct port, everything works automatically. The backend only raises errors when Ziniao is unreachable, guiding the user to the launcher script.

CDP Proxy Architecture

Each Ziniao store gets a stable CDP multiplexing proxy (app/browser/cdp_mux_proxy.py) that listens on 127.0.0.1:{proxy_port} and connects upstream (WebSocket) to the actual Ziniao debuggingPort (which changes on each startBrowser call). On WSL, the proxy connects to the Windows gateway IP instead of localhost. The browser-use wrapper's BU_CDP_WS env var (0.13; was --cdp-url) always points to the stable proxy port with a per-task client ID.

Multi-client support: Multiple browser-use CLI processes connect simultaneously, each via ws://127.0.0.1:{proxy_port}/client-{task_id}. The proxy multiplexes all clients onto a single upstream WebSocket to the browser, using: - Request ID rewriting: Client request IDs are remapped to globally unique IDs; responses are routed back to the originating client - Session-based event routing: CDP sessionId → owning client mapping ensures page events only reach the tab owner - Target filtering: Target.getTargets() responses filtered per-client; cross-client attachToTarget/closeTarget blocked - Pending event cache: Handles the race where Target.attachedToTarget arrives before Target.createTarget response (common with browser-use's setAutoAttach)

Lifecycle: The proxy stays alive while any task uses it. Client disconnect (clean or crash) triggers cleanup: close the client's tabs, remove routing entries. Startup cleanup closes orphan tabs from prior server crashes.

A legacy TCP relay (CDPTcpProxy in cdp_proxy.py) is kept as fallback for single-client scenarios.

Soft Stop-Gates

When the agent calls vibe_seller_set_task_result, the resolved result text runs through a small chain of soft gates before the task is allowed to settle. Each gate is a function that returns either None (allow) or a GateDeny with a short, agent-readable reason and a gate identifier. Code lives under app/ai/stop_gates/; the dispatch is in app/routers/tasks.py::set_task_result.

Why soft, not hard

Gates can be wrong, the model can be stubborn, and trapping the agent forever in a deny loop wastes tokens. Each gate is allowed at most SOFT_GATE_MAX_DENIALS denials per task (the agent sees the deny reason and can retry); past the cap the result is allowed through with the original text and a logger.warning(...). Deny counts are tracked in-memory by task id (record_attempt in app/ai/stop_gates/__init__.py); the current cap value lives there alongside the rationale.

Why at the MCP tool call, not the Stop hook

Some backends (notably DeepSeek under certain compaction conditions) never emit a Stop event, so a Stop-hook-only gate would be silently skipped. set_task_result is the one tool every successful task path calls, so the gate fires there.

Bundled gates

Module Concern Body of the check
app/ai/stop_gates/markdown_format.py Final result is well-formed Markdown — no leftover XML-style closing tags, no truncated fences Regex scan on the resolved text
app/ai/stop_gates/result_language.py Reply language matches the user's task language (the agent shouldn't answer a Chinese task in English) Character-set heuristic over the prompt + result, with carve-outs for technical identifiers

Adding a gate

  1. Drop a module under app/ai/stop_gates/ exporting a check(text, …) -> GateDeny | None callable. Free to take extra inputs (e.g. result_language.check also takes the task title + description).
  2. Wire it into the dispatch loop in set_task_result (for gate_module, gate_args in (…)).
  3. Add a unit test under tests/unit/test_stop_gates.py.

The dispatch loop deliberately runs gates in declaration order and short-circuits on the first deny — so put cheaper / more important gates first.

Plugin Architecture

The codebase is split into a public OSS core (vibe-seller, knows no customer) and private per-customer plugins (external wheels, on top of core). app/plugins.py is the inversion-of-control seam that makes this possible: core reads its gates, PreToolUse guards, browser backends, skill sources, prompt fragments, and background services from a registry that plugins populate at startup, rather than importing customer code directly. Removing a customer = not installing its wheel — zero core edits, nothing to re-leak. (Design-of-record: plugin_design_v2.md; module reference: backend.md § Plugin Framework.)

The contract

A plugin subclasses Plugin and implements install(ctx), calling the ExtensionContext.register_* methods. Registration is declarative — install records contributions into ctx and never touches the live FastAPI app, so the registry can be built lazily in app-less unit tests. App-level effects are applied separately by reading the populated context: main._wire_plugins mounts plugin routers / frontend-bundle routes; the lifespan starts/stops background services.

Contribution Registry method Read by
Stop-gate (set_task_result reviewer) register_gate stop_gates.get_registered_gates → resolve_skill_gates (see Soft Stop-Gates)
PreToolUse Bash guard register_pretool_gate bash_safety.first_bash_deny
Browser backend register_browser_backend BrowserManager._get_backend
Skill directory register_skill_source skills_sync (core app/skills is always first; first-source-wins on a name collision so a plugin can't shadow a core skill)
System-prompt fragment register_prompt_fragment prompt assembly (forward-looking)
Background service register_service lifespan (started as a task, cancelled on shutdown)
Server-reviewed audit marker register_review_marker bash_safety.check_review_status (a plugin with its own server-side completeness gate suppresses core's legacy REVIEW-file Stop gate for its audits — no double-gating)
Backend router / frontend JS bundle register_router / register_frontend_bundle main._wire_plugins + GET /api/plugins

OSS builtin vs plugins

The OSS core registers itself as the builtin plugin (app/builtin_plugin.py:BuiltinPlugin) through the exact same API a plugin uses — dogfood, and the registry is never empty. It is loaded by direct import (always present, no entry point needed) and fail-closed: if it can't load, startup aborts rather than running with no safety gates. External plugins are discovered via the vibe_seller.plugins entry-point group their wheels declare, loaded after the builtin in deterministic (name-sorted) order, and fail-open (a bad wheel is logged and skipped). Per-customer isolation is at pack/install time: a customer's box installs only that customer's plugin wheels, so other customers' code is absent at the wheel boundary.

Frontend extension

A plugin can add dashboard UI without forking the OSS frontend: it registers a compiled JS bundle (register_frontend_bundle), GET /api/plugins lists the active bundles, and the OSS React shell dynamically loads each one (requires_early_init bundles resolve before the app makes API calls). Ships inert in PR-1 (empty list in an OSS-only install); the React loader lands when the first plugin UI does.

Amazon Ads API service

A store can do its advertising over a hosted ads service instead of the browser. vibe-seller holds no Amazon credential and speaks no Amazon API: the service holds the Amazon authorization and does the calls.

  • Binding (app/ads_client.py): nothing to configure. The service address is the constant SERVICE_URL, and the installation registers itself (POST /installations) the first time Settings → Integrations syncs the stores — never at boot, so an installation that does not use ads never contacts the service. The key is stored encrypted in AppSettings and never returned by any route. The key alone reaches no store: each store is reached only after its owner consents on Amazon, and is revoked per store (POST /api/ads/stores/{id}/unbind, admin only).
  • One advertiser, several installations: Amazon's profile id says two stores are the same advertiser (names and local ids are each machine's own). Each installation that the owner consented from reads the same history; revoking on one leaves the others working.
  • Write-back: an authorized store's marketplaces are added to its platforms / countries / platform_countries — Amazon's word beats a guessed list. Nothing is removed, on sync or on revoke.
  • Key secrecy: the agent's one tool, vibe_seller_ads_call, goes through POST /api/ads/call, which adds the key server-side. The key never enters the agent's context, and paths that belong to a person (/me, /auth-url, /assignments, /ads-binding, /installations) are refused.
  • Authorization: the OAuth round trip happens on the service's domain, so a deployment needs no public address. POST /api/ads/stores/sync records which stores are authorized: Store.ads_store_keys ({marketplace: store_key}) and Store.ads_authorized.
  • store_key resolution: the agent never supplies a store key. The call names a marketplace; resolve_store_key() maps it to the key, and refuses rather than guesses when a store has several marketplaces and none was named. A task bound to a store reaches only that store.
  • File answers: the service answers a large list as CSV; it is saved to tasks/<id>/ads-data/ and the agent gets the path and row count.
  • Skill routing: an authorized store's task sees amazon-ads-api only, any other task the browser amazon-ads. See workspace.md § Ads skill routing, which also covers how the service-shipped skill is synced.

Cross-platform support (native Windows)

Vibe Seller runs on macOS, Linux, and natively on Windows (no WSL required). Every platform difference is centralised in app/platform.py (see backend.md) so the rest of the code stays platform-agnostic:

  • Process management — kill_process, is_process_alive, find_processes_by_pattern use psutil instead of pgrep/ps/ os.kill (Unix-only). The agent's _force_kill signals the POSIX process group via os.killpg where available and falls back to terminate()/kill() on Windows (where send_signal(SIGINT) raises).
  • Paths — venv_python/venv_executable/venv_bin_dir resolve Scripts/ on Windows vs bin/ on POSIX; prepend_to_path joins with os.pathsep (; on Windows). safe_chmod is a no-op on Windows.
  • Agent shell — the agent invokes browser-use through Claude Code's Bash tool, which uses Git Bash on Windows. So the existing bash per-store wrapper (app/browser/wrapper.py) works unchanged on every platform — there is no PowerShell wrapper.

Native Windows installer

installer/windows/ builds a self-contained VibeSeller-Setup.exe (Ollama-style: per-user install + a system-tray launcher that starts the server on login). It bundles a relocatable CPython (python-build-standalone), the app + deps as offline wheels, uv, full Git for Windows (git + the bash.exe Claude Code's Bash tool needs, plus curl/perl for the browser-use wrapper), and the claude CLI. It drives the user's installed Chrome — no bundled browser. Built on a GitHub-hosted windows-latest runner (.github/workflows/windows-installer.yml); see installer/windows/README.md.