# ClaudeDo.Worker

ASP.NET Core hosted service that executes tasks via Claude CLI in isolated environments.

**Deeper detail lives in `docs/explore-notes/`** — read the matching note before deep-exploring:
[worker-task-pipeline](../../docs/explore-notes/worker-task-pipeline.md) ·
[usage-monitoring](../../docs/explore-notes/usage-monitoring.md) ·
[external-mcp](../../docs/explore-notes/external-mcp.md) ·
[review-merge](../../docs/explore-notes/review-merge.md) ·
[conpty-sessions](../../docs/explore-notes/conpty-sessions.md)

## Folder Layout

```
Worker/
  State/        — TaskStateService + TransitionResult (sole owner of Status/PlanningPhase/BlockedBy writes)
  Queue/        — IQueueWaker, IQueuePicker, QueueService, OverrideSlotService, RunCancellationRegistry
  Lifecycle/    — StaleTaskRecovery, TaskResetService, TaskMergeService, VerifyCommandRunner,
                  ClaudeCliPreflight (resolves via ExecutableResolver, see Key Components below),
                  OrphanRecovery, PlanningLineageRecovery,
                  AttachmentOrphanRecovery, PromptFileRecovery  (last four = startup sweeps)
  Worktrees/    — WorktreeMaintenanceService
  Agents/       — AgentFileService, DefaultAgentSeeder
  Runner/       — TaskRunner + Claude CLI integration; TaskRunMcpService/Context/TokenRegistry (in-task MCP)
  Planning/     — PlanningSessionManager, PlanningChainCoordinator, PlanningMcpService,
                  PlanningMergeOrchestrator, PlanningAggregator, InteractiveLaunchSpecService,
                  session-context/token-auth types, WindowsTerminalLauncher (ITerminalLauncher)
  Refine/       — RefineRunner + RefinePrompt (hub `RefineTask`)
  External/     — ExternalMcpService + sibling tool classes (always-on MCP for general sessions)
  Findings/     — FindingsStore + FindingsStoreLocator: the per-project `.claudedo/` trap store
  Git/          — GitExcludeWriter (`.git/info/exclude`) + GitHead, shared by skill seeding and findings
  Config/       — WorkerConfig
  Hub/          — WorkerHub, HubBroadcaster
  Logging/      — LogRingBuffer (30-min window) + BroadcastLogSink (Serilog → footer + overlay)
  Report/       — ClaudeHistoryReader, WeekReportPromptBuilder, WeekReportService
  Prime/        — "Prime Claude" schedules: PrimeScheduler, PrimeRunner, PrimePrompts,
                  DailyPrepPrompt, PrimeScheduleValidation, NextDueCalculator, PrimeScheduleSignal
  Online/       — optional Online Inbox sync (off by default; zero network when disabled)
  Tickets/      — Bandel ticket-system integration (per-list import + status write-back),
                  inactive without ticket_api_base_url: TicketSystemConfig/TicketClientFactory,
                  BandelTicketClient, TicketDtos, TicketStatusMap, TicketStatusSync,
                  TicketImportService
  Usage/        — OAuth usage monitor, gate, throttle, per-session token reader;
                  TokenTracker/ = the external analytics backend (cost + per-model/per-task breakdown)
```

Interfaces (`IQueueWaker`, `IPrimeClock`, `ITaskStateService`, …) live in an `Interfaces/`
subfolder within their area; the namespace stays the area namespace.

## Architecture

