Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ From a source checkout:
uv sync
uv run dispatch --help
uv run dispatch doctor --no-app-server
uv run dispatch models --no-refresh
uv run dispatch up --json
uv run dispatch daemon status
```
Expand Down Expand Up @@ -55,6 +56,9 @@ indexed view of an attached thread.
assistant work completed. Use `get` to inspect the latest turn state and persisted
App Server errors, or `watch` for a bounded live event sample. Slash commands in
`--text` are plain text; use `--goal` when creating a native App Server goal.
Use `dispatch models` before pinning model or service-tier presets; Dispatch
resolves aliases such as `fast` from the live App Server model catalog and keeps
omitted model/tier values on Codex defaults.

For the operator guide, CLI/MCP examples, triggers, and plugin setup, start at
[`docs/usage/README.md`](docs/usage/README.md).
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -69,8 +69,7 @@ effort = "low"

Omit `model` unless you intentionally want Codex to use an explicit model.
When a preset does pin a model, choose it from the live App Server catalog
(`dispatch model list` once that surface exists) rather than from docs or stale
examples.
(`dispatch models`) rather than from docs or stale examples.

Merge order:

Expand Down
6 changes: 5 additions & 1 deletion docs/development/design.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ Projections (pure functions over the registry, mirroring Trails' `derive* → cr
- Thread management/search: `attach <thread-id> [--sync]` ·
`rename <selector> <new>` · `archive <selector>` · `restore <selector>` ·
`search <query>` with `--thread`/repo/directory/date/managed filters
- Model catalog: `models [--no-refresh]`
- Sending: `send <selector> "…"` with `--mode send|steer|queue|interject|context`
and equivalent mutually exclusive `--steer`, `--queue`, `--interject`,
`--context`; `stop <selector>` is cancel-only.
Expand All @@ -105,7 +106,7 @@ for collisions. Titles and `@handles` are mutable convenience labels.
| Op | App Server call | Notes (verified) |
| --- | --- | --- |
| `open` | `thread/start` (then register) | `sandbox` is a STRING enum (`read-only`/`workspace-write`/`danger-full-access`); persists by default (`ephemeral:false`) → spawned lanes show in desktop app, matching the `→ @project:name` convention. |
| `new` | `thread/start` + `thread/name/set` + optional `thread/goal/set` + optional `turn/start` | Applies `.dispatch/config.toml` defaults/presets, name prefixes, verified session/turn options, optional native goal, and optional initial payload. `service_tier` is sent to both thread creation and the initial turn when configured. Output reports request acceptance, not assistant completion. |
| `new` | `thread/start` + `thread/name/set` + optional `thread/goal/set` + optional `turn/start` | Applies `.dispatch/config.toml` defaults/presets, name prefixes, verified session/turn options, optional native goal, and optional initial payload. Explicit `service_tier` values are resolved through the App Server model catalog before being sent to thread creation and the initial turn; omitted model/tier values preserve Codex defaults. Output reports request acceptance, not assistant completion. |
| `attach` | `thread/read(includeTurns:false)` (+ register) | Metadata-only by default: verifies the thread id, registers a turn-write locked attached lane, assigns a dispatch ref, and stores sync state without loading turn history. `--sync` runs a quick local index refresh after registration. |
| `sync` | `thread/read(includeTurns:false)` + bounded local JSONL parsing | Refreshes dispatch's index/cache for a managed thread: source file identity, sync state, latest event timestamp, latest turn id, preview, and selected metadata. Does not copy transcripts wholesale or grant attached-lane write authority. |
| `send` (`mode=send`) | `turn/start` | Delivers a message the lane processes + answers. The DM/`send_message_to_thread` equivalent. `sandboxPolicy` here is an OBJECT (`{type:"readOnly"}`) — different encoding than `thread/start.sandbox`. |
Expand All @@ -120,6 +121,7 @@ for collisions. Titles and `@handles` are mutable convenience labels.
| `search` (`search`) | experimental `thread/search` for broad search; `thread/read(includeTurns:true)` for one-thread search | Broad search uses App Server search plus dispatch-side managed/unmanaged, repo/directory, and date filters. Thread-focused search reads one transcript and scans locally because App Server search has no thread-id filter. |
| `roster` (`list`) | `thread/list` + registry + status | List results are under `result.data` (NOT `result.threads`); `useStateDbOnly:true` reads the persisted store. Current App Server also supports native `archived`, `cwd`, `searchTerm`, `sourceKinds`, and sort filters. |
| `discover` (`list --unmanaged`) | `thread/list` state DB only | Lists persisted active Codex sessions that could be attached; asks for recently updated rows and does not resume or register them. |
| `models` | `config/read` + optional `model/list` | Reports current Codex model defaults and the App Server model catalog, including service-tier aliases such as user-facing `fast` to server-facing ids like `priority`. `--no-refresh` reads the registry cache plus current config defaults. |
| `show` (`get`) | registry + optional `thread/read(includeTurns:true)` | Compact managed-thread summary with sync state and latest observed turn runtime/error state; optional transcript convenience. |
| `transcript` (`tail`) | `thread/read(includeTurns:true)` | Persisted turn/item snapshot, not a full execution log. |
| `watch` (`watch`) | raw app-server event stream, bounded by limit/timeout | Request/response bounded sample; a true infinite tail needs a subscription control-socket extension. |
Expand Down Expand Up @@ -164,6 +166,8 @@ The client supports the full responder loop. v1 surfaces `waiting_on_approval` a
- `lanes`: id, ref, ref_source/ref_payload/ref_mixer, handle (`@name` / `→ @project:name`), role, cwd, source (`own`|`attached`), status, pinned, created_at, updated_at, last_event_at.
- `lane_sync_sources`: lane, sync state, source path/file identity, source size/mtime, parsed offsets, line count, last synced timestamp, error.
- `lane_snapshots`: lane, display name, preview, cwd, source/model/session facts, latest event timestamp, latest turn id, transcript-partial flag.
- `model_catalog`: provider/model rows refreshed from App Server `model/list`, including reasoning efforts, service tiers, aliases, and first/last seen timestamps.
- `lane_model_settings`: per-lane model/provider/reasoning/service-tier provenance, distinguishing Dispatch-authored settings from configured defaults and observed metadata.
- `triggers`: id, name, lane selector, when-spec (json), action-spec (json), guard-spec (json), enabled, last_fired_at.
- `actions_log`: id, ts, lane, op, trigger_id?, request/decision, outcome — full audit of every send/action.

