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: 2 additions & 2 deletions docs/development/design.md
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,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. 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. |
| `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 `sandbox`, approval, model, and `service_tier` values are sent as overrides; omitted values are omitted from App Server calls so Codex global/profile/project config can apply. Explicit `service_tier` values are resolved through the App Server model catalog before being sent to thread creation and the initial turn. 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 @@ -134,7 +134,7 @@ for collisions. Titles and `@handles` are mutable convenience labels.
| `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. |
| `history` (`history`) | `thread/read(includeTurns:true)` + registry/sync facts | Transcript intelligence surface. Bare `dispatch history` summarizes managed lanes; `dispatch history <selector>` reports per-thread summary/tools/files/items with optional filters and best-effort worktree facts. |
| `history` (`history`) | `thread/read(includeTurns:true)` + registry/sync facts | Transcript intelligence surface. Bare `dispatch history` summarizes managed lanes with transcript size, tools, visible subagent ids, worktree identity, and dirty changed-file facts; `dispatch history <selector>` reports per-thread summary/tools/files/items with optional filters. |
| `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. |
| `goal-get/set/clear` (`goal status/set/clear`) | `thread/goal/{get,set,clear}` | Native App Server goal lifecycle for owned lanes. |
| `fork` | `thread/fork` + register | Creates a new owned lane; attached source lanes remain locked until cross-process fork semantics are verified. |
Expand Down
31 changes: 25 additions & 6 deletions docs/usage/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,19 @@ DISPATCH_HOME=/tmp/dispatch-dev uv run dispatch up

The lower-level overrides are `DISPATCH_SOCKET`, `DISPATCH_DB`, and `DISPATCH_PIDFILE`.

## Shell Completions

Dispatch exposes completion scripts from the derived CLI surface:

```bash
uv run dispatch completion bash
uv run dispatch completion zsh
uv run dispatch completion fish
```

For ad hoc use, evaluate the generated script in your shell. For durable installs,
write it to your shell's completion directory.

## Doctor And Recovery

`dispatch doctor` is the first diagnostic command for users and agents. It returns JSON
Expand Down Expand Up @@ -233,9 +246,11 @@ effort = "low"
```

Preset order matters: later presets win, and CLI flags win over presets.
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.
Omit sandbox, approval, model, and service-tier fields unless you intentionally
want Dispatch to send explicit overrides. When these fields are omitted, Dispatch
omits them from `thread/start` and the initial `turn/start` so Codex/App Server can
apply its global, profile, and project-local configuration. Dispatch still records
the configured model defaults reported by `config/read` when available.

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

Expand Down Expand Up @@ -500,16 +515,20 @@ uv run dispatch tail <dispatch-ref> --limit 50

Use `history` when you want transcript inspection and rollups rather than only recent
items. Bare `history` summarizes managed lanes; passing a selector drills into one
thread and can show summary, items, tools, or files. `--type`, `--tool`, and `--grep`
filter item views; `--raw` includes raw App Server item payloads for jq-heavy
inspection.
thread and can show summary, items, tools, or files. Overview rows include transcript
size, estimated tokens, active dates, deduped tool names, subagent thread ids when
visible, best-effort git worktree identity, and dirty changed-file names from the
lane cwd. `--type`, `--tool`, and `--grep` filter item views; `--cwd`, `--source`,
`--status`, `--has-tool`, `--changed/--clean`, and `--min-bytes` filter overview
rows; `--raw` includes raw App Server item payloads for jq-heavy inspection.

```bash
uv run dispatch history
uv run dispatch history <dispatch-ref>
uv run dispatch history <dispatch-ref> --view tools
uv run dispatch history <dispatch-ref> --view files
uv run dispatch history <dispatch-ref> --view items --tool bash --grep "git status" --raw
uv run dispatch history --has-tool bash --changed --min-bytes 100000
```

