Skip to content

feat: add first-class host UX for plan management (#3844) - #3853

Draft
Sayt-0 wants to merge 5 commits into
mainfrom
feat/host-plan-management-3844
Draft

feat: add first-class host UX for plan management (#3844)#3853
Sayt-0 wants to merge 5 commits into
mainfrom
feat/host-plan-management-3844

Conversation

@Sayt-0

@Sayt-0 Sayt-0 commented Jul 27, 2026

Copy link
Copy Markdown
Member

Summary

Closes #3844.

Implements the full host-facing plan management surface in four independent layers, each buildable and testable on its own.


What's in this PR

1. pkg/tools/builtin/plan — minimal backward-compatible extensions

Export Purpose
ValidateName(name string) error Canonical name rule, re-used by pkg/plans so both surfaces enforce the same pattern
CorruptPlanError{File, Err} Typed error for decode failures; identical message, distinguishable via errors.As
SharedStorage() Storage Returns the storage behind the process-wide singleton so TUI and agent tools serialize on the same mutex
ChangeNotifier interface + SetChangeCallback Fires after every successful write/status/delete, never on failures; atomic.Pointer makes per-turn re-wiring race-safe

2. pkg/plans — host-facing service package

Single Service interface consumed by both CLI and TUI.

  • Unified Plan model: explicit Scope (shared | session), Version *int is nil for session plans (no revisions).
  • Six typed host errors: NotFoundError, ValidationError, CorruptError, StorageError, ConflictError (with Expected/Current), UnsupportedError.
  • Operations: List, Get, Create, Update, SetStatus, Delete, Export — all with ExpectedVersion / force semantics.
  • Create is create-only (expected revision 0 → conflict if exists); Update is update-only (MustExist).
  • Session mutations return UnsupportedError with an actionable reason.
  • List with SessionID includes only that session's plan; stale session files are never enumerated globally.

3. docker agent plans — CLI command group

plans list [--json] [--session <id>]
plans get [<name>] [--scope shared|session] [--session <id>]
plans create <name> --file <path> [--title|--author|--status] [--json]
plans update <name> --file <path> [--expected-version <n>|--force] [--json]
plans status <name> <status> [--expected-version <n>|--force] [--json]
plans export [<name>] --output <path> [--json]
plans delete [<name>] [--expected-version <n>|--force] [--json]
  • Mutations require exactly one of --expected-version or --force (Cobra mutual-exclusion guard).
  • --json: schema_version:"1" envelope on stdout; single-line JSON error on stderr with stable code/scope/name/op/expected_version/current_version.
  • Exit code 3 for conflicts, 1 for all other failures.

4. TUI /plans browser + detail

  • /plans slash command (command palette discoverable, dropped in lean mode).
  • Browser: scope · identity · status · version · relative updated time · title; filter with /, keyboard nav, mouse click/double-click; r refresh, x export, s set status, d delete (confirmation), n new, e edit. Session plan rows show explanatory notifications for unsupported actions.
  • Detail: explicit scope description, all metadata, scrollable markdown content.
  • Live refresh: SessionPlanUpdatedEvent refreshes for the current session; PlanChangedEvent refreshes for any shared plan mutation, including from background tabs.
  • Broadcastable mechanism on dialog.Manager: data-refresh messages reach all dialogs in the stack so a browser buried under the detail stays in sync.

5. pkg/runtime — PlanChangedEvent

PlanChangedEvent (type plan_changed) carries scope/name/action/version — never content — wired via tools.As[plan.ChangeNotifier] with a non-blocking sink (same RAG forwarder pattern).


Tests

Package New tests
pkg/tools/builtin/plan 8 callback tests + 3 host-surface tests
pkg/plans 34 service tests
cmd/root 30 CLI tests (hermetic, injected service)
pkg/runtime 3 event tests
pkg/tui/dialog 19 browser/detail tests
pkg/tui 17 app-handler tests

All pass with go test -count=1; gofmt clean; lint 0 new issues.


Known gaps (noted in issue, out of scope)

  • Cross-process optimistic locking: FilesystemStorage uses an in-process mutex; two separate processes can race on the revision bump. The existing docs already state this limitation for distinct processes.
  • Single active ChangeNotifier sink: sufficient for the single-TUI case; a fan-out registry is the natural next step for multi-session server contexts.
  • TUI export TOCTOU: the no-overwrite check is stat + write; a truly atomic O_EXCL variant would close the rare race window.

Sayt-0 added 5 commits July 27, 2026 18:53
… ChangeNotifier

- ValidateName: canonical name rule now exported so pkg/plans validates
  identically to the storage without duplicating the regexp.
- CorruptPlanError: typed error replacing the anonymous fmt.Errorf in
  load(); same message, cause wrapped, distinguishable via errors.As.
- SharedStorage(): returns the storage behind the process-wide singleton
  so a TUI and agent tools in one process share one mutex.
- ChangeNotifier interface + SetChangeCallback on ToolSet: fires after
  every successful write/status/delete, never on failures; stored as an
  atomic.Pointer so per-turn re-configuration from concurrent sessions is
  race-free. Discoverable through toolset wrappers via tools.As.
New pkg/plans wraps the existing plan.Storage and sessionplan helpers
behind a single Service interface consumed by both the CLI and the TUI,
so neither surface ever reads plan files directly.

- Unified Plan model with explicit Scope (shared | session) and nil
  Version for session plans (no revisions).
- Six typed host errors: NotFoundError, ValidationError, CorruptError,
  StorageError, ConflictError (with Expected/Current), UnsupportedError.
- Service operations: List, Get, Create, Update, SetStatus, Delete,
  Export — all with ExpectedVersion / force semantics.
- Create is create-only (expected revision 0); Update is update-only
  (MustExist); session mutations return UnsupportedError immediately.
- List with SessionID includes only that session's plan; stale session
  files are never enumerated globally.
- Export is atomic (atomicfile.Write + parent mkdir) for both scopes.
- 34 unit tests covering all error kinds, create-only conflict, stale
  update/status/delete preserving newer content, session scope,
  injected in-memory storage, and NewService nil-storage panic.
Implements the CLI surface from #3844: list, get, create, update, status,
export, delete — all built over pkg/plans.Service to avoid any direct
storage access.

Key behaviors:
- Mutations require exactly one of --expected-version <n> or --force
  (Cobra MarkFlagsOneRequired + MarkFlagsMutuallyExclusive); nil expected
  version is never sent by accident.
- --expected-version validated >= 1; create is guard-free (service
  enforces create-only via expected revision 0).
- Session plans (--session <id> / --scope session): list/get/export work;
  mutations surface the service's UnsupportedError with actionable reason.
- --json: stable schema_version:"1" envelope on stdout; single-line JSON
  error object on stderr with code/scope/name/op/expected_version/
  current_version, never mixing prose. All codes are typed, not text-parsed.
- Exit code 3 for conflicts, 1 for all other failures; Cobra SilenceErrors
  so stderr stays one object in --json mode.
- Service is built lazily inside RunE so plan.SharedStorage() does not
  resolve the data directory before --data-dir is applied.
- 30 hermetic CLI tests with injected temp-backed service.
Adds PlanChangedEvent (type plan_changed) carrying scope/name/action/
version but never content, so a UI refreshes through its own plan service
rather than trusting event payloads.

- configureToolsetHandlers wires a non-blocking callback via
  tools.As[plan.ChangeNotifier] — same pattern as the RAG event
  forwarder — so a stale sink can never hang a tool call.
- Event registered in the client registry so remote runtimes decode it
  as *PlanChangedEvent rather than dropping it.
- 3 tests: serialization (no content field), client decode over SSE,
  and end-to-end write/status/delete emit vs. failed-write no-emit
  through configureToolsetHandlers.
Implements the TUI host-management surface from #3844.

Slash command:
- /plans opens the plan browser (command palette discoverable, immediate,
  silently dropped in lean mode like /settings).

Browser dialog (pkg/tui/dialog/plan_browser.go):
- Lists shared plans + active session plan (no global session sweep),
  showing scope, identity, status, version (- for session), relative
  updated time, and title; all columns safely truncated.
- Keyboard: ↑/↓ navigate, / filter (Esc exits filter), Enter → detail,
  r refresh, x export, s set status, d delete (confirmation), n new plan
  (name dialog → $EDITOR), e edit ($EDITOR with pre-seeded content).
- Session plan rows show an explanatory notification instead of an action
  dialog for s/d/e.
- Double-click opens detail; warnings shown in footer.
- Mouse click navigates, double-click opens detail.

Detail dialog (pkg/tui/dialog/plan_detail.go):
- Explicit scope line ("shared — collaborative, versioned" /
  "session — owned by its session, read-only here"), all metadata,
  scrollable markdown-rendered content with width-cap fallback.
- Same action keys as browser; session scope hides s/e/d from help and
  shows an explanation instead.
- Implements PlanDetailViewer so the app can re-fetch the exact plan on
  refresh.

App handlers (pkg/tui/plans.go):
- Plans service built lazily (plan.SharedStorage() only resolved after
  --data-dir is applied); injectable for tests.
- All mutations version-guarded (never nil by accident); conflicts
  surface current version, keep newer content, refresh dialogs.
- Export: deterministic filename (<name>.md / session-plan-<8chars>.md),
  refuses to overwrite an existing file.
- Create/edit: temp file + tea.ExecProcess, drift detected before editor
  opens, draft kept on conflict.
- SessionPlanUpdatedEvent: refresh open dialogs only for the current
  session, forward to chat page.
- PlanChangedEvent: refresh open dialogs regardless of which session
  emitted it; background-tab mutations also refresh via handleRoutedMsg.

Broadcastable mechanism (pkg/tui/dialog/dialog.go):
- Messages implementing Broadcastable are delivered to all dialogs in
  the stack so a browser buried under the detail stays in sync.

19 browser/detail tests + 17 app-handler tests.
@Sayt-0

Sayt-0 commented Jul 27, 2026

Copy link
Copy Markdown
Member Author

not finished yet don't approve or merge

@aheritier aheritier added area/cli CLI commands, flags, output formatting area/docs Documentation changes area/runtime Runtime engine, agent loop execution, tool dispatch, loop detection area/tools For features/issues/fixes related to the usage of built-in and MCP tools area/tui For features/issues/fixes related to the TUI kind/feat PR adds a new feature (maps to feat:). Use on PRs only. labels Jul 27, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/cli CLI commands, flags, output formatting area/docs Documentation changes area/runtime Runtime engine, agent loop execution, tool dispatch, loop detection area/tools For features/issues/fixes related to the usage of built-in and MCP tools area/tui For features/issues/fixes related to the TUI kind/feat PR adds a new feature (maps to feat:). Use on PRs only.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat: add first-class host UX for plan management

2 participants