From 22440a24f24e1525e92b9c5a9b20db211aa45c49 Mon Sep 17 00:00:00 2001 From: Matt Galligan Date: Fri, 5 Jun 2026 16:08:21 -0400 Subject: [PATCH] feat: add stable thread refs and flat cli --- .agents/plans/stable-refs-flat-cli/GOAL.md | 34 +++ .agents/plans/stable-refs-flat-cli/PLAN.md | 213 ++++++++++++++++++ .agents/plans/stable-refs-flat-cli/REFS.md | 71 ++++++ .agents/plans/stable-refs-flat-cli/RETRO.md | 142 ++++++++++++ .claude/rules/contracts.md | 2 +- AGENTS.md | 6 +- README.md | 23 +- ...dispatch-local-refs-and-flat-thread-cli.md | 80 +++++++ docs/adrs/README.md | 1 + docs/development/design.md | 59 ++--- docs/usage/README.md | 110 ++++----- plugins/dispatch/README.md | 4 +- skills/dispatch/SKILL.md | 90 ++++---- skills/dm/SKILL.md | 18 +- .../dispatch/contracts/derive_cli.py | 162 +++---------- .../dispatch/contracts/derive_mcp.py | 12 +- src/outfitter/dispatch/core/handlers.py | 123 +++++++--- src/outfitter/dispatch/core/models.py | 97 +++++--- src/outfitter/dispatch/core/ops.py | 64 ++++-- src/outfitter/dispatch/core/selectors.py | 113 ++++++++++ src/outfitter/dispatch/core/triggers.py | 13 +- src/outfitter/dispatch/registry/models.py | 4 + src/outfitter/dispatch/registry/refs.py | 29 +++ src/outfitter/dispatch/registry/store.py | 104 ++++++++- tests/core/test_handlers.py | 4 +- tests/core/test_selectors.py | 81 +++++++ tests/core/test_triggers.py | 18 ++ tests/registry/test_store.py | 83 ++++++- tests/surfaces/test_derive_cli.py | 95 ++++---- tests/surfaces/test_derive_mcp.py | 12 +- tests/surfaces/test_mcp_routing.py | 31 ++- tests/surfaces/test_parity.py | 39 ++-- 32 files changed, 1447 insertions(+), 490 deletions(-) create mode 100644 .agents/plans/stable-refs-flat-cli/GOAL.md create mode 100644 .agents/plans/stable-refs-flat-cli/PLAN.md create mode 100644 .agents/plans/stable-refs-flat-cli/REFS.md create mode 100644 .agents/plans/stable-refs-flat-cli/RETRO.md create mode 100644 docs/adrs/0019-dispatch-local-refs-and-flat-thread-cli.md create mode 100644 src/outfitter/dispatch/core/selectors.py create mode 100644 src/outfitter/dispatch/registry/refs.py create mode 100644 tests/core/test_selectors.py diff --git a/.agents/plans/stable-refs-flat-cli/GOAL.md b/.agents/plans/stable-refs-flat-cli/GOAL.md new file mode 100644 index 0000000..edf5acc --- /dev/null +++ b/.agents/plans/stable-refs-flat-cli/GOAL.md @@ -0,0 +1,34 @@ +# Stable refs and flat CLI - pasteable goal + +```text +/goal Work in /Users/mg/Developer/outfitter/dispatch. Implement the stable refs and flat CLI/MCP reshape described in .agents/plans/stable-refs-flat-cli/PLAN.md. + +First verify live repo state, PR #32/top-level thread actions state, and current Graphite stack. If PR #32 is not merged, either stack on it or stop with the exact reason if stacking would be unsafe. Do not rely on chat memory when the plan packet or repo state disagrees. + +Objective: ship dispatch-local short refs for managed lanes, a shared selector resolver, a flatter thread-oriented CLI, a split between persisted `tail` and bounded live `watch`, and updated MCP/docs/skills/ADRs. Keep the full Codex UUID accepted everywhere. Treat titles and @handles as mutable convenience labels, not stable identity. Keep `dispatch mcp` as the top-level MCP server entrypoint. + +Implementation constraints: +- Preserve contract-first/no-drift architecture. Add behavior as ops/models/derived routes, not separate hand-written surfaces. +- Every managed lane must have a unique stored `ref`. +- Ref format is ``, with source `0` for Codex, payload from `sha256("codex:" + thread_id)` encoded in base58btc, and mixer allocated by the registry on collision. +- Mutating/destructive commands must not fuzzy-resolve ambiguous names. +- `tail` means persisted conversation history. `watch` means bounded live App Server event sample. Do not present `tail --follow` as canonical. +- Flatten canonical CLI commands away from `dispatch lane ...`; no compatibility aliases are required unless they are temporary and clearly non-canonical. +- `dispatch new --no-send` is the open-without-initial-turn shape; do not keep a separate canonical `open`. +- `dispatch list` is the managed-thread overview and `dispatch list --unmanaged` is discover. `dispatch search` needs global, thread-focused, repo/dir, managed/unmanaged, and date-window filters where supported. +- `rename`, `archive`, and `restore` should accept refs and full Codex thread ids. `restore` must not start a turn. +- MCP should stay grouped by workflow/safety and must keep exact annotations; do not mirror every CLI command into its own tool. +- Update README, docs/usage, design docs, ADRs, root AGENTS/CLAUDE guidance, `.claude/rules`, skills, plugin/MCP docs if affected, schema examples, and tests. + +Work loop: +1. Read PLAN.md and REFS.md. +2. Inspect current code and route/schema shape. +3. Implement in small coherent slices. +4. Add/update tests for allocator, migration, resolver, CLI routes/schema, MCP projection, output schemas, and docs/skill drift. +5. Run focused tests first, then `just check`. +6. Run a local review loop; fix all P0/P1/P2 findings. +7. Submit as one or more draft PRs only after local checks are green. +8. Keep RETRO.md current with decisions, checks, review results, PRs, and any deferred P3s. + +Done only when the branch or stack is draft-submitted, green locally, review P0/P1/P2 clear, docs/skills/ADRs updated, and RETRO.md contains the final proof. +``` diff --git a/.agents/plans/stable-refs-flat-cli/PLAN.md b/.agents/plans/stable-refs-flat-cli/PLAN.md new file mode 100644 index 0000000..66b7f73 --- /dev/null +++ b/.agents/plans/stable-refs-flat-cli/PLAN.md @@ -0,0 +1,213 @@ +# Stable refs and flat CLI - implementation plan + +Follow-up packet for making dispatch easier to operate with durable short refs, a flatter CLI, and MCP tools that stay grouped for agents. Pasteable goal: [`GOAL.md`](./GOAL.md). Execution ledger: [`RETRO.md`](./RETRO.md). References: [`REFS.md`](./REFS.md). + +This packet assumes the top-level thread action work from PR #32 is available, or the implementation branch is stacked on it. If PR #32 has not merged, start from that branch; otherwise start from current `main`. + +## Objective + +Ship a cleaner public model: + +- Codex UUIDv7 remains the canonical durable thread id and is always accepted. +- Dispatch assigns every managed lane a short local `ref` for daily CLI/MCP use. +- Titles and `@handles` are mutable convenience labels, not stable identity. +- Common thread operations move to top-level CLI commands instead of `dispatch lane ...`. +- `tail` and `watch` split into persisted history vs bounded live event sample. +- `dispatch mcp` remains the top-level MCP server entrypoint. +- MCP remains grouped by workflow and safety boundary, not mirrored one-command-per-CLI-route. + +## Product model + +Use these terms consistently: + +- **Codex thread id**: full UUIDv7 from Codex/App Server. Canonical global identity. Always accepted. +- **Dispatch ref**: short registry-assigned id for a managed lane. Stable within the dispatch registry. +- **Title/name**: mutable Codex thread title. Agents may update it as work progresses. +- **Handle/alias**: optional human-friendly convenience selector, often `@...`, never the primary stable id. +- **Managed**: registered in dispatch. +- **Unmanaged**: visible Codex thread not registered in dispatch. +- **Synced**: dispatch refreshed its local index/cache for a managed lane. Sync does not grant ownership or write authority. + +Internal code may keep using `lane` where it means "managed thread with registry state." User-facing CLI/help/docs should prefer "thread," "ref," "title," and "managed/unmanaged" unless the internal distinction is being explained. + +## Dispatch ref format + +Use an 8-character maximum ref with a 6-character initial format: + +```text + +``` + +For Codex: + +```text +0k7M4a +``` + +Rules: + +- `source`: one source/harness character. Use `"0"` for Codex. +- `payload4`: first four base58btc characters derived from `sha256("codex:" + thread_id)`. +- `mixer`: one collision-allocation character chosen by the registry. +- Base58btc alphabet: `123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz`. +- First attempt uses the first mixer character in the alphabet. If the ref is already allocated to a different thread, try the next mixer character until a free ref is found. +- Store the allocated ref. Do not recompute a different ref on every display. +- If the mixer alphabet is exhausted for one payload, fail loudly and require the full Codex thread id. This should be practically unreachable for a local registry. + +The ref is dispatch-local. If a registry is deleted and rebuilt, refs will usually be identical, but a collided mixer can differ depending on allocation order. That is acceptable because the full Codex UUID is the durable escape hatch. + +## Selector resolution + +Add one shared selector resolver used by core handlers and all surfaces. It should return a structured resolved target with thread id, managed lane when present, selector kind, ref when known, title/name, source, and ambiguity candidates when resolution fails. + +Resolution order: + +1. Exact dispatch ref. +2. Exact full Codex thread id. +3. Exact managed lane id, if distinct. +4. Exact managed handle/alias, if unique. +5. Exact current title/name, if unique. +6. Historical title/name/alias, only if implemented in this slice and unique. +7. Fuzzy title matching only for read/discovery flows, not for mutating or destructive ops. + +Mutating/destructive ops must not guess. If a selector is ambiguous, return a typed validation/not-found style error with candidate rows containing `ref`, full id prefix, title/name, managed state, and cwd when available. + +## CLI surface + +Promote the common thread operations to top level: + +```bash +dispatch new ... +dispatch attach +dispatch list +dispatch list --unmanaged +dispatch get +dispatch send +dispatch stop +dispatch tail +dispatch watch +dispatch sync +dispatch rename +dispatch archive <selector> +dispatch restore <selector> +dispatch search <query> + +dispatch goal status <selector> +dispatch goal set <selector> <objective> +dispatch goal clear <selector> + +dispatch trigger add|list|rm|pause|resume +dispatch daemon status|log +dispatch schema <command> +dispatch mcp +dispatch doctor +dispatch up +dispatch down +``` + +Reserved top-level commands are `new`, `attach`, `list`, `get`, `send`, `stop`, `tail`, `watch`, `sync`, `rename`, `archive`, `restore`, `search`, `goal`, `trigger`, `daemon`, `schema`, `mcp`, `doctor`, `up`, and `down`. Do not let generic selector parsing intercept reserved words. `dispatch mcp` must remain the MCP server entrypoint. + +Specific CLI semantics to preserve: + +- `dispatch new` is the creation path for a new dispatch-owned Codex thread. Opening a thread without sending an initial payload is `dispatch new --no-send`; do not keep a separate canonical `open` command for that. +- `dispatch attach <thread-id>` registers an existing Codex thread as managed by dispatch. It must not start a turn. +- `dispatch sync <selector>` refreshes dispatch's local index/cache for a managed thread. Sync is separate from ownership and does not turn unmanaged threads into writable owned threads. +- `dispatch list` should be the ordinary overview: show managed threads with `ref`, title/name, full id or id prefix, status, cwd/repo when available, sync state, and useful recency/last-event facts when cheap. +- `dispatch list --unmanaged` is the discover path for visible Codex threads that dispatch does not manage yet. Do not keep a separate canonical `discover` command. +- `dispatch get <selector>` is the focused single-thread status/metadata view. +- `dispatch search <query>` should support global search plus useful filters: managed/unmanaged, `--thread <selector>` for one focused thread, `--repo <path>` or `--dir <path>` when App Server/dispatch metadata can support it, and `--since`/`--until` date windows. +- `dispatch rename`, `archive`, and `restore` should accept managed refs and full Codex thread ids. `restore` only unarchives; it must not resume or start a turn. +- `dispatch stop <selector>` should accept the selector positionally. `--lane` is not the canonical shape. + +Remove `tail --follow` from the primary surface. Use: + +- `dispatch tail <selector>` for persisted conversation history from `thread/read(includeTurns:true)`. +- `dispatch watch <selector>` for a bounded live App Server event sample with `--limit` and `--timeout`. + +Do not claim infinite streaming until the control socket can support a real subscription. A future true stream can become `dispatch watch --follow`; do not reserve that behavior for `tail`. + +No backwards-compatible `lane` aliases are required yet unless they materially simplify incremental implementation. If aliases are temporarily kept during transition, docs/help/schema parity should still present the flat commands as canonical. + +## MCP surface + +Keep MCP grouped by workflow and safety boundary. Do not mirror every flattened CLI command as a separate tool. + +Rename or reshape lane-named MCP tools toward thread/ref language while preserving intent grouping: + +- `dispatch_thread_read`: get/list/discover/tail/watch/search-style read actions. +- `dispatch_thread_write`: new/attach/sync/send/stop/rename/restore/fork/compact-style write actions where intent is non-destructive. +- `dispatch_thread_destroy`: archive/rollback and other destructive actions. +- `dispatch_goal`: goal status/set/clear, grouped only if annotations remain correct; otherwise split read/write/destroy as the current projection requires. +- `dispatch_trigger_read`, `dispatch_trigger_write`, `dispatch_trigger_destroy` or the existing equivalent if annotations stay exact. +- `dispatch_daemon_read`: daemon status/log. + +Do not mix different MCP safety annotations inside one tool if the current projection cannot represent that accurately. If grouping by workflow would blur `readOnlyHint` or `destructiveHint`, split by intent. + +Every MCP structured output that identifies a managed thread should include `ref`, full id, title/name when available, managed state, source, status, and cwd when available. Inputs should accept the same selector strings as CLI inputs. + +## Registry and models + +Add ref storage to managed lanes: + +- `ref` string, unique and non-null for every managed lane after migration. +- Optional allocation metadata if useful for tests/debugging: `ref_source`, `ref_payload`, `ref_mixer`. +- Title/name snapshot as a separate concept from handle/alias if not already modeled cleanly. + +Add a migration that backfills refs for existing lanes. Allocate in stable order by created timestamp, then lane id, so rebuild behavior is deterministic when possible. + +Outputs that currently return `LaneRef`, lane list items, search matches, discovered sessions, and thread action refs should expose `ref` when known. Unmanaged discover/search results may omit `ref` unless they have been indexed into a local cache; do not invent refs for unmanaged threads unless they are stored and resolvable. + +## Docs, skills, and ADRs + +Update durable docs and agent-facing skills as part of the implementation, not as follow-up cleanup: + +- README quick start and examples use flat commands and refs. +- `docs/usage/README.md` explains refs, full Codex IDs, mutable titles, managed/unmanaged/synced, `tail` vs `watch`, and `dispatch mcp`. +- `docs/development/design.md` reflects the public CLI, MCP grouping, and internal lane terminology. +- Add a new ADR for dispatch-local refs and flat thread CLI, or extend ADR-0018 if the team decides one ADR is enough. +- Update root `AGENTS.md`, `CLAUDE.md` shims, and path-scoped `.claude/rules/` when their lexicon or examples become stale. +- Update `.claude/rules/contracts.md` and client rules if route/schema examples change. +- Update `skills/dispatch/SKILL.md` to teach agents to prefer refs over handles and to use flat commands. +- Update `skills/dm/SKILL.md` so short-message workflows target refs/owned managed lanes and do not treat mutable handles as identity. +- Update plugin/MCP setup docs only if tool names or schemas change; keep `dispatch mcp` as the entrypoint. + +## Verification + +Required tests: + +- Ref allocation for owned lanes, attached lanes, and forked lanes. +- Collision allocation path with a forced payload collision. +- Migration backfills unique refs. +- Resolver accepts ref, full Codex id, managed lane id, and unique handle/title. +- Resolver rejects ambiguous selectors with candidate data. +- Mutating/destructive ops do not fuzzy-resolve. +- Flat CLI routes invoke canonical ops with the expected params. +- `dispatch schema <flat command>` resolves to the canonical op for list/get/tail/watch/sync/attach/search/rename/archive/restore. +- `tail` and `watch` are separate CLI routes; `tail --follow` is not canonical. +- MCP projection tests reflect renamed/grouped thread tools and exact safety annotations. +- Output schemas include `ref` wherever managed threads are returned. +- Docs/skill drift checks by grep or targeted review: no canonical examples use `dispatch lane ...` unless explicitly explaining legacy/internal terminology. + +Manual/local proving: + +- Run `dispatch list --json`, `dispatch schema list`, `dispatch schema tail`, `dispatch schema watch`, and representative help output in-tree. +- With isolated `DISPATCH_HOME`, create or fake multiple lanes and verify refs remain stable across daemon restart. +- Optional live App Server smoke should be read-only/cheap, with isolated dispatch state and no sends to unrelated user sessions. + +Final gate: `just check`, local review loop, and draft PR submission. + +## Execution model + +Prefer one branch, `feat/stable-refs-flat-cli`, unless the implementation becomes too large. If splitting becomes necessary, use this order: + +1. Ref allocator, registry migration, output models. +2. Selector resolver and handler adoption. +3. Flat CLI routes plus `tail`/`watch` split. +4. MCP tool rename/grouping and schema parity. +5. Docs, skills, ADR, and final review cleanup. + +Each branch must pass `just check` and a local review with no unresolved P0/P1/P2 before moving on. Keep PRs draft until CI and local review are clean. + +## Done + +Done only when every managed lane has a stable local ref, all relevant selectors accept refs and full Codex ids, the canonical CLI is flat, `tail` and `watch` are separate and honest, MCP remains grouped and annotation-correct, docs/skills/ADRs are updated, and local review plus `just check` are green. diff --git a/.agents/plans/stable-refs-flat-cli/REFS.md b/.agents/plans/stable-refs-flat-cli/REFS.md new file mode 100644 index 0000000..4e25754 --- /dev/null +++ b/.agents/plans/stable-refs-flat-cli/REFS.md @@ -0,0 +1,71 @@ +# Stable refs and flat CLI - references + +## Current repo context + +- PR #32 introduced top-level `rename`, `archive`, `restore`, and `search`, plus managed/unmanaged/sync language. +- Current public docs still use `lane` heavily because that was the prior operator grammar. +- The contract projection intentionally allows CLI command paths to differ from op ids and MCP grouping as long as schemas/errors/annotations derive from the op registry. +- Existing `dispatch mcp` entrypoint must stay available for plugin/MCP clients. + +## Live Codex ID observations + +Recent Codex thread IDs are UUID-looking and UUIDv7-ish/time-sortable: + +```text +019e8a09-5021-7b63-9d95-402b7c7d345e @Dispatch +019e92f0-8eb5-7723-a733-1ec8af27b0db @Skillset +019e844c-e49e-7450-8845-77140f51db52 @Lewis +019e8476-0ccf-79b3-8967-58f47df22c9e @Numero +019e9598-9214-7ed1-ac40-52d6d675d3e7 Ship dispatch reliability fixes +``` + +Local `~/.config/codex/session_index.jsonl` sample had 837 known ids at investigation time. First UUID chunks already collided because the left side is timestamp-heavy. Examples: + +```text +019e78e3-a298-70d3-97af-1d682fae392d +019e78e3-ed44-7c93-b786-df085e30f9b3 +019e78e3-ca27-7f73-95bc-b8b695810c3c +019e78e3-5e64-7be0-b866-d231d4e431eb +019e78e3-8295-7340-91d0-de5999bc5cf6 +019e78e3-a779-7432-a74a-73f0141a6db6 +``` + +Raw left-prefix truncation of the Codex UUID should remain an escape hatch only. It should not be the primary short ref strategy. + +## Ref design notes + +Rejected: + +- First UUID chunk as ref: collisions already exist locally. +- Last 3 chars of UUID group 3 plus group 4: collision-free in the sample, but only about 26 random-ish bits and depends on UUIDv7 layout. +- Fixed 8-char hash prefix with no allocation: workable, but if refs are dispatch-local, allocation gives better user experience and simpler collision handling. + +Preferred: + +- Dispatch-local assigned ref. +- Full Codex UUID remains canonical. +- Hash payload avoids dependence on UUID layout. +- Mixer character resolves collisions by allocation, not by hoping birthday math never bites. + +## CLI decisions from discussion + +- Flatten canonical operator commands: `dispatch list`, `dispatch get`, `dispatch attach`, `dispatch sync`, `dispatch tail`, `dispatch watch`. +- Keep grouped subdomains where they are real: `goal`, `trigger`, `daemon`, `schema`, `mcp`. +- Keep `dispatch mcp` open and reserved as the MCP server entrypoint. +- Fold "open but do not send" into `dispatch new --no-send`. +- Make `dispatch list` the normal operational overview and `dispatch list --unmanaged` the discover path. +- Keep `dispatch search <query>` top-level, with filters for managed/unmanaged, one focused thread, repo/dir, and date windows where the underlying data supports it. +- Split `tail` and `watch`: + - `tail`: persisted conversation history. + - `watch`: bounded live App Server event sample. +- Do not use `tail --follow` as the canonical shape until real streaming exists. + +## Files likely affected + +This plan intentionally avoids file-by-file implementation lock-in, but likely surfaces include: + +- Registry schema/store and lane models. +- Core handlers and any selector helpers. +- Contract models/ops and derived CLI/MCP projections. +- Surface parity/schema tests. +- README, docs/usage, docs/development/design, ADRs, root AGENTS/CLAUDE guidance, `.claude/rules`, first-party skills, and plugin/MCP docs. diff --git a/.agents/plans/stable-refs-flat-cli/RETRO.md b/.agents/plans/stable-refs-flat-cli/RETRO.md new file mode 100644 index 0000000..8fedb88 --- /dev/null +++ b/.agents/plans/stable-refs-flat-cli/RETRO.md @@ -0,0 +1,142 @@ +# Stable refs and flat CLI - execution ledger + +Status: implementation in progress on `feat/stable-refs-flat-cli`, stacked on PR #32 +(`feat/top-level-thread-actions`). + +## Handoff + +- Plan written from the 2026-06-05 dispatch identity/CLI/MCP design discussion. +- No implementation has been performed in this packet yet. +- Before starting, verify current branch/PR state, especially whether PR #32 has merged or whether this work should stack on it. + +## Decisions to preserve + +- Full Codex UUID is canonical and always accepted. +- Dispatch ref is short, local, assigned, and stored. +- Titles and `@handles` are mutable convenience labels. +- Flat CLI is canonical for thread operations. +- `dispatch mcp` remains the MCP server entrypoint. +- MCP stays grouped by workflow/safety, with exact annotations. +- `tail` and `watch` are separate commands with different semantics. + +## Execution log + +- 2026-06-05: Verified live repo state before editing: + - Current branch was `feat/top-level-thread-actions`, matching PR #32 head. + - PR #32 is open/draft, merge state clean, and CI checks green. + - Graphite visible stack was `main` -> `feat/top-level-thread-actions`. + - Created stacked branch `feat/stable-refs-flat-cli`. +- Added dispatch-local Codex ref allocator: + - Format `<source><payload4><mixer>`. + - Source `0` for Codex. + - Payload is base58btc from `sha256("codex:" + thread_id)`. + - Mixer allocated from the base58btc alphabet on collision. +- Bumped registry schema to v3 and backfilled refs for existing lanes in + `created_at, id` order. +- Added shared selector resolver: + - Exact ref, full thread id, lane id, handle, title. + - Fuzzy title matching only when read flows opt in. + - Mutating/destructive flows use exact resolution and return ambiguity + candidates instead of guessing. +- Added refs to managed-thread output models and examples. +- Flattened canonical CLI routes: + - `attach`, `list`, `list --unmanaged`, `get`, `tail`, `watch`, `sync`. + - `search --thread` is canonical; `--lane` remains accepted as a temporary + compatibility alias. + - `tail --follow` no longer maps to `watch`. +- Renamed grouped MCP tools from lane language to thread language: + - `dispatch_thread_read` + - `dispatch_thread_write` + - `dispatch_thread_destroy` +- Updated README, usage docs, design doc, ADR index, new ADR-0019, root + AGENTS lexicon, contract rules, dispatch/dm skills, and plugin README. +- Local review fixes: + - Exact title resolution now accepts an owned handle without the leading `@` + so current Codex titles like `Docs Thread` resolve when the stored handle is + `@Docs Thread`. + - Managed-thread outputs now include optional `cwd` alongside `ref`, full id, + handle/title, source, and status. + - Trigger runner resolution uses the shared selector resolver so trigger lane + selectors can be dispatch refs. +- Follow-up P2 fix from PR #33 review: + - `ActionAck`, `GoalView`, `LaneSyncResult`, `TranscriptOutput`, and + `WatchOutput` now include the same managed-thread identity fields: + `lane`, `ref`, full `id`, `title`, `handle`, `managed`, `source`, `status`, + and `cwd`. + - `lane` remains as a compatibility field for the full Codex thread id. + - Added schema and MCP structured-content regression tests for the identity + fields. + - Cleaned cheap user-facing help/docs wording from lane terminology to + thread/ref terminology where it did not require a broad rename. +- Submitted draft PR: + - PR #33: https://github.com/outfitter-dev/dispatch/pull/33 + - Base: `feat/top-level-thread-actions` (PR #32) + - Head: `feat/stable-refs-flat-cli` + +## Verification log + +- Focused: + - `uv run pytest tests/registry/test_store.py tests/core/test_examples.py -q` + -> 14 passed. + - `uv run pytest tests/core/test_selectors.py tests/core/test_handlers.py tests/core/test_examples.py -q` + -> 56 passed. + - `uv run pytest tests/surfaces -q` -> 25 passed. + - `uv run pytest tests/registry/test_store.py tests/core/test_selectors.py tests/core/test_handlers.py tests/surfaces -q` + -> 91 passed. +- Manual schema checks: + - `uv run dispatch schema list` + - `uv run dispatch schema tail` + - `uv run dispatch schema watch` + - Verified `list` resolves to `roster`, `tail` to `transcript`, `watch` to + `watch`, and managed list output requires `ref`. + - Follow-up schema verification: + - `uv run dispatch schema send` + - `uv run dispatch schema 'goal status'` + - `uv run dispatch schema sync` + - `uv run dispatch schema tail` + - `uv run dispatch schema watch` + - Verified each output schema includes `lane`, `ref`, `id`, `title`, + `handle`, `managed`, `source`, `status`, and `cwd`. +- Manual isolated CLI smoke: + - `DISPATCH_HOME=$(mktemp -d)/dispatch uv run dispatch up` + - `uv run dispatch list --json` -> `{"lanes": []}` + - `uv run dispatch schema list` + - `uv run dispatch schema tail` + - `uv run dispatch schema watch` + - `uv run dispatch down` +- Static checks: + - `uv run ruff check src/outfitter/dispatch tests` -> passed. + - `uv run mypy src tests --strict` -> passed. + - Follow-up static checks: + - `uv run ruff check src/outfitter/dispatch tests` -> passed. + - `uv run ruff format --check src/outfitter/dispatch tests` -> passed. + - `uv run mypy src tests --strict` -> passed. +- Full gate: + - `just check` -> passed. + - Latest run: 189 passed, 9 deselected; build and package content check passed. + - Follow-up run after managed-output identity fix: `just check` -> passed, + 190 passed, 9 deselected; build and package content check passed. +- PR checks: + - PR #33 CI `check` completed successfully: + https://github.com/outfitter-dev/dispatch/actions/runs/27037566995/job/79805326778 + - Follow-up PR #33 CI `check` after managed-output identity fix completed successfully: + https://github.com/outfitter-dev/dispatch/actions/runs/27038216121/job/79807480244 + +## Review log + +- Local self-review, 2026-06-05: + - Reviewed registry migration/allocation, selector resolution, CLI/MCP + projection changes, output schemas, and docs/skill drift. + - P2 fixed: exact title matching did not recognize handle-without-`@`. + - P2 fixed: managed outputs did not expose `cwd` where available. + - No open P0/P1/P2 after fixes and `just check`. +- Draft PR #33 body updated with context, test proof, and risks. +- Follow-up local self-review, 2026-06-05: + - Reviewed the reported P2 against `PLAN.md:146` and `PLAN.md:188`. + - Fixed identity context for send/goal/sync/tail/watch outputs. + - Added regression coverage at schema and MCP structured-content levels. + - No open P0/P1/P2 before rerunning the full gate. + +## Deferred/P3 notes + +- No deferred P3s recorded yet. diff --git a/.claude/rules/contracts.md b/.claude/rules/contracts.md index 9bc2eee..40762b1 100644 --- a/.claude/rules/contracts.md +++ b/.claude/rules/contracts.md @@ -15,7 +15,7 @@ An **op** is the single source for one operation. Author only what's irreducible Register the op in `ops.py`. Then make sure each projection has an intentional route for it: simple ops may map directly, while ergonomic surfaces may group or -compose ops (for example `lane list --unmanaged` → `discover`, `goal status` → +compose ops (for example `list --unmanaged` → `discover`, `goal status` → `goal-get`, and grouped MCP tools with an `op` selector). The projection must be derived from the registry; never hand-implement the same behavior separately in a surface. diff --git a/AGENTS.md b/AGENTS.md index 82d32c2..411dde7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -43,12 +43,14 @@ Read `docs/development/design.md` and `.agents/plans/v0/PLAN.md` before implemen Use the project language consistently: -- **lane** — a managed Codex thread (own or attached). Not "thread", "agent", or "unit" in user-facing text. +- **lane** — internal term for a managed Codex thread (own or attached) with registry state. +- **thread** — user-facing Codex conversation/session. Public CLI/help/docs should prefer "thread" unless the managed-lane authority distinction matters. +- **ref** — dispatch-local short stable selector for a managed lane. Full Codex thread ids are always accepted; titles and `@handles` are mutable labels. - **op** — one authored operation (input/output/intent/examples/handler). The contract unit. Not "command" or "tool" (those are surface projections of an op). - **surface** — a derived rendering of the op registry: CLI, MCP, remote. Surfaces are projected, never hand-written per-op. - **trigger** — an automated when→action→lane binding (time or event). Not "rule" (collides with agent rules), "automation", or "job". - **daemon** (`dispatchd`) — the long-lived host owning the app-server and core; the CLI is a thin client to it. -- **register/registry** — the durable store of lanes and triggers. +- **register/registry** — the durable store of lanes, refs, sync state, and triggers. ## Core rules (summary; full detail in `.claude/rules/`) diff --git a/README.md b/README.md index ed8b9f8..d500fe2 100644 --- a/README.md +++ b/README.md @@ -24,26 +24,29 @@ uv run dispatch up uv run dispatch daemon status ``` -Open an owned lane, send it work, and inspect the daemon: +Create an owned managed thread, send it work, and inspect the daemon: ```bash uv run dispatch new \ --name docs \ --cwd /path/to/dispatch \ --text "Please summarize the current stack state." -uv run dispatch lane tail "@[dispatch] docs" --limit 20 -uv run dispatch goal set "@[dispatch] docs" "Finish the docs review." +uv run dispatch list +uv run dispatch tail <dispatch-ref> --limit 20 +uv run dispatch goal set <dispatch-ref> "Finish the docs review." uv run dispatch daemon log --limit 10 uv run dispatch down ``` -Use owned lanes for turn-writing work. Existing desktop Codex threads can be attached as -managed lanes, but ADR-0005 still blocks turn-writing and history-mutating commands such -as `send`, `stop`, `goal set`, `goal clear`, `lane fork`, `lane rollback`, and -`lane compact` on attached lanes. Metadata/lifecycle actions (`rename`, `archive`, -`restore`) can target managed lanes or raw unmanaged Codex thread ids, and `search` can -span both. Attach is metadata-only by default; use `dispatch lane sync <lane>` when you -want dispatch to refresh its local indexed view of an attached thread. +Use owned managed threads for turn-writing work. Existing desktop Codex threads can be attached as +managed threads, but ADR-0005 still blocks turn-writing and history-mutating commands such +as `send`, `stop`, `goal set`, and `goal clear` on attached lanes. Every managed +thread has a dispatch-local `ref`; full Codex thread UUIDs remain accepted everywhere. +Titles and `@handles` are mutable convenience labels, not stable identity. Metadata +lifecycle actions (`rename`, `archive`, `restore`) can target managed refs or raw +unmanaged Codex thread ids, and `search` can span both. Attach is metadata-only by +default; use `dispatch sync <selector>` when you want dispatch to refresh its local +indexed view of an attached thread. For the operator guide, CLI/MCP examples, triggers, and plugin setup, start at [`docs/usage/README.md`](docs/usage/README.md). diff --git a/docs/adrs/0019-dispatch-local-refs-and-flat-thread-cli.md b/docs/adrs/0019-dispatch-local-refs-and-flat-thread-cli.md new file mode 100644 index 0000000..680dbc4 --- /dev/null +++ b/docs/adrs/0019-dispatch-local-refs-and-flat-thread-cli.md @@ -0,0 +1,80 @@ +# ADR-0019: Dispatch-Local Refs and Flat Thread CLI + +## Status + +Accepted. + +## Context + +Codex thread ids are full UUID-like identifiers and remain the only global identity. +They are durable but awkward for daily operator use. Earlier dispatch surfaces leaned on +`@handles`, titles, and `dispatch lane ...` commands. That made common operations verbose +and blurred stable identity with mutable labels that agents may change while working. + +ADR-0018 moved rename/archive/restore/search to top-level thread actions. The next step is +to make all common thread operations use the same flat shape and give every managed lane a +short stable registry-local selector. + +## Decision + +Every managed lane stores a unique dispatch-local `ref`. The full Codex thread id is always +accepted and remains the durable escape hatch. Titles and `@handles` are mutable convenience +labels, not identity. + +Codex refs use: + +```text +<source><payload4><mixer> +``` + +`source` is `0` for Codex. `payload4` is the first four base58btc characters from +`sha256("codex:" + thread_id)`. `mixer` is allocated by the registry from the base58btc +alphabet. On collision, the registry tries the next mixer character and stores the +allocated ref. If the mixer alphabet is exhausted for one payload, dispatch fails loudly +and the operator can use the full Codex thread id. + +The canonical CLI for common thread operations is flat: + +```bash +dispatch new ... +dispatch attach <thread-id> +dispatch list +dispatch list --unmanaged +dispatch get <selector> +dispatch send <selector> <text> +dispatch stop <selector> +dispatch tail <selector> +dispatch watch <selector> +dispatch sync <selector> +dispatch rename <selector> <title> +dispatch archive <selector> +dispatch restore <selector> +dispatch search <query> +``` + +`dispatch new --no-send` is the open-without-initial-turn shape. `tail` means persisted +conversation history. `watch` means a bounded live App Server event sample; `tail --follow` +is not canonical. + +MCP remains grouped by workflow and safety boundary. It uses thread/ref tool names +(`dispatch_thread_read`, `dispatch_thread_write`, `dispatch_thread_destroy`) and preserves +exact safety annotations instead of mirroring every CLI command one-for-one. + +## Consequences + +- Operators get stable short refs for managed lanes without giving up full Codex ids. +- Mutating and destructive operations do not fuzzy-resolve ambiguous titles or handles. +- Read/discovery flows may use fuzzy title matching only when it resolves uniquely. +- Registry migration must backfill refs for existing lanes in deterministic order. +- Docs, skills, and examples should prefer flat commands and refs. Older `lane ...` + examples are historical unless explicitly labeled as internal/legacy. + +## Alternatives Considered + +- UUID prefixes: rejected because local Codex UUID prefixes already collide in timestamp-heavy + UUIDv7 prefixes. +- Hash-only fixed prefixes: workable, but allocation gives clearer collision behavior in a + local registry. +- Keep `dispatch lane ...` canonical: rejected because common thread actions are easier to + learn and script as top-level commands, while internal code can still use `lane` for the + managed-thread registry concept. diff --git a/docs/adrs/README.md b/docs/adrs/README.md index a406233..380e25f 100644 --- a/docs/adrs/README.md +++ b/docs/adrs/README.md @@ -27,3 +27,4 @@ Files are `NNNN-slug.md`. Copy [`template.md`](template.md) to start one. Keep t | [0016](0016-history-goals-and-bounded-watch.md) | History, Goals, and Bounded Watch | Accepted | | [0017](0017-progressive-thread-sync-index.md) | Progressive Thread Sync Index | Accepted | | [0018](0018-top-level-thread-actions-and-search.md) | Top-Level Thread Actions and Search | Accepted | +| [0019](0019-dispatch-local-refs-and-flat-thread-cli.md) | Dispatch-Local Refs and Flat Thread CLI | Accepted | diff --git a/docs/development/design.md b/docs/development/design.md index 36587f0..c7a1194 100644 --- a/docs/development/design.md +++ b/docs/development/design.md @@ -2,7 +2,7 @@ A local control plane for orchestrating Codex agent lanes (threads) over the Codex App Server: create/attach lanes, send work or context, queue delivery, stop active turns, and automate pings on time- and event-based triggers. One authored contract per operation is projected onto multiple surfaces — CLI now, MCP now, remote control later — with no drift. -Status: approved design, implemented through v0. Companion research (verified against `codex-cli 0.136.0-alpha.2`): [`docs/research/app-server-verification.md`](../research/app-server-verification.md) and [`docs/research/orchestration-thesis.md`](../research/orchestration-thesis.md). Decisions: [`docs/adrs/`](../adrs/). Execution ledger: [`../../.agents/plans/v0/RETRO.md`](../../.agents/plans/v0/RETRO.md). +Status: approved design, implemented through v0 and updated for dispatch-local refs / flat thread CLI. Companion research (verified against `codex-cli 0.136.0-alpha.2`): [`docs/research/app-server-verification.md`](../research/app-server-verification.md) and [`docs/research/orchestration-thesis.md`](../research/orchestration-thesis.md). Decisions: [`docs/adrs/`](../adrs/). Execution ledger: [`../../.agents/plans/v0/RETRO.md`](../../.agents/plans/v0/RETRO.md). ## Naming @@ -73,26 +73,31 @@ Projections (pure functions over the registry, mirroring Trails' `derive* → cr ## Command surface (v1) - Daemon lifecycle: `up` / `down` (process) · `daemon status` · `daemon log` -- Lane creation: `new <name> [--preset ...] [--text ...] [--no-send]` -- Lane reads/discovery: `lane get <lane>` · `lane status <lane>` · `lane list` · - `lane list --unmanaged` · `lane sync <lane>` · `lane tail <lane>` · - `lane tail <lane> --follow` -- Lane management/history: `rename <target> <new>` · `archive <target>` · - `restore <target>` · `search <query>` with lane/repo/directory/date filters · - `lane attach <thread> [--sync]` · `lane rename <old> <new>` · - `lane fork <lane>` · `lane rollback <lane>` · `lane compact <lane>` · - `lane archive <target>` · `lane restore <target>` · `lane search <lane> <query>` -- Sending: `send <lane> "…"` with `--mode send|steer|queue|interject|context` +- Thread creation: `new <name> [--preset ...] [--text ...] [--no-send]` +- Thread reads/discovery: `get <selector>` · `list` · `list --unmanaged` · + `sync <selector>` · `tail <selector>` · `watch <selector>` +- Thread management/search: `attach <thread-id> [--sync]` · + `rename <selector> <new>` · `archive <selector>` · `restore <selector>` · + `search <query>` with `--thread`/repo/directory/date/managed filters +- Sending: `send <selector> "…"` with `--mode send|steer|queue|interject|context` and equivalent mutually exclusive `--steer`, `--queue`, `--interject`, - `--context`; `stop <lane>` / `stop --lane <lane>` is cancel-only. -- Goals: `goal status <lane>` · `goal set <lane> <objective>` · `goal clear <lane>` + `--context`; `stop <selector>` is cancel-only. +- Goals: `goal status <selector>` · `goal set <selector> <objective>` · + `goal clear <selector>` - Triggers: `trigger add` · `trigger list` · `trigger rm <id>` · `trigger pause <id>` · `trigger resume <id>` - Schemas: `schema <command>` prints derived input/output schemas for shell automation. MCP tools are an ergonomic projection of the same ops, grouped by workflow and safety -boundary rather than forced to be one tool per op. The noun for a managed thread is -**lane**. +boundary rather than forced to be one tool per op. Internally, a managed thread with +registry state is still a **lane**. Public CLI/help/docs prefer **thread**, **ref**, +**managed/unmanaged**, and **synced** unless the internal authority distinction matters. + +Every managed lane stores a dispatch-local `ref` alongside the full Codex thread id. +The full Codex id remains accepted everywhere. Refs are assigned as +`<source><payload4><mixer>`; Codex refs use source `0`, a four-character base58btc +payload from `sha256("codex:" + thread_id)`, and a registry-allocated mixer character +for collisions. Titles and `@handles` are mutable convenience labels. ## App Server integration (verified primitives → ops) @@ -100,23 +105,23 @@ boundary rather than forced to be one tool per op. The noun for a managed thread | --- | --- | --- | | `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 `turn/start` | Applies `.dispatch/config.toml` defaults/presets, name prefixes, verified session/turn options, and optional initial payload. | -| `attach` | `thread/read(includeTurns:false)` (+ register) | Metadata-only by default: verifies the thread id, registers a turn-write locked attached lane, and stores sync state without loading turn history. `--sync` runs a quick local index refresh after registration. | -| `sync` (`lane sync`) | `thread/read(includeTurns:false)` + bounded local JSONL parsing | Refreshes dispatch's index/cache for a lane: 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. | +| `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`. | | `send` (`mode=queue`) | registry queue + later `turn/start` | Persists local queued delivery and starts one queued turn when the lane becomes idle. | | `send` (`mode=steer`) | `turn/steer` | Requires `expectedTurnId` (the active turn id from `turn/started`). Adds input to an in-flight turn. | | `send` (`mode=context`) | `thread/inject_items` | Silent model-visible context injection (Responses-API items); no turn runs. Trigger actions still call this lower-level behavior `brief`. | | `send` (`mode=interject`) | `turn/interrupt` + `turn/start` | Requires an active turn id, cancels that turn, then starts replacement work. | | `stop` | `turn/interrupt` | Requires an active turn id and cancels the active turn without replacement text. | -| `lane-rename` (`rename`, `lane rename`) | `thread/name/set` (+ registry update when managed) | Accepts a managed lane id/handle or a raw unmanaged Codex thread id. Unresolved `@handles` fail as missing lanes rather than falling through as raw ids. | -| `archive` (`archive`, `lane archive`) | `thread/archive` | Accepts managed lanes or unmanaged raw thread ids. If App Server reports `no rollout found` for an owned no-rollout lane, dispatch archives the local registry entry so throwaway lanes can be cleaned up. | -| `restore` (`restore`, `lane restore`) | `thread/unarchive` | Restores the archived Codex thread only; does not resume or start a new turn. | -| `search` (`search`, `lane search`) | experimental `thread/search` for broad search; `thread/read(includeTurns:true)` for one-lane search | Broad search uses App Server search plus dispatch-side managed/unmanaged, repo/directory, and date filters. Lane-focused search reads one transcript and scans locally because App Server search has no thread-id filter. | -| `roster` (`lane list`) | `thread/list` + registry + status | List results are under `result.data` (NOT `result.threads`); `useStateDbOnly:true` reads the persisted store. | -| `discover` (`lane list --unmanaged`) | `thread/list` state DB only | Lists persisted Codex sessions that could be attached; it does not resume or register them. | -| `show` (`lane get/status`) | registry + optional `thread/read(includeTurns:true)` | Compact lane summary; optional transcript convenience. | -| `transcript` (`lane tail`) | `thread/read(includeTurns:true)` | Persisted turn/item snapshot, not a full execution log. | -| `watch` (`lane tail --follow`) | raw app-server event stream, bounded by limit/timeout | Request/response bounded sample; a true infinite tail needs a subscription control-socket extension. | +| `lane-rename` (`rename`) | `thread/name/set` (+ registry update when managed) | Accepts a managed ref, full Codex thread id, or unique convenience label. Mutating actions do not fuzzy-resolve ambiguous names. | +| `archive` (`archive`) | `thread/archive` | Accepts managed refs or unmanaged raw thread ids. If App Server reports `no rollout found` for an owned no-rollout lane, dispatch archives the local registry entry so throwaway lanes can be cleaned up. | +| `restore` (`restore`) | `thread/unarchive` | Restores the archived Codex thread only; does not resume or start a new turn. | +| `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. | +| `discover` (`list --unmanaged`) | `thread/list` state DB only | Lists persisted Codex sessions that could be attached; it does not resume or register them. | +| `show` (`get`) | registry + optional `thread/read(includeTurns:true)` | Compact managed-thread summary; 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. | | `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. | | `rollback` | `thread/rollback` | Drops persisted turns only; does not revert workspace files. | @@ -155,7 +160,7 @@ The client supports the full responder loop. v1 surfaces `waiting_on_approval` a ## Data model (registry, SQLite) -- `lanes`: id, handle (`@name` / `→ @project:name`), role, cwd, source (`own`|`attached`), status, pinned, created_at, updated_at, last_event_at. +- `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. - `triggers`: id, name, lane selector, when-spec (json), action-spec (json), guard-spec (json), enabled, last_fired_at. diff --git a/docs/usage/README.md b/docs/usage/README.md index af20566..c77e9d8 100644 --- a/docs/usage/README.md +++ b/docs/usage/README.md @@ -117,7 +117,7 @@ Common recovery paths: - Registry integrity failure: stop the daemon, back up the database at the path shown by doctor, and recreate it or inspect with `sqlite3`. - App Server initialize failure: run `codex app-server --listen stdio://` directly in - the same shell and fix the Codex CLI/auth problem before relying on lane operations. + the same shell and fix the Codex CLI/auth problem before relying on thread operations. ## Release Publishing @@ -166,10 +166,15 @@ Use `new --no-send` when you want to create the lane first and send later: ```bash uv run dispatch new --name docs-review --cwd /path/to/dispatch --no-send -uv run dispatch lane list -uv run dispatch send @docs-review "Review the README for missing usage steps." +uv run dispatch list +uv run dispatch send <dispatch-ref> "Review the README for missing usage steps." ``` +Every managed thread gets a dispatch-local `ref`, for example `0k7M4a`. Use refs +for day-to-day commands. The full Codex thread id is still the canonical global +identity and is accepted everywhere. Titles and `@handles` are mutable labels; +they are convenient, but not stable identity. + Example `.dispatch/config.toml`: ```toml @@ -215,8 +220,7 @@ uv run dispatch send @docs-review "Stop that and focus on operator docs first." Use `stop` to cancel the active turn without sending replacement text: ```bash -uv run dispatch stop @docs-review -uv run dispatch stop --lane @docs-review +uv run dispatch stop <dispatch-ref> ``` Use `send --queue` when delivery should wait for the lane to become idle. The message is @@ -226,29 +230,29 @@ stored in dispatch's durable registry and starts one queued turn per idle transi uv run dispatch send @docs-review "Run this after the active turn." --queue ``` -## Lane History And Goals +## Thread History, Watch, And Goals -`lane get` remains the compact lane summary: +`get` is the compact managed-thread summary: ```bash -uv run dispatch lane get @docs-review +uv run dispatch get <dispatch-ref> ``` -Use `lane tail` when you want persisted turn history. It reads `thread/read` with +Use `tail` when you want persisted turn history. It reads `thread/read` with `includeTurns:true` and returns a compact item list; it is a history snapshot, not a full execution log. App Server does not support `includeTurns` on ephemeral threads. ```bash -uv run dispatch lane tail @docs-review --limit 50 +uv run dispatch tail <dispatch-ref> --limit 50 ``` -Use `lane tail --follow` for a bounded live event sample from dispatch's app-server stream. +Use `watch` for a bounded live event sample from dispatch's app-server stream. It returns raw App Server method names and params for the selected lane until `--limit` events arrive or `--timeout` elapses. It is intentionally bounded because the current control socket is request/response JSONL, not a subscription protocol. ```bash -uv run dispatch lane tail @docs-review --follow --limit 20 --timeout 10 +uv run dispatch watch <dispatch-ref> --limit 20 --timeout 10 ``` Native App Server goals can be read, set, and cleared on owned lanes: @@ -262,36 +266,21 @@ uv run dispatch goal clear @docs-review Creating a goal requires an objective. After a goal exists, `goal set` can update `--status` or `--token-budget`. App Server goals require non-ephemeral threads. -`fork`, `rollback`, and `compact` expose stable App Server history controls: - -```bash -uv run dispatch lane fork @docs-review --name docs-review-copy -uv run dispatch lane rollback @docs-review --turns 1 -uv run dispatch lane compact @docs-review -``` - -`rollback` only truncates persisted App Server history. It does not revert local files. -Treat it as a conversation-history operation, not a source-control undo. +`tail --follow` is not canonical; use `watch`. True long-lived streaming will use a +future subscription-capable watch surface. ## Thread Actions And Search `rename`, `archive`, and `restore` are top-level thread actions. They accept a managed -lane id, a managed `@handle`, or a raw unmanaged Codex thread id: +dispatch ref, a full Codex thread id, or a unique convenience label: ```bash -uv run dispatch rename @docs-review docs-review-final -uv run dispatch archive @docs-review +uv run dispatch rename <dispatch-ref> docs-review-final +uv run dispatch archive <dispatch-ref> uv run dispatch restore <codex-thread-id> ``` `restore` unarchives the thread only; it does not resume the thread or start a new turn. -Use the `lane` group when you want the same actions to read as lane management: - -```bash -uv run dispatch lane rename @docs-review docs-review-final -uv run dispatch lane archive @docs-review -uv run dispatch lane restore @docs-review -``` Use `search` to search Codex thread history without first attaching every thread: @@ -299,11 +288,10 @@ Use `search` to search Codex thread history without first attaching every thread uv run dispatch search "schema drift" uv run dispatch search "schema drift" --managed uv run dispatch search "schema drift" --unmanaged -uv run dispatch search "schema drift" --lane @docs-review +uv run dispatch search "schema drift" --thread <dispatch-ref> uv run dispatch search "schema drift" --repo . uv run dispatch search "schema drift" --dir /path/to/project uv run dispatch search "schema drift" --since 2026-06-01 --until 2026-06-05 -uv run dispatch lane search @docs-review "schema drift" ``` Broad search uses the App Server experimental `thread/search` primitive, then applies @@ -314,26 +302,26 @@ local substring scan. Date bounds accept ISO dates or datetimes and default to f ## Discover Sessions -`lane list` lists the lanes dispatch already manages. `lane list --unmanaged` is the other +`list` shows the threads dispatch already manages. `list --unmanaged` is the other half: it lists the persisted Codex sessions on this machine — desktop threads and prior runs — that you could attach. It uses App Server `thread/list` in state-db-only mode, so it is fast and read-only; it never resumes, writes, or registers anything. ```bash -uv run dispatch lane list --unmanaged --limit 20 +uv run dispatch list --unmanaged --limit 20 ``` Each row carries `id`, `name`, a shortened `preview`, `cwd`, `status`, `source`, and `ephemeral`. Use the `id` with `attach` to bring a session under management: ```bash -uv run dispatch lane attach <id-from-lane-list-unmanaged> -uv run dispatch lane attach <id-from-lane-list-unmanaged> --sync +uv run dispatch attach <id-from-list-unmanaged> +uv run dispatch attach <id-from-list-unmanaged> --sync ``` -Keep the two straight: `lane list --unmanaged` shows unmanaged Codex sessions that are not -registered in dispatch; `lane list` shows managed lanes (owned or already attached). Sync is -separate from both: `lane sync` refreshes dispatch's local index for a managed lane, but it +Keep the two straight: `list --unmanaged` shows unmanaged Codex sessions that are not +registered in dispatch; `list` shows managed threads (owned or already attached). Sync is +separate from both: `sync` refreshes dispatch's local index for a managed thread, but it does not change ownership or write authority. ## Attached Lanes @@ -341,8 +329,8 @@ does not change ownership or write authority. Attach registers an existing Codex thread by raw thread id: ```bash -uv run dispatch lane attach <codex-thread-id> -uv run dispatch lane sync <lane> +uv run dispatch attach <codex-thread-id> +uv run dispatch sync <dispatch-ref-or-thread-id> ``` Attached lanes allow observation, sync, and explicit metadata/lifecycle actions such as @@ -358,20 +346,20 @@ does not call `thread/resume` or load turn history. If the app-server is wedged metadata read stalls, attach fails with a clear `app_server` error and registers no lane — it never leaves a half-attached entry behind. -Use `lane sync` to refresh dispatch's local indexed view of an attached lane. Sync reads the +Use `sync` to refresh dispatch's local indexed view of an attached lane. Sync reads the official metadata and, when Codex exposes a local rollout path, parses bounded top+tail JSONL records into a compact cache: source file identity, sync state, latest event timestamp, latest turn id, and a preview. It does not copy the full transcript by default. ```bash -uv run dispatch lane sync <lane> -uv run dispatch lane sync <lane> --full -uv run dispatch lane get <lane> -uv run dispatch lane list +uv run dispatch sync <dispatch-ref-or-thread-id> +uv run dispatch sync <dispatch-ref-or-thread-id> --full +uv run dispatch get <dispatch-ref-or-thread-id> +uv run dispatch list ``` -`lane sync --full` scans the whole current source file and marks the cache complete for that -file identity. It is still an index refresh, not a write to the Codex thread. `lane tail` +`sync --full` scans the whole current source file and marks the cache complete for that +file identity. It is still an index refresh, not a write to the Codex thread. `tail` continues to use official `thread/read(includeTurns:true)` persisted history when you want App Server turn summaries. @@ -381,7 +369,7 @@ When referring to a Codex thread in docs or prompts, prefer a readable handle wi [@Dispatch](codex://threads/<codex-thread-id>) ``` -Use the raw thread id for command arguments. Use the Markdown link in human-facing text. +Use refs or raw thread ids for command arguments. Use the Markdown link in human-facing text. ## Triggers @@ -392,7 +380,7 @@ Interval trigger: ```bash uv run dispatch trigger add \ --name docs-pulse \ - --lane @docs-review \ + --lane <dispatch-ref> \ --when interval \ --seconds 1800 \ --action send \ @@ -404,7 +392,7 @@ Cron trigger: ```bash uv run dispatch trigger add \ --name weekday-standup \ - --lane @docs-review \ + --lane <dispatch-ref> \ --when cron \ --cron "0 9 * * 1-5" \ --action send \ @@ -416,7 +404,7 @@ Idle trigger: ```bash uv run dispatch trigger add \ --name after-idle \ - --lane @docs-review \ + --lane <dispatch-ref> \ --when idle_for \ --seconds 900 \ --action brief \ @@ -445,9 +433,10 @@ make that contract explicit in scripts. `schema` prints the input and output sch derived from the contract registry: ```bash -uv run dispatch lane list --json +uv run dispatch list --json uv run dispatch schema send -uv run dispatch schema "lane sync" +uv run dispatch schema sync +uv run dispatch schema watch uv run dispatch schema "goal set" ``` @@ -460,9 +449,11 @@ 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 lane read/write/destroy, trigger +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. +Structured MCP outputs that identify a managed thread include the dispatch `ref`, full +Codex id, title/handle, managed/source/status, and cwd when available. The workspace Codex plugin at [`plugins/dispatch/`](../../plugins/dispatch/) exposes that MCP server through [`plugins/dispatch/.mcp.json`](../../plugins/dispatch/.mcp.json). The @@ -478,8 +469,7 @@ If Codex does not pick up the plugin immediately, restart Codex for this workspa spike confirmed cross-process history discovery/resume, not live co-presence. - Do not install the generated launchd plist with `launchctl` unless the user explicitly wants persistent autostart. -- `lane tail` is a persisted history snapshot. `lane tail --follow` is a bounded live - event sample. Neither is a durable infinite tail yet; that needs a subscription-capable - control socket. +- `tail` is a persisted history snapshot. `watch` is a bounded live event sample. + Neither is a durable infinite tail yet; that needs a subscription-capable control socket. - `rollback` does not revert workspace files. Use Git or another workspace mechanism for file-level undo. diff --git a/plugins/dispatch/README.md b/plugins/dispatch/README.md index ca92bea..3f09296 100644 --- a/plugins/dispatch/README.md +++ b/plugins/dispatch/README.md @@ -8,8 +8,8 @@ This workspace-local plugin exposes: `dispatch mcp`. The MCP server and skills expose the same derived operation registry as the CLI, -including lane creation/messaging, bounded tails, native goals, history controls, -triggers, schemas, and daemon status/log reads. +including managed-thread creation/messaging, dispatch refs, persisted `tail`, +bounded live `watch`, native goals, triggers, schemas, and daemon status/log reads. Run `dispatch doctor` after installing or upgrading dispatch. It verifies the CLI entrypoints, Codex CLI/auth footprint, daemon socket/pidfile state, registry diff --git a/skills/dispatch/SKILL.md b/skills/dispatch/SKILL.md index eafecd7..5f7f623 100644 --- a/skills/dispatch/SKILL.md +++ b/skills/dispatch/SKILL.md @@ -22,16 +22,15 @@ When you are in this repo, prefer the in-tree command: uv run dispatch --help ``` -The current operator grammar is: +The current canonical operator grammar is: - health: `doctor` - daemon process: `up`, `down` - daemon reads: `daemon status`, `daemon log` -- create/send/search: `new`, `send`, `stop`, `search` -- top-level thread actions: `rename`, `archive`, `restore` -- lanes: `lane get`, `lane status`, `lane list`, `lane list --unmanaged`, - `lane attach`, `lane sync`, `lane rename`, `lane search`, `lane tail`, `lane fork`, - `lane rollback`, `lane compact`, `lane archive`, `lane restore` +- thread lifecycle/read/search: `new`, `attach`, `list`, `list --unmanaged`, + `get`, `sync`, `tail`, `watch`, `search` +- thread actions: `rename`, `archive`, `restore` +- message verbs: `send`, `stop` - goals: `goal status`, `goal set`, `goal clear` - triggers: `trigger add`, `trigger list`, `trigger rm`, `trigger pause`, `trigger resume` @@ -49,7 +48,7 @@ uv run dispatch daemon status uv run dispatch daemon log --limit 10 ``` -Use `uv run dispatch doctor` before relying on live lane operations in a new or +Use `uv run dispatch doctor` before relying on live thread operations in a new or untrusted environment. It checks PATH visibility, Codex CLI/auth footprint, daemon socket/pidfile state, registry schema/integrity, packaged skills/plugin assets, and a low-risk Codex App Server initialize smoke. Use `--no-app-server` @@ -65,11 +64,16 @@ 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`. -## Lane Rules +## Thread Selectors And Lane Rules + +Every managed thread has a stored dispatch-local `ref`. Prefer refs for command +arguments. The full Codex thread id is always accepted. Titles and `@handles` +are mutable convenience labels; use them only when a unique human label is more +useful than a ref. Owned lanes are created by dispatch and are writable. Prefer `new` for a -configured lane; it applies `.dispatch/config.toml`, presets, name prefixes, and -can send an initial turn: +configured managed thread; it applies `.dispatch/config.toml`, presets, name +prefixes, and can send an initial turn: ```bash uv run dispatch new --name my-lane --cwd /path/to/project --text "Do the bounded thing." @@ -79,8 +83,8 @@ uv run dispatch new --name my-lane --preset reviewer --no-send Attached lanes are existing desktop Codex threads registered by raw thread id: ```bash -uv run dispatch lane attach <codex-thread-id> -uv run dispatch lane attach <codex-thread-id> --sync +uv run dispatch attach <codex-thread-id> +uv run dispatch attach <codex-thread-id> --sync ``` Attached lanes are managed by dispatch but are not turn-writable in v0. Do not @@ -94,12 +98,12 @@ interlock. Attach is compact by default: it verifies the thread with `thread/read(includeTurns:false)`, registers metadata, and does not resume turn -history. Use `--sync` or `lane sync` when you want dispatch to refresh its local +history. Use `--sync` or `sync` when you want dispatch to refresh its local indexed view. ```bash -uv run dispatch lane sync <lane> -uv run dispatch lane sync <lane> --full +uv run dispatch sync <dispatch-ref-or-thread-id> +uv run dispatch sync <dispatch-ref-or-thread-id> --full ``` Sync indexes source identity, sync state, latest event time, latest turn id, and @@ -109,21 +113,21 @@ lanes. ## Discover Sessions -`lane list` shows lanes dispatch already manages. `lane list --unmanaged` lists +`list` shows threads dispatch already manages. `list --unmanaged` lists persisted Codex sessions that are not registered in dispatch. It uses App Server `thread/list` in state-db-only mode. It is read-only and does not resume or register anything: ```bash -uv run dispatch lane list -uv run dispatch lane list --unmanaged --limit 20 +uv run dispatch list +uv run dispatch list --unmanaged --limit 20 ``` -Use a discovered session `id` with `lane attach <id>`. +Use a discovered session `id` with `attach <id>`. ## Search And Thread Actions -Use top-level actions when you want to work with either managed lanes or raw +Use top-level actions when you want to work with either managed threads or raw unmanaged Codex thread ids: ```bash @@ -140,16 +144,15 @@ Use `search` before attaching when you need to find the right existing thread: uv run dispatch search "schema drift" uv run dispatch search "schema drift" --managed uv run dispatch search "schema drift" --unmanaged -uv run dispatch search "schema drift" --lane @my-lane +uv run dispatch search "schema drift" --thread <dispatch-ref> uv run dispatch search "schema drift" --repo . uv run dispatch search "schema drift" --dir /path/to/project uv run dispatch search "schema drift" --since 2026-06-01 --until 2026-06-05 -uv run dispatch lane search @my-lane "schema drift" ``` Broad search uses experimental App Server `thread/search` plus dispatch-side filters. Lane-focused search reads one thread transcript and scans locally. -Sync is separate: `lane sync` refreshes dispatch's local index for a managed +Sync is separate: `sync` refreshes dispatch's local index for a managed lane, but it does not attach unmanaged sessions or grant write authority. ## Message Verbs @@ -171,8 +174,7 @@ the lane is idle. Use `stop` to cancel the active turn without replacement text: ```bash -uv run dispatch stop @my-lane -uv run dispatch stop --lane @my-lane +uv run dispatch stop <dispatch-ref> ``` For short inter-lane chat, use the companion `$dm` skill, which is backed by @@ -180,47 +182,39 @@ For short inter-lane chat, use the companion `$dm` skill, which is backed by ## History, Watch, And Goals -Use `lane get` for compact lane metadata: +Use `get` for compact managed-thread metadata: ```bash -uv run dispatch lane get @my-lane +uv run dispatch get <dispatch-ref> ``` -Use `lane tail` for persisted turn history: +Use `tail` for persisted turn history: ```bash -uv run dispatch lane tail @my-lane --limit 50 +uv run dispatch tail <dispatch-ref> --limit 50 ``` -`lane tail` uses App Server `includeTurns`, which is not available for ephemeral +`tail` uses App Server `includeTurns`, which is not available for ephemeral threads. -Use `lane tail --follow` only for a bounded live event sample. It returns raw App +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: ```bash -uv run dispatch lane tail @my-lane --follow --limit 20 --timeout 10 +uv run dispatch watch <dispatch-ref> --limit 20 --timeout 10 ``` Use native goals on owned lanes when a worker has a durable objective: ```bash uv run dispatch goal set @my-lane "Loop until checks are green." -uv run dispatch goal status @my-lane -uv run dispatch goal clear @my-lane +uv run dispatch goal status <dispatch-ref> +uv run dispatch goal clear <dispatch-ref> ``` Goals require non-ephemeral App Server threads. -Use history controls carefully: - -```bash -uv run dispatch lane fork @my-lane --name my-lane-copy -uv run dispatch lane rollback @my-lane --turns 1 -uv run dispatch lane compact @my-lane -``` - -`rollback` truncates persisted App Server history only; it does not revert files. +`tail --follow` is not canonical; use `watch`. ## Markdown Thread Links @@ -232,8 +226,8 @@ label: @Target destination: codex://threads/<codex-thread-id> ``` -Use raw thread ids for `lane attach`. Use lane ids or `@handles` for dispatch -lane arguments. +Use raw thread ids for `attach`. Use refs or full thread ids for dispatch +thread arguments. ## Triggers @@ -242,7 +236,7 @@ A trigger binds `when -> action -> lane`. ```bash uv run dispatch trigger add \ --name pulse \ - --lane @my-lane \ + --lane <dispatch-ref> \ --when interval \ --seconds 1800 \ --action send \ @@ -291,8 +285,8 @@ plugin bundle under `outfitter.dispatch.assets`; use the repo copies for editing - Do not install launchd autostart unless the user explicitly asks. - Start troubleshooting with `dispatch doctor`; use its recovery hints rather than guessing about stale sockets, PATH, auth, or registry shape. -- Do not describe `lane tail --follow` as streaming forever; it is a bounded - event sample until dispatch grows a subscription-capable control socket. +- Do not describe `tail --follow` as canonical or streaming forever. Use `watch` + for bounded live samples until dispatch grows a subscription-capable control socket. - Do not treat `rollback` as file undo. - If a request becomes long-running owned work, use a proper delegated lane or goal workflow rather than a casual message. diff --git a/skills/dm/SKILL.md b/skills/dm/SKILL.md index a76be11..7d9de80 100644 --- a/skills/dm/SKILL.md +++ b/skills/dm/SKILL.md @@ -21,7 +21,7 @@ Use dispatch first: ```bash uv run dispatch doctor --no-app-server -uv run dispatch lane list +uv run dispatch list uv run dispatch daemon status ``` @@ -29,7 +29,7 @@ If the environment is new, run `uv run dispatch doctor` once before messaging. Fix PATH, Codex auth, stale daemon files, registry, or plugin asset warnings before assuming a DM failure is about the target lane. -The target should be an owned dispatch lane. Attached lanes are not +The target should be an owned dispatch lane selected by dispatch ref when possible. Attached lanes are not turn-writable in v0, so dispatch will reject DM/send verbs against them. If the user wants to message an existing desktop Codex thread, attach/sync it @@ -47,7 +47,7 @@ label: @Target destination: codex://threads/<target-thread-id> ``` -Use the lane handle or lane id for `dispatch send`. Use the URI link in +Use the dispatch ref or full thread id for `dispatch send`. Use the URI link in the message body so the recipient and the human can open the thread directly. ## Message Shape @@ -65,7 +65,7 @@ Contract: read-only, brief answer, reply in this lane unless asked otherwise. Then deliver it: ```bash -uv run dispatch send @Target '<message>' +uv run dispatch send <target-ref> '<message>' ``` Keep DMs conversational and bounded. Prefer one ask. Include only the context @@ -74,16 +74,16 @@ transcript. Do not use `$dm` to smuggle in broad implementation work. ## Harvesting -`dispatch send` starts the target turn. Use `dispatch lane get` and `dispatch daemon log` -to confirm lane state and accepted actions: +`dispatch send` starts the target turn. Use `dispatch get` and `dispatch daemon log` +to confirm thread state and accepted actions: ```bash -uv run dispatch lane get @Target +uv run dispatch get <target-ref> uv run dispatch daemon log --limit 10 ``` -Important v0 limitation: `dispatch lane get` is lane metadata, not a transcript -reader. Use `dispatch lane tail @Target --limit 50` for persisted history when +Important v0 limitation: `dispatch get` is thread metadata, not a transcript +reader. Use `dispatch tail <target-ref> --limit 50` for persisted history when the lane is non-ephemeral, or open the Codex thread link. Do not pretend dispatch harvested text it cannot currently read. diff --git a/src/outfitter/dispatch/contracts/derive_cli.py b/src/outfitter/dispatch/contracts/derive_cli.py index a194601..344c62a 100644 --- a/src/outfitter/dispatch/contracts/derive_cli.py +++ b/src/outfitter/dispatch/contracts/derive_cli.py @@ -35,19 +35,14 @@ class CliRoute: _ROUTES: tuple[CliRoute, ...] = ( CliRoute(("new",), "new"), + CliRoute(("attach",), "attach", ("thread",)), + CliRoute(("get",), "show", ("lane",)), + CliRoute(("tail",), "transcript", ("lane",)), + CliRoute(("watch",), "watch", ("lane",)), + CliRoute(("sync",), "sync", ("lane",)), CliRoute(("rename",), "lane-rename", ("old", "new")), CliRoute(("archive",), "archive", ("target",)), CliRoute(("restore",), "restore", ("target",)), - CliRoute(("lane", "get"), "show", ("lane",)), - CliRoute(("lane", "status"), "show", ("lane",)), - CliRoute(("lane", "attach"), "attach", ("thread",)), - CliRoute(("lane", "sync"), "sync", ("lane",)), - CliRoute(("lane", "rename"), "lane-rename", ("old", "new")), - CliRoute(("lane", "fork"), "fork", ("lane",)), - CliRoute(("lane", "rollback"), "rollback", ("lane",)), - CliRoute(("lane", "compact"), "compact", ("lane",)), - CliRoute(("lane", "archive"), "archive", ("target",)), - CliRoute(("lane", "restore"), "restore", ("target",)), CliRoute(("goal", "status"), "goal-get", ("lane",)), CliRoute(("goal", "clear"), "goal-clear", ("lane",)), CliRoute(("trigger", "add"), "trigger-add"), @@ -74,14 +69,7 @@ def derive_cli( _register_command(app, ("send",), _send_command(registry.get("send"), invoke, renderer)) _register_command(app, ("stop",), _stop_command(registry.get("stop"), invoke, renderer)) _register_command(app, ("search",), _search_command(registry.get("search"), invoke, renderer)) - _register_command(app, ("lane", "list"), _lane_list_command(registry, invoke, renderer), groups) - _register_command( - app, - ("lane", "search"), - _lane_search_command(registry.get("search"), invoke, renderer), - groups, - ) - _register_command(app, ("lane", "tail"), _lane_tail_command(registry, invoke, renderer), groups) + _register_command(app, ("list",), _list_command(registry, invoke, renderer)) _register_command( app, ("trigger", "list"), @@ -182,7 +170,7 @@ def _parameters(op: Op, *, positionals: tuple[str, ...] = ()) -> list[inspect.Pa def _send_command(op: Op, invoke: Invoker, render: Renderer) -> Callable[..., None]: def command( - lane: Annotated[str, typer.Argument(help="Lane id or @handle.")], + lane: Annotated[str, typer.Argument(help="Thread selector.")], text: Annotated[str, typer.Argument(help="Message text.")], mode: Annotated[_SendMode, typer.Option("--mode", help="Delivery mode.")] = "send", steer: Annotated[bool, typer.Option("--steer", help="Steer an active turn.")] = False, @@ -219,8 +207,8 @@ def _flag_modes( def _stop_command(op: Op, invoke: Invoker, render: Renderer) -> Callable[..., None]: def command( - lane_arg: Annotated[str | None, typer.Argument(help="Lane id or @handle.")] = None, - lane: Annotated[str | None, typer.Option("--lane", help="Lane id or @handle.")] = None, + lane_arg: Annotated[str | None, typer.Argument(help="Thread selector.")] = None, + lane: Annotated[str | None, typer.Option("--lane", help="Thread selector.")] = None, json: Annotated[ bool, typer.Option("--json", help="Render machine-readable JSON output.") ] = False, @@ -228,7 +216,9 @@ def command( selected = lane_arg or lane if selected is None or (lane_arg is not None and lane is not None): typer.secho( - "dispatch: provide one lane as an argument or with --lane", fg="red", err=True + "dispatch: provide one thread selector as an argument or with --lane", + fg="red", + err=True, ) raise typer.Exit(code=2) result = invoke(op.id, {"lane": selected}) @@ -243,7 +233,8 @@ def _search_command(op: Op, invoke: Invoker, render: Renderer) -> Callable[..., def command( query: Annotated[str, typer.Argument(help="Substring/full-text query.")], lane: Annotated[ - str | None, typer.Option("--lane", help="Limit to one lane/thread id.") + str | None, + typer.Option("--thread", "--lane", help="Limit to one thread selector."), ] = None, directory: Annotated[ str | None, @@ -252,7 +243,7 @@ def command( repo: Annotated[ str | None, typer.Option("--repo", help="Only include threads under this repo root.") ] = None, - managed: Annotated[bool, typer.Option("--managed", help="Only managed lanes.")] = False, + managed: Annotated[bool, typer.Option("--managed", help="Only managed threads.")] = False, unmanaged: Annotated[ bool, typer.Option("--unmanaged", help="Only unmanaged Codex threads.") ] = False, @@ -305,62 +296,6 @@ def command( return command -def _lane_search_command(op: Op, invoke: Invoker, render: Renderer) -> Callable[..., None]: - def command( - lane: Annotated[str, typer.Argument(help="Lane id, @handle, or raw Codex thread id.")], - query: Annotated[str, typer.Argument(help="Substring/full-text query.")], - directory: Annotated[ - str | None, - typer.Option("--directory", "--dir", help="Only include threads under this directory."), - ] = None, - repo: Annotated[ - str | None, typer.Option("--repo", help="Only include threads under this repo root.") - ] = None, - archived: Annotated[ - bool, typer.Option("--archived", help="Search archived threads.") - ] = False, - since: Annotated[ - str | None, typer.Option("--since", help="Inclusive ISO date/time lower bound.") - ] = None, - until: Annotated[ - str | None, typer.Option("--until", help="Inclusive ISO date/time upper bound.") - ] = None, - date_field: Annotated[ - _SearchSortKey, typer.Option("--date-field", help="Timestamp field for date filters.") - ] = "updated_at", - limit: Annotated[int, typer.Option(help="Max matches to return.")] = 20, - max_scan: Annotated[ - int, typer.Option("--max-scan", help="Max transcript items to scan.") - ] = 200, - json: Annotated[ - bool, typer.Option("--json", help="Render machine-readable JSON output.") - ] = False, - ) -> None: - _invoke_search( - op, - invoke, - render, - query=query, - lane=lane, - directory=directory, - repo=repo, - managed=False, - unmanaged=False, - archived=archived, - since=since, - until=until, - date_field=date_field, - sort="updated_at", - ascending=False, - limit=limit, - max_scan=max_scan, - json=json, - ) - - command.__doc__ = op.summary - return command - - def _invoke_search( op: Op, invoke: Invoker, @@ -405,9 +340,7 @@ def _invoke_search( _ignore_json(json) -def _lane_list_command( - registry: OpRegistry, invoke: Invoker, render: Renderer -) -> Callable[..., None]: +def _list_command(registry: OpRegistry, invoke: Invoker, render: Renderer) -> Callable[..., None]: roster = registry.get("roster") discover = registry.get("discover") @@ -416,7 +349,7 @@ def command( bool, typer.Option("--unmanaged", help="List attachable unmanaged sessions.") ] = False, include_archived: Annotated[ - bool, typer.Option(help="Include archived managed lanes.") + bool, typer.Option(help="Include archived managed threads.") ] = False, limit: Annotated[int, typer.Option(help="Max unmanaged sessions to list.")] = 50, json: Annotated[ @@ -430,43 +363,13 @@ def command( render(op, invoke(op.id, params)) _ignore_json(json) - command.__doc__ = "List managed lanes, or unmanaged discoverable sessions." - return command - - -def _lane_tail_command( - registry: OpRegistry, invoke: Invoker, render: Renderer -) -> Callable[..., None]: - transcript = registry.get("transcript") - watch = registry.get("watch") - - def command( - lane: Annotated[str, typer.Argument(help="Lane id or @handle.")], - follow: Annotated[ - bool, typer.Option("--follow", help="Collect a bounded live event sample.") - ] = False, - limit: Annotated[int, typer.Option(help="Max transcript items or events to return.")] = 50, - timeout: Annotated[ - float, typer.Option(help="Seconds to wait when --follow is used.") - ] = 10.0, - json: Annotated[ - bool, typer.Option("--json", help="Render machine-readable JSON output.") - ] = False, - ) -> None: - op = watch if follow else transcript - params: dict[str, object] = {"lane": lane, "limit": limit} - if follow: - params["timeout"] = timeout - render(op, invoke(op.id, params)) - _ignore_json(json) - - command.__doc__ = "Read transcript history, or a bounded live event sample with --follow." + command.__doc__ = "List managed threads, or unmanaged discoverable sessions." return command def _goal_set_command(op: Op, invoke: Invoker, render: Renderer) -> Callable[..., None]: def command( - lane: Annotated[str, typer.Argument(help="Lane id or @handle.")], + lane: Annotated[str, typer.Argument(help="Thread selector.")], objective: Annotated[str | None, typer.Argument(help="Goal objective text.")] = None, status: Annotated[str | None, typer.Option("--status", help="Goal status.")] = None, token_budget: Annotated[ @@ -524,24 +427,17 @@ def command( def _schema_op_id(command: str) -> str: aliases = { "stop": "stop", + "list": "roster", + "list-unmanaged": "discover", + "attach": "attach", + "get": "show", + "tail": "transcript", + "watch": "watch", + "sync": "sync", "search": "search", "rename": "lane-rename", "archive": "archive", "restore": "restore", - "lane-get": "show", - "lane-status": "show", - "lane-list": "roster", - "lane-list-unmanaged": "discover", - "lane-attach": "attach", - "lane-sync": "sync", - "lane-rename": "lane-rename", - "lane-search": "search", - "lane-fork": "fork", - "lane-rollback": "rollback", - "lane-compact": "compact", - "lane-archive": "archive", - "lane-restore": "restore", - "lane-tail": "transcript", "goal-status": "goal-get", "goal-set": "goal-set", "goal-clear": "goal-clear", @@ -555,10 +451,10 @@ def _schema_op_id(command: str) -> str: flags = {part for part in parts if part.startswith("--")} words = [part for part in parts if not part.startswith("--")] normalized = "-".join(words) if words else command.strip().replace(" ", "-") - if normalized == "lane-list" and "--unmanaged" in flags: + if normalized == "tail" and "--follow" in flags: + return "__unknown_tail_follow__" + if normalized == "list" and "--unmanaged" in flags: return "discover" - if normalized == "lane-tail" and "--follow" in flags: - return "watch" return aliases.get(normalized, normalized) diff --git a/src/outfitter/dispatch/contracts/derive_mcp.py b/src/outfitter/dispatch/contracts/derive_mcp.py index fac3acd..f9ba328 100644 --- a/src/outfitter/dispatch/contracts/derive_mcp.py +++ b/src/outfitter/dispatch/contracts/derive_mcp.py @@ -42,8 +42,8 @@ class _ToolGroup: _GROUPS: tuple[_ToolGroup, ...] = ( _ToolGroup( - name="dispatch_lane_read", - summary="Read lane and session state.", + name="dispatch_thread_read", + summary="Read managed and unmanaged thread state.", intent="read", actions=( ("roster", "roster"), @@ -56,8 +56,8 @@ class _ToolGroup: ), ), _ToolGroup( - name="dispatch_lane_write", - summary="Create lanes and send messages to owned lanes.", + name="dispatch_thread_write", + summary="Create, attach, sync, rename, and send to managed threads.", intent="write", actions=( ("open", "open"), @@ -74,8 +74,8 @@ class _ToolGroup: ), ), _ToolGroup( - name="dispatch_lane_destroy", - summary="Destroy or archive lane state.", + name="dispatch_thread_destroy", + summary="Archive or destroy managed thread state.", intent="destroy", actions=(("archive", "archive"), ("goal_clear", "goal-clear"), ("rollback", "rollback")), ), diff --git a/src/outfitter/dispatch/core/handlers.py b/src/outfitter/dispatch/core/handlers.py index 05af59b..6c19ac8 100644 --- a/src/outfitter/dispatch/core/handlers.py +++ b/src/outfitter/dispatch/core/handlers.py @@ -11,7 +11,7 @@ import asyncio from datetime import datetime, time from pathlib import Path -from typing import cast +from typing import TypedDict, cast from pydantic import ValidationError as PydanticValidationError @@ -33,7 +33,7 @@ ValidationError, project_error, ) -from outfitter.dispatch.registry.models import Lane, LaneStatus, LaneSync, SyncState +from outfitter.dispatch.registry.models import Lane, LaneSource, LaneStatus, LaneSync, SyncState from . import queue from .models import ( @@ -84,6 +84,7 @@ WatchOutput, ) from .new_config import NewSettings, resolve_new +from .selectors import resolve_managed_selector, resolve_thread_selector from .sync import scan_codex_jsonl _READ_ONLY = SandboxPolicy(type="readOnly") @@ -95,8 +96,27 @@ _PREVIEW_MAX = 80 +class _ManagedIdentityPayload(TypedDict): + lane: str + ref: str + id: str + title: str | None + handle: str | None + managed: bool + source: LaneSource + status: LaneStatus + cwd: str | None + + def _ref(lane: Lane) -> LaneRef: - return LaneRef(id=lane.id, handle=lane.handle, source=lane.source, status=lane.status) + return LaneRef( + ref=lane.ref, + id=lane.id, + handle=lane.handle, + source=lane.source, + status=lane.status, + cwd=lane.cwd, + ) def _action_ref( @@ -108,14 +128,30 @@ def _action_ref( if lane is None: return ThreadActionRef(id=thread_id, managed=False, source="unmanaged", status=status) return ThreadActionRef( + ref=lane.ref, id=lane.id, handle=lane.handle, managed=True, source=lane.source, status=status or lane.status, + cwd=lane.cwd, ) +def _managed_identity(lane: Lane) -> _ManagedIdentityPayload: + return { + "lane": lane.id, + "ref": lane.ref, + "id": lane.id, + "title": lane.handle.removeprefix("@"), + "handle": lane.handle, + "managed": True, + "source": lane.source, + "status": lane.status, + "cwd": lane.cwd, + } + + def _sync_view(sync: LaneSync | None) -> LaneSyncView: if sync is None: return LaneSyncView() @@ -140,26 +176,22 @@ def _handle(name: str) -> str: async def _resolve(ctx: Ctx, ref: str) -> Lane: - lane = await _find_lane(ctx, ref) - if lane is None: - raise NotFoundError(f"no lane {ref!r}") - return lane + resolved = await resolve_managed_selector(ctx, ref, allow_fuzzy=False) + if resolved.lane is None: + raise NotFoundError(f"no managed thread {ref!r}") + return resolved.lane async def _find_lane(ctx: Ctx, ref: str) -> Lane | None: - lane = await ctx.registry.find_lane(ref) - if lane is None: - lane = await ctx.registry.find_lane_by_handle(ref) - return lane + try: + return (await resolve_managed_selector(ctx, ref, allow_fuzzy=False)).lane + except NotFoundError: + return None async def _resolve_thread_target(ctx: Ctx, ref: str) -> tuple[str, Lane | None]: - lane = await _find_lane(ctx, ref) - if lane is not None: - return lane.id, lane - if ref.startswith("@"): - raise NotFoundError(f"no lane {ref!r}") - return ref, None + resolved = await resolve_thread_selector(ctx, ref, allow_unmanaged_raw=True, allow_fuzzy=False) + return resolved.thread_id, resolved.lane def _require_writable(lane: Lane) -> None: @@ -417,7 +449,7 @@ async def send(inp: LaneTextInput, ctx: Ctx) -> ActionAck: await ctx.client.turn_start(lane.id, inp.text, cwd=lane.cwd or ".", sandbox_policy=_READ_ONLY) await ctx.registry.update_lane_status(lane.id, "busy") await ctx.registry.log_action("send", lane=lane.id, detail=inp.text[:120]) - return ActionAck(lane=lane.id, op="send") + return ActionAck(**_managed_identity(lane), op="send") async def send_message(inp: SendInput, ctx: Ctx) -> ActionAck: @@ -439,7 +471,7 @@ async def send_message(inp: SendInput, ctx: Ctx) -> ActionAck: ) await ctx.registry.update_lane_status(lane.id, "busy") await ctx.registry.log_action("send", lane=lane.id, detail=inp.text[:120]) - return ActionAck(lane=lane.id, op="interject") + return ActionAck(**_managed_identity(lane), op="interject") case "queue": lane = await _resolve(ctx, inp.lane) _require_writable(lane) @@ -449,7 +481,7 @@ async def send_message(inp: SendInput, ctx: Ctx) -> ActionAck: await queue.drain_next_queued_message(ctx, lane.id) pending = await ctx.registry.pending_message_count(lane.id) return ActionAck( - lane=lane.id, + **_managed_identity(lane), op="queue", detail=f"queued message {message.id}; pending={pending}", ) @@ -461,7 +493,7 @@ async def steer(inp: LaneTextInput, ctx: Ctx) -> ActionAck: turn_id = _require_active_turn(lane, "steer") await ctx.client.turn_steer(lane.id, turn_id, inp.text) await ctx.registry.log_action("steer", lane=lane.id, detail=inp.text[:120]) - return ActionAck(lane=lane.id, op="steer") + return ActionAck(**_managed_identity(lane), op="steer") async def brief(inp: LaneTextInput, ctx: Ctx) -> ActionAck: @@ -474,7 +506,7 @@ async def brief(inp: LaneTextInput, ctx: Ctx) -> ActionAck: } await ctx.client.inject_items(lane.id, [item]) await ctx.registry.log_action("brief", lane=lane.id, detail=inp.text[:120]) - return ActionAck(lane=lane.id, op="brief") + return ActionAck(**_managed_identity(lane), op="brief") async def interrupt(inp: LaneInput, ctx: Ctx) -> ActionAck: @@ -483,7 +515,7 @@ async def interrupt(inp: LaneInput, ctx: Ctx) -> ActionAck: turn_id = _require_active_turn(lane, "interrupt") await ctx.client.turn_interrupt(lane.id, turn_id) await ctx.registry.log_action("interrupt", lane=lane.id) - return ActionAck(lane=lane.id, op="interrupt") + return ActionAck(**_managed_identity(lane), op="interrupt") async def stop(inp: LaneInput, ctx: Ctx) -> ActionAck: @@ -492,11 +524,14 @@ async def stop(inp: LaneInput, ctx: Ctx) -> ActionAck: turn_id = _require_active_turn(lane, "stop") await ctx.client.turn_interrupt(lane.id, turn_id) await ctx.registry.log_action("stop", lane=lane.id) - return ActionAck(lane=lane.id, op="stop") + return ActionAck(**_managed_identity(lane), op="stop") async def show(inp: ShowInput, ctx: Ctx) -> LaneDetail: - lane = await _resolve(ctx, inp.lane) + resolved = await resolve_managed_selector(ctx, inp.lane, allow_fuzzy=True) + if resolved.lane is None: + raise NotFoundError(f"no managed thread {inp.lane!r}") + lane = resolved.lane sync = await ctx.registry.get_lane_sync(lane.id) transcript: list[TranscriptItem] = [] if inp.include_transcript: @@ -504,6 +539,7 @@ async def show(inp: ShowInput, ctx: Ctx) -> LaneDetail: transcript = _transcript_from_thread(result, limit=inp.max_items) return LaneDetail( id=lane.id, + ref=lane.ref, handle=lane.handle, source=lane.source, status=lane.status, @@ -520,7 +556,7 @@ async def sync_lane(inp: LaneSyncInput, ctx: Ctx) -> LaneSyncResult: await ctx.registry.log_action( "sync", lane=lane.id, detail=f"state={sync.state}; full={inp.full}" ) - return LaneSyncResult(lane=lane.id, sync=_sync_view(sync)) + return LaneSyncResult(**_managed_identity(lane), sync=_sync_view(sync)) async def rename_lane(inp: LaneRenameInput, ctx: Ctx) -> ThreadActionRef: @@ -541,9 +577,12 @@ async def rename_lane(inp: LaneRenameInput, ctx: Ctx) -> ThreadActionRef: async def watch(inp: WatchInput, ctx: Ctx) -> WatchOutput: - lane = await _resolve(ctx, inp.lane) + resolved = await resolve_managed_selector(ctx, inp.lane, allow_fuzzy=True) + if resolved.lane is None: + raise NotFoundError(f"no managed thread {inp.lane!r}") + lane = resolved.lane if inp.timeout == 0: - return WatchOutput(lane=lane.id, events=[], timed_out=True) + return WatchOutput(**_managed_identity(lane), events=[], timed_out=True) stream = ctx.client.raw_events(lane.id) events: list[WatchEvent] = [] timed_out = False @@ -569,14 +608,17 @@ async def watch(inp: WatchInput, ctx: Ctx) -> WatchOutput: aclose = getattr(stream, "aclose", None) if aclose is not None: await aclose() - return WatchOutput(lane=lane.id, events=events, timed_out=timed_out) + return WatchOutput(**_managed_identity(lane), events=events, timed_out=timed_out) async def transcript(inp: TranscriptInput, ctx: Ctx) -> TranscriptOutput: - lane = await _resolve(ctx, inp.lane) + resolved = await resolve_managed_selector(ctx, inp.lane, allow_fuzzy=True) + if resolved.lane is None: + raise NotFoundError(f"no managed thread {inp.lane!r}") + lane = resolved.lane result = await ctx.client.thread_read(lane.id, include_turns=True) return TranscriptOutput( - lane=lane.id, + **_managed_identity(lane), items=_transcript_from_thread(result, limit=inp.limit), ) @@ -695,7 +737,10 @@ async def _search_one_thread( until: float | None, ) -> SearchOutput: assert inp.lane is not None - thread_id, _lane = await _resolve_thread_target(ctx, inp.lane) + resolved = await resolve_thread_selector( + ctx, inp.lane, allow_unmanaged_raw=True, allow_fuzzy=True + ) + thread_id = resolved.thread_id result = await ctx.client.thread_read(thread_id, include_turns=True) try: thread = ThreadResult.model_validate(result).thread @@ -804,6 +849,7 @@ def _search_match( source = lane.source if lane is not None else "unmanaged" return SearchMatch( id=thread.id, + ref=lane.ref if lane is not None else None, handle=lane.handle if lane is not None else None, managed=lane is not None, source=source, @@ -887,9 +933,12 @@ def _goal(goal: ThreadGoal) -> Goal: async def goal_get(inp: GoalGetInput, ctx: Ctx) -> GoalView: - lane = await _resolve(ctx, inp.lane) + resolved = await resolve_managed_selector(ctx, inp.lane, allow_fuzzy=True) + if resolved.lane is None: + raise NotFoundError(f"no managed thread {inp.lane!r}") + lane = resolved.lane goal = await ctx.client.thread_goal_get(lane.id) - return GoalView(lane=lane.id, goal=_goal(goal) if goal is not None else None) + return GoalView(**_managed_identity(lane), goal=_goal(goal) if goal is not None else None) async def goal_set(inp: GoalSetInput, ctx: Ctx) -> GoalView: @@ -909,7 +958,7 @@ async def goal_set(inp: GoalSetInput, ctx: Ctx) -> GoalView: token_budget=inp.token_budget, ) await ctx.registry.log_action("goal-set", lane=lane.id, detail=inp.objective) - return GoalView(lane=lane.id, goal=_goal(goal)) + return GoalView(**_managed_identity(lane), goal=_goal(goal)) async def goal_clear(inp: GoalClearInput, ctx: Ctx) -> GoalView: @@ -917,7 +966,7 @@ async def goal_clear(inp: GoalClearInput, ctx: Ctx) -> GoalView: _require_writable(lane) await ctx.client.thread_goal_clear(lane.id) await ctx.registry.log_action("goal-clear", lane=lane.id) - return GoalView(lane=lane.id, goal=None) + return GoalView(**_managed_identity(lane), goal=None) async def fork(inp: ForkInput, ctx: Ctx) -> LaneRef: @@ -967,7 +1016,7 @@ async def compact(inp: CompactInput, ctx: Ctx) -> ActionAck: _require_writable(lane) await ctx.client.thread_compact_start(lane.id) await ctx.registry.log_action("compact", lane=lane.id) - return ActionAck(lane=lane.id, op="compact") + return ActionAck(**_managed_identity(lane), op="compact") async def roster(inp: RosterInput, ctx: Ctx) -> Roster: diff --git a/src/outfitter/dispatch/core/models.py b/src/outfitter/dispatch/core/models.py index 24cbabb..21dc5a1 100644 --- a/src/outfitter/dispatch/core/models.py +++ b/src/outfitter/dispatch/core/models.py @@ -24,15 +24,27 @@ ThreadActionSource = Literal["own", "attached", "unmanaged"] SearchSortKey = Literal["created_at", "updated_at"] SearchDateField = Literal["created_at", "updated_at"] +THREAD_SELECTOR_DESCRIPTION = ( + "Thread selector: dispatch ref, full Codex thread id, or unique handle/title." +) +EXISTING_THREAD_SELECTOR_DESCRIPTION = ( + "Existing thread selector: dispatch ref, full Codex thread id, or unique handle/title." +) +SOURCE_THREAD_SELECTOR_DESCRIPTION = ( + "Source thread selector: dispatch ref, full Codex thread id, or unique handle/title." +) +TARGET_THREAD_SELECTOR_DESCRIPTION = ( + "Target thread selector: dispatch ref, full Codex thread id, or unique handle/title." +) class OpenInput(BaseModel): - name: str = Field(description="Lane name; becomes the @handle.") - cwd: str = Field(default=".", description="Working directory for the lane.") + name: str = Field(description="Thread title; also seeds the mutable @handle.") + cwd: str = Field(default=".", description="Working directory for the managed thread.") class NewInput(BaseModel): - name: str = Field(description="Lane name; prefix/presets may decorate it.") + name: str = Field(description="Thread title; prefix/presets may decorate it.") preset: list[str] = Field( default_factory=list, description="Preset(s) to apply, left to right." ) @@ -61,12 +73,12 @@ class NewInput(BaseModel): class AttachInput(BaseModel): - thread: str = Field(description="App Server threadId of an existing (desktop) lane.") + thread: str = Field(description="Full Codex thread id of an existing thread.") sync: bool = Field(default=False, description="Run a quick sync after attaching.") class LaneTextInput(BaseModel): - lane: str = Field(description="Lane id or @handle.") + lane: str = Field(description=THREAD_SELECTOR_DESCRIPTION) text: str = Field(description="Message text.") @@ -83,35 +95,33 @@ class SendInput(LaneTextInput): class LaneInput(BaseModel): - lane: str = Field(description="Lane id or @handle.") + lane: str = Field(description=THREAD_SELECTOR_DESCRIPTION) class ThreadTargetInput(BaseModel): - target: str = Field(description="Lane id, @handle, or raw Codex thread id.") + target: str = Field(description=THREAD_SELECTOR_DESCRIPTION) class LaneSyncInput(BaseModel): - lane: str = Field(description="Lane id or @handle.") + lane: str = Field(description=THREAD_SELECTOR_DESCRIPTION) full: bool = Field(default=False, description="Scan the full source file instead of top+tail.") class LaneRenameInput(BaseModel): - old: str = Field(description="Existing lane id, @handle, or raw Codex thread id.") - new: str = Field(description="New lane handle/thread name; @ is optional for managed lanes.") + old: str = Field(description=EXISTING_THREAD_SELECTOR_DESCRIPTION) + new: str = Field(description="New mutable thread title/handle; @ is optional.") class SearchInput(BaseModel): query: str = Field(description="Substring/full-text query for Codex thread search.") - lane: str | None = Field( - default=None, description="Limit search to one lane id, @handle, or raw Codex thread id." - ) + lane: str | None = Field(default=None, description="Limit search to one thread selector.") directory: str | None = Field( default=None, description="Only include threads whose cwd is inside this directory." ) repo: str | None = Field( default=None, description="Only include threads whose cwd is inside this repo root." ) - managed: bool = Field(default=False, description="Only include dispatch-managed lanes.") + managed: bool = Field(default=False, description="Only include dispatch-managed threads.") unmanaged: bool = Field(default=False, description="Only include unmanaged Codex threads.") archived: bool = Field( default=False, description="Search archived threads instead of active ones." @@ -132,7 +142,7 @@ class SearchInput(BaseModel): class ShowInput(BaseModel): - lane: str = Field(description="Lane id or @handle.") + lane: str = Field(description=THREAD_SELECTOR_DESCRIPTION) include_transcript: bool = Field( default=False, description="Include a compact transcript from thread/read." ) @@ -140,7 +150,7 @@ class ShowInput(BaseModel): class WatchInput(BaseModel): - lane: str = Field(description="Lane id or @handle.") + lane: str = Field(description=THREAD_SELECTOR_DESCRIPTION) limit: int = Field(default=20, ge=1, description="Max live App Server events to collect.") timeout: float = Field( default=10.0, @@ -151,27 +161,27 @@ class WatchInput(BaseModel): class TranscriptInput(BaseModel): - lane: str = Field(description="Lane id or @handle.") + lane: str = Field(description=THREAD_SELECTOR_DESCRIPTION) limit: int = Field(default=50, ge=1, description="Max compact transcript items to return.") class GoalGetInput(BaseModel): - lane: str = Field(description="Lane id or @handle.") + lane: str = Field(description=THREAD_SELECTOR_DESCRIPTION) class GoalSetInput(BaseModel): - lane: str = Field(description="Lane id or @handle.") + lane: str = Field(description=THREAD_SELECTOR_DESCRIPTION) objective: str | None = Field(default=None, description="Goal objective text.") status: ThreadGoalStatus | None = Field(default=None, description="Goal status.") token_budget: int | None = Field(default=None, ge=1, description="Optional token budget.") class GoalClearInput(BaseModel): - lane: str = Field(description="Lane id or @handle.") + lane: str = Field(description=THREAD_SELECTOR_DESCRIPTION) class ForkInput(BaseModel): - lane: str = Field(description="Source lane id or @handle.") + lane: str = Field(description=SOURCE_THREAD_SELECTOR_DESCRIPTION) name: str = Field(description="Name for the new forked lane.") cwd: str | None = Field(default=None, description="Working directory override for the fork.") sandbox: ThreadSandbox | None = Field( @@ -194,12 +204,12 @@ class ForkInput(BaseModel): class RollbackInput(BaseModel): - lane: str = Field(description="Lane id or @handle.") + lane: str = Field(description=THREAD_SELECTOR_DESCRIPTION) turns: int = Field(default=1, ge=1, description="Turns to drop from the end of history.") class CompactInput(BaseModel): - lane: str = Field(description="Lane id or @handle.") + lane: str = Field(description=THREAD_SELECTOR_DESCRIPTION) class RosterInput(BaseModel): @@ -214,18 +224,40 @@ class DiscoverInput(BaseModel): class LaneRef(BaseModel): + ref: str id: str handle: str source: LaneSource status: LaneStatus + cwd: str | None = None class ThreadActionRef(BaseModel): + ref: str | None = None id: str handle: str | None = None managed: bool source: ThreadActionSource status: str | None = None + cwd: str | None = None + + +class ManagedThreadIdentity(BaseModel): + """Stable identity fields for outputs that refer to one managed thread. + + ``lane`` stays as the compatibility field for the full Codex thread id. + ``id`` names the same full id explicitly for thread-oriented consumers. + """ + + lane: str + ref: str + id: str + title: str | None = None + handle: str | None = None + managed: bool = True + source: LaneSource + status: LaneStatus + cwd: str | None = None class LaneSyncView(BaseModel): @@ -248,7 +280,6 @@ class NewLane(LaneRef): class LaneDetail(LaneRef): - cwd: str | None = None active_turn_id: str | None = None sync: LaneSyncView = Field(default_factory=LaneSyncView) transcript: list[TranscriptItem] = Field(default_factory=list) @@ -267,14 +298,12 @@ class WatchEvent(BaseModel): request_id: int | None = None -class WatchOutput(BaseModel): - lane: str +class WatchOutput(ManagedThreadIdentity): events: list[WatchEvent] timed_out: bool -class TranscriptOutput(BaseModel): - lane: str +class TranscriptOutput(ManagedThreadIdentity): items: list[TranscriptItem] @@ -289,20 +318,17 @@ class Goal(BaseModel): token_budget: int | None = None -class GoalView(BaseModel): - lane: str +class GoalView(ManagedThreadIdentity): goal: Goal | None = None -class ActionAck(BaseModel): - lane: str +class ActionAck(ManagedThreadIdentity): op: str accepted: bool = True detail: str | None = None -class LaneSyncResult(BaseModel): - lane: str +class LaneSyncResult(ManagedThreadIdentity): sync: LaneSyncView @@ -328,6 +354,7 @@ class Discovery(BaseModel): class SearchMatch(BaseModel): + ref: str | None = None id: str handle: str | None = None managed: bool @@ -357,7 +384,7 @@ class SearchOutput(BaseModel): class TriggerAddInput(BaseModel): name: str = Field(description="Trigger name.") - lane: str = Field(description="Target lane id or @handle.") + lane: str = Field(description=TARGET_THREAD_SELECTOR_DESCRIPTION) when: TriggerWhenKind = Field(description="Trigger condition.") action: TriggerActionKind = Field(description="Action to run when it fires.") text: str = Field(description="Action text (prompt or injected context).") diff --git a/src/outfitter/dispatch/core/ops.py b/src/outfitter/dispatch/core/ops.py index a79d4c8..2c5df33 100644 --- a/src/outfitter/dispatch/core/ops.py +++ b/src/outfitter/dispatch/core/ops.py @@ -57,7 +57,7 @@ OPEN = define_op( id="open", - summary="Open a new owned lane.", + summary="Primitive: start and register a new owned managed thread.", input=OpenInput, output=LaneRef, intent="write", @@ -67,7 +67,14 @@ Example( "alpha", input={"name": "alpha", "cwd": "."}, - output={"id": "lane-1", "handle": "@alpha", "source": "own", "status": "idle"}, + output={ + "ref": "0BGeK1", + "id": "lane-1", + "handle": "@alpha", + "source": "own", + "status": "idle", + "cwd": ".", + }, ) ], ) @@ -83,12 +90,14 @@ examples=[ Example( "idle", - input={"name": "alpha", "send": False}, + input={"name": "alpha", "cwd": "/work", "prefix": "[dispatch]", "send": False}, output={ + "ref": "0BGeK1", "id": "lane-1", "handle": "@[dispatch] alpha", "source": "own", "status": "idle", + "cwd": "/work", "sent": False, }, ) @@ -97,7 +106,7 @@ ATTACH = define_op( id="attach", - summary="Attach to an existing lane (turn-write locked; ADR-0005).", + summary="Attach to an existing Codex thread (turn-write locked; ADR-0005).", input=AttachInput, output=LaneRef, intent="write", @@ -107,14 +116,21 @@ Example( "resume", input={"thread": "T1"}, - output={"id": "T1", "handle": "@T1", "source": "attached", "status": "idle"}, + output={ + "ref": "0ABFs1", + "id": "T1", + "handle": "@T1", + "source": "attached", + "status": "idle", + "cwd": None, + }, ) ], ) SEND = define_op( id="send", - summary="Send, steer, interject, queue, or inject context into a lane.", + summary="Send, steer, interject, queue, or inject context into a managed thread.", input=SendInput, output=ActionAck, intent="write", @@ -127,7 +143,7 @@ STOP = define_op( id="stop", - summary="Cancel a lane's active turn.", + summary="Cancel a managed thread's active turn.", input=LaneInput, output=ActionAck, intent="write", @@ -138,7 +154,7 @@ SHOW = define_op( id="show", - summary="Show a lane's current detail.", + summary="Show a managed thread's current detail.", input=ShowInput, output=LaneDetail, intent="read", @@ -149,7 +165,7 @@ LANE_RENAME = define_op( id="lane-rename", - summary="Rename a lane handle or Codex thread.", + summary="Rename a managed thread label or unmanaged Codex thread.", input=LaneRenameInput, output=ThreadActionRef, intent="write", @@ -160,11 +176,13 @@ "unmanaged", input={"old": "T1", "new": "docs"}, output={ + "ref": None, "id": "T1", "handle": None, "managed": False, "source": "unmanaged", "status": None, + "cwd": None, }, ) ], @@ -172,7 +190,7 @@ TRANSCRIPT = define_op( id="transcript", - summary="Read a compact persisted transcript for a lane.", + summary="Read compact persisted history for a managed thread.", input=TranscriptInput, output=TranscriptOutput, intent="read", @@ -183,7 +201,7 @@ WATCH = define_op( id="watch", - summary="Collect live raw App Server events for a lane.", + summary="Collect a bounded live App Server event sample for a managed thread.", input=WatchInput, output=WatchOutput, intent="read", @@ -194,7 +212,7 @@ SYNC = define_op( id="sync", - summary="Progressively sync an attached lane's local Codex thread index.", + summary="Refresh a managed thread's local dispatch index.", input=LaneSyncInput, output=LaneSyncResult, intent="write", @@ -205,7 +223,7 @@ ROSTER = define_op( id="roster", - summary="List managed lanes.", + summary="List managed threads.", input=RosterInput, output=Roster, intent="read", @@ -250,7 +268,7 @@ ARCHIVE = define_op( id="archive", - summary="Archive a lane or unmanaged Codex thread.", + summary="Archive a managed or unmanaged Codex thread.", input=ThreadTargetInput, output=ThreadActionRef, intent="destroy", @@ -261,11 +279,13 @@ "unmanaged", input={"target": "T1"}, output={ + "ref": None, "id": "T1", "handle": None, "managed": False, "source": "unmanaged", "status": "archived", + "cwd": None, }, ) ], @@ -273,7 +293,7 @@ RESTORE = define_op( id="restore", - summary="Restore an archived lane or unmanaged Codex thread.", + summary="Restore an archived managed or unmanaged Codex thread.", input=ThreadTargetInput, output=ThreadActionRef, intent="write", @@ -284,11 +304,13 @@ "unmanaged", input={"target": "T1"}, output={ + "ref": None, "id": "T1", "handle": None, "managed": False, "source": "unmanaged", "status": "unknown", + "cwd": None, }, ) ], @@ -296,7 +318,7 @@ GOAL_GET = define_op( id="goal-get", - summary="Read a lane's native App Server goal.", + summary="Read a managed thread's native App Server goal.", input=GoalGetInput, output=GoalView, intent="read", @@ -307,7 +329,7 @@ GOAL_SET = define_op( id="goal-set", - summary="Set or update a lane's native App Server goal.", + summary="Set or update a managed thread's native App Server goal.", input=GoalSetInput, output=GoalView, intent="write", @@ -320,7 +342,7 @@ GOAL_CLEAR = define_op( id="goal-clear", - summary="Clear a lane's native App Server goal.", + summary="Clear a managed thread's native App Server goal.", input=GoalClearInput, output=GoalView, intent="destroy", @@ -331,7 +353,7 @@ FORK = define_op( id="fork", - summary="Fork a lane into a new owned lane.", + summary="Fork a managed thread into a new owned managed thread.", input=ForkInput, output=LaneRef, intent="write", @@ -342,7 +364,7 @@ ROLLBACK = define_op( id="rollback", - summary="Rollback persisted lane history; does not revert workspace files.", + summary="Rollback persisted managed-thread history; does not revert workspace files.", input=RollbackInput, output=LaneRef, intent="destroy", @@ -353,7 +375,7 @@ COMPACT = define_op( id="compact", - summary="Start App Server context compaction for a lane.", + summary="Start App Server context compaction for a managed thread.", input=CompactInput, output=ActionAck, intent="write", diff --git a/src/outfitter/dispatch/core/selectors.py b/src/outfitter/dispatch/core/selectors.py new file mode 100644 index 0000000..3accaac --- /dev/null +++ b/src/outfitter/dispatch/core/selectors.py @@ -0,0 +1,113 @@ +"""Shared selector resolution for managed and unmanaged Codex threads.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Literal + +from outfitter.dispatch.contracts.context import Ctx +from outfitter.dispatch.contracts.errors import NotFoundError, ValidationError +from outfitter.dispatch.registry.models import Lane + +SelectorKind = Literal["ref", "thread_id", "lane_id", "handle", "title", "fuzzy_title", "raw"] + + +@dataclass(frozen=True) +class ResolvedTarget: + selector: str + kind: SelectorKind + thread_id: str + lane: Lane | None = None + + @property + def ref(self) -> str | None: + return self.lane.ref if self.lane is not None else None + + @property + def title(self) -> str | None: + return self.lane.handle if self.lane is not None else None + + @property + def managed(self) -> bool: + return self.lane is not None + + +async def resolve_managed_selector( + ctx: Ctx, selector: str, *, allow_fuzzy: bool = False +) -> ResolvedTarget: + resolved = await resolve_thread_selector( + ctx, + selector, + allow_unmanaged_raw=False, + allow_fuzzy=allow_fuzzy, + ) + if resolved.lane is None: + raise NotFoundError(f"no managed thread {selector!r}") + return resolved + + +async def resolve_thread_selector( + ctx: Ctx, + selector: str, + *, + allow_unmanaged_raw: bool, + allow_fuzzy: bool = False, +) -> ResolvedTarget: + lane = await ctx.registry.find_lane_by_ref(selector) + if lane is not None: + return ResolvedTarget(selector=selector, kind="ref", thread_id=lane.id, lane=lane) + + lane = await ctx.registry.find_lane(selector) + if lane is not None: + return ResolvedTarget(selector=selector, kind="thread_id", thread_id=lane.id, lane=lane) + + handle_matches = await ctx.registry.find_lanes_by_handle(selector) + if len(handle_matches) == 1: + lane = handle_matches[0] + return ResolvedTarget(selector=selector, kind="handle", thread_id=lane.id, lane=lane) + if len(handle_matches) > 1: + _raise_ambiguous(selector, handle_matches) + + title_matches = _unique_lanes(await ctx.registry.find_lanes_by_title(selector)) + if len(title_matches) == 1: + lane = title_matches[0] + return ResolvedTarget(selector=selector, kind="title", thread_id=lane.id, lane=lane) + if len(title_matches) > 1: + _raise_ambiguous(selector, title_matches) + + if allow_fuzzy: + fuzzy_matches = _unique_lanes(await ctx.registry.fuzzy_find_lanes_by_title(selector)) + if len(fuzzy_matches) == 1: + lane = fuzzy_matches[0] + return ResolvedTarget( + selector=selector, kind="fuzzy_title", thread_id=lane.id, lane=lane + ) + if len(fuzzy_matches) > 1: + _raise_ambiguous(selector, fuzzy_matches) + + if selector.startswith("@"): + raise NotFoundError(f"no managed thread {selector!r}") + if allow_unmanaged_raw: + return ResolvedTarget(selector=selector, kind="raw", thread_id=selector) + raise NotFoundError(f"no managed thread {selector!r}") + + +def _unique_lanes(lanes: list[Lane]) -> list[Lane]: + by_id: dict[str, Lane] = {} + for lane in lanes: + by_id.setdefault(lane.id, lane) + return list(by_id.values()) + + +def _raise_ambiguous(selector: str, lanes: list[Lane]) -> None: + candidates = [ + { + "ref": lane.ref, + "id_prefix": lane.id[:8], + "title": lane.handle, + "managed": True, + "cwd": lane.cwd, + } + for lane in lanes + ] + raise ValidationError(f"ambiguous selector {selector!r}; candidates={candidates!r}") diff --git a/src/outfitter/dispatch/core/triggers.py b/src/outfitter/dispatch/core/triggers.py index 1dca166..c325c6b 100644 --- a/src/outfitter/dispatch/core/triggers.py +++ b/src/outfitter/dispatch/core/triggers.py @@ -13,7 +13,7 @@ from outfitter.dispatch.client.errors import ClientError from outfitter.dispatch.contracts.context import Ctx -from outfitter.dispatch.contracts.errors import DispatchError, project_error +from outfitter.dispatch.contracts.errors import DispatchError, NotFoundError, project_error from outfitter.dispatch.registry.models import ( Action, BriefAction, @@ -25,6 +25,7 @@ from . import handlers from .models import LaneTextInput +from .selectors import resolve_managed_selector Clock = Callable[[], datetime] @@ -38,11 +39,11 @@ def action_op(action: Action) -> str: async def resolve_lane(ctx: Ctx, selector: str) -> Lane | None: - """Resolve a lane selector (id or @handle) to a lane, or None.""" - lane = await ctx.registry.find_lane(selector) - if lane is None: - lane = await ctx.registry.find_lane_by_handle(selector) - return lane + """Resolve a managed thread selector to a lane, or None.""" + try: + return (await resolve_managed_selector(ctx, selector, allow_fuzzy=False)).lane + except NotFoundError: + return None class TriggerRunner: diff --git a/src/outfitter/dispatch/registry/models.py b/src/outfitter/dispatch/registry/models.py index 2f19f0d..80ce5d6 100644 --- a/src/outfitter/dispatch/registry/models.py +++ b/src/outfitter/dispatch/registry/models.py @@ -17,6 +17,10 @@ class Lane(BaseModel): """A managed Codex thread — one row of the ``lanes`` table.""" id: str # the App Server threadId + ref: str # dispatch-local stable short ref + ref_source: str + ref_payload: str + ref_mixer: str handle: str # "@name" (own) or "→ @project:name" / desktop title (attached) role: str | None = None cwd: str | None = None diff --git a/src/outfitter/dispatch/registry/refs.py b/src/outfitter/dispatch/registry/refs.py new file mode 100644 index 0000000..0663882 --- /dev/null +++ b/src/outfitter/dispatch/registry/refs.py @@ -0,0 +1,29 @@ +"""Dispatch-local short refs for managed Codex threads.""" + +from __future__ import annotations + +from hashlib import sha256 + +BASE58BTC_ALPHABET = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz" +CODEX_REF_SOURCE = "0" + + +def codex_ref_payload(thread_id: str) -> str: + """Return the four-character base58btc hash payload for a Codex thread id.""" + + digest = sha256(f"codex:{thread_id}".encode()).digest() + return _base58btc(digest)[:4] + + +def make_ref(*, source: str, payload: str, mixer: str) -> str: + return f"{source}{payload}{mixer}" + + +def _base58btc(data: bytes) -> str: + value = int.from_bytes(data, "big") + encoded = "" + while value: + value, remainder = divmod(value, 58) + encoded = BASE58BTC_ALPHABET[remainder] + encoded + leading_zeroes = len(data) - len(data.lstrip(b"\0")) + return (BASE58BTC_ALPHABET[0] * leading_zeroes) + (encoded or BASE58BTC_ALPHABET[0]) diff --git a/src/outfitter/dispatch/registry/store.py b/src/outfitter/dispatch/registry/store.py index 3891017..4d216be 100644 --- a/src/outfitter/dispatch/registry/store.py +++ b/src/outfitter/dispatch/registry/store.py @@ -27,9 +27,10 @@ Trigger, WhenAdapter, ) +from .refs import BASE58BTC_ALPHABET, CODEX_REF_SOURCE, codex_ref_payload, make_ref Clock = Callable[[], datetime] -SCHEMA_VERSION = 2 +SCHEMA_VERSION = 3 def _utcnow() -> datetime: @@ -39,6 +40,10 @@ def _utcnow() -> datetime: _SCHEMA = """ CREATE TABLE IF NOT EXISTS 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, @@ -136,6 +141,7 @@ async def open(cls, path: str | Path = ":memory:", now: Clock = _utcnow) -> Regi ) await store._conn.executescript(_SCHEMA) if user_version < SCHEMA_VERSION: + await store._migrate(user_version) await store._conn.execute(f"PRAGMA user_version = {SCHEMA_VERSION}") await store._conn.commit() return store @@ -143,6 +149,33 @@ async def open(cls, path: str | Path = ":memory:", now: Clock = _utcnow) -> Regi async def close(self) -> None: await self._conn.close() + async def _migrate(self, user_version: int) -> None: + if user_version < 3: + await self._ensure_ref_columns() + async with self._conn.execute( + "SELECT id FROM lanes WHERE ref IS NULL OR ref = '' ORDER BY created_at, id" + ) as cur: + rows = await cur.fetchall() + for row in rows: + thread_id = str(row["id"]) + ref, source, payload, mixer = await self._allocate_ref_parts(thread_id) + await self._conn.execute( + "UPDATE lanes SET ref = ?, ref_source = ?, ref_payload = ?, ref_mixer = ? " + "WHERE id = ?", + (ref, source, payload, mixer, thread_id), + ) + await self._conn.execute( + "CREATE UNIQUE INDEX IF NOT EXISTS idx_lanes_ref ON lanes(ref)" + ) + + async def _ensure_ref_columns(self) -> None: + async with self._conn.execute("PRAGMA table_info(lanes)") as cur: + rows = await cur.fetchall() + columns = {str(row["name"]) for row in rows} + for name in ("ref", "ref_source", "ref_payload", "ref_mixer"): + if name not in columns: + await self._conn.execute(f"ALTER TABLE lanes ADD COLUMN {name} TEXT") + # --- lanes ---------------------------------------------------------------- async def add_lane( @@ -157,8 +190,13 @@ async def add_lane( pinned: bool = False, ) -> Lane: now = self._now() + ref, ref_source, ref_payload, ref_mixer = await self._allocate_ref_parts(id) lane = Lane( id=id, + ref=ref, + ref_source=ref_source, + ref_payload=ref_payload, + ref_mixer=ref_mixer, handle=handle, role=role, cwd=cwd, @@ -190,8 +228,13 @@ async def add_lane_with_sync( if sync.lane != id: raise ValueError(f"sync lane {sync.lane!r} does not match lane id {id!r}") now = self._now() + ref, ref_source, ref_payload, ref_mixer = await self._allocate_ref_parts(id) lane = Lane( id=id, + ref=ref, + ref_source=ref_source, + ref_payload=ref_payload, + ref_mixer=ref_mixer, handle=handle, role=role, cwd=cwd, @@ -220,10 +263,15 @@ async def add_lane_with_sync( async def _insert_lane(self, lane: Lane) -> None: await self._conn.execute( - "INSERT INTO lanes (id, handle, role, cwd, source, status, pinned, active_turn_id, " - "created_at, updated_at, last_event_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + "INSERT INTO lanes (id, ref, ref_source, ref_payload, ref_mixer, handle, role, cwd, " + "source, status, pinned, active_turn_id, created_at, updated_at, last_event_at) " + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", ( lane.id, + lane.ref, + lane.ref_source, + lane.ref_payload, + lane.ref_mixer, lane.handle, lane.role, lane.cwd, @@ -237,16 +285,66 @@ async def _insert_lane(self, lane: Lane) -> None: ), ) + async def _allocate_ref_parts(self, thread_id: str) -> tuple[str, str, str, str]: + source = CODEX_REF_SOURCE + payload = codex_ref_payload(thread_id) + for mixer in BASE58BTC_ALPHABET: + candidate = make_ref(source=source, payload=payload, mixer=mixer) + existing = await self.find_lane_by_ref(candidate) + if existing is None or existing.id == thread_id: + return candidate, source, payload, mixer + raise RuntimeError( + f"ref mixer alphabet exhausted for Codex thread hash payload {payload!r}; " + "use the full Codex thread id" + ) + async def find_lane(self, lane_id: str) -> Lane | None: async with self._conn.execute("SELECT * FROM lanes WHERE id = ?", (lane_id,)) as cur: row = await cur.fetchone() return _row_to_lane(row) if row is not None else None + async def find_lane_by_ref(self, ref: str) -> Lane | None: + async with self._conn.execute("SELECT * FROM lanes WHERE ref = ?", (ref,)) as cur: + row = await cur.fetchone() + return _row_to_lane(row) if row is not None else None + async def find_lane_by_handle(self, handle: str) -> Lane | None: async with self._conn.execute("SELECT * FROM lanes WHERE handle = ?", (handle,)) as cur: row = await cur.fetchone() return _row_to_lane(row) if row is not None else None + async def find_lanes_by_handle(self, handle: str) -> list[Lane]: + async with self._conn.execute("SELECT * FROM lanes WHERE handle = ?", (handle,)) as cur: + rows = await cur.fetchall() + return [_row_to_lane(row) for row in rows] + + async def find_lanes_by_title(self, title: str) -> list[Lane]: + async with self._conn.execute( + """ + SELECT lanes.* FROM lanes + LEFT JOIN lane_snapshots snap ON snap.lane = lanes.id + WHERE snap.display_name = ? OR lanes.handle = ? OR ltrim(lanes.handle, '@') = ? + ORDER BY lanes.created_at, lanes.id + """, + (title, title, title), + ) as cur: + rows = await cur.fetchall() + return [_row_to_lane(row) for row in rows] + + async def fuzzy_find_lanes_by_title(self, title: str) -> list[Lane]: + pattern = f"%{title}%" + async with self._conn.execute( + """ + SELECT lanes.* FROM lanes + LEFT JOIN lane_snapshots snap ON snap.lane = lanes.id + WHERE snap.display_name LIKE ? OR lanes.handle LIKE ? OR ltrim(lanes.handle, '@') LIKE ? + ORDER BY lanes.created_at, lanes.id + """, + (pattern, pattern, pattern), + ) as cur: + rows = await cur.fetchall() + return [_row_to_lane(row) for row in rows] + async def get_lane(self, lane_id: str) -> Lane: lane = await self.find_lane(lane_id) if lane is None: diff --git a/tests/core/test_handlers.py b/tests/core/test_handlers.py index 37aa343..50a4f4b 100644 --- a/tests/core/test_handlers.py +++ b/tests/core/test_handlers.py @@ -157,9 +157,11 @@ async def test_new_lane_initial_send_failure_leaves_lane_registered( async def test_send_resolves_by_handle(store: Registry) -> None: client = FakeLaneClient() ctx = make_ctx(store, client) - await handlers.open_lane(OpenInput(name="beta"), ctx) + ref = await handlers.open_lane(OpenInput(name="beta"), ctx) ack = await handlers.send(LaneTextInput(lane="@beta", text="hi"), ctx) assert ack.lane == "lane-1" + by_ref = await handlers.send(LaneTextInput(lane=ref.ref, text="again"), ctx) + assert by_ref.lane == "lane-1" async def test_send_modes_context_and_interject(store: Registry) -> None: diff --git a/tests/core/test_selectors.py b/tests/core/test_selectors.py new file mode 100644 index 0000000..1bbcfe9 --- /dev/null +++ b/tests/core/test_selectors.py @@ -0,0 +1,81 @@ +"""Selector resolver tests for refs, full ids, labels, and ambiguity.""" + +from __future__ import annotations + +from collections.abc import AsyncIterator + +import pytest +import pytest_asyncio + +from outfitter.dispatch.contracts.errors import NotFoundError, ValidationError +from outfitter.dispatch.core.selectors import resolve_managed_selector, resolve_thread_selector +from outfitter.dispatch.registry.models import LaneSync +from outfitter.dispatch.registry.store import Registry +from tests.fakes import make_ctx + + +@pytest_asyncio.fixture +async def store() -> AsyncIterator[Registry]: + s = await Registry.open() + try: + yield s + finally: + await s.close() + + +async def test_resolver_accepts_ref_full_id_handle_and_title(store: Registry) -> None: + lane = await store.add_lane(id="thread-1", handle="@docs", source="own", cwd="/work") + await store.upsert_lane_sync( + LaneSync(lane=lane.id, state="metadata", display_name="Docs Thread") + ) + ctx = make_ctx(store) + + assert (await resolve_managed_selector(ctx, lane.ref)).thread_id == "thread-1" + assert (await resolve_managed_selector(ctx, "thread-1")).kind == "thread_id" + assert (await resolve_managed_selector(ctx, "@docs")).kind == "handle" + assert (await resolve_managed_selector(ctx, "Docs Thread")).kind == "title" + + +async def test_resolver_accepts_current_title_without_handle_prefix(store: Registry) -> None: + lane = await store.add_lane(id="thread-1", handle="@Docs Thread", source="own") + + resolved = await resolve_managed_selector(make_ctx(store), "Docs Thread") + + assert resolved.thread_id == lane.id + assert resolved.kind == "title" + + +async def test_resolver_rejects_ambiguous_title_with_candidates(store: Registry) -> None: + one = await store.add_lane(id="thread-1", handle="@one", source="own", cwd="/a") + two = await store.add_lane(id="thread-2", handle="@two", source="attached", cwd="/b") + await store.upsert_lane_sync(LaneSync(lane=one.id, state="metadata", display_name="Same")) + await store.upsert_lane_sync(LaneSync(lane=two.id, state="metadata", display_name="Same")) + + with pytest.raises(ValidationError) as exc: + await resolve_managed_selector(make_ctx(store), "Same") + + message = str(exc.value) + assert "ambiguous selector" in message + assert one.ref in message + assert two.ref in message + + +async def test_fuzzy_resolution_is_read_only_opt_in(store: Registry) -> None: + lane = await store.add_lane(id="thread-1", handle="@release-notes", source="own") + ctx = make_ctx(store) + + with pytest.raises(NotFoundError): + await resolve_managed_selector(ctx, "release", allow_fuzzy=False) + + resolved = await resolve_managed_selector(ctx, "release", allow_fuzzy=True) + assert resolved.thread_id == lane.id + assert resolved.kind == "fuzzy_title" + + +async def test_thread_resolver_can_return_raw_unmanaged_id(store: Registry) -> None: + resolved = await resolve_thread_selector( + make_ctx(store), "019e9598-9214-7ed1-ac40-52d6d675d3e7", allow_unmanaged_raw=True + ) + + assert resolved.managed is False + assert resolved.thread_id == "019e9598-9214-7ed1-ac40-52d6d675d3e7" diff --git a/tests/core/test_triggers.py b/tests/core/test_triggers.py index 302065f..18e8453 100644 --- a/tests/core/test_triggers.py +++ b/tests/core/test_triggers.py @@ -64,6 +64,24 @@ async def test_interval_fires_then_waits_for_the_window(store: Registry) -> None assert await scheduler.tick() == 1 # 61s since last fire +async def test_trigger_runner_resolves_dispatch_ref(store: Registry) -> None: + clock = FakeClock(_T0) + client = FakeLaneClient() + ctx = make_ctx(store, client) + lane = await store.add_lane(id="L1", handle="@x", source="own", status="idle") + trig = Trigger( + id="t1", + name="p", + lane=lane.ref, + when=IntervalWhen(seconds=1), + action=SendAction(text="ping"), + ) + await store.add_trigger(trig) + + assert await TriggerRunner(ctx, clock).maybe_fire(trig, reason="time") is True + assert any(name == "turn_start" and kw["thread_id"] == "L1" for name, kw in client.calls) + + async def test_idle_for_fires_once_per_idle_period(store: Registry) -> None: clock = FakeClock(_T0) ctx = make_ctx(store) diff --git a/tests/registry/test_store.py b/tests/registry/test_store.py index be40888..110d7d2 100644 --- a/tests/registry/test_store.py +++ b/tests/registry/test_store.py @@ -4,13 +4,15 @@ from collections.abc import AsyncIterator from datetime import UTC, datetime +from pathlib import Path import pytest import pytest_asyncio from outfitter.dispatch.contracts.errors import NotFoundError from outfitter.dispatch.registry.models import LaneSync -from outfitter.dispatch.registry.store import Registry +from outfitter.dispatch.registry.refs import BASE58BTC_ALPHABET, codex_ref_payload +from outfitter.dispatch.registry.store import SCHEMA_VERSION, Registry def _clock() -> datetime: @@ -29,10 +31,89 @@ async def store() -> AsyncIterator[Registry]: async def test_add_and_get_lane(store: Registry) -> None: lane = await store.add_lane(id="L1", handle="@alpha", source="own", cwd="/work") assert lane.id == "L1" + assert lane.ref.startswith("0") + assert lane.ref_payload == codex_ref_payload("L1") + assert lane.ref_mixer == BASE58BTC_ALPHABET[0] assert lane.source == "own" assert lane.created_at == _clock() got = await store.get_lane("L1") assert got.handle == "@alpha" + assert await store.find_lane_by_ref(lane.ref) == got + + +async def test_refs_allocated_for_owned_attached_and_forked_lanes(store: Registry) -> None: + owned = await store.add_lane(id="owned", handle="@owned", source="own") + attached = await store.add_lane(id="attached", handle="@attached", source="attached") + forked = await store.add_lane(id="owned-fork", handle="@forked", source="own") + + assert len({owned.ref, attached.ref, forked.ref}) == 3 + assert owned.ref_source == "0" + assert attached.ref_source == "0" + assert forked.ref_source == "0" + + +async def test_ref_collision_allocates_next_mixer( + store: Registry, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setattr("outfitter.dispatch.registry.store.codex_ref_payload", lambda _id: "zzzz") + + first = await store.add_lane(id="first", handle="@first", source="own") + second = await store.add_lane(id="second", handle="@second", source="own") + + assert first.ref == "0zzzz1" + assert second.ref == "0zzzz2" + assert second.ref_mixer == BASE58BTC_ALPHABET[1] + + +async def test_v2_registry_migration_backfills_unique_refs(tmp_path: Path) -> None: + db = tmp_path / "registry.sqlite3" + import aiosqlite + + conn = await aiosqlite.connect(db) + await conn.executescript( + """ + CREATE TABLE lanes ( + id TEXT PRIMARY KEY, + 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, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + last_event_at TEXT + ); + INSERT INTO lanes ( + id, handle, source, status, pinned, created_at, updated_at + ) VALUES + ( + 'B', '@b', 'own', 'idle', 0, + '2026-06-03T12:00:02+00:00', '2026-06-03T12:00:02+00:00' + ), + ( + 'A', '@a', 'attached', 'idle', 0, + '2026-06-03T12:00:01+00:00', '2026-06-03T12:00:01+00:00' + ); + PRAGMA user_version = 2; + """ + ) + await conn.commit() + await conn.close() + + migrated = await Registry.open(db, now=_clock) + try: + lanes = await migrated.list_lanes() + assert [lane.id for lane in lanes] == ["A", "B"] + assert len({lane.ref for lane in lanes}) == 2 + assert all(lane.ref for lane in lanes) + async with migrated._conn.execute("PRAGMA user_version") as cur: + row = await cur.fetchone() + assert row is not None + assert int(row[0]) == SCHEMA_VERSION + finally: + await migrated.close() async def test_get_missing_lane_raises_not_found(store: Registry) -> None: diff --git a/tests/surfaces/test_derive_cli.py b/tests/surfaces/test_derive_cli.py index 5d2c787..c0045de 100644 --- a/tests/surfaces/test_derive_cli.py +++ b/tests/surfaces/test_derive_cli.py @@ -103,7 +103,7 @@ def invoke(op_id: str, params: dict[str, object]) -> dict[str, object]: renamed = runner.invoke(app, ["rename", "@old", "new"]) restored = runner.invoke(app, ["restore", "@old"]) - searched = runner.invoke(app, ["search", "needle", "--lane", "@old", "--limit", "5"]) + searched = runner.invoke(app, ["search", "needle", "--thread", "@old", "--limit", "5"]) assert renamed.exit_code == 0 assert restored.exit_code == 0 @@ -133,7 +133,7 @@ def invoke(op_id: str, params: dict[str, object]) -> dict[str, object]: ] -def test_lane_group_routes_core_lane_commands() -> None: +def test_flat_thread_routes_core_commands() -> None: calls: list[tuple[str, dict[str, object]]] = [] def invoke(op_id: str, params: dict[str, object]) -> dict[str, object]: @@ -168,41 +168,29 @@ def invoke(op_id: str, params: dict[str, object]) -> dict[str, object]: app = derive_cli(REGISTRY, invoke) - managed = runner.invoke(app, ["lane", "list"]) - unmanaged = runner.invoke(app, ["lane", "list", "--unmanaged", "--limit", "5"]) - renamed = runner.invoke(app, ["lane", "rename", "@old", "new"]) - lane_search = runner.invoke(app, ["lane", "search", "@old", "needle"]) - restored = runner.invoke(app, ["lane", "restore", "@old"]) + managed = runner.invoke(app, ["list"]) + unmanaged = runner.invoke(app, ["list", "--unmanaged", "--limit", "5"]) + attached = runner.invoke(app, ["attach", "thread-1"]) + got = runner.invoke(app, ["get", "@old"]) + tailed = runner.invoke(app, ["tail", "@old"]) + watched = runner.invoke(app, ["watch", "@old", "--limit", "2", "--timeout", "1"]) + synced = runner.invoke(app, ["sync", "@old"]) assert managed.exit_code == 0 assert unmanaged.exit_code == 0 - assert renamed.exit_code == 0 - assert lane_search.exit_code == 0 - assert restored.exit_code == 0 + assert attached.exit_code == 0 + assert got.exit_code == 0 + assert tailed.exit_code == 0 + assert watched.exit_code == 0 + assert synced.exit_code == 0 assert calls == [ ("roster", {"include_archived": False}), ("discover", {"limit": 5}), - ("lane-rename", {"old": "@old", "new": "new"}), - ( - "search", - { - "query": "needle", - "lane": "@old", - "directory": None, - "repo": None, - "managed": False, - "unmanaged": False, - "archived": False, - "since": None, - "until": None, - "date_field": "updated_at", - "sort": "updated_at", - "ascending": False, - "limit": 20, - "max_scan": 200, - }, - ), - ("restore", {"target": "@old"}), + ("attach", {"thread": "thread-1", "sync": False}), + ("show", {"lane": "@old", "include_transcript": False, "max_items": 20}), + ("transcript", {"lane": "@old", "limit": 50}), + ("watch", {"lane": "@old", "limit": 2, "timeout": 1.0}), + ("sync", {"lane": "@old", "full": False}), ] @@ -252,9 +240,9 @@ def invoke(op_id: str, params: dict[str, object]) -> dict[str, object]: } app = derive_cli(REGISTRY, invoke) - declined = runner.invoke(app, ["lane", "archive", "L1"], input="n\n") + declined = runner.invoke(app, ["archive", "L1"], input="n\n") assert declined.exit_code != 0 - confirmed = runner.invoke(app, ["lane", "archive", "L1"], input="y\n") + confirmed = runner.invoke(app, ["archive", "L1"], input="y\n") assert confirmed.exit_code == 0 @@ -269,7 +257,7 @@ def invoke(op_id: str, params: dict[str, object]) -> dict[str, object]: } app = derive_cli(REGISTRY, invoke) - result = runner.invoke(app, ["lane", "archive", "L1", "--json"], input="y\n") + result = runner.invoke(app, ["archive", "L1", "--json"], input="y\n") assert result.exit_code == 0 assert "Run destroy op" not in result.stdout @@ -307,31 +295,32 @@ def test_schema_command_stays_plain_json_when_color_is_forced() -> None: def test_schema_command_resolves_composed_cli_routes() -> None: app = derive_cli(REGISTRY, lambda _op, _params: {}) - unmanaged = runner.invoke(app, ["schema", "lane list --unmanaged"]) + unmanaged = runner.invoke(app, ["schema", "list --unmanaged"]) search = runner.invoke(app, ["schema", "search"]) - lane_search = runner.invoke(app, ["schema", "lane search"]) - follow = runner.invoke(app, ["schema", "lane tail --follow"]) - fork = runner.invoke(app, ["schema", "lane fork"]) - rollback = runner.invoke(app, ["schema", "lane rollback"]) - compact = runner.invoke(app, ["schema", "lane compact"]) - archive = runner.invoke(app, ["schema", "lane archive"]) + attach = runner.invoke(app, ["schema", "attach"]) + get = runner.invoke(app, ["schema", "get"]) + tail = runner.invoke(app, ["schema", "tail"]) + watch = runner.invoke(app, ["schema", "watch"]) + sync = runner.invoke(app, ["schema", "sync"]) + follow = runner.invoke(app, ["schema", "tail --follow"]) + archive = runner.invoke(app, ["schema", "archive"]) restore = runner.invoke(app, ["schema", "restore"]) assert unmanaged.exit_code == 0 assert '"op": "discover"' in unmanaged.output assert search.exit_code == 0 assert '"op": "search"' in search.output - assert lane_search.exit_code == 0 - assert '"op": "search"' in lane_search.output - assert follow.exit_code == 0 - assert '"op": "watch"' in follow.output - assert '"timeout"' in follow.output - assert fork.exit_code == 0 - assert '"op": "fork"' in fork.output - assert rollback.exit_code == 0 - assert '"op": "rollback"' in rollback.output - assert compact.exit_code == 0 - assert '"op": "compact"' in compact.output + assert attach.exit_code == 0 + assert '"op": "attach"' in attach.output + assert get.exit_code == 0 + assert '"op": "show"' in get.output + assert tail.exit_code == 0 + assert '"op": "transcript"' in tail.output + assert watch.exit_code == 0 + assert '"op": "watch"' in watch.output + assert sync.exit_code == 0 + assert '"op": "sync"' in sync.output + assert follow.exit_code == 2 assert archive.exit_code == 0 assert '"op": "archive"' in archive.output assert restore.exit_code == 0 @@ -353,5 +342,5 @@ def invoke(op_id: str, params: dict[str, object]) -> dict[str, object]: raise typer.Exit(code=4) app = derive_cli(REGISTRY, invoke) - result = runner.invoke(app, ["lane", "get", "ghost"]) + result = runner.invoke(app, ["get", "ghost"]) assert result.exit_code == 4 diff --git a/tests/surfaces/test_derive_mcp.py b/tests/surfaces/test_derive_mcp.py index fdc1cfa..3f6279c 100644 --- a/tests/surfaces/test_derive_mcp.py +++ b/tests/surfaces/test_derive_mcp.py @@ -9,9 +9,9 @@ def test_grouped_tools_are_agent_oriented_not_one_per_op() -> None: projection = derive_mcp_projection(REGISTRY) assert [t.name for t in projection.tools] == [ - "dispatch_lane_read", - "dispatch_lane_write", - "dispatch_lane_destroy", + "dispatch_thread_read", + "dispatch_thread_write", + "dispatch_thread_destroy", "dispatch_trigger_read", "dispatch_trigger_write", "dispatch_trigger_destroy", @@ -31,7 +31,7 @@ def test_action_schema_and_annotations_from_op() -> None: projection = derive_mcp_projection(REGISTRY) tools = {t.name: t for t in projection.tools} - lane_write = tools["dispatch_lane_write"] + lane_write = tools["dispatch_thread_write"] assert lane_write.annotations is not None assert lane_write.annotations.readOnlyHint is False assert lane_write.annotations.destructiveHint is False @@ -46,7 +46,7 @@ def test_action_schema_and_annotations_from_op() -> None: "restore", } - lane_read = tools["dispatch_lane_read"] + lane_read = tools["dispatch_thread_read"] assert lane_read.annotations is not None assert lane_read.annotations.readOnlyHint is True assert lane_read.annotations.idempotentHint is True @@ -57,7 +57,7 @@ def test_action_schema_and_annotations_from_op() -> None: "search", } - lane_destroy = tools["dispatch_lane_destroy"] + lane_destroy = tools["dispatch_thread_destroy"] assert lane_destroy.annotations is not None assert lane_destroy.annotations.destructiveHint is True assert {s["properties"]["op"]["const"] for s in lane_destroy.inputSchema["oneOf"]} >= { diff --git a/tests/surfaces/test_mcp_routing.py b/tests/surfaces/test_mcp_routing.py index 08bc457..e1db5b6 100644 --- a/tests/surfaces/test_mcp_routing.py +++ b/tests/surfaces/test_mcp_routing.py @@ -13,6 +13,8 @@ from outfitter.dispatch.surfaces.mcp import handle_tool_call from tests.fakes import make_ctx +_IDENTITY_FIELDS = {"lane", "ref", "id", "title", "handle", "managed", "source", "status", "cwd"} + @pytest_asyncio.fixture async def socket_path(socket_dir: Path) -> AsyncIterator[Path]: @@ -29,73 +31,80 @@ async def socket_path(socket_dir: Path) -> AsyncIterator[Path]: async def test_tool_calls_open_send_roster(socket_path: Path) -> None: opened = await handle_tool_call( - socket_path, "dispatch_lane_write", {"op": "open", "name": "alpha", "cwd": "/w"} + socket_path, "dispatch_thread_write", {"op": "open", "name": "alpha", "cwd": "/w"} ) assert not opened.isError assert opened.structuredContent is not None assert opened.structuredContent["handle"] == "@alpha" sent = await handle_tool_call( - socket_path, "dispatch_lane_write", {"op": "send", "lane": "lane-1", "text": "hi"} + socket_path, "dispatch_thread_write", {"op": "send", "lane": "lane-1", "text": "hi"} ) assert sent.structuredContent is not None assert sent.structuredContent["accepted"] is True + assert set(sent.structuredContent) >= _IDENTITY_FIELDS + assert sent.structuredContent["ref"] == opened.structuredContent["ref"] - roster = await handle_tool_call(socket_path, "dispatch_lane_read", {"op": "roster"}) + roster = await handle_tool_call(socket_path, "dispatch_thread_read", {"op": "roster"}) assert roster.structuredContent is not None assert len(roster.structuredContent["lanes"]) == 1 async def test_tool_calls_transcript_goal_and_compact(socket_path: Path) -> None: opened = await handle_tool_call( - socket_path, "dispatch_lane_write", {"op": "open", "name": "alpha", "cwd": "/w"} + socket_path, "dispatch_thread_write", {"op": "open", "name": "alpha", "cwd": "/w"} ) assert not opened.isError goal = await handle_tool_call( socket_path, - "dispatch_lane_write", + "dispatch_thread_write", {"op": "goal_set", "lane": "lane-1", "objective": "ship"}, ) assert goal.structuredContent is not None assert goal.structuredContent["goal"]["objective"] == "ship" + assert set(goal.structuredContent) >= _IDENTITY_FIELDS got = await handle_tool_call( socket_path, - "dispatch_lane_read", + "dispatch_thread_read", {"op": "goal_get", "lane": "lane-1"}, ) assert got.structuredContent is not None assert got.structuredContent["goal"]["status"] == "active" + assert set(got.structuredContent) >= _IDENTITY_FIELDS transcript = await handle_tool_call( socket_path, - "dispatch_lane_read", + "dispatch_thread_read", {"op": "transcript", "lane": "lane-1", "limit": 5}, ) assert transcript.structuredContent is not None assert transcript.structuredContent["items"] == [] + assert set(transcript.structuredContent) >= _IDENTITY_FIELDS compact = await handle_tool_call( socket_path, - "dispatch_lane_write", + "dispatch_thread_write", {"op": "compact", "lane": "lane-1"}, ) assert compact.structuredContent is not None assert compact.structuredContent["accepted"] is True + assert set(compact.structuredContent) >= _IDENTITY_FIELDS synced = await handle_tool_call( socket_path, - "dispatch_lane_write", + "dispatch_thread_write", {"op": "sync", "lane": "lane-1"}, ) assert synced.structuredContent is not None assert synced.structuredContent["sync"]["state"] == "metadata" + assert set(synced.structuredContent) >= _IDENTITY_FIELDS async def test_tool_call_error_projects_full_taxonomy_into_meta(socket_path: Path) -> None: result = await handle_tool_call( - socket_path, "dispatch_lane_read", {"op": "show", "lane": "ghost"} + socket_path, "dispatch_thread_read", {"op": "show", "lane": "ghost"} ) assert result.isError is True assert result.meta is not None @@ -106,7 +115,7 @@ async def test_tool_call_error_projects_full_taxonomy_into_meta(socket_path: Pat async def test_tool_call_rejects_unknown_grouped_action(socket_path: Path) -> None: - result = await handle_tool_call(socket_path, "dispatch_lane_read", {"op": "send"}) + result = await handle_tool_call(socket_path, "dispatch_thread_read", {"op": "send"}) assert result.isError is True assert result.meta is not None assert result.meta["dispatchCode"] == "mcp_route_error" diff --git a/tests/surfaces/test_parity.py b/tests/surfaces/test_parity.py index 82ba25b..103d661 100644 --- a/tests/surfaces/test_parity.py +++ b/tests/surfaces/test_parity.py @@ -36,25 +36,17 @@ def _stub_invoke(op_id: str, params: dict[str, object]) -> dict[str, object]: "send": "send", "stop": "stop", "new": "new", + "attach": "attach", + "list": "roster", + "list --unmanaged": "discover", + "get": "show", + "tail": "transcript", + "watch": "watch", + "sync": "sync", "search": "search", "rename": "lane-rename", "archive": "archive", "restore": "restore", - "lane get": "show", - "lane status": "show", - "lane list": "roster", - "lane list --unmanaged": "discover", - "lane attach": "attach", - "lane sync": "sync", - "lane rename": "lane-rename", - "lane search": "search", - "lane fork": "fork", - "lane rollback": "rollback", - "lane compact": "compact", - "lane archive": "archive", - "lane restore": "restore", - "lane tail": "transcript", - "lane tail --follow": "watch", "goal status": "goal-get", "goal set": "goal-set", "goal clear": "goal-clear", @@ -97,7 +89,7 @@ def test_cli_schema_routes_cover_public_ops() -> None: app = derive_cli(REGISTRY, _stub_invoke) routed_ops = set(_EXPECTED_CLI_SCHEMA_ROUTES.values()) - assert set(REGISTRY.ids()) - routed_ops == {"open"} + assert set(REGISTRY.ids()) - routed_ops == {"open", "fork", "rollback", "compact"} for command, op_id in _EXPECTED_CLI_SCHEMA_ROUTES.items(): result = runner.invoke(app, ["schema", command]) @@ -105,6 +97,17 @@ def test_cli_schema_routes_cover_public_ops() -> None: assert json.loads(result.output)["op"] == op_id +def test_managed_thread_outputs_include_stable_identity_fields() -> None: + app = derive_cli(REGISTRY, _stub_invoke) + required = {"lane", "ref", "id", "title", "handle", "managed", "source", "status", "cwd"} + + for command in ("send", "goal status", "sync", "tail", "watch"): + result = runner.invoke(app, ["schema", command]) + assert result.exit_code == 0, command + output = json.loads(result.output)["output"] + assert required <= set(output["properties"]), command + + def test_cli_composed_routes_invoke_canonical_ops() -> None: calls: list[tuple[str, dict[str, object]]] = [] @@ -132,8 +135,8 @@ def invoke(op_id: str, params: dict[str, object]) -> dict[str, object]: assert runner.invoke(app, ["send", "@docs", "hi", "--context"]).exit_code == 0 assert runner.invoke(app, ["stop", "@docs"]).exit_code == 0 - assert runner.invoke(app, ["lane", "list", "--unmanaged"]).exit_code == 0 - assert runner.invoke(app, ["lane", "sync", "@docs"]).exit_code == 0 + assert runner.invoke(app, ["list", "--unmanaged"]).exit_code == 0 + assert runner.invoke(app, ["sync", "@docs"]).exit_code == 0 assert runner.invoke(app, ["search", "needle", "--managed"]).exit_code == 0 assert runner.invoke(app, ["goal", "status", "@docs"]).exit_code == 0