Expand Down
11 changes: 10 additions & 1 deletion docs/research/app-server-verification.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,16 @@ Lifecycle/threads/turns: `thread/start resume fork read list loaded/list archive
those when they match existing CLI/MCP semantics, then keep dispatch-side
filters for managed/unmanaged and date predicates.
- `turn/start` accepts `serviceTier` plus richer context/environment metadata.
Dispatch projects `service_tier` through configured `new` turns.
Dispatch resolves explicit `service_tier` values before projecting them
through configured `new` turns.
- `model/list` is the authoritative catalog for model ids, reasoning-effort
support, and service tiers. Prefer `serviceTiers` over the deprecated
`additionalSpeedTiers`; user-facing labels such as `fast` can map to a
server-facing tier id such as `priority` when the catalog advertises a tier
named `Fast`.
- `config/read` reports the current Codex defaults (model/provider,
reasoning effort, service tier). Dispatch records those defaults for output
truth but does not send omitted model/tier values just to mirror config.
- `thread/resume` accepts `excludeTurns` / `initialTurnsPage`, useful for future
live observation without heavy initial history hydration.

Expand Down
33 changes: 29 additions & 4 deletions docs/usage/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ Use this clean-machine smoke after installing or upgrading:
```bash
dispatch doctor
dispatch schema send
dispatch models --no-refresh
dispatch up --json
dispatch daemon status
dispatch down --json
Expand Down Expand Up @@ -199,11 +200,32 @@ developer_file = ".dispatch/instructions/reviewer.md"
sandbox = "workspace-write"
approval_policy = "on-request"
developer_file = ".dispatch/instructions/builder.md"

[presets.fast]
service_tier = "fast"
effort = "low"
```

Preset order matters: later presets win, and CLI flags win over presets.
When `service_tier` is configured, dispatch sends it to both `thread/start` and
the optional initial `turn/start` request.
Omit `model` unless you intentionally want Codex to use an explicit model. An
omitted model or service tier keeps the Codex default call shape; Dispatch still
records the configured default reported by `config/read` when it is available.

Use `models` before pinning model or service-tier presets:

```bash
uv run dispatch models
uv run dispatch models --no-refresh
uv run dispatch schema models
```

`models` refreshes from App Server `model/list` by default and reports the
configured default from `config/read`, each model's reasoning efforts, service
tiers, and aliases. For example, the user-facing `fast` alias resolves through
the advertised service tier named `Fast` and may send `serviceTier:"priority"`
to the App Server. If a requested tier is unavailable for the selected/default
model, `new` fails before starting the thread and prints the available tiers.
`--no-refresh` reads the local catalog cache plus current config defaults.

Use `--goal` to create a native App Server goal before the initial message is sent.
Slash commands in `--text` are not interpreted by dispatch; `--text "/goal ..."`
Expand Down Expand Up @@ -473,6 +495,7 @@ uv run dispatch schema send
uv run dispatch schema "list --unmanaged"
uv run dispatch schema sync
uv run dispatch schema watch
uv run dispatch schema models
uv run dispatch schema "goal set"
```

