diff --git a/docs/development/design.md b/docs/development/design.md index 80f857d..543c906 100644 --- a/docs/development/design.md +++ b/docs/development/design.md @@ -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`. | @@ -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 ` 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 ` 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. | diff --git a/docs/usage/README.md b/docs/usage/README.md index 5246951..f489a49 100644 --- a/docs/usage/README.md +++ b/docs/usage/README.md @@ -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 @@ -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: @@ -500,9 +515,12 @@ uv run dispatch tail --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 @@ -510,6 +528,7 @@ uv run dispatch history uv run dispatch history --view tools uv run dispatch history --view files uv run dispatch history --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. diff --git a/pyproject.toml b/pyproject.toml index 9bb647e..ebbde9d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" diff --git a/skills/dispatch/SKILL.md b/skills/dispatch/SKILL.md index 3b71b00..b5ae4ca 100644 --- a/skills/dispatch/SKILL.md +++ b/skills/dispatch/SKILL.md @@ -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 @@ -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. @@ -346,8 +365,15 @@ uv run dispatch history uv run dispatch history --view tools uv run dispatch history --view files uv run dispatch history --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: diff --git a/src/outfitter/dispatch/client/client.py b/src/outfitter/dispatch/client/client.py index 104037f..1ea766f 100644 --- a/src/outfitter/dispatch/client/client.py +++ b/src/outfitter/dispatch/client/client.py @@ -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, @@ -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, @@ -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, diff --git a/src/outfitter/dispatch/client/models.py b/src/outfitter/dispatch/client/models.py index 2c9e3b6..9053a42 100644 --- a/src/outfitter/dispatch/client/models.py +++ b/src/outfitter/dispatch/client/models.py @@ -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 @@ -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 diff --git a/src/outfitter/dispatch/contracts/context.py b/src/outfitter/dispatch/contracts/context.py index 4921c5e..ed70ab4 100644 --- a/src/outfitter/dispatch/contracts/context.py +++ b/src/outfitter/dispatch/contracts/context.py @@ -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, @@ -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, diff --git a/src/outfitter/dispatch/contracts/derive_cli.py b/src/outfitter/dispatch/contracts/derive_cli.py index 8360ef4..6897f59 100644 --- a/src/outfitter/dispatch/contracts/derive_cli.py +++ b/src/outfitter/dispatch/contracts/derive_cli.py @@ -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] = {} @@ -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[ @@ -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, }, diff --git a/src/outfitter/dispatch/core/handlers.py b/src/outfitter/dispatch/core/handlers.py index eb7815a..ad58a5c 100644 --- a/src/outfitter/dispatch/core/handlers.py +++ b/src/outfitter/dispatch/core/handlers.py @@ -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, @@ -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, @@ -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, @@ -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, @@ -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: @@ -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) diff --git a/src/outfitter/dispatch/core/history.py b/src/outfitter/dispatch/core/history.py index 24e4eb1..3ffc003 100644 --- a/src/outfitter/dispatch/core/history.py +++ b/src/outfitter/dispatch/core/history.py @@ -43,7 +43,7 @@ def summarize_history( sync: LaneSync | None, worktree: HistoryWorktree | None = None, ) -> tuple[HistoryThreadSummary, list[HistoryItem], list[HistoryToolStat], list[HistoryFileStat]]: - items = _all_history_items(result, raw=False) + items = _all_history_items(result, raw=True) thread = result.get("thread") turns = _turns(thread if isinstance(thread, dict) else {}) transcript_bytes = ( @@ -114,6 +114,7 @@ def _detect_worktree_sync(cwd: str) -> HistoryWorktree: branch = _git(cwd, "branch", "--show-current") head = _git(cwd, "rev-parse", "--short", "HEAD") common_dir = _git(cwd, "rev-parse", "--git-common-dir") + changed_files = _changed_files(cwd) detected = bool(repo and common_dir and Path(common_dir).name == "worktrees") return HistoryWorktree( detected=detected, @@ -121,6 +122,9 @@ def _detect_worktree_sync(cwd: str) -> HistoryWorktree: repo=repo, branch=branch, head=head, + dirty=bool(changed_files), + changed_files_count=len(changed_files), + changed_files=changed_files[:50], is_codex_worktree=_looks_like_codex_worktree(path), ) @@ -142,6 +146,22 @@ def _git(cwd: str, *args: str) -> str | None: return value or None +def _changed_files(cwd: str) -> list[str]: + raw = _git(cwd, "status", "--porcelain=v1", "-uno") + if raw is None: + return [] + files: list[str] = [] + for line in raw.splitlines(): + if not line: + continue + path = line[2:].strip() + if " -> " in path: + _old, _arrow, path = path.partition(" -> ") + if path: + files.append(path) + return sorted(set(files)) + + def _looks_like_codex_worktree(path: Path) -> bool: raw = str(path) return "/.config/codex/worktrees/" in raw or "/.codex/worktrees/" in raw diff --git a/src/outfitter/dispatch/core/models.py b/src/outfitter/dispatch/core/models.py index 4774143..021a446 100644 --- a/src/outfitter/dispatch/core/models.py +++ b/src/outfitter/dispatch/core/models.py @@ -244,6 +244,23 @@ class HistoryInput(BaseModel): item_type: str | None = Field(default=None, description="Only include matching item types.") tool: str | None = Field(default=None, description="Only include matching tool names.") grep: str | None = Field(default=None, description="Only include items containing text.") + cwd: str | None = Field( + default=None, description="Only include threads whose cwd contains text." + ) + source: LaneSource | None = Field(default=None, description="Only include threads by source.") + status: LaneStatus | None = Field(default=None, description="Only include threads by status.") + has_tool: str | None = Field( + default=None, description="Only include summaries using this tool." + ) + changed: bool | None = Field( + default=None, + description=( + "Only include summaries with changed workspace files when true, clean when false." + ), + ) + min_bytes: int | None = Field( + default=None, ge=0, description="Only include summaries with at least this transcript size." + ) raw: bool = Field(default=False, description="Include raw App Server item payloads.") limit: int = Field(default=50, ge=1, description="Max rows/items to return.") @@ -584,6 +601,9 @@ class HistoryWorktree(BaseModel): repo: str | None = None branch: str | None = None head: str | None = None + dirty: bool = False + changed_files_count: int = 0 + changed_files: list[str] = Field(default_factory=list) is_codex_worktree: bool = False diff --git a/src/outfitter/dispatch/core/new_config.py b/src/outfitter/dispatch/core/new_config.py index b76af26..4f7fa91 100644 --- a/src/outfitter/dispatch/core/new_config.py +++ b/src/outfitter/dispatch/core/new_config.py @@ -134,8 +134,6 @@ def resolve_new( settings = NewSettings( cwd=str(start_cwd), - sandbox="read-only", - approval_policy="never", ephemeral=False, prefix="[${DISPATCH.CWD.REPO}]", ).merged(config.defaults) diff --git a/src/outfitter/dispatch/core/ops.py b/src/outfitter/dispatch/core/ops.py index ae84fda..f8582ab 100644 --- a/src/outfitter/dispatch/core/ops.py +++ b/src/outfitter/dispatch/core/ops.py @@ -242,8 +242,8 @@ }, "packet": None, "settings": { - "sandbox": "read-only", - "approval_policy": "never", + "sandbox": None, + "approval_policy": None, "approvals_reviewer": None, "model": None, "model_provider": None, diff --git a/src/outfitter/dispatch/core/turn_settings.py b/src/outfitter/dispatch/core/turn_settings.py index 86636dd..aa8cd7a 100644 --- a/src/outfitter/dispatch/core/turn_settings.py +++ b/src/outfitter/dispatch/core/turn_settings.py @@ -2,7 +2,7 @@ from __future__ import annotations -from dataclasses import dataclass, field +from dataclasses import dataclass from outfitter.dispatch.client.models import ( ApprovalPolicy, @@ -19,8 +19,8 @@ @dataclass(frozen=True) class TurnStartSettings: - sandbox_policy: SandboxPolicy = field(default_factory=lambda: SandboxPolicy(type="readOnly")) - approval_policy: ApprovalPolicy = "never" + sandbox_policy: SandboxPolicy | None = None + approval_policy: ApprovalPolicy | None = None approvals_reviewer: ApprovalsReviewer | None = None effort: Effort | None = None summary: ReasoningSummary | None = None @@ -44,8 +44,8 @@ def runtime_settings_for_lane( *, lane: str, updated_at: str, - sandbox: ThreadSandbox = "read-only", - approval_policy: ApprovalPolicy = "never", + sandbox: ThreadSandbox | None = None, + approval_policy: ApprovalPolicy | None = None, approvals_reviewer: ApprovalsReviewer | None = None, effort: Effort | None = None, summary: ReasoningSummary | None = None, @@ -74,7 +74,9 @@ async def load_turn_start_settings(registry: Registry, lane_id: str) -> TurnStar if stored is None: return TurnStartSettings() return TurnStartSettings( - sandbox_policy=thread_sandbox_to_turn_policy(stored.sandbox), + sandbox_policy=( + thread_sandbox_to_turn_policy(stored.sandbox) if stored.sandbox is not None else None + ), approval_policy=stored.approval_policy, approvals_reviewer=stored.approvals_reviewer, effort=stored.effort, diff --git a/src/outfitter/dispatch/registry/models.py b/src/outfitter/dispatch/registry/models.py index cc1737f..50cc779 100644 --- a/src/outfitter/dispatch/registry/models.py +++ b/src/outfitter/dispatch/registry/models.py @@ -61,8 +61,8 @@ class LaneModelSettings(BaseModel): class LaneRuntimeSettings(BaseModel): lane: str - sandbox: ThreadSandbox = "read-only" - approval_policy: ApprovalPolicy = "never" + sandbox: ThreadSandbox | None = None + approval_policy: ApprovalPolicy | None = None approvals_reviewer: ApprovalsReviewer | None = None effort: Effort | None = None summary: ReasoningSummary | None = None diff --git a/src/outfitter/dispatch/registry/store.py b/src/outfitter/dispatch/registry/store.py index 8d968c4..996ec3c 100644 --- a/src/outfitter/dispatch/registry/store.py +++ b/src/outfitter/dispatch/registry/store.py @@ -35,7 +35,7 @@ from .refs import BASE58BTC_ALPHABET, CODEX_REF_SOURCE, codex_ref_payload, make_ref Clock = Callable[[], datetime] -SCHEMA_VERSION = 7 +SCHEMA_VERSION = 8 _QUEUED_MESSAGES_SCHEMA = """ CREATE TABLE IF NOT EXISTS queued_messages ( @@ -160,8 +160,8 @@ def _utcnow() -> datetime: ); CREATE TABLE IF NOT EXISTS lane_runtime_settings ( lane TEXT PRIMARY KEY, - sandbox TEXT NOT NULL DEFAULT 'read-only', - approval_policy TEXT NOT NULL DEFAULT 'never', + sandbox TEXT, + approval_policy TEXT, approvals_reviewer TEXT, effort TEXT, summary TEXT, @@ -237,6 +237,8 @@ async def _migrate(self, user_version: int) -> None: await self._ensure_queued_messages_foreign_key() if user_version < 7: await self._ensure_lane_runtime_settings_table() + if user_version < 8: + await self._allow_nullable_lane_runtime_policy() async def _ensure_ref_columns(self) -> None: async with self._conn.execute("PRAGMA table_info(lanes)") as cur: @@ -300,8 +302,8 @@ async def _ensure_lane_runtime_settings_table(self) -> None: """ CREATE TABLE IF NOT EXISTS lane_runtime_settings ( lane TEXT PRIMARY KEY, - sandbox TEXT NOT NULL DEFAULT 'read-only', - approval_policy TEXT NOT NULL DEFAULT 'never', + sandbox TEXT, + approval_policy TEXT, approvals_reviewer TEXT, effort TEXT, summary TEXT, @@ -315,6 +317,45 @@ async def _ensure_lane_runtime_settings_table(self) -> None: """ ) + async def _allow_nullable_lane_runtime_policy(self) -> None: + async with self._conn.execute("PRAGMA table_info(lane_runtime_settings)") as cur: + rows = await cur.fetchall() + policy_columns = { + str(row["name"]): int(row["notnull"]) + for row in rows + if str(row["name"]) in {"sandbox", "approval_policy"} + } + if policy_columns.get("sandbox") == 0 and policy_columns.get("approval_policy") == 0: + return + await self._conn.executescript( + """ + CREATE TABLE lane_runtime_settings_new ( + lane TEXT PRIMARY KEY, + sandbox TEXT, + approval_policy TEXT, + approvals_reviewer TEXT, + effort TEXT, + summary TEXT, + model TEXT, + service_tier TEXT, + output_schema TEXT, + personality TEXT, + updated_at TEXT NOT NULL, + FOREIGN KEY(lane) REFERENCES lanes(id) ON DELETE CASCADE + ); + INSERT INTO lane_runtime_settings_new ( + lane, sandbox, approval_policy, approvals_reviewer, effort, summary, model, + service_tier, output_schema, personality, updated_at + ) + SELECT + lane, sandbox, approval_policy, approvals_reviewer, effort, summary, model, + service_tier, output_schema, personality, updated_at + FROM lane_runtime_settings; + DROP TABLE lane_runtime_settings; + ALTER TABLE lane_runtime_settings_new RENAME TO lane_runtime_settings; + """ + ) + async def _prune_orphan_lane_children(self) -> None: for table in ( "lane_sync_sources", diff --git a/src/outfitter/dispatch/surfaces/cli.py b/src/outfitter/dispatch/surfaces/cli.py index 3115b66..e34fbc1 100644 --- a/src/outfitter/dispatch/surfaces/cli.py +++ b/src/outfitter/dispatch/surfaces/cli.py @@ -9,9 +9,12 @@ import sqlite3 from functools import partial from pathlib import Path -from typing import Annotated +from typing import Annotated, Literal, cast import typer +from click.core import Command +from click.shell_completion import get_completion_class +from typer.main import get_command from outfitter.dispatch import config from outfitter.dispatch.contracts.derive_cli import derive_cli @@ -20,6 +23,7 @@ CLI_SURFACE_CONTROL_PATHS: tuple[tuple[str, ...], ...] = ( ("doctor",), ("mcp",), + ("completion",), ("up",), ("down",), ("registry", "migrate"), @@ -108,6 +112,26 @@ def _mcp() -> None: run_mcp(path) + @app.command(name="completion", help="Print a shell completion script.") + def _completion( + shell: Annotated[ + Literal["bash", "zsh", "fish"], + typer.Argument(help="Shell to generate completions for."), + ], + ) -> None: + completion_cls = get_completion_class(shell) + if completion_cls is None: + typer.secho(f"dispatch: unsupported shell {shell!r}", fg="red", err=True) + raise typer.Exit(code=2) + click_command = cast(Command, get_command(app)) + complete = completion_cls( + click_command, + {}, + "dispatch", + "_DISPATCH_COMPLETE", + ) + typer.echo(complete.source()) + @app.command(name="doctor", help="Diagnose install, daemon, registry, and app-server health.") def _doctor( json_output: Annotated[ diff --git a/tests/client/test_client.py b/tests/client/test_client.py index c6b0f08..a3a1599 100644 --- a/tests/client/test_client.py +++ b/tests/client/test_client.py @@ -71,8 +71,6 @@ async def test_thread_start_parses_thread_info( sent = fake.sent[-1] assert sent["params"] == { "cwd": "/work", - "sandbox": "read-only", - "approvalPolicy": "never", "ephemeral": True, } @@ -279,8 +277,6 @@ async def test_turn_start_sends_service_tier_when_set( "threadId": "L1", "input": [{"type": "text", "text": "go"}], "cwd": "/work", - "approvalPolicy": "never", - "sandboxPolicy": {"type": "readOnly"}, "serviceTier": "priority", } diff --git a/tests/client/test_models.py b/tests/client/test_models.py index 972229f..9d17f36 100644 --- a/tests/client/test_models.py +++ b/tests/client/test_models.py @@ -31,10 +31,16 @@ def test_thread_start_sandbox_is_string_enum() -> None: params = ThreadStartParams(cwd="/work", sandbox="workspace-write", ephemeral=True) dumped = params.model_dump(by_alias=True, exclude_none=True) assert dumped["sandbox"] == "workspace-write" # STRING, not an object - assert dumped["approvalPolicy"] == "never" + assert "approvalPolicy" not in dumped assert dumped["ephemeral"] is True +def test_thread_start_omits_policy_fields_when_inheriting_codex_config() -> None: + params = ThreadStartParams(cwd="/work") + dumped = params.model_dump(by_alias=True, exclude_none=True) + assert dumped == {"cwd": "/work", "ephemeral": False} + + def test_thread_start_includes_rich_session_options() -> None: params = ThreadStartParams( cwd="/work", @@ -73,6 +79,13 @@ def test_turn_start_sandbox_policy_is_object_and_camelcased() -> None: assert "effort" not in dumped # None excluded +def test_turn_start_omits_policy_fields_when_inheriting_codex_config() -> None: + params = TurnStartParams(thread_id="t1", input=[TextInput(text="hi")], cwd="/work") + dumped = params.model_dump(by_alias=True, exclude_none=True) + assert "approvalPolicy" not in dumped + assert "sandboxPolicy" not in dumped + + def test_turn_start_includes_effort_when_set() -> None: params = TurnStartParams(thread_id="t1", input=[TextInput(text="hi")], cwd="/w", effort="low") assert params.model_dump(by_alias=True, exclude_none=True)["effort"] == "low" diff --git a/tests/core/test_handlers.py b/tests/core/test_handlers.py index 2769cc9..73f8a6e 100644 --- a/tests/core/test_handlers.py +++ b/tests/core/test_handlers.py @@ -136,6 +136,32 @@ async def test_new_lane_sets_name_and_sends_initial_turn(store: Registry, tmp_pa ) +async def test_new_lane_omits_policy_fields_to_inherit_codex_config( + store: Registry, tmp_path: Path +) -> None: + repo = tmp_path / "dispatch" + repo.mkdir() + (repo / ".git").mkdir() + client = FakeLaneClient() + ctx = make_ctx(store, client) + + out = await handlers.new_lane(NewInput(name="builder", cwd=str(repo), text="start"), ctx) + + assert out.message_accepted is True + assert any( + name == "thread_start" and kw["sandbox"] is None and kw["approval_policy"] is None + for name, kw in client.calls + ) + assert any( + name == "turn_start" and kw["sandbox_policy"] is None and kw["approval_policy"] is None + for name, kw in client.calls + ) + settings = await store.get_lane_runtime_settings(out.id) + assert settings is not None + assert settings.sandbox is None + assert settings.approval_policy is None + + async def test_new_lane_resolves_fast_service_tier_alias_and_records_provenance( store: Registry, tmp_path: Path ) -> None: @@ -401,7 +427,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, @@ -1262,6 +1288,86 @@ async def test_status_and_log_reflect_activity(store: Registry) -> None: assert "send" in ops +class _HistoryReadClient(FakeLaneClient): + def __init__(self, results: dict[str, dict[str, object]]) -> None: + super().__init__() + self.results = results + + async def thread_read(self, thread_id: str, include_turns: bool = False) -> dict[str, object]: + self._record("thread_read", thread_id=thread_id, include_turns=include_turns) + return self.results[thread_id] + + +def _thread_history(*, tool: str, path: str = "src/app.py") -> dict[str, object]: + return { + "thread": { + "id": "thread", + "turns": [ + { + "id": "turn-1", + "createdAt": "2026-06-16T10:00:00Z", + "items": [ + {"id": "msg-1", "type": "message", "role": "user", "text": "do it"}, + { + "id": "tool-1", + "type": "tool_call", + "toolName": tool, + "text": f"{tool} touched {path}", + "path": path, + }, + ], + } + ], + } + } + + +async def test_history_overview_filters_by_tool_and_changed_worktree( + store: Registry, tmp_path: Path +) -> None: + dirty_repo = _git_repo_for_worktree(tmp_path / "dirty") + (dirty_repo / "README.md").write_text("changed\n") + clean_repo = _git_repo_for_worktree(tmp_path / "clean") + dirty = await store.add_lane( + id="dirty-lane", handle="@dirty", source="own", cwd=str(dirty_repo) + ) + clean = await store.add_lane( + id="clean-lane", handle="@clean", source="own", cwd=str(clean_repo) + ) + client = _HistoryReadClient( + { + dirty.id: _thread_history(tool="bash", path="README.md"), + clean.id: _thread_history(tool="python", path="src/app.py"), + } + ) + ctx = make_ctx(store, client) + + out = await handlers.history(HistoryInput(has_tool="bash", changed=True), ctx) + + assert out.mode == "overview" + assert [thread.id for thread in out.threads] == ["dirty-lane"] + assert out.threads[0].worktree.dirty is True + assert out.threads[0].worktree.changed_files == ["README.md"] + assert out.threads[0].unique_tools == ["bash"] + + +async def test_history_summary_reports_worktree_changed_files( + store: Registry, tmp_path: Path +) -> None: + repo = _git_repo_for_worktree(tmp_path / "repo") + (repo / "README.md").write_text("changed\n") + lane = await store.add_lane(id="lane-1", handle="@lane", source="own", cwd=str(repo)) + ctx = make_ctx(store, _HistoryReadClient({lane.id: _thread_history(tool="bash")})) + + out = await handlers.history(HistoryInput(lane="@lane"), ctx) + + assert out.thread is not None + assert out.thread.worktree.repo == str(repo) + assert out.thread.worktree.dirty is True + assert out.thread.worktree.changed_files_count == 1 + assert out.thread.worktree.changed_files == ["README.md"] + + async def test_attach_is_idempotent(store: Registry) -> None: client = FakeLaneClient() ctx = make_ctx(store, client) diff --git a/tests/fakes.py b/tests/fakes.py index a2972a0..80e59b0 100644 --- a/tests/fakes.py +++ b/tests/fakes.py @@ -95,8 +95,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, @@ -296,7 +296,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, diff --git a/tests/fixtures/app_server/thread_read/history_v2.json b/tests/fixtures/app_server/thread_read/history_v2.json new file mode 100644 index 0000000..df85541 --- /dev/null +++ b/tests/fixtures/app_server/thread_read/history_v2.json @@ -0,0 +1,45 @@ +{ + "thread": { + "id": "019f0000-0000-7000-9000-000000000042", + "sessionId": "019f0000-0000-7000-9000-000000000042", + "name": "[dispatch] history fixture", + "cwd": "/fixture/history", + "turns": [ + { + "id": "turn-1", + "status": "completed", + "createdAt": "2026-06-16T12:00:00.000Z", + "items": [ + { + "id": "item-user-1", + "type": "userMessage", + "text": "Inspect the repo." + }, + { + "id": "item-tool-1", + "type": "toolCall", + "toolName": "bash", + "text": "git status --short", + "path": "README.md" + }, + { + "id": "item-file-1", + "type": "changes", + "changes": [ + { + "path": "src/outfitter/dispatch/core/history.py", + "status": "modified" + } + ], + "text": "Changed history parser." + }, + { + "id": "item-agent-1", + "type": "agentMessage", + "text": "Spawned subagent 019f0000-0000-7000-9000-000000000099 for review." + } + ] + } + ] + } +} diff --git a/tests/fixtures/test_corpus.py b/tests/fixtures/test_corpus.py index 8ebb4ac..7e9cc59 100644 --- a/tests/fixtures/test_corpus.py +++ b/tests/fixtures/test_corpus.py @@ -2,6 +2,7 @@ from __future__ import annotations +from datetime import UTC, datetime from pathlib import Path from typing import cast @@ -17,7 +18,9 @@ ThreadInfo, ThreadListResult, ) +from outfitter.dispatch.core.history import summarize_history from outfitter.dispatch.core.sync import SyncLimits, scan_codex_jsonl +from outfitter.dispatch.registry.models import Lane from . import copy_fixture, load_json, load_jsonl @@ -45,6 +48,42 @@ def test_app_server_protocol_fixtures_validate_against_wire_models() -> None: assert thread.turns[0]["id"] == "turn-1" +def test_history_v2_fixture_summarizes_tools_files_and_subagents() -> None: + payload = load_json("app_server", "thread_read", "history_v2.json") + lane = Lane( + id="019f0000-0000-7000-9000-000000000042", + ref="0Hist1", + ref_source="fixture", + ref_payload="history", + ref_mixer="fixture", + handle="@history", + source="own", + status="idle", + cwd="/fixture/history", + created_at=datetime(2026, 6, 16, tzinfo=UTC), + updated_at=datetime(2026, 6, 16, tzinfo=UTC), + ) + + summary, items, tools, files = summarize_history(payload, lane=lane, sync=None) + + assert summary.turns == 1 + assert summary.messages == 2 + assert summary.tool_calls == 1 + assert summary.unique_tools == ["bash"] + assert summary.subagent_thread_ids == ["019f0000-0000-7000-9000-000000000099"] + assert [item.type for item in items] == [ + "userMessage", + "toolCall", + "changes", + "agentMessage", + ] + assert [tool.tool for tool in tools] == ["bash"] + assert [file.path for file in files] == [ + "README.md", + "src/outfitter/dispatch/core/history.py", + ] + + def test_app_server_event_fixture_projects_to_normalized_events() -> None: projected = [] for message in load_jsonl("app_server", "events", "turn_failure_unsupported_model.jsonl"): diff --git a/tests/registry/test_store.py b/tests/registry/test_store.py index 9cb8396..0cce169 100644 --- a/tests/registry/test_store.py +++ b/tests/registry/test_store.py @@ -11,7 +11,7 @@ import pytest_asyncio from outfitter.dispatch.contracts.errors import NotFoundError -from outfitter.dispatch.registry.models import LaneSync +from outfitter.dispatch.registry.models import LaneRuntimeSettings, LaneSync from outfitter.dispatch.registry.refs import BASE58BTC_ALPHABET, codex_ref_payload from outfitter.dispatch.registry.store import SCHEMA_VERSION, Registry from tests.fixtures.registry.builders import ( @@ -276,6 +276,69 @@ async def test_lane_runtime_settings_roundtrip(store: Registry) -> None: assert await store.get_lane_runtime_settings("missing") is None +async def test_migration_allows_inherited_runtime_policy(tmp_path: Path) -> None: + db = tmp_path / "registry.db" + async with aiosqlite.connect(db) as conn: + await conn.executescript( + """ + CREATE TABLE lanes ( + id TEXT PRIMARY KEY, + ref TEXT NOT NULL UNIQUE, + ref_source TEXT NOT NULL, + ref_payload TEXT NOT NULL, + ref_mixer TEXT NOT NULL, + handle TEXT NOT NULL, + role TEXT, + cwd TEXT, + source TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'unknown', + pinned INTEGER NOT NULL DEFAULT 0, + active_turn_id TEXT, + latest_turn_id TEXT, + latest_turn_status TEXT, + latest_error TEXT, + latest_error_at TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + last_event_at TEXT + ); + CREATE TABLE lane_runtime_settings ( + lane TEXT PRIMARY KEY, + sandbox TEXT NOT NULL DEFAULT 'read-only', + approval_policy TEXT NOT NULL DEFAULT 'never', + approvals_reviewer TEXT, + effort TEXT, + summary TEXT, + model TEXT, + service_tier TEXT, + output_schema TEXT, + personality TEXT, + updated_at TEXT NOT NULL + ); + PRAGMA user_version = 7; + """ + ) + await conn.execute( + "INSERT INTO lanes (id, ref, ref_source, ref_payload, ref_mixer, handle, " + "source, status, created_at, updated_at) " + "VALUES ('L1', '0BGeK1', '0', 'payload', '00', '@a', 'own', 'idle', ?, ?)", + (_clock().isoformat(), _clock().isoformat()), + ) + await conn.commit() + + migrated = await Registry.open(db, now=_clock) + inherited = LaneRuntimeSettings( + lane="L1", + sandbox=None, + approval_policy=None, + updated_at=migrated.now_iso(), + ) + await migrated.upsert_lane_runtime_settings(inherited) + + assert await migrated.get_lane_runtime_settings("L1") == inherited + await migrated.close() + + async def test_get_missing_lane_raises_not_found(store: Registry) -> None: assert await store.find_lane("nope") is None with pytest.raises(NotFoundError): diff --git a/tests/surfaces/test_derive_cli.py b/tests/surfaces/test_derive_cli.py index 31a512b..5e85c69 100644 --- a/tests/surfaces/test_derive_cli.py +++ b/tests/surfaces/test_derive_cli.py @@ -472,6 +472,8 @@ def invoke(op_id: str, params: dict[str, object]) -> dict[str, object]: "bash", "--grep", "git", + "--has-tool", + "bash", "--raw", "--limit", "5", @@ -489,6 +491,12 @@ def invoke(op_id: str, params: dict[str, object]) -> dict[str, object]: "item_type": None, "tool": None, "grep": None, + "cwd": None, + "source": None, + "status": None, + "has_tool": None, + "changed": None, + "min_bytes": None, "raw": False, "limit": 50, }, @@ -501,6 +509,12 @@ def invoke(op_id: str, params: dict[str, object]) -> dict[str, object]: "item_type": "tool", "tool": "bash", "grep": "git", + "cwd": None, + "source": None, + "status": None, + "has_tool": "bash", + "changed": None, + "min_bytes": None, "raw": True, "limit": 5, }, diff --git a/tests/test_smoke.py b/tests/test_smoke.py index 096199c..6d25471 100644 --- a/tests/test_smoke.py +++ b/tests/test_smoke.py @@ -26,6 +26,13 @@ def test_cli_version_renders() -> None: assert result.output.startswith("dispatch ") +def test_cli_completion_command_prints_script() -> None: + result = runner.invoke(cli_app, ["completion", "bash"]) + assert result.exit_code == 0, result.output + assert "_DISPATCH_COMPLETE" in result.output + assert "dispatch" in result.output + + def test_daemon_help_renders() -> None: result = runner.invoke(daemon_app, ["--help"]) assert result.exit_code == 0 diff --git a/uv.lock b/uv.lock index 327213e..ae0974a 100644 --- a/uv.lock +++ b/uv.lock @@ -466,7 +466,7 @@ wheels = [ [[package]] name = "outfitter-dispatch" -version = "0.7.0" +version = "0.8.0" source = { editable = "." } dependencies = [ { name = "aiosqlite" },