- **Program.cs** — loads config, inits schema, registers DI, configures SignalR on `/hub`, binds to `127.0.0.1:47821`
- **TaskStateService** — the **only** component that writes `Status`, `PlanningPhase`, `BlockedByTaskId`, `DependsOnTaskId`. All transitions return a `TransitionResult` (no exceptions on invalid moves). Wakes the queue and advances the planning chain on child terminal transitions. Every transition broadcasts via a private `NotifyAsync(taskId)` — never call `_broadcaster.TaskUpdated` directly from this class, `NotifyAsync` also drives `TicketStatusSync.SyncAsync` (see Tickets below) and a direct call skips that transition's ticket write-back. `SetDependsOnAsync` rejects a self-reference, an unknown dependency id, or a link that would create a cycle (walks the proposed predecessor's own `DependsOnTaskId` chain).
- **IQueueWaker / IQueuePicker / QueueService** — waker is a singleton `SemaphoreSlim`; picker performs the atomic `Queued → Running` claim filtered by `BlockedByTaskId IS NULL`, `is_manual = 0`, schedule, and (`DependsOnTaskId IS NULL` OR the dependency's `Status = 'done'`); QueueService is a thin `BackgroundService` looping on the waker, dispatching via `TaskRunner`. Per tick it also applies the usage throttle and gate → [usage-monitoring](../../docs/explore-notes/usage-monitoring.md).
- **RunCancellationRegistry** — taskId → running-run CTS. Lets `TaskStateService.CancelAsync` kill a cancelled task's process without a DI cycle.
- **OverrideSlotService** — owns `RunNow` / `ContinueTask`; goes through `TaskStateService.StartRunningAsync` (caller-driven, serialized by slot lock).
- **StaleTaskRecovery** — startup-only; calls `TaskStateService.RecoverStaleRunningAsync` to flip orphaned `Running` rows to `Failed`.
- **External/*** — always-on MCP tools for general Claude sessions, scoped to *starting* and *observing* sessions (no multi-turn, planning internals, or app-settings writes). Auth via optional `X-ClaudeDo-Key`. **Task-ID resolution:** every tool parameter that accepts a task id (`taskId`, `parentId`, `taskIds`, etc.) is wired through `TaskIdResolver`, which accepts both `#123` (short number-based handle) and bare GUIDs. A bare all-digits string is never a GUID and is unambiguous. Branch names and worktree paths continue to use the GUID (`claudedo/{id}`) — the `Number` is a display alias, never identity. Every task-returning tool stamps the task's `Number` in its DTO payload and the MCP tool descriptions carry a shared clause instructing the agent to **refer to tasks as `#<number>` when reporting to the user** (otherwise the number sits unused in the payload). **Hard convention (test-enforced):** every optional/filter parameter needs a C# default value — the MCP schema only marks a parameter optional when one exists; `ExternalMcpToolSchemaTests` guards this. **Other conventions (not test-enforced):** no tool returns bare `Task`/a nullable payload directly (write tools return a confirmation record like `RunTaskNowResult`; read tools use an explicit `Found`/`Available` flag); tool descriptions follow rules documented in `External/McpToolDocs.cs`. Full tool inventory + per-tool behaviour → [external-mcp](../../docs/explore-notes/external-mcp.md).

## Status Model

`TaskEntity` carries orthogonal fields — lifecycle, planning hierarchy, and chain blocking are
not conflated.

| Field | Values | Meaning |
|---|---|---|
| `Status` | `Idle`, `Queued`, `Running`, `WaitingForChildren`, `WaitingForReview`, `Done`, `Failed`, `Cancelled` | Lifecycle only. `WaitingForChildren` = parent's own work done, waiting on children. |
| `PlanningPhase` | `None`, `Active`, `Finalized` | Parent-only marker. `Active` ≈ legacy `Planning`; `Finalized` ≈ legacy `Planned`. |
| `BlockedByTaskId` | nullable FK | Replaces legacy `Waiting`. A queued row with a non-null value is skipped by the picker. Internal to `PlanningChainCoordinator` — resolves (or cascades) on ANY terminal state of the predecessor. Because it self-resolves, `wait_for_task_change` deliberately keeps **waiting** through it instead of reporting `"Blocked"`. |
| `DependsOnTaskId` | nullable FK | User/MCP-declared predecessor (`add_task`/`update_task`), separate from `BlockedByTaskId` because the semantics differ: the picker only skips a queued row while the dependency's `Status` isn't `Done` -- a Failed/Cancelled dependency does **not** cascade or auto-resolve, the dependent just stays blocked (see `QueuePicker`, `TaskStateService.SetDependsOnAsync`). `get_task`/`list_tasks`/`batch_get_tasks` surface this as `blocked`/`blockedReason`; `wait_for_task_change` reports `"Blocked"` instead of silently running out its timeout. |
| `IsManual` | bool | Reminder only the user can do. `EnqueueAsync`/`StartRunningAsync` refuse it, the picker skips it, `GetDailyPrepCandidates` never offers it. An interactive ConPTY session is still allowed. |
| `ReviewFeedback` | nullable string | Reviewer's rejection comment; consumed and cleared by `QueueService` on the next re-run. |

Allowed transitions (enforced by `TaskStateService`):

```
Idle               → Queued | Running (RunNow) | Cancelled (external update_task_status only, allowFromIdle: true)
Queued             → Running | Cancelled | Idle | Failed (OverrideSlotService preflight gap)
Running            → WaitingForReview (standalone success, no children)
                   | WaitingForChildren (parent with pending children)
                   | Done (planning/improvement child success) | Failed | Cancelled
WaitingForChildren → WaitingForReview (all children terminal) | Cancelled
WaitingForReview   → Done (approve) | Queued (reject-rerun, +feedback) | Idle (reject-park) | Cancelled
Done               → Idle (re-run)
Failed             → Idle | Queued
Cancelled          → Idle | Queued

any except Running → Done (manual done toggle, SetTaskDone)
```

The manual done toggle is the one cross-cutting transition — it ticks a task off from **any**
status except `Running`, skipping the merge on purpose (a worktree-less list-handler task has
nothing to merge; a worktree task's branch just stays Active for the worktrees overview).

**Unified parent model.** Every parent — planning *or* improvement — flows
`… → WaitingForChildren → WaitingForReview → Done` via the single `TryAdvanceParentAsync`.
Planning/improvement **children** go straight to `Done`; only the parent is reviewed.

**Approve = merge the whole unit.** `ApproveReview` / `review_task` approve is the single
review+merge action — there is no separate "Merge all". Details, plus the post-merge verify
gate and the conflict resolver → [review-merge](../../docs/explore-notes/review-merge.md).

## Planning Flow

`PlanningSessionManager.FinalizeAsync` is the single path:

1. `_state.FinalizePlanningAsync(parent)` flips `PlanningPhase` to `Finalized` and `Status` to `WaitingForChildren` (or `WaitingForReview` if childless).
2. `PlanningChainCoordinator.SetupChainAsync(parent, enqueue: false)` establishes the blocked-by chain (child[i] → child[i-1]) but **leaves children `Idle`** — finalize never auto-queues. Queueing is a deliberate user action: `QueuePlanAsync` (hub `QueuePlanningSubtasksAsync`, the "Queue plan" button) calls `SetupChainAsync(parent, enqueue: true)`.
3. Once queued, the first child is woken automatically; successors unblock as their predecessor reaches a terminal state via `OnChildFinishedAsync`.

A child that hits a roadblock (fails, or reports `CLAUDEDO_BLOCKED`) does **not** advance the
parent — it stays in `WaitingForChildren` until every child is terminal, with blocked children
surfaced on the parent's Session tab instead.

`TaskRepository.FinalizePlanningAsync` no longer exists; the `Mark*Async` repository helpers are
`internal` — only `TaskStateService` calls them.

## Task Execution Pipeline

`TaskRunner` orchestrates: load task + config → worktree or sandbox → mark running → build args →
spawn `ClaudeProcess` → stream NDJSON through `StreamAnalyzer` → on success auto-commit + store
run record, on failure retry once via `--resume` then fail.

Full flow, invariants, and model/effort/max-turns resolution (including the low-preset turn trap)
→ [worker-task-pipeline](../../docs/explore-notes/worker-task-pipeline.md).

## Key Components

- **ClaudeCliPreflight** — startup check that `claude --version` runs; resolves the binary via `ExecutableResolver` first and returns a clear "not found on PATH" result instead of a raw `Process.Start` exception when it doesn't. *(Not yet on `main` — see below.)*
- **ClaudeProcess** — spawns `claude -p --output-format stream-json --verbose --permission-mode auto` (or whatever app settings specify). Prompt via stdin, NDJSON from stdout. CancellationToken kills the process tree. Resolves `_cfg.ClaudeBin` via `ExecutableResolver` (`ClaudeDo.Data`, shared with `ClaudeCliPreflight` and the Installer's checks) before spawning — a `.cmd`/`.bat` shim (e.g. an npm-installed `claude`) is launched through `cmd.exe /c` since `UseShellExecute = false` can't exec a shim directly; a resolved `.exe` starts exactly as before. Throws if nothing resolves. *(Not yet on `main` — see `Environment Checks` in `ClaudeDo.Installer/CLAUDE.md`.)*
- **ClaudeArgsBuilder** — `--model`, `--effort`, `--max-turns`, `--append-system-prompt`, `--agents`, `--json-schema`, `--resume`
- **StreamAnalyzer** — parses NDJSON; extracts session_id, token counts, turn counts, result text, structured output. Replaced MessageParser.
- **WorktreeManager** — worktrees on `claudedo/{taskId[:8]}` branches; commits with semantic messages, updates DB with head commit + diff stats
- **CommitMessageBuilder** — `{commitType}(slug): title\n\ndescription\n\nClaudeDo-Task: taskId`; `BuildMerge` is the merge-commit variant (`{commitType}(slug): merge title` + trailer). **Every merge caller passes a blank commit message on purpose** — `TaskMergeService` fills in `BuildMerge` from the task's commit type and its list's name, which is the only place that knows both. Don't reintroduce a caller-side literal.
- **TaskResetService** — discards a failed task's worktree, resets the row to Idle, preserves run history
- **AgentFileService** — manages `~/.claudeDo/agents/*.md`; list/refresh via SignalR
- **LogWriter** — async StreamWriter wrapper, auto-creates parent dirs

Each CLI invocation is recorded in `task_runs` via `TaskRunRepository`. ⚠️ Token fields come from
the **session transcript**, not the stream-json result event, as a per-run delta →
[usage-monitoring](../../docs/explore-notes/usage-monitoring.md). `TaskRunner.ContinueAsync` sends
a follow-up prompt to an existing session via `--resume <session_id>`.

## Prime Claude

Every schedule carries a `PrimeActionKind` (`Ping` — the default for new schedules — `FillMyDay`,
or `Custom`). `PrimeRunner.FireAsync` holds one `SemaphoreSlim` gate for **all** kinds, then
branches:

| Kind | Prompt | Args | Log + `Prep*` events |
|---|---|---|---|
| `Ping` | `PrimePrompts.PingPrompt`, fixed | `--max-turns 1 --strict-mcp-config` (no MCP server loads at all) | none |
| `FillMyDay` | `DailyPrepPrompt.BuildPrompt` (+ the schedule's prompt as an *addition*) | the two MyDay MCP tools | `daily-prep.log`, truncated per run, + `PrepStarted`/`PrepLine`/`PrepFinished` |
| `Custom` | the schedule's prompt *verbatim* | `--allowedTools mcp__claudedo` — no Read/Write/Edit/Bash | none |

Ping and Custom deliberately write **no** log and raise **no** `Prep*` events: the prep log
belongs to the MyDay selection and would otherwise be overwritten with ping noise. All three
kinds still broadcast `PrimeFired` and update `last_run_at`.

`WorkerHub.RunDailyPrepNow` (the "Plan day" button) pins its synthetic schedule to `FillMyDay` —
the button means "fill my day" no matter how the schedules are configured.
`WorkerHub.UpsertPrimeSchedule` runs `PrimeScheduleValidation.Validate` first, so a `Custom`
schedule can never be saved without a prompt.

**PrimeScheduler** (`BackgroundService`) computes the next due time from `prime_schedules` and
calls `IPrimeRunner.FireAsync`; manual runs arrive via `WorkerHub.RunDailyPrepNow`. The
`SemaphoreSlim` single-flight gate lives **in `PrimeRunner`**, not the scheduler, so both paths go
through it (returns "already running").

**PrimeRunner** builds a fixed prompt via `DailyPrepPrompt.BuildPrompt` (parameterized by
`AppSettings.DailyPrepMaxTasks` + today's date), then runs `claude -p` with
`--permission-mode acceptEdits --max-turns 30` and `--allowedTools
mcp__claudedo__get_daily_prep_candidates mcp__claudedo__set_my_day`. It relies on the
globally-registered `claudedo` MCP (installer's `RegisterMcpStep`) — no separate `--mcp-config`.

Claude then calls `get_daily_prep_candidates`, picks an effort-aware subset capped at
`DailyPrepMaxTasks`, and marks them via `set_my_day`. Each stdout line goes to the UI via
`IPrimeBroadcaster.PrepLineAsync` **and** to `DailyPrepPrompt.LogPath()` =
`<appdata>/logs/daily-prep.log`, which is **truncated at the start of each run** (last run only).
`PrepStarted`/`PrepFinished` bracket the run.

## SignalR Hub

`WorkerHub` is the canonical method list — grep it rather than trusting a doc inventory. Its wire
DTOs live in `ClaudeDo.Data/Wire.cs` (shared with `IWorkerClient` via a `<Using>` item), **not** in
`WorkerHub.cs` — don't re-declare one locally, that's how `MergePreviewDto` lost its verify fields
on the client side.
Groups: execution · review/merge · conflict resolver · planning sessions · interactive ConPTY
launch specs · worktrees · agents/settings/lists · reports/notes/prep · diagnostics · usage.
`IWorkerClient` in `ClaudeDo.Ui` mirrors it.

**HubBroadcaster** events (one per line so parallel features don't collide on the same line):
- `TaskStarted`
- `TaskFinished`
- `TaskMessage`
- `WorktreeUpdated`
- `TaskUpdated`
- `ListUpdated`
- `WorkerLog`
- `PrimeFired`
- `PrepStarted`
- `PrepLine`
- `PrepFinished`
- `OperationProgress` (generic `(opKey, phase, current, total)` channel; merge phases are its
  first producer — `IWorkerClient.MergeProgressEvent` on the Ui side is a thin forwarder over it
  for existing consumers, elapsed seconds riding in the `current` slot)
- `PlanningMergeStarted`
- `PlanningSubtaskMerged`
- `PlanningMergeConflict`
- `PlanningMergeAborted` (`planningTaskId, reason` — every call site passes a human-readable
  reason now, from a real merge failure's `ErrorMessage` to a plain "Merge aborted."; the UI
  flashes it via `FlashFooterError`)
- `PlanningCompleted`
- `RefineStarted`
- `RefineFinished`
- `UsageUpdated`

`WorkerLog` carries two sources: hand-curated business events (`_broadcaster.WorkerLog(...)` in
TaskRunner/TaskMergeService/TaskResetService) **and** every Serilog Warn/Error, re-broadcast by
`BroadcastLogSink` (deduped within a 120 s per-message window; SignalR plumbing source-contexts
filtered to avoid feedback loops). The sink also buffers **all** levels into `LogRingBuffer` for
`GetRecentLogs`.

## Findings store

Each list's working directory gets a `.claudedo/` folder: one markdown file per finding under
`traps/`, plus an `INDEX.md` that `FindingsStore` **rebuilds from disk on every save** (so findings
deleted or renamed by hand self-heal). Findings are traps and invariants only — lasting,
non-obvious, behaviour-changing. A fixed bug is git history, not a finding.

- **Written** via one `save_finding` tool registered on *both* MCP surfaces: `External/FindingsMcpTools`
  (needs a `list` argument; refuses rather than guessing when several lists qualify) and
  `Runner/TaskRunFindingsMcpTools` (resolves the list from `TaskRunMcpContextAccessor`). Same slug
  overwrites. Both go through `FindingsStoreLocator`, which always resolves to the list's
  `WorkingDir` — the **main checkout**, never a worktree copy, or parallel runs would collide on
  `INDEX.md`.
- **Read** with plain `Read`; there is deliberately no read tool. `TaskRunner.BuildFindingsPointer`
  adds one system-prompt layer naming the index, and only when the index file exists.
- `ListEntity.FindingsTracked` decides whether the folder is committed. When false, `FindingsStore`
  writes `/.claudedo/` to `.git/info/exclude` via `GitExcludeWriter` — per-clone, no tracked file
  touched.
- `INDEX.md` is read by every run, so it must stay short: `FindingsStore.WarnThreshold` (80) flips
  `nearCapacity` in the tool result as a prune signal.

## Config

`~/.claudeDo/worker.config.json`:

- `db_path`, `sandbox_root`, `log_root`
- `worktree_root_strategy` (`sibling` | `central`), `central_worktree_root`
- `queue_backstop_interval_ms` (30000) — also the gate/throttle recovery timer
- `signalr_port` (47821), `claude_bin`, `ticket_api_base_url` — the two UI-writable fields
  (`claude_bin`: Settings → Dateien → CLAUDE CLI, hub `GetClaudeBin`/`SetClaudeBin`;
  `ticket_api_base_url`: Settings → Ticketsystem, hub `GetTicketSettings`/`SetTicketApiBaseUrl`).
  Both edit the DI-singleton `WorkerConfig` in place *and* persist via their own `SaveKey` call
  (`SaveClaudeBin`/`SaveTicketApiBaseUrl`), so each applies without a restart. Every other field
  here is hand-edit only.
- `usage_poll_interval_seconds` (60, clamped to min 15 on load)
- `online_inbox` — `enabled` (false by default; when false the entire `Online/` stack is not registered), `api_base_url` (must be HTTPS or loopback, validated at startup), `poll_interval_seconds` (60), `zitadel.authority`/`client_id`/`scopes`. The refresh token is **not** in this file — DPAPI-encrypted at `~/.claudeDo/online-inbox.token`.

Per-list config (`list_config` in DB) provides defaults for `model`, `system_prompt`,
`agent_path`, `max_turns`, `session_skills`, `permission_mode`; tasks override each individually
(resolution lives in `EffectiveRunConfigResolver`, so `TaskRunner` and
`get_effective_run_config` can't drift). `verify_command` is list-only — no task-level override —
and is written via `set_list_config`'s `verifyCommand` parameter (`ConfigMcpTools`) and the UI's
list config editor; `permission_mode` and `ticket_project_id` are UI-only (`set_list_config`
preserves but does not expose them).

## Tickets (Bandel ticket system)

Optional, off by default. `TicketSystemConfig.IsConfigured` is the single gate (`worker.config.json`'s
`ticket_api_base_url` set **and** a PAT present) — false costs nothing: no DB query, no network. The
PAT is DPAPI-encrypted at `~/.claudeDo/ticket.pat` via `DpapiTokenStore` (same class the Online Inbox
refresh token uses, one instance per file, DI markers `OnlineRefreshTokenStore`/`TicketPatStore`
distinguish the two singletons). The token never touches `worker.config.json` or the DB.

- **Import** (`TicketImportService`, hub `ImportTickets`) — per-list, manual only (no polling): pulls
  the linked project's board (`ListConfigEntity.TicketProjectId`), keeps tickets that are `StatusId
  == 1` (Offen) *and* assigned to the PAT owner (case-insensitive name match), skips any `TicketRef`
  already present on the list, and creates one `Idle` task per remainder via `TaskRepository.AddAsync`
  (so it gets a `Number` like every other task). Already-imported tickets are never updated — a
  re-import would overwrite task notes.
- **Write-back** (`TicketStatusSync`) hangs off `TaskStateService.NotifyAsync` — the one place every
  status transition passes through. Mapping: `Running`/`WaitingForReview` → ticket status `2
  InBearbeitung`, `Done` → `3 Fertig`, everything else writes nothing (`1 Offen` is never written
  back — it's the entry state). A task without a `TicketRef`, or a `TicketRef` not shaped
  `bandel:<id>`, is a no-op before any DB read. Per-process de-dupe (`taskId → last-written status`)
  skips the redundant `Running → WaitingForReview` PATCH. Never throws — a ticket-system failure logs
  a warning and does not block the status transition or the queue.
- `BandelTicketClient` wraps the four REST calls (`GET /api/ticketsystem/pat/me`, `GET
  /api/Navigation/sidebar`, `GET /api/Board/project/{id}`, `PATCH /api/Ticket/{id}/status`); every
  response is unwrapped from `BandelEnvelope<T>` and a non-2xx/`success:false` throws
  `TicketApiException` with a readable message. ⚠️ The endpoint shapes were taken from the
  `Bandel.APIs` source, never confirmed against a live call — see `docs/open.md`.

## Notes

- The worker runs standalone — start it separately from the UI. Loopback only (127.0.0.1).
- `--permission-mode auto` by default; legacy `bypassPermissions` settings map to `auto` at dispatch time. `acceptEdits`, `plan`, `default` pass through unchanged.
- Worktree branches follow `claudedo/{id}`.
- **Both MCP servers run `Stateless = true`** (`Program.cs`). No `Mcp-Session-Id`, so a worker restart can't 404 a session that outlives it, and a 2026-07-28 client isn't refused back to the initialize handshake. The trade: **no** sampling, elicitation, resource subscriptions, unsolicited notifications, or legacy SSE endpoint — don't reach for those. In-tool progress notifications ride the POST's own response stream and work in both modes. All three consumers (`TaskRunner`, `PlanningSessionManager`, the installer's `RegisterMcpStep`) already use `type: "http"`.
- **An MCP tool that can run longer than ~5s reports progress.** Staying silent lets the MCP client abort after 300s idle while the worker keeps working — the caller sees an abort even though the operation is still running. `Lifecycle/ProgressReporter` is the one implementation (elapsed-time reporting via `RunAsync`, per-item `i/n` via `ReportItem`) — thread an `IProgress<ProgressNotificationValue>? progress = null` parameter through instead of writing another polling loop.