Expand All @@ -490,8 +513,10 @@ uv run dispatch mcp

MCP is grouped for agent ergonomics rather than one tool per op. Tools are grouped by
workflow and safety boundary, for example thread read/write/destroy, trigger
read/write/destroy, and daemon read tools. Each grouped call chooses an `op` inside the
tool, and that op's arguments/schema still derive from the same contract registry.
read/write/destroy, and daemon read tools. The daemon read tool includes the
`models` op so agents can discover valid model/service-tier choices without
guessing. Each grouped call chooses an `op` inside the tool, and that op's
arguments/schema still derive from the same contract registry.
Structured MCP outputs that identify a managed thread include the dispatch `ref`, full
Codex id, title/handle, managed/source/status, and cwd when available.

Expand Down
5 changes: 4 additions & 1 deletion plugins/dispatch/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,12 @@ This workspace-local plugin exposes:

The MCP server and skills expose the same derived operation registry as the CLI,
including managed-thread creation/messaging, dispatch refs, persisted `tail`,
bounded live `watch`, native goals, triggers, schemas, and daemon status/log reads.
bounded live `watch`, native goals, triggers, schemas, model catalog reads, and
daemon status/log reads.
`new --goal` creates native App Server goal state; `/goal ...` in message text is
plain text and should not be used as a goal substitute.
Use `dispatch models` or the MCP daemon-read `models` op before pinning explicit
model/service-tier presets.

Run `dispatch doctor` after installing or upgrading dispatch. It verifies the CLI
entrypoints, Codex CLI/auth footprint, daemon socket/pidfile state, registry
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "outfitter-dispatch"
version = "0.4.1"
version = "0.5.0"
description = "Local control plane for orchestrating Codex agent lanes over the Codex App Server."
readme = "README.md"
requires-python = ">=3.13"
Expand Down
18 changes: 18 additions & 0 deletions skills/dispatch/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ The current canonical operator grammar is:
- daemon process: `up`, `down`
- daemon reads: `daemon status`, `daemon log`
- registry recovery: `registry migrate`
- model catalog: `models`
- thread lifecycle/read/search: `new`, `attach`, `list`, `list --unmanaged`,
`get`, `sync`, `tail`, `watch`, `search`
- thread actions: `rename`, `archive`, `restore`
Expand Down Expand Up @@ -93,6 +94,20 @@ that shape so agents do not create a thread that only looks goal-driven.
use `get` to check `latest_turn`, `tail` for persisted history, or `watch` for a
bounded live sample.

Before choosing explicit `--model`, `--model-provider`, or `--service-tier`
values, ask dispatch for the live catalog:

```bash
uv run dispatch models
uv run dispatch models --no-refresh
uv run dispatch schema models
```

Omit model/tier values when Codex defaults are acceptable. If a preset uses a
user-facing tier such as `fast`, Dispatch resolves it through `model/list`
service tiers before starting the thread. Do not guess current model ids from
memory; use the catalog output and its `aliases` field.

Attached lanes are existing desktop Codex threads registered by raw thread id:

```bash
Expand Down Expand Up @@ -289,6 +304,7 @@ Use `schema` for derived input/output schemas:
```bash
uv run dispatch schema send
uv run dispatch schema "list --unmanaged"
uv run dispatch schema models
uv run dispatch schema "goal set"
```

Expand All @@ -307,6 +323,8 @@ The MCP surface is grouped for agent ergonomics, not one tool per CLI
subcommand. Tools are grouped by workflow and safety boundary, and each call
selects an `op` inside the tool. In this repo, the workspace-local Codex plugin
lives at `plugins/dispatch`. It exposes these skills and the same MCP registry.
Use the daemon-read MCP tool's `models` op before setting explicit model or
service-tier arguments.
If the plugin does not appear immediately, restart Codex for the workspace.
Installed PyPI packages also include read-only copies of these skills and the
plugin bundle under `outfitter.dispatch.assets`; use the repo copies for editing.
Expand Down
14 changes: 14 additions & 0 deletions src/outfitter/dispatch/client/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,14 +18,17 @@
from .errors import ClientError, TransportError
from .events import LaneEvent
from .models import (
AppModel,
ApprovalPolicy,
ApprovalsReviewer,
ClientInfo,
ConfigInfo,
Decision,
Effort,
InitializeParams,
InitializeResult,
InjectItemsParams,
ModelListResult,
Personality,
ReasoningSummary,
SandboxPolicy,
Expand Down Expand Up @@ -164,6 +167,17 @@ async def initialize(
await self._notify("initialized", {})
return InitializeResult.model_validate(result)

# --- config/models ---------------------------------------------------------

async def config_read(self) -> ConfigInfo:
result = await self._request("config/read", {})
payload = result.get("config") if isinstance(result.get("config"), dict) else result
return ConfigInfo.model_validate(payload)

async def model_list(self) -> list[AppModel]:
result = await self._request("model/list", {})
return ModelListResult.model_validate(result).data

# --- threads --------------------------------------------------------------

async def thread_start(
Expand Down
66 changes: 64 additions & 2 deletions src/outfitter/dispatch/client/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,9 @@

from __future__ import annotations

from typing import Literal
from typing import Any, Literal

from pydantic import BaseModel, ConfigDict, Field
from pydantic import BaseModel, ConfigDict, Field, field_validator
from pydantic.alias_generators import to_camel

ThreadSandbox = Literal["read-only", "workspace-write", "danger-full-access"]
Expand Down Expand Up @@ -78,6 +78,65 @@ class InitializeResult(WireModel):
platform_os: str | None = None


# --- config/model catalog ------------------------------------------------------


class ConfigInfo(WireModel):
"""Subset of ``config/read`` used for model/service-tier defaults."""

model: str | None = None
model_provider: str | None = None
service_tier: str | None = None
model_reasoning_effort: str | None = None


class ModelServiceTier(WireModel):
id: str
name: str
description: str


class AppModel(WireModel):
"""Subset of one ``model/list`` row.

``additionalSpeedTiers`` is deprecated by the app-server schema, but kept as
a fallback for older binaries. ``serviceTiers`` is the canonical source.
"""

id: str
model: str | None = None
display_name: str | None = None
name: str | None = None
description: str | None = None
is_default: bool | None = None
hidden: bool | None = None
default_reasoning_effort: str | None = None
supported_reasoning_efforts: list[str] = Field(default_factory=list)
default_service_tier: str | None = None
service_tiers: list[ModelServiceTier] = Field(default_factory=list)
additional_speed_tiers: list[str] = Field(default_factory=list)

@field_validator("supported_reasoning_efforts", mode="before")
@classmethod
def _normalize_supported_reasoning_efforts(cls, value: Any) -> list[str]:
if not isinstance(value, list):
return []
efforts: list[str] = []
for item in value:
if isinstance(item, str):
efforts.append(item)
elif isinstance(item, dict):
effort = item.get("reasoningEffort") or item.get("effort") or item.get("id")
if isinstance(effort, str):
efforts.append(effort)
return efforts


class ModelListResult(WireModel):
data: list[AppModel] = Field(default_factory=list)
next_cursor: str | None = None


# --- shared shapes ------------------------------------------------------------


Expand Down Expand Up @@ -116,6 +175,9 @@ class ThreadInfo(WireModel):
source: str | None = None
thread_source: str | None = None
model_provider: str | None = None
model: str | None = None
reasoning_effort: str | None = None
service_tier: str | None = None
created_at: int | None = None
updated_at: int | None = None
turns: list[dict[str, object]] = Field(default_factory=list)
Expand Down
6 changes: 6 additions & 0 deletions src/outfitter/dispatch/contracts/context.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,10 @@

from outfitter.dispatch.client.events import LaneEvent
from outfitter.dispatch.client.models import (
AppModel,
ApprovalPolicy,
ApprovalsReviewer,
ConfigInfo,
Decision,
Effort,
Personality,
Expand All @@ -47,6 +49,10 @@
class LaneClient(Protocol):
"""The App Server primitives handlers depend on (ADR-0006 DI seam)."""

async def config_read(self) -> ConfigInfo: ...

async def model_list(self) -> list[AppModel]: ...

async def thread_start(
self,
cwd: str | None,
Expand Down
1 change: 1 addition & 0 deletions src/outfitter/dispatch/contracts/derive_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ class CliRoute:
CliRoute(("rename",), "lane-rename", ("old", "new")),
CliRoute(("archive",), "archive", ("target",)),
CliRoute(("restore",), "restore", ("target",)),
CliRoute(("models",), "models"),
CliRoute(("goal", "status"), "goal-get", ("lane",)),
CliRoute(("goal", "clear"), "goal-clear", ("lane",)),
CliRoute(("trigger", "add"), "trigger-add"),
Expand Down
4 changes: 2 additions & 2 deletions src/outfitter/dispatch/contracts/derive_mcp.py
Original file line number Diff line number Diff line change
Expand Up @@ -104,9 +104,9 @@ class _ToolGroup:
),
_ToolGroup(
name="dispatch_daemon_read",
summary="Read daemon health and audit log state.",
summary="Read daemon, model catalog, and audit log state.",
intent="read",
actions=(("status", "status"), ("log", "log")),
actions=(("status", "status"), ("log", "log"), ("models", "models")),
),
)

Expand Down
Loading