Use `watch` for a bounded live event sample from dispatch's app-server stream.
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.7.0"
version = "0.8.0"
description = "Local control plane for orchestrating Codex agent lanes over the Codex App Server."
readme = "README.md"
requires-python = ">=3.13"
Expand Down
26 changes: 26 additions & 0 deletions skills/dispatch/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,19 @@ Runtime state defaults to `~/.dispatch`. Use `DISPATCH_HOME` for isolation when
testing. Do not point tests at the user's live `~/.codex`; the repo integration
suite uses an isolated `CODEX_HOME`.

## Shell Completions

Use the derived completion command when setting up an operator shell:

```bash
uv run dispatch completion bash
uv run dispatch completion zsh
uv run dispatch completion fish
```

Evaluate the generated script for ad hoc use, or write it to the shell's
completion directory for durable installs.

## Thread Selectors And Lane Rules

Every managed thread has a stored dispatch-local `ref`. Prefer refs for command
Expand All @@ -86,6 +99,12 @@ uv run dispatch new --name my-lane --goal "Loop until green." --text "Start with
uv run dispatch new --name my-lane --preset reviewer --no-send
```

Omit sandbox, approval, model, and service-tier settings when Codex defaults are
acceptable. `dispatch new` omits unset policy/model fields from `thread/start`
and initial `turn/start`, allowing Codex/App Server global, profile, and
project-local configuration to apply. Add explicit values only when the lane
needs Dispatch-owned overrides.

Use `--goal` for a native App Server goal before the initial turn. Do not put
`/goal ...` in `--text`; dispatch treats slash commands as plain text and rejects
that shape so agents do not create a thread that only looks goal-driven.
Expand Down Expand Up @@ -346,8 +365,15 @@ uv run dispatch history <dispatch-ref>
uv run dispatch history <dispatch-ref> --view tools
uv run dispatch history <dispatch-ref> --view files
uv run dispatch history <dispatch-ref> --view items --tool bash --grep "git status" --raw
uv run dispatch history --has-tool bash --changed --min-bytes 100000
```

Bare `history` includes transcript size, estimated tokens, active dates, deduped
tools, visible subagent thread ids, worktree identity, and dirty changed-file
names from each lane cwd. Overview filters include `--cwd`, `--source`,
`--status`, `--has-tool`, `--changed/--clean`, and `--min-bytes`. Item views use
`--type`, `--tool`, `--grep`, and optional `--raw`.

Use `watch` for a bounded live event sample. It returns raw App
Server method/params until a limit or timeout, and it is not an infinite tail:

Expand Down
8 changes: 4 additions & 4 deletions src/outfitter/dispatch/client/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -183,8 +183,8 @@ async def model_list(self) -> list[AppModel]:
async def thread_start(
self,
cwd: str | None,
sandbox: ThreadSandbox = "read-only",
approval_policy: ApprovalPolicy = "never",
sandbox: ThreadSandbox | None = None,
approval_policy: ApprovalPolicy | None = None,
approvals_reviewer: ApprovalsReviewer | None = None,
base_instructions: str | None = None,
developer_instructions: str | None = None,
Expand Down Expand Up @@ -373,7 +373,7 @@ async def turn_start(
thread_id: str,
text: str,
cwd: str,
approval_policy: ApprovalPolicy = "never",
approval_policy: ApprovalPolicy | None = None,
approvals_reviewer: ApprovalsReviewer | None = None,
sandbox_policy: SandboxPolicy | None = None,
effort: Effort | None = None,
Expand All @@ -389,7 +389,7 @@ async def turn_start(
cwd=cwd,
approval_policy=approval_policy,
approvals_reviewer=approvals_reviewer,
sandbox_policy=sandbox_policy if sandbox_policy is not None else SandboxPolicy(),
sandbox_policy=sandbox_policy,
effort=effort,
summary=summary,
model=model,
Expand Down
8 changes: 4 additions & 4 deletions src/outfitter/dispatch/client/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -188,8 +188,8 @@ class ThreadInfo(WireModel):

class ThreadStartParams(WireModel):
cwd: str | None = None
sandbox: ThreadSandbox = "read-only"
approval_policy: ApprovalPolicy = "never"
sandbox: ThreadSandbox | None = None
approval_policy: ApprovalPolicy | None = None
approvals_reviewer: ApprovalsReviewer | None = None
base_instructions: str | None = None
developer_instructions: str | None = None
Expand Down Expand Up @@ -334,9 +334,9 @@ class TurnStartParams(WireModel):
thread_id: str
input: list[TextInput]
cwd: str
approval_policy: ApprovalPolicy = "never"
approval_policy: ApprovalPolicy | None = None
approvals_reviewer: ApprovalsReviewer | None = None
sandbox_policy: SandboxPolicy = SandboxPolicy()
sandbox_policy: SandboxPolicy | None = None
effort: Effort | None = None
summary: ReasoningSummary | None = None
model: str | None = None
Expand Down
6 changes: 3 additions & 3 deletions src/outfitter/dispatch/contracts/context.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,8 +57,8 @@ async def model_list(self) -> list[AppModel]: ...
async def thread_start(
self,
cwd: str | None,
sandbox: ThreadSandbox = "read-only",
approval_policy: ApprovalPolicy = "never",
sandbox: ThreadSandbox | None = None,
approval_policy: ApprovalPolicy | None = None,
approvals_reviewer: ApprovalsReviewer | None = None,
base_instructions: str | None = None,
developer_instructions: str | None = None,
Expand Down Expand Up @@ -151,7 +151,7 @@ async def turn_start(
thread_id: str,
text: str,
cwd: str,
approval_policy: ApprovalPolicy = "never",
approval_policy: ApprovalPolicy | None = None,
approvals_reviewer: ApprovalsReviewer | None = None,
sandbox_policy: SandboxPolicy | None = None,
effort: Effort | None = None,
Expand Down
31 changes: 30 additions & 1 deletion src/outfitter/dispatch/contracts/derive_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,7 @@ def derive_cli(
name="dispatch",
help="Local control plane for orchestrating Codex agent lanes.",
no_args_is_help=True,
add_completion=False,
add_completion=True,
)
renderer = render if render is not None else _default_render
groups: dict[str, typer.Typer] = {}
Expand Down Expand Up @@ -513,6 +513,29 @@ def command(
grep: Annotated[
str | None, typer.Option("--grep", help="Only include items containing text.")
] = None,
cwd: Annotated[
str | None, typer.Option("--cwd", help="Only include threads whose cwd contains text.")
] = None,
source: Annotated[
str | None, typer.Option("--source", help="Only include threads by source.")
] = None,
status: Annotated[
str | None, typer.Option("--status", help="Only include threads by status.")
] = None,
has_tool: Annotated[
str | None, typer.Option("--has-tool", help="Only include summaries using this tool.")
] = None,
changed: Annotated[
bool | None,
typer.Option(
"--changed/--clean",
help="Only include summaries with changed or clean workspace files.",
),
] = None,
min_bytes: Annotated[
int | None,
typer.Option("--min-bytes", help="Only include transcripts at least this large."),
] = None,
raw: Annotated[bool, typer.Option("--raw", help="Include raw item payloads.")] = False,
limit: Annotated[int, typer.Option("--limit", help="Max rows/items to return.")] = 50,
json: Annotated[
Expand All @@ -527,6 +550,12 @@ def command(
"item_type": item_type,
"tool": tool,
"grep": grep,
"cwd": cwd,
"source": source,
"status": status,
"has_tool": has_tool,
"changed": changed,
"min_bytes": min_bytes,
"raw": raw,
"limit": limit,
},
Expand Down
46 changes: 36 additions & 10 deletions src/outfitter/dispatch/core/handlers.py
Original file line number Diff line number Diff line change
Expand Up @@ -520,8 +520,6 @@ async def new_lane(inp: NewInput, ctx: Ctx) -> NewLane:
policy=ctx.policy,
)
effective_cwd = workspace.effective_cwd
sandbox = settings.sandbox or "read-only"
approval_policy = settings.approval_policy or "never"
resolved_model = await resolve_model_settings(
ctx,
model=settings.model,
Expand All @@ -532,8 +530,8 @@ async def new_lane(inp: NewInput, ctx: Ctx) -> NewLane:
explicit_service_tier = resolved_model.resolved_service_tier if settings.service_tier else None
thread = await ctx.client.thread_start(
cwd=str(effective_cwd),
sandbox=sandbox,
approval_policy=approval_policy,
sandbox=settings.sandbox,
approval_policy=settings.approval_policy,
approvals_reviewer=settings.approvals_reviewer,
base_instructions=resolved.base_instructions,
developer_instructions=resolved.developer_instructions,
Expand All @@ -552,8 +550,8 @@ async def new_lane(inp: NewInput, ctx: Ctx) -> NewLane:
runtime_settings_for_lane(
lane=lane.id,
updated_at=ctx.registry.now_iso(),
sandbox=sandbox,
approval_policy=approval_policy,
sandbox=settings.sandbox,
approval_policy=settings.approval_policy,
approvals_reviewer=settings.approvals_reviewer,
effort=settings.effort,
summary=settings.summary,
Expand Down Expand Up @@ -626,9 +624,13 @@ async def new_lane(inp: NewInput, ctx: Ctx) -> NewLane:
lane.id,
settings.text,
cwd=str(effective_cwd),
approval_policy=approval_policy,
approval_policy=settings.approval_policy,
approvals_reviewer=settings.approvals_reviewer,
sandbox_policy=thread_sandbox_to_turn_policy(sandbox),
sandbox_policy=(
thread_sandbox_to_turn_policy(settings.sandbox)
if settings.sandbox is not None
else None
),
effort=settings.effort,
summary=settings.summary,
model=settings.model,
Expand Down Expand Up @@ -1061,8 +1063,13 @@ async def history(inp: HistoryInput, ctx: Ctx) -> HistoryOutput:
if mode == "overview":
if inp.lane is not None:
raise ValidationError("history overview does not accept a thread selector")
lanes = (await ctx.registry.list_lanes())[: inp.limit]
summaries = [await _history_summary_for_lane(lane, ctx) for lane in lanes]
summaries: list[HistoryThreadSummary] = []
for lane in await ctx.registry.list_lanes():
summary = await _history_summary_for_lane(lane, ctx)
if _history_summary_matches(summary, inp):
summaries.append(summary)
if len(summaries) >= inp.limit:
break
return HistoryOutput(mode="overview", threads=summaries)

if inp.lane is None:
Expand Down Expand Up @@ -1096,6 +1103,25 @@ def _history_mode(inp: HistoryInput) -> Literal["overview", "summary", "items",
return inp.view


def _history_summary_matches(summary: HistoryThreadSummary, inp: HistoryInput) -> bool:
if inp.cwd is not None and inp.cwd.casefold() not in (summary.cwd or "").casefold():
return False
if inp.source is not None and summary.source != inp.source:
return False
if inp.status is not None and summary.status != inp.status:
return False
if inp.has_tool is not None and not any(
inp.has_tool.casefold() in tool.casefold() for tool in summary.unique_tools
):
return False
if inp.changed is not None and summary.worktree.dirty != inp.changed:
return False
return not (
inp.min_bytes is not None
and (summary.transcript_bytes is None or summary.transcript_bytes < inp.min_bytes)
)


async def _history_summary_for_lane(lane: Lane, ctx: Ctx) -> HistoryThreadSummary:
result = await ctx.client.thread_read(lane.id, include_turns=True)
summary, _items, _tools, _files = await _history_details(lane, result, ctx)
Expand Down
Loading