Compare commits
165
Commits
v2.7.0
...
f3e6655fc2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f3e6655fc2 | ||
|
|
bf19e84e76 | ||
|
|
32ef88526e | ||
|
|
30efbcbf2f | ||
|
|
96723f4582 | ||
|
|
967359d6e7 | ||
|
|
ab888e5291 | ||
|
|
7337312eca | ||
|
|
c07225f530 | ||
|
|
ff52fb9eb1 | ||
|
|
109e85da83 | ||
|
|
4a28bfe82e | ||
|
|
27d50b81ff | ||
|
|
166021049a | ||
|
|
a78cc526a6 | ||
|
|
4310f88ebf | ||
|
|
b97f55bfb6 | ||
|
|
bac8387069 | ||
|
|
d1ed29f19f | ||
|
|
8dbdfb3b89 | ||
|
|
ddf68d66cc | ||
|
|
bd83cac57d | ||
|
|
b2940bf1ff | ||
|
|
7f03f04268 | ||
|
|
cc90600f72 | ||
|
|
003e7b2b78 | ||
|
|
c90f93e57f | ||
|
|
4d9ceefee2 | ||
|
|
176ba78e11 | ||
|
|
57c61a2043 | ||
|
|
79f90a9a8e | ||
|
|
efee14780b | ||
|
|
42a234b46e | ||
|
|
774f9d3d13 | ||
|
|
ed7cd7dcf6 | ||
|
|
d84607f796 | ||
|
|
6118698d8c | ||
|
|
1a988ff4fc | ||
|
|
9e2a15e421 | ||
|
|
1649291ef0 | ||
|
|
58741c2bd6 | ||
|
|
7affb4c204 | ||
|
|
0d1e3b9a6f | ||
|
|
aac84e48b7 | ||
|
|
0878bae19e | ||
|
|
20bce9b424 | ||
|
|
5d1d2d89d0 | ||
|
|
0358bcf8b2 | ||
|
|
c6213d77c4 | ||
|
|
e59f6c2438 | ||
|
|
46e177eb59 | ||
|
|
abac5e5150 | ||
|
|
7240b39f16 | ||
|
|
4a60c2bfa8 | ||
|
|
03c4ed4607 | ||
|
|
f3e16412f4 | ||
|
|
db4177db83 | ||
|
|
8bc7bc0c4f | ||
|
|
af16830060 | ||
|
|
b54a133c16 | ||
|
|
8247a749a0 | ||
|
|
f8c48e2ed7 | ||
|
|
3e07536ee9 | ||
|
|
6980aae77e | ||
|
|
75561930d7 | ||
|
|
b66ce580de | ||
|
|
70322c203c | ||
|
|
db1775fb69 | ||
|
|
76dfbc87eb | ||
|
|
7cfe280a23 | ||
|
|
e38fcec8cc | ||
|
|
423ab7f2a9 | ||
|
|
2ad9bdd851 | ||
|
|
46c664a03f | ||
|
|
445242cd7d | ||
|
|
86f962ee7d | ||
|
|
68aa2f5f4b | ||
|
|
76b748060c | ||
|
|
175f160956 | ||
|
|
cfd2936c25 | ||
|
|
3462ca1355 | ||
|
|
e717c901b2 | ||
|
|
0f187d8e82 | ||
|
|
f106c890b3 | ||
|
|
56f7d64f07 | ||
|
|
091aca521f | ||
|
|
730ecb1abc | ||
|
|
b6ecbb13f5 | ||
|
|
b5a8d58e62 | ||
|
|
b6791265d1 | ||
|
|
e113987d2e | ||
|
|
4309c4cb08 | ||
|
|
41c2de5ab0 | ||
|
|
a35103418d | ||
|
|
1b0ecbe361 | ||
|
|
83ca1fc6dc | ||
|
|
09e0772673 | ||
|
|
7e1b1177de | ||
|
|
b3a8373c70 | ||
|
|
e7c9ef891f | ||
|
|
ae6e95d2c0 | ||
|
|
8bb1014e54 | ||
|
|
66d2dae316 | ||
|
|
4988a42620 | ||
|
|
7c4bce63d3 | ||
|
|
ad2d91f658 | ||
|
|
cdfd0614dd | ||
|
|
1d258a3e2c | ||
|
|
23794ed21b | ||
|
|
0ce5bad9c1 | ||
|
|
f143d5fc18 | ||
|
|
487e75c031 | ||
|
|
583c98f2b7 | ||
|
|
adc0eaebb1 | ||
|
|
c25300526e | ||
|
|
49f78d2b8d | ||
|
|
a6af90ff0d | ||
|
|
d1df54cf5c | ||
|
|
be378ae0ea | ||
|
|
23b282573c | ||
|
|
6887b1d08d | ||
|
|
860201017c | ||
|
|
df16989435 | ||
|
|
d43b5fcefc | ||
|
|
29bd1b5069 | ||
|
|
a7d95a000a | ||
|
|
beef57fba2 | ||
|
|
a768bc4163 | ||
|
|
ecba12997a | ||
|
|
bee06bc307 | ||
|
|
4ef01274f9 | ||
|
|
8c251c78b1 | ||
|
|
2975f90f9d | ||
|
|
95eba1d5e7 | ||
|
|
663b09e0a0 | ||
|
|
5cc1ec98c0 | ||
|
|
05be07b28c | ||
|
|
d743a9d0e9 | ||
|
|
45bc324402 | ||
|
|
7fa43b5737 | ||
|
|
30bc12b0ac | ||
|
|
8b26e23d73 | ||
|
|
e88f9d01e6 | ||
|
|
dce42a9c22 | ||
|
|
bdee731376 | ||
|
|
d15aa27707 | ||
|
|
2700c3d817 | ||
|
|
f6cb8250bb | ||
|
|
79a1403834 | ||
|
|
91eb2996c8 | ||
|
|
19003e68b6 | ||
|
|
2a217336e8 | ||
|
|
18fb483788 | ||
|
|
f381ef28fd | ||
|
|
ca184511ae | ||
|
|
fe9a9596bd | ||
|
|
b153869216 | ||
|
|
2ebdadff08 | ||
|
|
b1e4e543dd | ||
|
|
a201d3f43d | ||
|
|
7d3d6d7b54 | ||
|
|
08ac8bf7b1 | ||
|
|
3ea48ff76d | ||
|
|
f02b7d6ee5 | ||
|
|
0e0438d3f2 |
+138
-1
@@ -1,5 +1,142 @@
|
||||
# Changelog
|
||||
|
||||
## v2.8.0 — 2026-08-06
|
||||
|
||||
### Features
|
||||
|
||||
- persist interactive session id so a closed/aborted ConPTY session can be resumed (1a988ff)
|
||||
- repo scan discovers nested repos in subfolders (7affb4c)
|
||||
- warn when the running worker predates the selected repo's merged HEAD (8bc7bc0)
|
||||
- add treatWaitingForChildrenAsBusy to wait_for_task_change (af16830)
|
||||
- warn on near-duplicate titles in add_task/batch_add_tasks (3e07536)
|
||||
- add Diagnose section to SettingsWindow (7e1b117)
|
||||
- raise wait_for_task_change timeout, expose queue slot state (d43b5fc)
|
||||
- add get_effective_run_config MCP tool (a768bc4)
|
||||
- hand off to a fresh session after Phase 2 (4ef0127)
|
||||
- add copy-last-40-lines button to log visualizer (8c251c7)
|
||||
- make max-turns ceiling editable in General tab (2975f90)
|
||||
- add SystemCheckPage with blocking-error gating (5cc1ec9)
|
||||
- pull in preflight check implementations as prerequisite for SystemCheckPage (05be07b)
|
||||
- add Claude CLI preflight checks (found, version, login, auto-mode) (d743a9d)
|
||||
- add Git, GitIdentity, Port, and WriteAccess preflight checks (45bc324)
|
||||
- add check abstraction and EnvironmentCheckService (8b26e23)
|
||||
- clamp max-turns to a configurable ceiling (08ac8bf)
|
||||
- make overview panes and queue strip individually resizable (3ea48ff)
|
||||
- add reading-discipline section to default system prompt (f02b7d6)
|
||||
|
||||
### Fixes
|
||||
|
||||
- honor RunCancellationRegistry.Register's return value at both dispatch sites (109e85d)
|
||||
- treat an immediate ConPTY exit as a start failure (4a28bfe)
|
||||
- surface merge-drain cancel rejection in task-row quick cancel (27d50b8)
|
||||
- close interactive-session gate bypasses in reset-and-retry and plan queueing (1660210)
|
||||
- surface worker-offline failures when deleting a task (a78cc52)
|
||||
- bound the ConPTY launch-gate wait so a hung launch can't freeze other panes (4310f88)
|
||||
- reparse-check repo-scan root folder and move scan off UI thread (b97f55b)
|
||||
- serialize ConPTY env launch and close open-path dedupe races (176ba78)
|
||||
- gate Mission Control submit-for-review, add retry, fix event leak (57c61a2)
|
||||
- block cancelling a task while its unit merge is draining (79f90a9)
|
||||
- filter the transliterated 'fuer' stopword, not the untransliterated 'fur' (efee147)
|
||||
- claim Running before creating run resources (774f9d3)
|
||||
- gate queueing on an open interactive ConPTY session (d84607f)
|
||||
- route details-pane task delete through worker to advance blocked parents (9e2a15e)
|
||||
- advance planning chains and parents on stale-Running recovery (58741c2)
|
||||
- keep ConPTY sessions alive across Mission Control layout rebuilds (aac84e4)
|
||||
- suppress auto-open conflict resolver during MCP-driven merges (5d1d2d8)
|
||||
- release the ShellLink COM object so reading a .lnk doesn't lock it (c6213d7)
|
||||
- surface empty review ranges and blocked children over MCP (e59f6c2)
|
||||
- clean up three review leftovers from the list-handler run (b54a133)
|
||||
- make list_tasks/batch_get_tasks lean by default (f8c48e2)
|
||||
- restore ForegroundHelper.AllowAny call before wt.exe launch (46c664a)
|
||||
- skip rewriting the autostart shortcut when already current (445242c)
|
||||
- remove duplicate list-settings context menu entry (68aa2f5)
|
||||
- read the persisted MCP port on update instead of the wizard default (3462ca1)
|
||||
- show the MCP registration step in the progress list (e717c90)
|
||||
- refresh the run session id live, close the stale handoff pane (f106c89)
|
||||
- stop NumericUpDown from writing null into non-nullable settings (56f7d64)
|
||||
- apply the verify gate to worktree-less approvals and report it everywhere (091aca5)
|
||||
- recover the planning session id from the on-disk transcript (b679126)
|
||||
- make Files-tab system prompts view-only, drop external-editor edit path (6887b1d)
|
||||
- broadcast TaskUpdated on queue-claimed task start (8602010)
|
||||
- remove duplicate Let-Claude entry point and icon collision (df16989)
|
||||
- filter EF Core/ASP.NET Core noise out of log ring buffer (29bd1b5)
|
||||
- clarify worktree commits aren't auto-commits (a7d95a0)
|
||||
- resolve claude CLI shims (.cmd/.bat) not just .exe on PATH (e88f9d0)
|
||||
- stop 429s with an activity-dependent poll cadence + manual refresh (2700c3d)
|
||||
- pass FakeTranscriptUsageReader to TaskRunner in FailureDiagnosisTests (f6cb825)
|
||||
- stop on-disk prompt overrides from freezing forever (b153869)
|
||||
- surface the real reason a Claude run failed instead of a generic exit-code message (2ebdadf)
|
||||
- record real raw token usage per run, not the uncached remainder (7d3d6d7)
|
||||
|
||||
### Performance
|
||||
|
||||
- stop echoing task description from writing MCP tools (ecba129)
|
||||
|
||||
### Documentation
|
||||
|
||||
- document the ConPTY env race and open-path dedupe fixes (4d9ceef)
|
||||
- reflect claim-before-create ordering in worker-task-pipeline (42a234b)
|
||||
- document the queueing gate on open ConPTY sessions (ed7cd7d)
|
||||
- document interactive session id persistence and resume precedence (6118698)
|
||||
- document stale-Running chain/parent advance fix (1649291)
|
||||
- document the kill-on-detach gotcha and ConPtyPaneHost reparenting (0d1e3b9)
|
||||
- point both drift checks at the merged state (0878bae)
|
||||
- point external-mcp drift check at the merged state (03c4ed4)
|
||||
- evaluate planning-chain fork-base options, recommend fork-from-predecessor (db4177d)
|
||||
- record the verify-gate reach and the NumericUpDown null trap (0f187d8)
|
||||
- mark the usage-monitor pass done and drop the light-theme checks (730ecb1)
|
||||
- record the findings from the 2026-08-06 visual pass (b6ecbb1)
|
||||
- re-verify the 2026-07-24 findings and drop the fixed ones (b5a8d58)
|
||||
- drop the verified 2026-07-27 visual-pass block, keep its design notes (e113987)
|
||||
- mark the MaxTurnsCeiling editor as shipped (4309c4c)
|
||||
- drop the stale reserved-slot comment now that Claude Help Me shipped (41c2de5)
|
||||
- document Environment Checks + note unmerged prerequisite gap (a6af90f)
|
||||
- break growing enumerations into one entry per line (beef57f)
|
||||
- root-cause --permission-mode auto + CLI/.NET preflight research (dce42a9)
|
||||
- rename ConPTY session UI text to interactive session (b1e4e54)
|
||||
- update for v2.7.0 (0e0438d)
|
||||
|
||||
## v2.7.0 — 2026-08-05
|
||||
|
||||
### Features
|
||||
|
||||
- add usage monitor modal with gauges and model/task usage analysis (8103006)
|
||||
- add usage pill to footer and mission control header (7cbd4e6)
|
||||
- expose usage/model-usage hub surface and persist run model (d4cd202)
|
||||
- add roadblock reply field to the ROADBLOCK card (d2ca7fb)
|
||||
- record merge commit SHA and add revert_merge tool (10e561f)
|
||||
- let CreateChildTask set maxTurns on child tasks (65db1cd)
|
||||
- add post-merge verification gate for list merges (c9ba1e2)
|
||||
- add preview_merge and preview_merge_set MCP tools (394feba)
|
||||
- add splitting, turns-preflight, merge-order rules to list-handler prompt (8ed6e08)
|
||||
- add wait_for_task_change MCP tool (194ce58)
|
||||
- render task descriptions into the list-handler brief (b38b085)
|
||||
- add TranscriptUsageReader for per-model token usage (e926f4d)
|
||||
- add OAuth usage client + poller (20d17c6)
|
||||
- add usage gate thresholds and task run model column (bedd2de)
|
||||
- give the list handler its own review task (c07c1f7)
|
||||
- clear focus from textboxes on Escape in the main window (52c2186)
|
||||
- show newest log entries first in Log Visualizer (47edab5)
|
||||
|
||||
### Fixes
|
||||
|
||||
- install a real localizer in UsagePillViewModelTests (677a4c1)
|
||||
- avoid int CommandParameter cast crash in usage monitor presets (57326f1)
|
||||
- transport ConPTY task brief via file, not CLI argument (3972ce5)
|
||||
- drop notification for the removed CanPickUpInTerminal property (eee87f0)
|
||||
- normalize model alias before preset lookup, wire DefaultMaxTurns as fallback (7d636c6)
|
||||
- only ask list-handler dedupe questions when a candidate exists (fd50a4f)
|
||||
|
||||
### Refactoring
|
||||
|
||||
- remove pick-up-in-terminal, keep ConPTY as the single session entry (87de53e)
|
||||
|
||||
### Documentation
|
||||
|
||||
- mark the 2026-08-05 list-handler run complete (032debc)
|
||||
- document usage monitor & gate across CLAUDE.md files (cf9dd1c)
|
||||
- update for v2.6.0 (342a061)
|
||||
|
||||
## v2.6.0 — 2026-08-04
|
||||
|
||||
### Fixes
|
||||
@@ -541,7 +678,7 @@
|
||||
- initialize Localizer at app startup from config/OS culture (f529a5f)
|
||||
- add Language preference and Save() to AppSettings (6a85d82)
|
||||
- add Avalonia loc:Tr markup extension and LocalizedString (35ad171)
|
||||
- seed en.json and wire locale copy to app output (3c40bb5)
|
||||
- seed en.json and wire locale copy to app output (3c40bb5e)
|
||||
- add CultureResolver for OS-culture mapping (d95d55e)
|
||||
- add Localizer with fallback chain and change event (d22b50e)
|
||||
- add LocaleStore folder discovery (a83a0c4)
|
||||
|
||||
@@ -11,10 +11,13 @@ Two-process system communicating over SignalR (`127.0.0.1:47821`):
|
||||
- **ClaudeDo.Data** — SQLite data layer, repositories, models, GitService
|
||||
- **ClaudeDo.Worker** — ASP.NET Core hosted service, task queue, Claude CLI runner
|
||||
- **ClaudeDo.Localization** — `locales/en.json` + `locales/de.json` and the lookup service
|
||||
- **ClaudeDo.Releases** — Gitea release client (`IReleaseClient`), used by the Ui update check and the Installer
|
||||
- **ClaudeDo.Installer** — WPF (`UseWPF`) setup app; install/update/uninstall step pipeline
|
||||
- **tests/** — six xUnit projects (Worker, Data, Ui, Localization, Installer, Releases); Worker.Tests run real SQLite and real git
|
||||
|
||||
Each project has its own `CLAUDE.md` — those are the living per-project docs.
|
||||
Per-project `CLAUDE.md` files exist for **App, Data, Installer, Ui, Worker, and Worker.Tests** —
|
||||
those are the living per-project docs. Localization, Releases, and the other five test projects
|
||||
have none; this file plus the code is all there is for them.
|
||||
|
||||
## Tech Stack
|
||||
|
||||
@@ -39,7 +42,7 @@ Each project has its own `CLAUDE.md` — those are the living per-project docs.
|
||||
- EF Core migrations manage schema (Migrations/ folder in ClaudeDo.Data)
|
||||
- `IDbContextFactory<ClaudeDoDbContext>` used by singleton consumers (e.g. Worker)
|
||||
- Entity configuration via `IEntityTypeConfiguration<T>` in Configuration/ folder
|
||||
- Task status flow: Idle | Queued -> Running -> WaitingForReview -> Done | Failed | Cancelled. A task that spawns/has children passes through WaitingForChildren first, then surfaces for review once every child is terminal — this is the single parent model for both planning and improvement parents (planning/improvement *children* themselves go straight to Done, only the parent is reviewed). From review you can approve, reject-rerun (Queued, resumes the session with feedback), reject-park (Idle), or cancel. Approve is the single review+merge action: a childless task merges its own worktree then Done (conflicts keep it in WaitingForReview); a task with children drives the unit merge (parent worktree if any + each Done child in order, with conflict continue/abort). Tasks with no active worktree (sandbox run) approve straight to Done. In the detail pane, **Approve & Merge is gated behind opening the diff**: when there is something to inspect (worktree diff / merged range / children combined diff) the button stays disabled until the diff or combined-diff viewer has been opened once, and re-locks per run (any state change resets it); tasks with nothing to inspect are never gated. The row-level quick-approve in the task list is an intentional bypass.
|
||||
- Task status flow: `Idle | Queued -> Running -> WaitingForReview -> Done | Failed | Cancelled`; a task with children passes through `WaitingForChildren` first. **Approve is the single review+merge action** (no separate "Merge all"), and in the detail pane it's gated behind opening the diff. Full transition table → `src/ClaudeDo.Worker/CLAUDE.md`; merge/review/gate mechanics → `docs/explore-notes/review-merge.md`.
|
||||
- Worktree state flow: Active -> Merged | Discarded | Kept
|
||||
- The queue picker claims tasks by `Status=Queued` (with `BlockedByTaskId IS NULL`); the legacy tag system was removed
|
||||
- Interfaces live in an `Interfaces/` subfolder beside their consumers (namespace unchanged)
|
||||
@@ -84,4 +87,4 @@ dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release
|
||||
- `docs/improvement-plan.md` — improvement snapshot from 2026-04-13 (historical)
|
||||
- `docs/prompts-inventory.md`, `docs/mailbox-proposal.md` — reference material (mailbox integration is parked)
|
||||
- `CHANGELOG.md` — Keep a Changelog format, maintained on release
|
||||
- `docs/explore-notes/` — distilled maps of complex subsystems from deep exploration (detail too fine for a CLAUDE.md). **Before** deep-exploring a subsystem, check for a matching note first; **after** a deep explore, distill durable findings back and bump its "verified against" commit. Always verify against current code before trusting. See `docs/explore-notes/README.md`.
|
||||
- `docs/explore-notes/` — distilled maps of complex subsystems (detail too fine for a CLAUDE.md, read on demand). **Before** deep-exploring a subsystem, check for a matching note first; **after** a deep explore, distill durable findings back and bump its "verified against" commit. Always verify against current code before trusting. See `docs/explore-notes/README.md`. Current notes: `worker-task-pipeline`, `usage-monitoring`, `external-mcp`, `review-merge`, `conpty-sessions`, `installer-preflight`.
|
||||
|
||||
@@ -10,6 +10,17 @@ These sit **between** the CLAUDE.md files and the code:
|
||||
too fine-grained for a CLAUDE.md but stable enough to be worth caching. Read on demand.
|
||||
- **code** — the only source of truth.
|
||||
|
||||
## Index
|
||||
|
||||
| Note | Covers |
|
||||
|---|---|
|
||||
| [worker-task-pipeline](worker-task-pipeline.md) | `TaskRunner` end-to-end: config resolution, worktree, CLI invocation, streaming, commit |
|
||||
| [usage-monitoring](usage-monitoring.md) | OAuth usage endpoint, gate, throttle, per-run token accounting, usage pill/modal |
|
||||
| [external-mcp](external-mcp.md) | The `claudedo` MCP tool surface + its two test-enforced conventions |
|
||||
| [review-merge](review-merge.md) | Approve=merge-unit, verify gate, `MergeCommit`/revert, diff stack, conflict resolver |
|
||||
| [conpty-sessions](conpty-sessions.md) | Interactive/planning/list-handler launch specs + the arg-flattening gotcha |
|
||||
| [installer-preflight](installer-preflight.md) | CLI version/login/auto-mode research, the `ExecutableResolver`/shim root cause, and the Installer's `Checks/`+`SystemCheckPage` implementation status |
|
||||
|
||||
## Rules
|
||||
|
||||
- **Only stable structure.** Flows, responsibilities, entry points, invariants, relative
|
||||
|
||||
@@ -0,0 +1,257 @@
|
||||
# ConPTY interactive sessions & launch specs
|
||||
|
||||
> **Explore-note — verify before trusting.** Distilled map of a subsystem, not authoritative.
|
||||
> Last verified against commit `8dbdfb3` (2026-08-06).
|
||||
> Drift check: `git log --oneline bdee731..HEAD -- src/ClaudeDo.Worker/Planning src/ClaudeDo.Worker/Hub src/ClaudeDo.Worker/Runner/ClaudeArgsBuilder.cs src/ClaudeDo.Ui/ViewModels/MissionControlViewModel.cs src/ClaudeDo.Ui/Views/InteractiveTerminalView.axaml`
|
||||
> Stable structure only (no line numbers). See docs/explore-notes/README.md.
|
||||
|
||||
Covers `InteractiveLaunchSpecService` and the four kinds of embedded ConPTY session the UI
|
||||
process hosts (real `claude` TUI in a Mission Control tile).
|
||||
|
||||
Autonomous queue tasks are **not** covered here — they stay on the stream-json path via
|
||||
`TaskRunner`. See [worker-task-pipeline.md](worker-task-pipeline.md).
|
||||
|
||||
## The four session kinds
|
||||
|
||||
| Kind | Hub spec method | Notes |
|
||||
|---|---|---|
|
||||
| Task session | `GetInteractiveLaunchSpec` | Effort from the task/list model preset |
|
||||
| Ad-hoc | `GetAdHocLaunchSpec` | Effort from the global default |
|
||||
| Planning | (planning start/resume) | Effort from `PlanningAlias`; uses `--permission-mode default`, **not** `plan` |
|
||||
| List handler | `GetMergeHelperLaunchSpec` | Effort from list config; `--permission-mode auto` (unattended) |
|
||||
|
||||
## ⚠️ Gotcha: never pass task free-text as a CLI argument
|
||||
|
||||
**No ConPTY path ever passes task free-text (title / description / brief) as a CLI argument.**
|
||||
Every one of them writes it to a file first and hands `claude` a single-line kickoff pointing at
|
||||
that file, exposed via `--add-dir`.
|
||||
|
||||
Two independent reasons:
|
||||
|
||||
1. The ConPTY host flattens `Args` into **one command line** to spawn the process, and `claude`
|
||||
re-splits that line on whitespace. Any token starting with `-` in real task text (e.g. `->`,
|
||||
`--abort`) is then misread as an unknown option.
|
||||
2. A raw multi-line positional prompt truncates at its **first newline** regardless.
|
||||
|
||||
A fresh task session's brief lives at `~/.todo-app/task-sessions/<taskId>/brief.md`
|
||||
(`InteractiveLaunchSpecService.BuildFreshTaskArgsAsync`). A task with neither title nor
|
||||
description skips the file **and** the positional arg entirely — it still gets `--session-id`.
|
||||
|
||||
## Argument ordering
|
||||
|
||||
Every spec passes `--effort <level>` from the relevant model's preset. It **leads** the args —
|
||||
except for a fresh task session with a brief, where `--add-dir <sessionDir>` must come first so
|
||||
`--effort` (a single-value flag) can sit directly before the positional kickoff.
|
||||
|
||||
`--model` is deliberately **NOT** forced on an interactive session — the user can still switch
|
||||
models in the TUI.
|
||||
|
||||
## Resuming a task session (`TaskEntity.InteractiveSessionId`)
|
||||
|
||||
`claude --session-id <uuid>` lets the caller pre-assign a conversation's session id instead of
|
||||
waiting for the CLI to generate one. `BuildForTaskAsync` uses this so a closed or aborted
|
||||
interactive task session can be resumed even if it never got far enough to write anything to its
|
||||
own transcript:
|
||||
|
||||
1. **Resume check.** If the task isn't on a freshly (re)created worktree, `BuildForTaskAsync`
|
||||
picks a session to resume with `task.InteractiveSessionId ?? run?.SessionId` — this task's own
|
||||
last *interactive* conversation takes precedence over the latest *autonomous* run's session,
|
||||
since they're distinct conversations even against the same worktree. A task that has only ever
|
||||
run autonomously still resumes into that run's session the first time it's opened interactively
|
||||
(this is the pre-existing behavior `run?.SessionId` alone used to provide).
|
||||
2. **Fresh path.** If neither is available (never run any way, or `isFreshWorktree`), a new
|
||||
`Guid.NewGuid()` is generated and persisted to `TaskEntity.InteractiveSessionId` via
|
||||
`TaskRepository.SetInteractiveSessionIdAsync` — **before** the `LaunchSpec` is returned, i.e.
|
||||
before the ConPTY host ever spawns `claude`. `BuildFreshTaskArgsAsync` then passes it as
|
||||
`--session-id <guid>`, placed as the single-value flag directly before the positional kickoff
|
||||
(or, with no brief, right after `--effort`).
|
||||
3. **Fresh worktree wins.** `isFreshWorktree` forces `run` to `null` *and* is checked before
|
||||
reading `task.InteractiveSessionId`, so a recreated worktree never resumes a stale id from
|
||||
either source — it always takes the fresh path, which overwrites the stale
|
||||
`InteractiveSessionId` with the new one.
|
||||
|
||||
Net effect: reopening an interactive session for a task (pane closed, process killed, whatever)
|
||||
resumes the same claude conversation, because the id was committed to the DB before the previous
|
||||
launch even started.
|
||||
|
||||
## List handler ("Let Claude handle it")
|
||||
|
||||
`BuildForMergeHelperAsync` uses `--permission-mode auto` so it runs unattended. The
|
||||
`--allowedTools` allowlist is the security boundary:
|
||||
`mcp__claudedo__*,Read,Grep,Glob,Edit,Bash,WebFetch,WebSearch,Skill`.
|
||||
|
||||
`MCP_TOOL_TIMEOUT` is 200 s here — `TaskWaitMcpTools` clamps its own timeout to 170 s to stay
|
||||
comfortably under it (see [external-mcp.md](external-mcp.md)).
|
||||
|
||||
### The host task and its commit range
|
||||
|
||||
The handler run **owns a real ClaudeDo task**, created by `CreateMergeHelperTask` (hub) →
|
||||
`InteractiveLaunchSpecService.CreateMergeHelperTaskAsync`, called by the UI *before* it opens
|
||||
the tile:
|
||||
|
||||
- One new task per run in that list, `Idle` + `IsManual=true` (never queued).
|
||||
- Title/description localized via `missionControl.mergeHelperTaskTitle` /
|
||||
`mergeHelperTaskDescriptionHeader`.
|
||||
- `TaskEntity.HandlerBaseCommit` stamped to the list repo's current HEAD.
|
||||
|
||||
The host task **never gets a worktree of its own** — the handler commits straight into the
|
||||
list's working dir and merges the tasks it handles itself. Consequences:
|
||||
|
||||
- `SubmitTaskForReview` branches on whether the task has a `WorktreeEntity`: with one, it
|
||||
commits the worktree; without one, it stamps `HandlerHeadCommit` to the list repo's current
|
||||
HEAD. Both paths then flip the task `Idle`/`Failed` → `WaitingForReview`.
|
||||
- `GetTaskDiff` and the UI's `DetailsIslandViewModel` / `MergeSectionViewModel` fall back to the
|
||||
`HandlerBaseCommit`..`HandlerHeadCommit` range whenever `Worktree` is null.
|
||||
|
||||
### UI flow
|
||||
|
||||
`MergeHelperSelectionModalViewModel` — checkbox picker over one list's non-terminal, non-manual
|
||||
tasks, pre-ticking the actionable ones. **List-scoped only** (`Configure(listId, listName)`, no
|
||||
global scope). Opened from the list row's context menu, which is hidden when the list has no
|
||||
working dir.
|
||||
|
||||
On confirm: `ListsIslandViewModel` raises `LetClaudeHandleRequested` → shell →
|
||||
`MissionControlViewModel.OpenMergeHelperConPtySessionAsync`, which calls
|
||||
`CreateMergeHelperTaskAsync` and then opens a **task-based** tile (deduped by `TaskId` like
|
||||
`OpenConPtySessionAsync`, **not** `CreateAdHoc`) running the five-phase handler prompt.
|
||||
|
||||
## Tile lifecycle
|
||||
|
||||
`ConPtyPaneViewModel` resolves its own launch spec — the ctor takes a descriptor **factory**,
|
||||
the host wires handlers and then calls `Start()`. So the tile appears **immediately** with its
|
||||
spinner while the worker is still preparing the worktree. A failed launch keeps the tile with an
|
||||
inline error banner instead of the tile never appearing.
|
||||
|
||||
`SubmitForReviewCommand.CanExecute` also gates on `Terminal.IsStarting` / `StartError` /
|
||||
`HasExited` (not just `IsTaskBased`) — a starting or dead pane can't offer a review it would only
|
||||
have the worker reject, and `MissionControlViewModel.OnPaneSubmitForReview` sets the pane's
|
||||
`IsSubmitPending` flag for the duration of the round trip so a rapid double-click can't race two
|
||||
`SubmitTaskForReviewAsync` calls. A failed launch also offers `RetryCommand` (visible whenever
|
||||
`HasExited && StartError != null`) — it swaps in a fresh `InteractiveTerminalViewModel` and calls
|
||||
`Start()` again on the **same** pane/`TaskId` dedupe slot, since `PtyTerminalSession` throws on a
|
||||
second `StartAsync` call and can't be restarted in place.
|
||||
|
||||
### ⚠️ Gotcha: the terminal library kills its child on visual-tree detach
|
||||
|
||||
`Iciclecreek.Avalonia.Terminal`'s `TerminalView.OnDetachedFromLogicalTree` calls
|
||||
`CleanupProcess()` (kills the PTY child) unless `BeginReparent()` suppressed it — and Mission
|
||||
Control detaches pane views routinely (`RebuildOverviewGrid` recreates everything on any pane
|
||||
add/remove/column change; focus-mode tab switches re-present content). Two-part defense (since
|
||||
`aac84e4`):
|
||||
|
||||
1. `PtyTerminalSession.StartAsync` puts the control in **permanent reparent mode** right after
|
||||
`LaunchProcess()` — `EndReparent` is deliberately never called. Teardown is explicit only:
|
||||
`ConPtyPaneViewModel.Dispose` → `Terminal.Kill()` (pane close, VM disposal via DI on exit).
|
||||
2. `ConPtyPaneHost` (the DataTemplate content for a pane) reparents **one long-lived
|
||||
`ConPtyPaneView` per pane VM** (`ConditionalWeakTable`, view pins its own `DataContext`)
|
||||
instead of letting the template instantiate a fresh view — a fresh view would render a dead,
|
||||
empty terminal because the running session is bound to the original `TerminalControl`.
|
||||
Hosts only steal the view while `IsEffectivelyVisible`; the layout toggle posts a reclaim
|
||||
pass (`MissionControlView.ReclaimVisiblePaneHosts`) so the now-visible layout re-steals.
|
||||
|
||||
`Ellipse.spinner` (IslandStyles) is the shared indeterminate spinner — used for a starting pane
|
||||
(`InteractiveTerminalViewModel.IsStarting`) and in place of the refine button while
|
||||
`TaskRowViewModel.IsRefining`.
|
||||
|
||||
### ⚠️ Gotcha: env-var launch race across sessions
|
||||
|
||||
`PtyTerminalSession.StartAsync` applies `TerminalLaunchDescriptor.Env` via
|
||||
`Environment.SetEnvironmentVariable` onto the **whole UI process** (Porta.Pty has no per-launch
|
||||
env seam — it always inherits the calling process's environment), then calls
|
||||
`TerminalControl.LaunchProcess()`. Two sessions starting back-to-back (e.g. planning sessions for
|
||||
two different tasks) could interleave: task B's `SetEnvironmentVariable` calls could land between
|
||||
task A's env-set and its `LaunchProcess()` fork, so task A's `claude` process inherits B's env
|
||||
(e.g. `CLAUDEDO_PLANNING_TOKEN`) and fails its own MCP auth. Fixed by serializing the
|
||||
set-env-then-launch critical section behind a process-wide `static SemaphoreSlim(1,1)` in
|
||||
`PtyTerminalSession`. Env leakage onto the whole process *after* a launch has forked remains a
|
||||
documented limitation — only the fork-time race is closed.
|
||||
|
||||
### ⚠️ Gotcha: open-path dedupe races
|
||||
|
||||
`MissionControlViewModel.OpenConPtySessionAsync` / `OpenPlanningConPtySessionAsync` dedupe by
|
||||
`TaskId` against `ConPtySessions`, but the check ran before an **awaited** DB title lookup and
|
||||
only `AddConPtyPane` registers the pane — two rapid invocations for the same task (e.g. a
|
||||
double-click) could both pass the dedupe check before either pane existed, opening two panes.
|
||||
`OpenMergeHelperConPtySessionAsync` was worse: it awaits `CreateMergeHelperTaskAsync` (which mints
|
||||
a brand-new task id every call) *before* any `TaskId` dedupe is even possible, so a double-trigger
|
||||
always minted two host tasks in the DB.
|
||||
|
||||
Fixed with synchronous, pre-await claims: `_pendingTaskOpens` (shared by the two `TaskId`-keyed
|
||||
open paths) and `_pendingMergeHelperLists` (keyed by `listId`, guarding the whole method since
|
||||
there's no `TaskId` yet to dedupe on) are `HashSet<string>` fields checked-and-added at method
|
||||
entry, before any `await`, and released in a `finally`. A second overlapping call for the same key
|
||||
bails out immediately instead of racing past the collection-based dedupe.
|
||||
|
||||
## Focus / key handling
|
||||
|
||||
`InteractiveTerminalView` lives in `MissionControlWindow`, so the `FocusClearing` Escape handler
|
||||
(scoped to `MainWindow` via `AddClassHandler<MainWindow>`) never runs there — **Escape always
|
||||
reaches the PTY**. See the note in `src/ClaudeDo.Ui/CLAUDE.md`.
|
||||
|
||||
`TaskRowViewModel.HasInteractiveSession` shows an accent "Interactive" chip instead of "Parked";
|
||||
tapping it jumps to that Mission Control pane. `TasksIslandViewModel.SyncInteractiveSessions`
|
||||
mirrors Mission Control's open panes onto the rows.
|
||||
|
||||
### Queueing is gated on an open session (UI-only, since `d84607f`)
|
||||
|
||||
A task-based session leaves the row `Idle` (sessions never write `Status`), so nothing on the
|
||||
worker side distinguishes it from a plain idle task. `TaskRowViewModel.CanSendToQueue` and
|
||||
`MissionControlViewModel.EnqueueTaskAsync` (drag-to-queue onto the Command Center window) both
|
||||
check for an open session before queueing — the row via `HasInteractiveSession`, the drag path via
|
||||
`ConPtySessions.Any(s => s.TaskId == taskId)` (Mission Control's own authoritative pane list,
|
||||
since the mirrored bool on the row could lag). Queueing a task open in a hand-driven ConPTY pane
|
||||
would otherwise let the picker spawn an autonomous `claude` process into the same worktree the
|
||||
user is editing. Both enqueue paths (`TasksIslandViewModel.SendToQueueAsync` and
|
||||
`MissionControlViewModel.EnqueueTaskAsync`) also route through `IWorkerClient.SetTaskStatusAsync`
|
||||
(hub `SetTaskStatus` → `TaskStateService.EnqueueAsync`) instead of a raw EF write, so the
|
||||
manual/draft-child guards apply on both paths too.
|
||||
|
||||
## System-prompt matrix (autonomous vs. interactive)
|
||||
|
||||
Autonomous and interactive sessions do **not** share a system prompt. Per start path:
|
||||
|
||||
| Start path | Entry point | System prompt |
|
||||
|---|---|---|
|
||||
| Autonomous run/continue/retry | `TaskRunner.ResolveConfigAsync` → `ClaudeArgsBuilder.Build` | `--append-system-prompt <text>`, recomputed and re-sent on **every** invocation including a `--resume` continue (`PromptKind.System` + improvement/list/task overrides) |
|
||||
| Interactive task session, fresh | `InteractiveLaunchSpecService.BuildForTaskAsync` → `BuildFreshTaskArgsAsync` | none — no `--append-system-prompt(-file)` at all |
|
||||
| Interactive task session, resume | `InteractiveLaunchSpecService.BuildForTaskAsync` → `WindowsTerminalLauncher.BuildResumeArgs` | none — only `--resume <id>` (+ `--effort`) |
|
||||
| Ad-hoc directory session | `InteractiveLaunchSpecService.BuildForDirectoryAsync` | none |
|
||||
| Planning session start | `InteractiveLaunchSpecService.BuildPlanningStart` → `WindowsTerminalLauncher.BuildPlanningStartArgs` | `--append-system-prompt-file <path>` (`PromptKind.Planning`) |
|
||||
| Planning session resume | `InteractiveLaunchSpecService.BuildPlanningResume` → `WindowsTerminalLauncher.BuildPlanningResumeArgs` | none — only `--permission-mode default --allowedTools <planning allowlist> --resume <id>` |
|
||||
| List handler ("Let Claude handle it") | `InteractiveLaunchSpecService.BuildForMergeHelperAsync` | `--append-system-prompt-file <path>` (`PromptKind.MergeHelper`), always fresh — this path never resumes |
|
||||
|
||||
So every interactive resume (task session and planning) drops the system prompt entirely — it's
|
||||
not that they inherit the autonomous one, it's that **no** `claude` process on any resume path
|
||||
ever passes `--append-system-prompt(-file)`.
|
||||
|
||||
### Does `--resume` bring back a prior `--append-system-prompt`? No.
|
||||
|
||||
Checked by reading real session transcripts (`~/.claude/projects/<cwd>/<sessionId>.jsonl`) for
|
||||
several autonomous ClaudeDo task runs, including ones with multiple invocations (initial run +
|
||||
`ContinueAsync`/retry on the same session id, confirmed via that project's `task_runs` history).
|
||||
Grepped for the `PromptKind.System` default text ("You are completing one well-defined task
|
||||
autonomously...") and for any `"type":"system"` entry or `message.role == "system"` anywhere in
|
||||
those files: the prompt text only ever showed up as ordinary tool-result content (e.g. a task
|
||||
that happened to read `PromptFiles.cs`'s own source), never as a persisted system/config entry.
|
||||
No session transcript — autonomous or interactive — carries a system-role message or a
|
||||
per-session record of the CLI flags it was launched with; there is no sidecar file next to the
|
||||
`.jsonl` either. The system prompt is purely a per-process request parameter the CLI builds fresh
|
||||
from that invocation's own flags, never replayed from a resumed session's history. This matches
|
||||
why `TaskRunner.ContinueAsync` (autonomous) explicitly re-resolves and re-passes
|
||||
`--append-system-prompt` on every continue instead of relying on `--resume` to carry it —
|
||||
if inheritance worked, that re-resolution would be redundant.
|
||||
|
||||
**Conclusion: no leak.** An interactive resume (task or planning) does not pick up the autonomous
|
||||
run's `--append-system-prompt` text — including the "commit your work" / `CLAUDEDO_BLOCKED`
|
||||
instructions from `PromptKind.System`. It simply runs with the `claude` CLI's own baseline system
|
||||
prompt, same as every other path in this table that passes no system-prompt flag. No code change
|
||||
needed here.
|
||||
|
||||
## Related hub methods
|
||||
|
||||
`GetInteractiveLaunchSpec`, `GetAdHocLaunchSpec`, `GetMergeHelperLaunchSpec`,
|
||||
`CreateMergeHelperTask`, `SubmitTaskForReview`.
|
||||
|
||||
Planning sessions: `StartPlanningSession`, `ResumePlanningSession`, `DiscardPlanningSession`,
|
||||
`FinalizePlanningSession`, `QueuePlanningSubtasks`, `GetPendingDraftCount`,
|
||||
`GetPlanningAggregate`, `BuildPlanningIntegrationBranch`.
|
||||
@@ -0,0 +1,213 @@
|
||||
# External MCP tool surface
|
||||
|
||||
> **Explore-note — verify before trusting.** Distilled map of a subsystem, not authoritative.
|
||||
> Last verified against commit `20bce9b` (2026-08-06).
|
||||
> Drift check: `git log --oneline 20bce9b..HEAD -- src/ClaudeDo.Worker/External`
|
||||
> Stable structure only (no line numbers). See docs/explore-notes/README.md.
|
||||
|
||||
Covers `src/ClaudeDo.Worker/External/` — the always-on MCP tools ClaudeDo exposes to general
|
||||
Claude sessions. Registered explicitly in `Program.cs`'s external app via `.WithTools<T>()`.
|
||||
Server name `claudedo`, registered globally by the installer's `RegisterMcpStep`, so callers
|
||||
need no `--mcp-config`.
|
||||
|
||||
**Scope boundary:** these tools cover *starting* and *observing* sessions plus task/list CRUD
|
||||
and git/merge operations. They deliberately do **not** expose multi-turn control, planning
|
||||
session internals, or app-settings writes. Auth via an optional `X-ClaudeDo-Key` header.
|
||||
|
||||
## Hard conventions (enforced by tests)
|
||||
|
||||
1. **Every optional/filter parameter needs a C# default value** (e.g. `string? status = null`).
|
||||
The MCP schema only marks a parameter optional when it has one — nullability alone does
|
||||
not do it. `ExternalMcpToolSchemaTests` guards this by reflection.
|
||||
2. **No tool returns bare `Task` or a nullable payload directly.** An MCP client cannot tell
|
||||
an empty/omitted response apart from a dropped one.
|
||||
- *Write* tools return a small confirmation record — `{ ok/deleted/removed/reset/started:
|
||||
true, <id>, ... }` (`DeleteListResult`, `RunTaskNowResult`, `ResetFailedTaskResult`,
|
||||
`RemoveAttachmentResult`; `SetListConfigResult` / `SetTaskConfigResult` additionally echo
|
||||
the resulting config so the caller can see which fields were set vs. cleared to null).
|
||||
- *Read* tools that may have nothing to return use an explicit `Found` / `Available` flag
|
||||
alongside the nullable payload (`TaskConfigResult`, `BatchGetTaskResult`, `TaskLogResult`).
|
||||
- The same flag-alongside-nullable-payload idiom also covers "which of two shapes did you
|
||||
get": `ListTasks`/`BatchGetTasks` take `includeDescription` (default `false`) and return
|
||||
`ListTasksResult`/`BatchGetTaskResult`, where exactly one of the lean (`TaskRefDto`) and
|
||||
full (`TaskDto`, incl. Description/Result) fields is populated per the flag — keeps a
|
||||
list of verbosely-described tasks from blowing past the response size limit by default.
|
||||
3. `ExternalMcpExceptionFilter.Wrap` is registered as a call-tool filter so
|
||||
`InvalidOperationException` / `ArgumentException` messages survive as `McpException` —
|
||||
otherwise the SDK's catch-all replaces any non-`McpException` with a generic
|
||||
*"An error occurred invoking 'X'."*
|
||||
|
||||
## Tool classes
|
||||
|
||||
### `ExternalMcpService` — task CRUD, execution, git
|
||||
|
||||
Task: `ListTaskLists`, `ListTasks`, `GetTask`, `AddTask`, `AddSubtask`, `UpdateTask`,
|
||||
`UpdateTaskStatus`, `GetTaskStatusValues`, `ReviewTask`, `RunTaskNow`, `ContinueTask`,
|
||||
`CancelTask`, `DeleteTask`.
|
||||
|
||||
Worktree/git: `GetTaskWorktree`, `GetTaskDiff`, `MergeTask`, `ContinueMerge`, `AbortMerge`,
|
||||
`PreviewMerge`, `PreviewMergeSet`, `RevertMerge`, `ListWorktrees`, `CleanupTaskWorktree`.
|
||||
|
||||
Daily prep: `GetDailyPrepCandidates`, `SetMyDay`.
|
||||
|
||||
### Other classes
|
||||
|
||||
| Class | Tools |
|
||||
|---|---|
|
||||
| `BatchMcpTools` | `BatchGetTasks`, `BatchAddTasks`, `BatchUpdateTaskStatus`, `BatchCancelTasks`, `BatchDeleteTasks`, `BatchSetMyDay`, `BatchCleanupTaskWorktrees` |
|
||||
| `ListMcpTools` | `CreateList`, `UpdateList`, `DeleteList` |
|
||||
| `ConfigMcpTools` | `GetListConfig`, `SetListConfig`, `GetTaskConfig`, `SetTaskConfig`, `GetEffectiveRunConfig` |
|
||||
| `RunHistoryMcpTools` | `ListRuns`, `GetRun`, `GetTaskLog` |
|
||||
| `AgentMcpTools` | `ListAgents` |
|
||||
| `LifecycleMcpTools` | `ResetFailedTask` |
|
||||
| `AppSettingsMcpTools` | `GetAppSettings` (read-only) |
|
||||
| `TaskWaitMcpTools` | `WaitForTaskChange` |
|
||||
| `QueueStateMcpTools` | `GetQueueState` |
|
||||
| `AttachmentMcpTools` | `AddTaskAttachment`, `ListTaskAttachments`, `RemoveTaskAttachment` |
|
||||
|
||||
## Per-tool behaviour worth knowing
|
||||
|
||||
**`ListTasks`** — `includeDescription=false` (default) returns lean `TaskRefDto` references in
|
||||
`tasks` (`tasksFull` null); `includeDescription=true` returns full `TaskDto`s (incl.
|
||||
Description/Result) in `tasksFull` instead (`tasks` null). Filtering by `createdBy`/`status`
|
||||
happens before the lean/full projection either way.
|
||||
|
||||
**`UpdateTaskStatus`** accepts `Idle` / `Queued` / `Cancelled` / `Done` only.
|
||||
- `Cancelled` goes through `TaskStateService.CancelAsync(..., allowFromIdle: true)` — the
|
||||
**only** caller that opts into cancelling from `Idle`. `PlanningChainCoordinator` relies on
|
||||
`Idle` staying a no-op there by default, because a child parked back to `Idle` mid-chain is
|
||||
a manual opt-out signal.
|
||||
- `Done` goes through `TaskStateService.ForceSetStatusAsync` (the same unconditional write the
|
||||
UI's "set status freely" affordance uses) but is **refused** for a task with an active
|
||||
worktree, since that would skip `review_task`'s merge.
|
||||
|
||||
**`AddTask`** — always creates the task; also returns `possibleDuplicates` (up to 3, id/title/status
|
||||
only, no descriptions) — open (non-terminal) tasks in the *same list* whose normalized title
|
||||
overlaps strongly with the new one. Cheap word-overlap heuristic (`ExternalMcpService`'s
|
||||
`FindPossibleDuplicatesAsync`/`NormalizeTitleWords`), no embeddings/LLM call, no blocking —
|
||||
the caller just gets a heads-up to relay. `BatchAddTasks` carries the same field per item.
|
||||
|
||||
**`ReviewTask`** — `approve` / `reject_rerun` / `reject_park` / `cancel` for a
|
||||
`WaitingForReview` task. Approve is review+merge exactly like the hub's `ApproveReview`: unit
|
||||
merge for parents, worktree merge into optional `targetBranch` for childless tasks. Conflicts
|
||||
are reported in `ReviewTaskResult`.
|
||||
- A parent's approve also returns `emptyChildren`: the `Done` children about to be unit-merged
|
||||
whose own review range contributed nothing (computed the same way as `PreviewMerge`'s
|
||||
`isEmpty`, before the merge starts so it reflects what's about to be approved). Surfaces a
|
||||
child that reported `CLAUDEDO_BLOCKED` and committed no code — previously that child reached
|
||||
`Done` and merged silently with `changedFileCount: 0`, indistinguishable from a small-but-real
|
||||
change. `TaskRefDto.roadblockCount` (on every task-returning tool, stamped by `TaskRunner` from
|
||||
`result.Blocks.Count`) is the MCP-visible signal for *why* a child is empty.
|
||||
|
||||
**`PreviewMerge`** — non-destructive `git merge-tree --write-tree` mergeability check for one
|
||||
task's worktree branch against `targetBranch` (default: the repo's current branch). Returns
|
||||
status / conflictFiles / changedFileCount / `behind` / `isEmpty`. Unlike
|
||||
`TaskMergeService.PreviewAsync`'s silent *"unavailable"*, this **throws a clear error** when the
|
||||
task has neither an active worktree nor a handler commit range, or the list's working dir is
|
||||
missing.
|
||||
- `isEmpty` = the review range contributed nothing — zero files changed against the worktree's
|
||||
base commit, or (for a worktree-less list-handler host task) `HandlerBaseCommit ==
|
||||
HandlerHeadCommit`. Distinguishes a genuinely empty branch from one that merely made a small
|
||||
change (`changedFileCount: 0` alone reads as "tiny", not "nothing to review") — the gap that
|
||||
let two blocked planning children reach `Done` with unmerged empty branches unnoticed.
|
||||
- A worktree-less handler task has no separate branch to `merge-tree`-preview (its commits
|
||||
already sit in `list.WorkingDir`) — `PreviewMergeCoreAsync` falls back to a synthetic `clean`
|
||||
preview over its own `HandlerBaseCommit..HandlerHeadCommit` diff-stat instead of throwing
|
||||
"has no worktree".
|
||||
|
||||
**`PreviewMergeSet`** — same preview for a batch (each entry also carries `isEmpty`), plus a
|
||||
file→tasks overlap report built from each task's own diff-stat. ⚠️ That overlap report is a
|
||||
**same-file-name hint only** — it is blind to cross-file collisions (e.g. the CS0103 case that
|
||||
motivated it). A task that fails to preview gets `error` set and is excluded from the overlap
|
||||
instead of aborting the batch.
|
||||
|
||||
**`RevertMerge`** — undoes a previously merged task's merge commit on `targetBranch` via
|
||||
`git revert -m 1`. Always a **new commit**, never a reset/rewrite, because the target working
|
||||
directory is shared with other concurrent sessions.
|
||||
- Requires the task to be `Done` with a `Merged` worktree carrying a recorded
|
||||
`WorktreeEntity.MergeCommit`. A task merged before that field existed has none and is
|
||||
**refused rather than guessed** via `git log`.
|
||||
- On success the task returns to `WaitingForReview` and the worktree moves to `Kept` — not
|
||||
`Active` (its directory/branch are typically already gone from the original merge's cleanup)
|
||||
and not `Merged`/`Discarded` (`WorktreeMaintenanceService` sweeps those).
|
||||
- A conflicting revert is aborted immediately; no half-resolved state is left in the tree.
|
||||
|
||||
**`BatchMcpTools`** — best-effort loops over the `ExternalMcpService` single-entity methods.
|
||||
**Sequential**, because the scoped `DbContext` is not thread-safe. Merge/review stay
|
||||
single-task. Every tool returns a per-item result array (`{ id/index, ok, error?, … }`) — a
|
||||
failing item never aborts the rest — and rejects batches over **100 items**.
|
||||
`BatchGetTasks` mirrors `ListTasks`'s `includeDescription` flag (default `false`): a found item's
|
||||
`BatchGetTaskResult` carries `task` (lean) or `taskFull` (full), never both.
|
||||
|
||||
**`GetTaskLog`** — latest run's log, tail-capped at 256 KB.
|
||||
|
||||
**`WaitForTaskChange(taskIds, timeoutSeconds = 60, treatWaitingForChildrenAsBusy = false)`** —
|
||||
blocks until any given task leaves `Queued`/`Running`, or times out. Returns immediately for a
|
||||
task already outside those two (unknown ids reported as status `"NotFound"`, also immediate).
|
||||
- Implemented as an **async DB poll** (short-lived `DbContext` per check, 500 ms delay, no
|
||||
held connection, no busy loop) rather than hooking `HubBroadcaster` — deliberately isolated
|
||||
so it can't regress the existing broadcast callers.
|
||||
- `timeoutSeconds` is clamped server-side to `TaskWaitMcpTools.MaxTimeoutSeconds` (900 s),
|
||||
comfortably under the `MCP_TOOL_TIMEOUT` (930 s) every ClaudeDo-owned claude launcher sets —
|
||||
`ClaudeProcess` for headless queue runs, `InteractiveLaunchSpecService` for every embedded
|
||||
ConPTY session (list handler, planning, interactive resume) — so the tool reports
|
||||
`timedOut: true` instead of racing the client's own abort. A caller running claude with a
|
||||
different (or default: 60 s) `MCP_TOOL_TIMEOUT` will still see its own client-side timeout
|
||||
fire first; the server has no way to detect or compensate for that.
|
||||
- **`treatWaitingForChildrenAsBusy` pitfall (default `false`, backward-compatible):** a planning
|
||||
parent with children goes `Running` → `WaitingForChildren` while children are still working,
|
||||
and by default that already counts as "changed" (it's outside `Queued`/`Running`) — so waiting
|
||||
on a parent returns immediately even though the unit isn't done. Set the flag to keep polling
|
||||
through `WaitingForChildren`; the call then only reports changed once the parent reaches
|
||||
`WaitingForReview` or a terminal status. Does not list or watch the parent's children —
|
||||
callers still need their own ids for that.
|
||||
- Replaced the list handler's old "sleep + poll `get_task` in a loop" Phase 3 instruction.
|
||||
|
||||
**`GetQueueState()`** — read-only snapshot so a caller doesn't have to infer queue state from
|
||||
`maxParallelExecutions` or repeated `wait_for_task_change` rounds:
|
||||
`{ configuredSlots, effectiveSlots, activeSlots: [{ slot, taskId, startedAt }], waitingTaskIds }`.
|
||||
- `configuredSlots`/`effectiveSlots` reuse `QueueService.GetSlotCountsAsync` — the same
|
||||
configured-vs-throttled computation `QueueService.ExecuteAsync` uses each tick (see
|
||||
[usage-monitoring](usage-monitoring.md) for the throttle staging) — so this tool can't drift
|
||||
from the queue's actual refill decision.
|
||||
- `activeSlots` reuses `QueueService.GetActive()` (already the source for the Hub's `GetActive`):
|
||||
`slot` is `"queue"` for a normal queue slot or `"override"` for the single
|
||||
`run_task_now`/`continue_task` slot.
|
||||
- `waitingTaskIds` is a fresh read-only query mirroring `QueuePicker.ClaimNextAsync`'s
|
||||
eligibility filter and order (`Queued`, unblocked, non-manual, due, `sort_order` then
|
||||
`created_at`) — it does not claim or mutate anything.
|
||||
|
||||
**`AttachmentMcpTools`** — re-attaching the same `fileName` overwrites. Add/remove refuse on a
|
||||
`Running` task.
|
||||
|
||||
**`GetDailyPrepCandidates`** — Idle, non-blocked tasks in a git repo **not** excluded by
|
||||
`AppSettings.ReportExcludedPaths` and not already `IsMyDay`, plus the current Idle MyDay tasks
|
||||
and `maxTasks` (= `DailyPrepMaxTasks`). Repo-exclusion logic lives in the `DailyPrepFilter`
|
||||
helper in the same file.
|
||||
|
||||
**`SetMyDay`** — sets `IsMyDay` (+ optional `SortOrder`). A server-side cap-guard rejects
|
||||
turning on MyDay beyond `DailyPrepMaxTasks` open (Idle) MyDay tasks.
|
||||
|
||||
**`GetEffectiveRunConfig`** — read-only report of what a task will *actually* run with (model,
|
||||
max turns, effort, permission mode, agent path, whether a system prompt is set, skill names),
|
||||
each with its source (`task`/`list`/`preset`/`global`); max turns additionally reports the raw
|
||||
requested value and whether it was clamped to `AppSettings.MaxTurnsCeiling`. Unlike
|
||||
`GetAppSettings`/`GetTaskConfig` (raw, possibly-unused config values), this goes through the same
|
||||
`EffectiveRunConfigResolver.Resolve` that `TaskRunner` itself runs with — see
|
||||
[worker-task-pipeline](./worker-task-pipeline.md)'s model/effort/max-turns section — so it can't
|
||||
drift from the real run. Reads (not writes) `AppSettingsRepository.GetAsync`, which backfills
|
||||
`model_presets` on first read after a null column; that backfill is pre-existing shared behavior,
|
||||
not a new side effect introduced by this tool.
|
||||
|
||||
## Model / max-turns on task creation
|
||||
|
||||
Task-generating tools (`AddTask`, planning `CreateChildTask`, `SuggestImprovement`) accept an
|
||||
optional `model`, alias-validated via `ModelRegistry.NormalizeAlias` (`haiku`/`sonnet`/`opus`,
|
||||
blank = inherit), so Claude can assign the cheapest capable model at creation time — the
|
||||
planning/system/improvement prompts instruct it to do so, using
|
||||
`ModelRegistry.ByCostAscending` as the cost order.
|
||||
|
||||
Planning's `CreateChildTask` **additionally** accepts an optional `maxTurns` (positive int;
|
||||
`0`/negative rejected with `ArgumentException`, null = inherit list/global default) so the
|
||||
planner can raise the turn budget for a subtask it knows will run long.
|
||||
`SuggestImprovement` and `AddTask` do **not** expose it.
|
||||
@@ -0,0 +1,252 @@
|
||||
# Installer preflight: CLI version gate & environment checks
|
||||
|
||||
> **Explore-note — verify before trusting.** Distilled map of a subsystem, not authoritative.
|
||||
> Last verified against commit `bdee731` (2026-08-05).
|
||||
> Drift check: `git log --oneline bdee731..HEAD -- src/ClaudeDo.Worker/Lifecycle/ClaudeCliPreflight.cs src/ClaudeDo.Worker/ClaudeDo.Worker.csproj src/ClaudeDo.App/ClaudeDo.App.csproj .gitea/workflows/release.yml src/ClaudeDo.Installer/Checks src/ClaudeDo.Data/Environment/ExecutableResolver.cs`
|
||||
> Stable structure only (no line numbers). See docs/explore-notes/README.md.
|
||||
|
||||
## Implementation status (as of 2026-08-05)
|
||||
|
||||
The research below (§1–5) led to an implementation, but it is **not on `main` yet** — it exists
|
||||
on two unmerged task branches:
|
||||
|
||||
- `claudedo/06aca9b3afec4b939f59b627bfe21737` — `src/ClaudeDo.Installer/Checks/*`
|
||||
(`GitCheck`, `GitIdentityCheck`, `WriteAccessCheck`, `PortCheck`, `ClaudeCliCheck`,
|
||||
`ClaudeVersionCheck`, `ClaudeAuthCheck`, `PermissionModeAutoCheck`, `EnvironmentCheckService`,
|
||||
`ClaudeCliLookup`) plus `SystemCheckPage` (the Fresh-Install wizard page hosting them) and its
|
||||
own copy of `src/ClaudeDo.Data/Environment/ExecutableResolver.cs`.
|
||||
- `claudedo/40272c0bb3b14562b59c022d09c382b6` — the original `ExecutableResolver.cs`, plus wiring
|
||||
it into `ClaudeDo.Worker`'s `ClaudeCliPreflight` and `ClaudeProcess` (the actual root-cause fix:
|
||||
both used to spawn `claude` with `UseShellExecute = false` and no `.cmd`/`.bat` shim resolution,
|
||||
so an npm-installed `claude.cmd` was invisible to the Worker even though it worked in a shell).
|
||||
|
||||
The two branches were authored independently and each vendored its own copy of
|
||||
`ExecutableResolver.cs` (identical except `06aca9b3` adds a `FallbackDirectories()` diagnostic
|
||||
helper); merging both cleanly requires picking one copy, not literally running `git merge` twice.
|
||||
Two planned follow-up tasks — a "Claude Help Me" button and a Config-mode Diagnose section —
|
||||
never got past a blocked first step, precisely because this prerequisite work wasn't on `main`
|
||||
when they ran. See `Environment Checks` in `src/ClaudeDo.Installer/CLAUDE.md` and `docs/open.md`
|
||||
for the current gap and the manual verification checklist.
|
||||
|
||||
What's confirmed as **matching the research below**: `ClaudeVersionCheck.MinimumVersion` is
|
||||
`2.1.220`, exactly the "verified-floor, not a proven minimum" constant from §3. `ClaudeAuthCheck`
|
||||
uses `claude auth status --json` exactly as recommended in §4, parsing only the `loggedIn` field.
|
||||
`PermissionModeAutoCheck` is the static "is `auto` listed in `--help`" check recommended in §2 —
|
||||
real org/model/plan eligibility is deliberately **not** checked, matching the recommendation not
|
||||
to build that (a real task run surfaces a startup rejection fast enough on its own). No .NET
|
||||
Desktop Runtime check was implemented as an `IEnvironmentCheck` (§5's registry-key detection
|
||||
remains a documented-but-unbuilt option, not currently gating anything).
|
||||
|
||||
---
|
||||
|
||||
Pure research, no code changed. Answers the five questions from the "Root Cause
|
||||
`--permission-mode auto`" task. Sources: the locally installed CLI (`claude --version` /
|
||||
`--help`), the official docs at `code.claude.com` (fetched 2026-08-05), and this repo's own
|
||||
csproj/workflow files.
|
||||
|
||||
## 1. Why can `--permission-mode auto` fail?
|
||||
|
||||
**It is very rarely a CLI-version problem.** Per the official permission-modes doc
|
||||
(`code.claude.com/docs/en/permission-modes#eliminate-prompts-with-auto-mode`), auto mode
|
||||
requires **all** of:
|
||||
|
||||
- **Plan**: any plan (Free/Pro/Max/Team/Enterprise) qualifies in principle.
|
||||
- **Organization**: on Team/Enterprise, on by default; an admin can disable it org-wide via
|
||||
`permissions.disableAutoMode: "disable"` in managed settings. When disabled this way, the CLI
|
||||
**"rejects `--permission-mode auto` at startup"** (verbatim from the doc) — not a runtime
|
||||
fallback, a hard reject.
|
||||
- **Model**: on the Anthropic API / Claude Platform on AWS — Opus 4.6+, Sonnet 4.6+, or Fable 5.
|
||||
On Bedrock / Vertex / Foundry / signed-in gateway sessions — only Sonnet 5, Opus 4.7+, Fable 5.
|
||||
Older models (Sonnet 4.5, Opus 4.5, Haiku, claude-3-*) are **not supported on any provider**.
|
||||
A per-org `availableModels` restriction that only allows an old model would silently make
|
||||
auto mode unavailable even on a Team/Enterprise org that hasn't touched `disableAutoMode`.
|
||||
- **Provider opt-in (historical)**: on Bedrock/Vertex/Foundry/gateway, CLI **v2.1.158–v2.1.206**
|
||||
required `CLAUDE_CODE_ENABLE_AUTO_MODE=1`; **v2.1.207** removed that requirement. This does
|
||||
**not** apply to a normal claude.ai / Console (Anthropic API) login — only to those four
|
||||
provider types.
|
||||
|
||||
A separate, easy-to-hit trap: `defaultMode: "auto"` in **project or local** settings
|
||||
(`.claude/settings.json`, `.claude/settings.local.json`) is silently **ignored** since v2.1.142
|
||||
— it must live in `~/.claude/settings.json` (user scope). A repo that ships `defaultMode: auto`
|
||||
in its own `.claude/settings.json` will start in Manual mode with **no error at all**. This
|
||||
doesn't apply to ClaudeDo's case (it passes `--permission-mode auto` as an explicit CLI flag,
|
||||
not via a checked-in settings file), but is worth knowing if the failure mode was "silently
|
||||
starts in Manual" rather than "CLI errors out".
|
||||
|
||||
Quoted from the docs, verbatim: *"If Claude Code reports auto mode as unavailable, one of these
|
||||
requirements is unmet; this is not a transient outage."*
|
||||
|
||||
**Not verifiable**: which of the above actually hit the colleague — we have no diagnostic from
|
||||
their machine (no `claude auth status --json` output, no `claude --version`, no org name). Do
|
||||
not guess; if this recurs, capture `claude auth status --json` and `claude --version` from the
|
||||
affected machine before further debugging.
|
||||
|
||||
## 2. How to reliably detect whether `auto` is supported
|
||||
|
||||
**Static, cheap, always safe (no API call):**
|
||||
- `claude --version` — semver string, e.g. `2.1.220 (Claude Code)`.
|
||||
- `claude --help` — the `--permission-mode <mode>` line lists the accepted enum. Confirmed by
|
||||
testing an invalid value locally:
|
||||
```
|
||||
$ claude -p "test" --permission-mode bogus
|
||||
error: option '--permission-mode <mode>' argument 'bogus' is invalid. Allowed choices are
|
||||
acceptEdits, auto, bypassPermissions, manual, dontAsk, plan.
|
||||
```
|
||||
This is validated by the CLI's arg parser (Commander.js) **before any network call** — exits
|
||||
1 immediately. So checking that `auto` is one of the listed choices is a legitimate, free,
|
||||
fast static check — but it only proves the *flag* is recognized, **not** that auto mode is
|
||||
actually usable (org/model/plan gating happens later, at session start, not at arg-parse time).
|
||||
- `claude auth status --json` — cheap, local/fast, **does not send a prompt**. Returns:
|
||||
```json
|
||||
{ "loggedIn": true, "authMethod": "claude.ai", "apiProvider": "firstParty",
|
||||
"email": "...", "orgId": "...", "orgName": "...", "subscriptionType": "team" }
|
||||
```
|
||||
This is the answer to question 4 (see below) and also gives `subscriptionType`/`orgName` —
|
||||
useful context but **still not a direct "is auto mode eligible" answer** (doesn't report the
|
||||
active model or `disableAutoMode` policy).
|
||||
|
||||
**No cheap subcommand exists for full eligibility.** Checked `claude auto-mode --help`: it only
|
||||
has `config` (effective auto-mode *rule* config — allow/deny lists, not eligibility),
|
||||
`defaults` (same, shipped defaults), `critique`, and `reset`. None report plan/model/org
|
||||
eligibility. `claude doctor` (non-interactive) does **not** report auto-mode eligibility either
|
||||
— it explicitly says *"For a full setup checkup that can also fix issues, run `/doctor` in a
|
||||
session"*; the in-session `/doctor` slash command is the one the docs say proposes
|
||||
`defaultMode: auto` when eligible, but that requires an interactive session, not a scriptable
|
||||
preflight.
|
||||
|
||||
**Dynamic (real probe) is the only way to fully confirm eligibility**, and per the docs an
|
||||
org-disabled or otherwise-ineligible account **rejects at startup** (fast, before any model
|
||||
turn) — so a probe doesn't have to be a full expensive run. A minimal probe such as
|
||||
`claude -p "ok" --permission-mode auto --max-turns 1 --output-format json` would fail fast on
|
||||
ineligibility (reject at startup) but still costs one real turn + tokens on the success path,
|
||||
and still requires a working prompt/response round trip on the happy path. **Recommendation**:
|
||||
don't build this into an automated preflight; a static version+flag check plus `auth status`
|
||||
covers the reliably-detectable ground, and a real first task run will surface an auto-mode
|
||||
rejection immediately and cheaply (fails at startup, not mid-task) if it's actually unavailable.
|
||||
**Implemented as:** `PermissionModeAutoCheck` (Warning) — the static flag-listed check only.
|
||||
|
||||
## 3. Minimum CLI version for the flags ClaudeDo uses
|
||||
|
||||
Flags used (from `ClaudeArgsBuilder` per `src/ClaudeDo.Worker/CLAUDE.md`): `--permission-mode
|
||||
auto`, `--effort`, `--agents`, `--json-schema`, `--append-system-prompt`, `--output-format
|
||||
stream-json --verbose`, `--resume`, and (installer) `claude mcp add --transport http --scope
|
||||
user`.
|
||||
|
||||
**Not verifiable precisely.** All eight of these are foundational, long-established flags.
|
||||
The official changelog (`raw.githubusercontent.com/anthropics/claude-code/main/CHANGELOG.md`)
|
||||
only retains roughly the last ~40 entries (oldest visible: `2.1.181`); auto mode itself, and
|
||||
`--json-schema`/`--resume`/`mcp add --transport` all clearly predate that window (the earliest
|
||||
found reference is a *bug fix* mentioning "sessions created before v2.1.85" for `--resume`,
|
||||
implying `--resume` existed well before 2.1.85). There is no accessible source that pins an
|
||||
"introduced in vX" date for any of these eight flags — guessing one would violate the task's
|
||||
explicit instruction not to invent a source-less root cause.
|
||||
|
||||
What **is** sourced, from the docs fetched 2026-08-05:
|
||||
- `--json-schema` + invalid-schema handling: before v2.1.205, an invalid schema was silently
|
||||
ignored (returned unstructured text); v2.1.205 made it a hard `Error: --json-schema is not a
|
||||
valid JSON Schema` exit. Not a "does it exist" gate, but changes error-handling behavior
|
||||
ClaudeDo might currently rely on failing loudly.
|
||||
- `--output-format stream-json --verbose` + `system/init.capabilities` array requires v2.1.205+
|
||||
(absent before). Not currently consumed by ClaudeDo per the Worker CLAUDE.md's stream handling
|
||||
description, so not a hard requirement today.
|
||||
- `system/init.mcp_server_errors` field requires v2.1.219+. Not currently consumed by ClaudeDo.
|
||||
- Manual-mode label/`manual` alias requires v2.1.200+ — irrelevant, ClaudeDo passes `auto`
|
||||
explicitly, never `manual`.
|
||||
- Auto mode's Bedrock/Vertex/Foundry/gateway opt-in-env-var requirement was removed in v2.1.207
|
||||
— irrelevant for a direct claude.ai/Console login (this machine: `apiProvider: "firstParty"`).
|
||||
|
||||
**Decided constant** (see below) is therefore **the newest version we can positively confirm
|
||||
works end-to-end on this machine** (`2.1.220`), not a proven theoretical minimum — because no
|
||||
lower true minimum is derivable from available sources without guessing.
|
||||
**Implemented as:** `ClaudeVersionCheck.MinimumVersion = new Version(2, 1, 220)` (Error).
|
||||
|
||||
## 4. Detecting "CLI is logged in" without sending a prompt
|
||||
|
||||
`claude auth status --json` (confirmed working, instant, no API/model call):
|
||||
```json
|
||||
{ "loggedIn": true, "authMethod": "claude.ai", "apiProvider": "firstParty",
|
||||
"email": "...", "orgId": "...", "orgName": "...", "subscriptionType": "team" }
|
||||
```
|
||||
This is the CLI's own maintained answer — cheaper and more robust than parsing
|
||||
`.credentials.json` directly (format may be internal/undocumented and is a hard-blocked file
|
||||
for this task's own tooling; the docs page confirms it lives at
|
||||
`%USERPROFILE%\.claude\.credentials.json` on Windows but say nothing about its schema being a
|
||||
stable public contract). `claude auth status --text` is available for a human-readable variant;
|
||||
`--json` is the default and the right one for a preflight to parse.
|
||||
**Implemented as:** `ClaudeAuthCheck` (Error) — parses only the `loggedIn` boolean; any other
|
||||
field, or a non-zero exit code, or unparseable JSON, becomes `Unknown` rather than `Failed`.
|
||||
|
||||
## 5. .NET runtimes required by the published `app\` / `worker\` artifacts
|
||||
|
||||
From `.gitea/workflows/release.yml` (the only build/publish pipeline in this repo) and the two
|
||||
csproj files:
|
||||
|
||||
- **`ClaudeDo.App`** (`net8.0`, Avalonia, `WinExe`) — published via
|
||||
`dotnet publish ... -r win-x64 --self-contained true`. **Self-contained**: bundles its own
|
||||
.NET 8 runtime. **No .NET runtime needs to be pre-installed** on the target machine for the
|
||||
app itself.
|
||||
- **`ClaudeDo.Worker`** (`net8.0`, `Microsoft.NET.Sdk.Web`, ASP.NET Core) — same treatment:
|
||||
`-r win-x64 --self-contained true`. Also fully self-contained; no ASP.NET Core runtime needs
|
||||
to be pre-installed.
|
||||
- **`ClaudeDo.Installer`** (`net8.0-windows`, WPF) is the one exception: published
|
||||
`--self-contained false -p:PublishSingleFile=true` — **framework-dependent**. The csproj
|
||||
comment explains why: *"the WPF runtime pack isn't distributed for cross-compile on Linux CI,
|
||||
which made self-contained bundles crash on startup with AV in the apphost."* The target
|
||||
machine **must** have the **.NET 8 Desktop Runtime (x64)** installed before running
|
||||
`ClaudeDo.Installer.exe` — this is the actual runtime-preflight gap, not the app/worker.
|
||||
|
||||
**How to check on a target machine**:
|
||||
- `dotnet --list-runtimes` — look for a `Microsoft.WindowsDesktop.App 8.0.x` line (Desktop
|
||||
Runtime, required by the installer). Requires the `dotnet` CLI itself to be on PATH; not
|
||||
guaranteed present on a fresh machine that never installed the SDK, only the runtime — in
|
||||
that case `dotnet` may not exist at all even though the runtime DLLs do.
|
||||
- Registry fallback (works even without the `dotnet` CLI on PATH):
|
||||
`HKLM\SOFTWARE\dotnet\Setup\InstalledVersions\x64\sharedfx\Microsoft.WindowsDesktop.App` —
|
||||
each installed version is a subkey/value here. This is the standard documented detection
|
||||
mechanism for .NET Desktop Runtime presence on Windows and is what most installer-detection
|
||||
tooling (e.g. Squirrel, WiX bundles) uses instead of shelling out to `dotnet`.
|
||||
- **Not independently verified in this task**: the exact registry key shape wasn't inspected
|
||||
live (would require reading `HKLM\SOFTWARE\dotnet\...` on this machine, which is standard
|
||||
.NET installer-detection convention, but out of scope to screenshot/dump here since the task
|
||||
is docs-only and this is a well-documented, non-project-specific Windows convention).
|
||||
**Not implemented** as an `IEnvironmentCheck` — none of the shipped checks verify the Desktop
|
||||
Runtime; if the Installer itself is running at all, .NET 8 Desktop Runtime is implicitly
|
||||
present (framework-dependent publish would otherwise fail to launch).
|
||||
|
||||
## Beschlossene Konstanten
|
||||
|
||||
| Constant | Value | Confidence |
|
||||
|---|---|---|
|
||||
| Minimum CLI version | `2.1.220` | **Verified-floor, not a proven minimum.** This is the newest version confirmed installed and working end-to-end on a dev machine for every flag ClaudeDo uses. No lower true minimum could be sourced (see §3) — treat any lower value as a guess. |
|
||||
| Credentials file path | `%USERPROFILE%\.claude\.credentials.json` (Windows) | Sourced from official docs; content/schema not inspected (hard-blocked secrets file). |
|
||||
| Login-check command | `claude auth status --json` | Verified locally, instant, no model call. Fields: `loggedIn`, `authMethod`, `apiProvider`, `email`, `orgId`, `orgName`, `subscriptionType`. |
|
||||
| App/Worker runtime requirement | **None** — self-contained win-x64 publish | Sourced from `.gitea/workflows/release.yml`. |
|
||||
| Installer runtime requirement | **.NET 8 Desktop Runtime (x64)** must be pre-installed | Sourced from `.gitea/workflows/release.yml` comment + `ClaudeDo.Installer.csproj`. |
|
||||
|
||||
## Erkennungsstrategie pro Check
|
||||
|
||||
| Check | Type | Command | Expected pass output | Implemented as |
|
||||
|---|---|---|---|---|
|
||||
| git present + version | static | `git --version` (via `ExecutableResolver`) | resolves, exit 0 | `GitCheck` (Error) |
|
||||
| git identity set | static | `git config --get user.name` / `user.email` | both non-empty | `GitIdentityCheck` (Warning) |
|
||||
| install dir + data dir writable | static | probe-file write/delete | succeeds | `WriteAccessCheck` (Error) |
|
||||
| SignalR/ExternalMcp ports free | static | `TcpListener` bind probe + owning-process lookup | free, or owned by running `ClaudeDo.Worker` | `PortCheck` (Warning) |
|
||||
| CLI present + version | static | `claude --version` | `X.Y.Z (Claude Code)`; parse and compare `X.Y.Z >= 2.1.220` | `ClaudeCliCheck` (Error) / `ClaudeVersionCheck` (Error) |
|
||||
| `auto` recognized as a flag value | static | `claude --help` (or trigger the parse error path) | `--permission-mode <mode>` help text lists `auto` among the choices | `PermissionModeAutoCheck` (Warning) |
|
||||
| CLI logged in | static/cheap | `claude auth status --json` | exit 0, `loggedIn: true` | `ClaudeAuthCheck` (Error) |
|
||||
| Auto mode actually eligible (org/model/plan) | **not statically detectable** | none exists | N/A — see §2; don't build this, let a real task run surface a fast startup rejection instead | not implemented (by design) |
|
||||
| .NET Desktop Runtime present (installer only) | static | `dotnet --list-runtimes` (if `dotnet` on PATH) or registry `HKLM\SOFTWARE\dotnet\Setup\InstalledVersions\x64\sharedfx\Microsoft.WindowsDesktop.App` | a `Microsoft.WindowsDesktop.App 8.0.x` entry exists | not implemented (see §5) |
|
||||
| App/Worker runtime present | **not needed** | — | self-contained, nothing to check | not implemented (not needed) |
|
||||
|
||||
## Not verifiable (explicit)
|
||||
|
||||
- The actual root cause on the colleague's machine — no diagnostic data was captured from it.
|
||||
- Exact stderr/exit-code wording the CLI prints when auto mode is rejected at startup for an
|
||||
ineligible org/model (docs state the *behavior* — "rejects `--permission-mode auto` at
|
||||
startup" — but not the literal message; this machine's account is eligible, so it couldn't be
|
||||
reproduced locally).
|
||||
- A true (not just "newest confirmed") minimum SemVer for `--effort`, `--agents`,
|
||||
`--json-schema`, `--append-system-prompt`, `--resume`, `claude mcp add --transport http
|
||||
--scope user` — all predate the retrievable changelog window.
|
||||
- Live inspection of the `HKLM\SOFTWARE\dotnet\Setup\InstalledVersions` registry shape on this
|
||||
machine (documented Windows convention, not independently screenshotted here).
|
||||
@@ -0,0 +1,233 @@
|
||||
# Review, merge & conflict resolution
|
||||
|
||||
> **Explore-note — verify before trusting.** Distilled map of a subsystem, not authoritative.
|
||||
> Last verified against commit `0d1e3b9` (2026-08-06).
|
||||
> Drift check: `git log --oneline 20bce9b..HEAD -- src/ClaudeDo.Worker/Lifecycle src/ClaudeDo.Worker/State src/ClaudeDo.Worker/Planning src/ClaudeDo.Ui/ViewModels/Conflicts src/ClaudeDo.Worker/External`
|
||||
> Stable structure only (no line numbers). See docs/explore-notes/README.md.
|
||||
|
||||
Covers the review→merge path: `TaskStateService` review transitions, `TaskMergeService`,
|
||||
`PlanningMergeOrchestrator`, the post-merge verify gate, and the UI conflict resolver.
|
||||
|
||||
## Approve = merge the whole unit
|
||||
|
||||
`ApproveReview` (hub) and `review_task` approve (MCP) are the **single** review+merge action.
|
||||
There is no separate "Merge all" entry.
|
||||
|
||||
- **Task with children** → drives `PlanningMergeOrchestrator`: merges the parent worktree if
|
||||
`Active`, then each `Done` child in order, then sets the parent `Done`. A mid-merge conflict
|
||||
pauses for `ContinuePlanningMerge` / `AbortPlanningMerge`.
|
||||
- **Childless task** → `TaskMergeService.ApproveAndMergeAsync`. A conflict keeps the task in
|
||||
`WaitingForReview`.
|
||||
- **No active worktree** (sandbox run) → straight to `Done`.
|
||||
|
||||
Review transitions all live in `TaskStateService`: `SubmitForReviewAsync`,
|
||||
`SubmitForChildrenAsync`, `ApproveReviewAsync`, `RejectToQueueAsync`, `RejectToIdleAsync`,
|
||||
`ClearReviewFeedbackAsync`.
|
||||
|
||||
`ReviewFeedback` (nullable string on `TaskEntity`) is the reviewer's rejection comment: set by
|
||||
`RejectToQueueAsync`, consumed and cleared by `QueueService` on the next re-run, where it
|
||||
becomes the next-turn prompt of the resumed Claude session.
|
||||
|
||||
## Unified parent model
|
||||
|
||||
Every parent — planning **or** improvement — flows
|
||||
`… → WaitingForChildren → WaitingForReview → Done`, advanced by the single
|
||||
`TaskStateService.TryAdvanceParentAsync`. It surfaces any `WaitingForChildren` parent for
|
||||
review once all children are terminal; failed/cancelled children are **annotated on the
|
||||
result, not wedged**.
|
||||
|
||||
- A planning parent enters `WaitingForChildren` at `FinalizePlanningAsync` (or
|
||||
`WaitingForReview` directly if it has no children).
|
||||
- An improvement parent enters it from `TaskRunner.HandleSuccess` when its run spawned children.
|
||||
- Planning/improvement **children** go straight to `Done` — no individual review. Only the
|
||||
parent is reviewed.
|
||||
|
||||
A child that hits a roadblock (fails, or reports `CLAUDEDO_BLOCKED` roadblocks) does **not**
|
||||
advance the parent — the parent stays in `WaitingForChildren` until every child is terminal.
|
||||
The UI surfaces blocked children on the parent's Session tab (`ChildOutcomes` + a "children
|
||||
need attention" band) so the roadblock is visible without forcing a transition.
|
||||
|
||||
A blocked planning/improvement child still goes straight to `Done` per the unified parent model
|
||||
above — it committed nothing, but nothing prevents its (empty) branch from being unit-merged
|
||||
like any other `Done` child once the parent is approved. The MCP surface has no UI equivalent of
|
||||
`ChildOutcomes`, so `review_task`'s approve on a parent additionally returns `emptyChildren` (the
|
||||
`Done` children whose review range is empty) and every task-returning tool exposes
|
||||
`TaskRefDto.roadblockCount` → [external-mcp.md](external-mcp.md) → `ReviewTask`/`PreviewMerge`.
|
||||
An empty branch is still mergeable by design (some tasks — e.g. an audit — legitimately produce
|
||||
no diff); this is a visibility fix, not a merge gate.
|
||||
|
||||
## Cancel is blocked while a unit merge is draining
|
||||
|
||||
`ApproveReview` on a task with children awaits `PlanningMergeOrchestrator.StartAsync` /
|
||||
`DrainAsync` synchronously — the parent sits in `WaitingForReview` for the whole (potentially
|
||||
minutes-long, one-child-at-a-time) drain. Without a guard, a concurrent `CancelReview` (UI or
|
||||
`update_task_status`/`cancel_task` via MCP) could flip the parent to `Cancelled` mid-drain while
|
||||
the orchestrator kept merging children's worktrees onto the target branch; `FinalizeParentDoneAsync`
|
||||
then finds the parent no longer `WaitingForReview` and gives up, leaving the merged children's
|
||||
diffs stranded with no rollback.
|
||||
|
||||
`TaskStateService.CancelAsync` now rejects with a `TransitionResult` reason whenever
|
||||
`PlanningMergeOrchestrator.HasActiveMerge(taskId)` is true for the task being cancelled, before any
|
||||
DB write. Cycle note: `TaskStateService` can't take a direct constructor dependency on
|
||||
`PlanningMergeOrchestrator` (which itself depends on `ITaskStateService`), so it takes a lazily-resolved
|
||||
`Func<IActiveMergeState>` instead — same cycle-breaking shape as the existing `Func<ITaskStateService>`
|
||||
handed to `PlanningChainCoordinator`. `IActiveMergeState` (`Planning/Interfaces/`) is `PlanningMergeOrchestrator`'s
|
||||
only public surface `TaskStateService` needs.
|
||||
|
||||
The UI mirrors this as polish: `DetailsIslandViewModel.IsMergeDraining` (set on
|
||||
`PlanningMergeStartedEvent`, cleared on `PlanningMergeAborted`/`PlanningCompleted` for the bound
|
||||
task) gates `CancelReviewCommand`'s `CanExecute` — same shape as `WorktreesOverviewModalViewModel.IsMerging`
|
||||
gating `CanMergeAll`. The worker-side guard remains the actual correctness fix; the button gate
|
||||
just avoids inviting a click the worker would reject. `CancelReviewAsync`'s catch now raises
|
||||
`ErrorReported` (→ shell `FlashFooterError`) instead of swallowing the rejection silently.
|
||||
|
||||
## Post-merge verify gate
|
||||
|
||||
A list can set `ListConfigEntity.VerifyCommand` (List Settings modal → Verification).
|
||||
Null/blank (the default) = **no gate**, behavior bit-identical to before the feature existed.
|
||||
|
||||
When set, `TaskMergeService` runs it via `VerifyCommandRunner` (`cmd.exe /c <command>`,
|
||||
10-minute fixed timeout, output tail-captured) in `list.WorkingDir` right after a successful
|
||||
`MergeNoFfAsync` / `ContinueMergeAsync` **and** worktree cleanup, but **before** the task is
|
||||
allowed to reach `Done`.
|
||||
|
||||
| Outcome | Effect |
|
||||
|---|---|
|
||||
| Exit 0 | Unchanged flow — worktree `Merged`, task `Done` if it was `WaitingForReview`. |
|
||||
| Non-zero exit or timeout | The git merge is **deliberately left in place** (no auto-revert — that's a separate, unbuilt feature). The worktree is still marked `Merged` (it's already gone from disk when `removeWorktree` was requested), but the task stays out of `Done`. |
|
||||
|
||||
On failure `MergeResult.Status` comes back `TaskMergeService.StatusVerifyFailed`
|
||||
(`"verify_failed"`) with an output excerpt in `ErrorMessage`. This flows through
|
||||
`MergeResultDto` (hub) and `ReviewTaskResult` (`review_task`) unchanged, because both already
|
||||
treat any non-`blocked`/`conflict` status generically. All three UI merge entry points handle it
|
||||
explicitly (detail-pane Approve, merge modal, worktrees batch) — a generic fallback there showed
|
||||
the raw status string instead of the failure.
|
||||
|
||||
**Worktree-less approvals are gated too.** A task with no active `WorktreeEntity` — a sandbox run,
|
||||
or a list-handler task that commits straight into `list.WorkingDir` — skips the merge entirely,
|
||||
but `ApproveAndMergeAsync` still runs the verify command (same per-repo gate, working dir =
|
||||
`list.WorkingDir`) before the task may reach `Done`. Without that, the run that lands the most on
|
||||
the target branch at once would be the one run nothing checks.
|
||||
|
||||
**Serialization:** a process-wide `ConcurrentDictionary<string, SemaphoreSlim>` keyed by
|
||||
`list.WorkingDir` serializes `MergeAsync` / `ContinueMergeAsync` (git ops + verify) per repo,
|
||||
so a verify run can't be interrupted by a second merge landing in the same working dir
|
||||
mid-build.
|
||||
|
||||
## `MergeCommit` and revert
|
||||
|
||||
`WorktreeEntity.MergeCommit` (nullable) is the SHA of the merge commit this worktree's branch
|
||||
produced on the target branch. Stamped by `TaskMergeService` the moment a merge/continue-merge
|
||||
succeeds, written **only** by `WorktreeRepository.SetMergedAsync` (which atomically sets
|
||||
`State=Merged` and stamps the SHA in one update).
|
||||
|
||||
It is the only thing that makes `revert_merge` possible without heuristically searching
|
||||
`git log` — see [external-mcp.md](external-mcp.md) → `RevertMerge`. Null for any worktree
|
||||
merged before the field existed.
|
||||
|
||||
## Review gate in the UI
|
||||
|
||||
**Approve & Merge is gated behind opening the diff.** When there is something to inspect
|
||||
(worktree diff / merged range / children combined diff), the button stays disabled until the
|
||||
diff or combined-diff viewer has been opened once. The gate **re-locks per run** — any state
|
||||
change resets it. Tasks with nothing to inspect are never gated.
|
||||
|
||||
The row-level quick-approve in the task list is an **intentional bypass**.
|
||||
|
||||
Implementation: `MergeSectionViewModel` owns merge-target selection, the mergeability
|
||||
indicator (`MergePreviewPresenter` over `PreviewMergeAsync`), and `OpenDiffAsync` /
|
||||
`ReviewCombinedDiffCommand` — both build a `DiffViewerViewModel`, call `ShowDiffViewer`, and
|
||||
fire the `DiffViewed` callback. `HasReviewableDiff` reports whether anything is inspectable
|
||||
and feeds the gate.
|
||||
|
||||
## Conflict resolver (in-app Rider-style 3-pane merge editor)
|
||||
|
||||
`ConflictResolverViewModel` + `Views/Conflicts/ConflictResolverView`. Handles **both**
|
||||
single-task and planning unit-merge conflicts.
|
||||
|
||||
### Model
|
||||
|
||||
Single-task mode starts the conflict merge, then parses each conflicted file into stable and
|
||||
conflict `MergeFileSegment`s via the worker's `GetMergeConflictDocuments`. Types live in
|
||||
`ConflictModels`: `MergeFile` / `MergeFileSegment` / `MergeConflictBlock`.
|
||||
|
||||
Exposed per active file: `ActiveOursText` / `ActiveResultText` / `ActiveTheirsText`
|
||||
(reconstructed from `MergeFile.OursText/ResultText/TheirsText`; Result seeds unresolved
|
||||
conflicts with Ours), plus `ActiveFile` / `SelectFileCommand` (multi-file switcher),
|
||||
`Current` / `Next` / `Previous` (focused-conflict nav), a per-file `PositionText` readout,
|
||||
per-block `AcceptOurs/Theirs/Both/Base` + `MergeFile.Compose`, and `CanContinue` gated on
|
||||
**every file resolved + no binary**. Each file is written via `WriteConflictResolution`.
|
||||
|
||||
**Planning mode** via `OpenForPlanningAsync(parentId, subtaskId)` loads the current subtask's
|
||||
mid-merge conflicts **without re-starting the merge** and routes continue/abort to
|
||||
`ContinuePlanningMerge` / `AbortPlanningMerge`, so a unit-merge conflict re-opens the editor
|
||||
per subtask via the `PlanningMergeConflict` broadcast.
|
||||
|
||||
**Except when an MCP session is driving the merge.** `PlanningMergeOrchestrator.StartAsync` takes
|
||||
an `externallyDriven` bool (default `false`; `ExternalMcpService.review_task`'s parent-with-children
|
||||
path passes `true`, since a running Claude session — not the UI — will resolve conflicts via
|
||||
`continue_merge`/`abort_merge`). The flag rides along on the `PlanningMergeConflict` broadcast
|
||||
(4th arg); `IslandsShellViewModel.OnPlanningMergeConflict` only auto-opens the resolver when it's
|
||||
`false` — otherwise it shows a persistent, non-auto-dismissing banner
|
||||
(`IsExternalMergeBannerVisible`) with a manual "Open resolver" button, cleared on
|
||||
`PlanningMergeAborted`/`PlanningCompleted`. This exists because two parties (a human and the
|
||||
driving session) could otherwise end up editing the same shared checkout at once.
|
||||
`PlanningMergeOrchestrator.GetActiveExternalConflictsAsync` (hub: `GetActiveExternalPlanningMergeConflicts`)
|
||||
re-derives this state by checking `GitService.IsMidMergeAsync` rather than trusting the in-memory
|
||||
flag alone, so a UI restart mid-merge (or a stale entry left behind if something resolved the
|
||||
repo outside the normal Continue/Abort path) can't show a phantom banner — the Ui calls it on
|
||||
`ConnectionRestoredEvent`. The childless single-task conflict path
|
||||
(`TaskMergeService.ApproveAndMergeAsync`) has no equivalent broadcast — it only sends the generic
|
||||
`TaskUpdated` — so it never auto-opened the resolver and needed no change.
|
||||
|
||||
### View
|
||||
|
||||
Three **AvaloniaEdit** panes showing the whole file: MAIN/ours (read-only) | editable Result |
|
||||
INCOMING/theirs (read-only). TextMate highlighting by extension (theme `StyleInclude` in
|
||||
`App.axaml`).
|
||||
|
||||
- A code-behind `IBackgroundRenderer` tints each conflict block (unresolved/resolved) across
|
||||
panes. Tints live in `Tokens.axaml` (`Merge*TintBrush`).
|
||||
- An `IReadOnlySectionProvider` + `TextAnchor` regions keep **only conflict spans** editable in
|
||||
Result; edits flow back to the block.
|
||||
- Each unresolved conflict starts **EMPTY** (a thin marker bar).
|
||||
- The between-pane gutter controls **toggle** each side in/out of the result: `›`/`‹` add
|
||||
MAIN/INCOMING in click order (first pick on top), clicking again removes that side — so a
|
||||
conflict can take main, incoming, both, or **neither**.
|
||||
- `FilesSummary` shows how many files still have conflicts. The three panes share a
|
||||
proportional synced vertical scroll.
|
||||
- A conflict overview ruler right of the Result pane (`ConflictMap`) maps every conflict in the
|
||||
file proportionally; click a tick to jump. Useful for long files.
|
||||
|
||||
### Entry points
|
||||
|
||||
Review **Approve** on conflict, and the **Merge** button in the Diff window (a conflicting
|
||||
`MergeTask` hands off via `RequestConflictResolution`).
|
||||
|
||||
## Hub methods
|
||||
|
||||
- Review/merge: `ApproveReview(taskId, targetBranch) -> MergeResultDto`,
|
||||
`ContinuePlanningMerge` / `AbortPlanningMerge`, `PreviewMerge(taskId, targetBranch) ->
|
||||
MergePreviewDto`, `RejectReviewToQueue`, `RejectReviewToIdle`, `CancelReview`, `MergeTask`,
|
||||
`GetMergeTargets`
|
||||
- Single-task conflict resolver: `StartConflictMerge`, `GetMergeConflictDocuments`,
|
||||
`WriteConflictResolution`, `ContinueConflictMerge`, `AbortConflictMerge` — note the
|
||||
service-level `TaskMergeService.ContinueMergeAsync` / `AbortMergeAsync` keep their own names.
|
||||
- Broadcast events: `PlanningMergeStarted`, `PlanningSubtaskMerged`, `PlanningMergeConflict`,
|
||||
`PlanningMergeAborted`, `PlanningCompleted`
|
||||
|
||||
## Diff stack (UI)
|
||||
|
||||
`UnifiedDiffParser` (static) parses `git diff` output into `DiffFileViewModel`s, detecting
|
||||
added/deleted/renamed/binary files and per-line numbers; `Flatten` injects file-header rows for
|
||||
a combined single-pane view. `DiffModels.cs` holds the shared types (`DiffLineViewModel`,
|
||||
`DiffFileViewModel`, `DiffLineKind`, `DiffFileStatus`, `SubtaskDiffRow`,
|
||||
`DiffTreeNodeViewModel`, `DiffTree`).
|
||||
|
||||
`DiffViewerViewModel` is one unified read-only viewer with two modes:
|
||||
- **Files** — dirty worktree / branch-vs-base / commit-range. Loads via `GitService`, folder
|
||||
file-tree left + per-file diff pane right, Merge button for a live branch source.
|
||||
- **Planning** — per-subtask diffs via `GetPlanningAggregateAsync`, subtask list left + flat
|
||||
diff right, combined integration-branch toggle.
|
||||
|
||||
`DiffLinesView` renders per-file content with binary/empty placeholders.
|
||||
@@ -0,0 +1,142 @@
|
||||
# Usage monitoring, gate & throttle
|
||||
|
||||
> **Explore-note — verify before trusting.** Distilled map of a subsystem, not authoritative.
|
||||
> Last verified against commit `f6cb825` (2026-08-05).
|
||||
> Drift check: `git log --oneline f6cb825..HEAD -- src/ClaudeDo.Worker/Usage src/ClaudeDo.Worker/Queue src/ClaudeDo.Ui/ViewModels/UsagePillViewModel.cs`
|
||||
> Stable structure only (no line numbers). See docs/explore-notes/README.md.
|
||||
|
||||
Covers `src/ClaudeDo.Worker/Usage/`, the queue's throttle/gate integration, per-run token
|
||||
accounting, and the UI surfaces (usage pill + usage monitor modal).
|
||||
|
||||
## Data source
|
||||
|
||||
`GET https://api.anthropic.com/api/oauth/usage` — an **undocumented** Anthropic endpoint,
|
||||
authenticated with the Bearer access token Claude Code itself keeps fresh at
|
||||
`~/.claude/.credentials.json`. ClaudeDo reads that token, never refreshes it, never logs it.
|
||||
|
||||
Because the endpoint is undocumented and can change without notice, **every consumer
|
||||
fails open**. That is the single most important invariant here.
|
||||
|
||||
## Components (`Usage/`)
|
||||
|
||||
| Type | Role |
|
||||
|---|---|
|
||||
| `UsageModels` | `UsageBucket` / `UsageLimitRow` / `UsageSnapshot`. `UsageBucket.Utilization` is already a 0–100 percent — compare directly against thresholds, don't rescale. |
|
||||
| `ClaudeOAuthUsageClient` | Reads the token, calls the endpoint. Defensive parsing: missing/null buckets → null, missing `limits` → empty list. |
|
||||
| `UsageState` | Threadsafe singleton. A failed poll **never** overwrites the last good snapshot — it only sets `LastError`. |
|
||||
| `UsageMonitorService` | `BackgroundService`; polls on `usage_poll_interval_seconds` (default 60, clamped to min 15 on config load), one poll at startup. Logs a failure at most once per distinct error message. Broadcasts `HubBroadcaster.UsageUpdated` after **every** tick, success or failure. |
|
||||
| `UsageSnapshotBuilder` | Builds the Hub-facing `UsageSnapshotDto` from `UsageState` + `IUsageGate` + `AppSettings`. The one shared place for stale/threshold/gate logic — `WorkerHub.GetUsageSnapshot` and `UsageMonitorService` must not diverge. |
|
||||
| `UsageGate` | Hard pause decision → `UsageGateDecision(IsBlocked, Reason)`. |
|
||||
| `UsageThrottle` | Pure static staging of parallelism ahead of the gate. |
|
||||
| `TranscriptUsageReader` | Aggregates token usage from Claude Code transcripts. |
|
||||
|
||||
Interfaces in `Usage/Interfaces/`: `IUsageClient`, `ITranscriptUsageReader`, `IUsageGate`.
|
||||
|
||||
## The gate (hard pause)
|
||||
|
||||
Thresholds: `AppSettings.UsageGateFiveHourPct` / `UsageGateSevenDayPct` (defaults 80/90).
|
||||
Blocked once `five_hour >= UsageGateFiveHourPct` **or** `seven_day >= UsageGateSevenDayPct`
|
||||
(`>=`, not `>`). Threshold `0` = that bucket never gates.
|
||||
|
||||
What it pauses: **only the queue's slot-fill loop** — new queued tasks don't start.
|
||||
Unaffected: already-running runs, `RunNow`, `ContinueTask`, interactive ConPTY sessions,
|
||||
planning sessions, daily prep (all bypass the queue).
|
||||
|
||||
**Fail-open**: no snapshot yet, a failed last poll, or an app-settings read error all
|
||||
resolve to not-blocked.
|
||||
|
||||
There is **no persistent pause state**. Recovery is just the queue's 30 s backstop timer
|
||||
(`queue_backstop_interval_ms`) re-evaluating the gate on its own once usage drops back
|
||||
under the threshold. A blocked↔free transition is logged and broadcast (`WorkerLog`, Warn
|
||||
on block / Info on resume) exactly **once per change**, not every tick.
|
||||
|
||||
## The throttle (staged parallelism)
|
||||
|
||||
`UsageThrottle.EffectiveSlots(configuredSlots, fiveHourPct, sevenDayPct, softPct, hardPct,
|
||||
gateFiveHourPct, gateSevenDayPct)` — pure static, no state.
|
||||
|
||||
Thresholds `usage_throttle_soft_pct` / `usage_throttle_hard_pct` (defaults 50/65).
|
||||
Whichever of 5h/7d is **more utilized** decides the stage:
|
||||
|
||||
| Utilization | Effective slots |
|
||||
|---|---|
|
||||
| below soft | full configured `max_parallel_executions` |
|
||||
| `>= softPct` | capped at 2 |
|
||||
| `>= hardPct` | capped at 1 |
|
||||
| `>=` either gate threshold | 0 — same hard block as `UsageGate` |
|
||||
|
||||
A threshold of `0` disables that stage. The `0` return is deliberately kept in sync with
|
||||
`UsageGate`'s hard block because both read the same gate thresholds — change one, change both.
|
||||
|
||||
Only **new** slot fills are affected; a run already occupying a slot when the stage tightens
|
||||
runs to completion. Same fail-open policy: no snapshot means no throttling.
|
||||
|
||||
The effective stage (configured vs. effective slots + the decisive bucket, `ThrottleBucket`
|
||||
= `"five_hour"` / `"seven_day"`) rides along on `UsageSnapshotDto` purely for UI display.
|
||||
It does **not** change what the gate gates on.
|
||||
|
||||
## Queue integration (`Queue/QueueService`)
|
||||
|
||||
Per loop tick:
|
||||
|
||||
1. `GetEffectiveMaxParallelAsync` reads `AppSettings.MaxParallelExecutions` and steps it
|
||||
down via `UsageThrottle.EffectiveSlots` against the current `UsageState` snapshot.
|
||||
A missing/failed snapshot fails open to the configured value. A **stage change** (not
|
||||
every tick) logs once.
|
||||
2. Separately, `IUsageGate.EvaluateAsync` — if blocked, the slot-fill loop is skipped
|
||||
entirely for that tick.
|
||||
|
||||
## Per-run token accounting
|
||||
|
||||
`task_runs` stores four raw token fields: `tokens_in` / `tokens_out` /
|
||||
`cache_read_tokens` / `cache_write_tokens`.
|
||||
|
||||
These are **not** read from the stream-json `result` event's `usage.input_tokens` — that is
|
||||
only the uncached remainder of a single API call and undercounts the real prompt size by
|
||||
orders of magnitude once caching kicks in.
|
||||
|
||||
Instead `TaskRunner.ApplyUsageAsync` calls
|
||||
`ITranscriptUsageReader.ReadSessionTotalsAsync(sessionId)` — the session transcript's
|
||||
cumulative raw totals across every assistant message, located by `{sessionId}.jsonl` — and
|
||||
stores the **delta** against prior `task_runs` rows sharing the same `session_id`, so a
|
||||
`--resume`'d run doesn't double-count turns already billed to an earlier run.
|
||||
|
||||
A missing/unreadable transcript leaves all four fields `null`; it never fails the run.
|
||||
|
||||
### `TranscriptUsageReader` details
|
||||
|
||||
Reads `~/.claude/projects/**/*.jsonl`, aggregating by date / model / scope (ClaudeDo vs
|
||||
Other), deduped by `requestId`, with a per-file length+mtime cache.
|
||||
|
||||
`<synthetic>`-model lines are skipped **everywhere** — they are not real API calls.
|
||||
|
||||
## UI surfaces
|
||||
|
||||
- **`UsagePillViewModel`** — one shared instance backs the `UsagePill` control in both the
|
||||
footer and the Mission Control header. Loads via `GetUsageSnapshotAsync`, updates live off
|
||||
`IWorkerClient.UsageUpdatedEvent`. Dot state priority is mutually exclusive:
|
||||
**blocked > stale > warn > normal**. `IsThrottled` (effective slots below configured, and
|
||||
not gate-blocked) adds a tooltip line naming effective/configured slots + decisive bucket.
|
||||
- **`UsageMonitorModalViewModel`** — opened from the pill. Renders one gauge **per row** in
|
||||
`UsageSnapshotDto.Limits` — deliberately **dynamic**, because the `seven_day_opus` /
|
||||
`seven_day_sonnet`-style buckets the raw API returns are plan-dependent and come back
|
||||
`null` on plans that don't have them; a fixed gauge layout would break. Also shows model
|
||||
usage (`GetModelUsageAsync`, ClaudeDo-vs-Other split per model) and top-task usage
|
||||
(`GetTaskUsageAsync`) over a 7d/30d preset or custom range.
|
||||
|
||||
## Hub surface
|
||||
|
||||
- `GetUsageSnapshot() -> UsageSnapshotDto` — percentages/limits/`FetchedAtUtc` are null and
|
||||
`IsStale=true` when no snapshot has landed yet. `IsStale` also trips on a failed last poll
|
||||
or a snapshot older than 3× `usage_poll_interval_seconds`.
|
||||
- `GetModelUsage(from, to)` — thin wrapper over `ITranscriptUsageReader.ReadAsync`.
|
||||
- `GetTaskUsage(from, to)` — top consumers from `task_runs` joined to task/list, grouped per
|
||||
task. Null token columns count as **0**, never drop the row. `Model` comes from that task's
|
||||
most recent run. Sorted by total tokens descending, capped at 100.
|
||||
- `UsageUpdated` event carries the same `UsageSnapshotDto`.
|
||||
|
||||
## Settings columns
|
||||
|
||||
`app_settings`: `usage_gate_five_hour_pct` / `usage_gate_seven_day_pct` (80/90),
|
||||
`usage_throttle_soft_pct` / `usage_throttle_hard_pct` (50/65). All four clamped 0..100 by
|
||||
`AppSettingsRepository.UpdateAsync`. Worker config: `usage_poll_interval_seconds`.
|
||||
@@ -1,6 +1,6 @@
|
||||
> **Explore-note — verify before trusting.** Distilled map of a subsystem, not authoritative.
|
||||
> Last verified against commit `896d4b5` (2026-07-23).
|
||||
> Drift check: `git log --oneline 896d4b5..HEAD -- src/ClaudeDo.Worker`
|
||||
> Last verified against commit `cc90600` (2026-08-06).
|
||||
> Drift check: `git log --oneline cc90600..HEAD -- src/ClaudeDo.Worker`
|
||||
> Stable structure only (no line numbers). See docs/explore-notes/README.md.
|
||||
|
||||
# Worker: Task Execution Pipeline
|
||||
@@ -28,9 +28,11 @@ How a task moves Queued → Running → terminal, across `src/ClaudeDo.Worker`
|
||||
|
||||
5. **Run Preparation** — `TaskRunner.RunAsync()` (Runner/TaskRunner.cs)
|
||||
- Loads task, list config, subtasks, attachments from the DB.
|
||||
- `StartRunningAsync()` (only if not pre-claimed): atomic claim to Running, **before any
|
||||
resource is created**. A rejected claim (task already Running) bails out immediately —
|
||||
no worktree, no MCP token file. Broadcasts TaskStarted.
|
||||
- `PrepareRunDirectoryAsync()`: worktree (via WorktreeManager) if the list has a WorkingDir,
|
||||
else sandbox. Generates a per-run MCP token, writes MCP config to disk.
|
||||
- `StartRunningAsync()` (only if not pre-claimed): atomic Queued → Running. Broadcasts TaskStarted.
|
||||
|
||||
6. **Claude Execution** — `TaskRunner.RunOnceAsync()` (Runner/TaskRunner.cs)
|
||||
- Creates a TaskRunEntity, points the task at the run's log path.
|
||||
@@ -50,9 +52,47 @@ How a task moves Queued → Running → terminal, across `src/ClaudeDo.Worker`
|
||||
- **Done** CompleteAsync (Running → Done) — top-level success.
|
||||
- **WaitingForReview** SubmitForReviewAsync (Running → WaitingForReview) — review gate.
|
||||
- **WaitingForChildren** SubmitForChildrenAsync (Running → WaitingForChildren) — blocks on children.
|
||||
Advances to WaitingForReview via `TryAdvanceParentAsync` once every remaining child is
|
||||
terminal (Done/Failed/Cancelled) — including zero children left, e.g. after the last child
|
||||
is deleted (`WorkerHub.DeleteTask` / `ExternalMcpService.DeleteTask` both call it).
|
||||
- **Failed** FailAsync (Running/Queued → Failed).
|
||||
- **Cancelled** CancelAsync (Running/Queued/WaitingForReview/WaitingForChildren → Cancelled).
|
||||
|
||||
## Model, effort & max-turns resolution
|
||||
|
||||
*(section added at commit `f6cb825`, 2026-08-05; resolver extraction added same day)*
|
||||
|
||||
The resolution below lives in `Runner/EffectiveRunConfigResolver.Resolve` (not inlined in
|
||||
`TaskRunner` anymore) so `TaskRunner.ResolveConfigAsync` and the read-only
|
||||
`get_effective_run_config` MCP tool (`External/ConfigMcpTools.cs`) share one codepath and can't
|
||||
report different numbers for the same task. The tool additionally surfaces, per field, whether
|
||||
it came from the task/list/preset/global layer, and — for max turns — the raw requested value
|
||||
plus whether it was clamped.
|
||||
|
||||
Step 6 builds the CLI args. Model and turn budget resolve like this:
|
||||
|
||||
1. **Effective model** — task override → list config → `AppSettings.DefaultModel`.
|
||||
2. **Preset row** — `ModelPresets.For(global.ModelPresets, model, global.DefaultMaxTurns)`.
|
||||
The model string is resolved through `ModelRegistry.TryNormalizeAlias` **first**, so a full
|
||||
CLI model id (e.g. `claude-sonnet-4-6`, not just the bare `sonnet`/`opus`/`haiku`/`fable`
|
||||
aliases) still hits its alias's preset row instead of missing every lookup. Only a model
|
||||
that normalizes to nothing recognized falls back to a synthesized row using
|
||||
`AppSettings.DefaultMaxTurns` — **never a hardcoded number, and it never throws**: an
|
||||
unknown model must not block a run.
|
||||
3. The preset supplies `--effort` and the **global** max-turns default. Task/list `MaxTurns`
|
||||
overrides still win over it.
|
||||
4. **Ceiling clamp** — `TaskRunner.ResolveMaxTurns` hard-clamps the resolved value to
|
||||
`AppSettings.MaxTurnsCeiling` (default 80). An override above the ceiling still starts, just
|
||||
capped, and a Warn logs the task id + requested + effective value.
|
||||
|
||||
⚠️ **Trap:** if `app_settings.model_presets` is somehow null, the fallback path decides the turn
|
||||
budget — which is why `AppSettingsRepository.GetAsync` backfills shipping defaults on the first
|
||||
read after null. Ship preset turns are low (haiku 20, sonnet 30, opus 40, fable 25), so a task
|
||||
that genuinely needs a long run must set its own `MaxTurns`.
|
||||
|
||||
Prompt composition: `TaskPromptComposer.Compose` injects attachment **absolute paths** as a
|
||||
read-only "## Reference files" section.
|
||||
|
||||
## Component Responsibilities
|
||||
|
||||
**Queue/**
|
||||
@@ -73,7 +113,11 @@ How a task moves Queued → Running → terminal, across `src/ClaudeDo.Worker`
|
||||
- `TaskStateService` — all task status transitions; guards preconditions; signals queue/hub.
|
||||
|
||||
**Lifecycle/** (startup recovery)
|
||||
- `StaleTaskRecovery` — tasks stuck Running after a crash/restart → Failed.
|
||||
- `StaleTaskRecovery` — tasks stuck Running after a crash/restart → Failed. The underlying
|
||||
`TaskStateService.RecoverStaleRunningAsync` bulk-flips Running→Failed, then re-runs the same
|
||||
chain/parent-advance side effects as a normal `FailAsync` (per recovered id, best-effort) so a
|
||||
crash mid-chain-child or mid-improvement-child doesn't leave a successor blocked forever or a
|
||||
`WaitingForChildren` parent wedged.
|
||||
- `OrphanRecovery` — dequeues children whose parent is no longer planning (stays attached).
|
||||
- `AttachmentOrphanRecovery` — cleans orphaned attachment files.
|
||||
- `TaskResetService` — manual reset to Idle.
|
||||
@@ -113,6 +157,15 @@ Program.cs (DI setup)
|
||||
- **Slot limit** — respects MaxParallelExecutions; a backstop timer wakes even if a Wake() is missed.
|
||||
- **Pre-claimed tasks** — the dispatcher pre-claims via the picker; the override slot
|
||||
(RunNow/ContinueTask) must call StartRunningAsync if a task is not pre-claimed.
|
||||
- **Claim before create** — `TaskRunner.RunAsync`'s unclaimed path calls `StartRunningAsync`
|
||||
*before* `PrepareRunDirectoryAsync`. RunNow racing the picker for the same Queued row used to
|
||||
create the worktree first and only claim afterwards, so the losing dispatch could hit
|
||||
WorktreeManager's branch-collision self-heal and force-remove the winner's live worktree
|
||||
mid-run. `OverrideSlotService.RunNow` also fast-rejects a task already Running in the DB
|
||||
(defense in depth; the picker's atomic SQL claim is the real arbiter either way).
|
||||
`RunCancellationRegistry.Register` refuses (and logs) a second registration for the same task
|
||||
id instead of silently overwriting the first, so a losing dispatch's cleanup can't unregister
|
||||
the winner's CTS out from under it.
|
||||
- **Terminal writes** — use `CancellationToken.None`; a task is never left Running after crash/cancel.
|
||||
- **Per-run MCP tokens** — each run gets a unique token scoping tool access; unregistered on end.
|
||||
- **Auto-retry** — one automatic retry if a session exists and the first run failed.
|
||||
|
||||
+173
-60
@@ -1,53 +1,38 @@
|
||||
# ClaudeDo — Offene Punkte
|
||||
|
||||
Stand: 2026-07-24. Diese Datei listet die **aktiv verifizierten Findings** aus der Verifikations-Session vom 2026-07-24. Die laufende Verifikations-Checkliste (Pass/Fail je Abschnitt, inkl. noch offener Abschnitte §7–§11 und Kanten) lebt in `docs/verification-handoff.md`. Erledigtes steht in den Commits/im Code, nicht hier.
|
||||
Stand: 2026-08-06. Der Findings-Block von 2026-07-24 wurde am 2026-08-06 **gegen den Code
|
||||
nachverifiziert** (statisch, nicht in der laufenden App); alles, was inzwischen gefixt ist, ist
|
||||
hier entfernt — Erledigtes steht in den Commits/im Code, nicht hier. Die alte Verifikations-
|
||||
Checkliste lebt in `docs/verification-handoff.md`.
|
||||
|
||||
---
|
||||
|
||||
## Bugs (verifiziert 2026-07-24)
|
||||
## Bugs (offen)
|
||||
|
||||
- **OUTCOME-Karte rendert rohes Structured-Output-JSON:** `TaskMonitorViewModel.ApplyOutcome` (ClaudeDo.Ui) setzt bei Tasks ohne Roadblock-Marker `SessionOutcome = result` wörtlich (Zeile ~261). Der Worker legt in `task.Result` das rohe `{"summary":…,"files_changed":[…]}` ab (die lesbare Fassung steht in `task_runs.resultMarkdown`), also zeigt die Detail-Insel OUTCOME als JSON-Blob statt als Text. Fix: (a) UI parst ein JSON-Result und zeigt `summary`, oder (b) Worker schreibt `summary`/`resultMarkdown` statt des JSON in `task.Result`. Verifiziert am Task `verif §1 diff matrix`. Deckt sich mit Memory `worker_testing_findings`.
|
||||
- **Approve & Merge schluckt einen „blocked"-Merge still:** `DetailsIslandViewModel.ApproveReviewAsync` reagiert nur auf `result.Status == "conflict"` (öffnet den Resolver); bei `"blocked"` (z.B. Ziel-Working-Tree hat uncommittete getrackte Änderungen) und anderen Nicht-`merged`/Nicht-`conflict`-Status passiert **nichts** — kein Footer-Fehler, kein Dialog (der `catch` greift nur bei Exceptions, „blocked" ist aber ein normaler Rückgabewert mit `ErrorMessage`). User sieht „Klick tut nichts". Doppelt verifiziert (Konflikt-Approve UND sauberer additiver Approve `verif §1b`, beide bei dirty `main`-Checkout still). Fix: bei `blocked`/unerwartetem Status `result.ErrorMessage` via `ShowErrorAsync`/`FlashFooterError` surfacen. Verstößt gegen `feedback_ui_error_surfacing`. (Der Konflikt-Resolver + 3-Pane-Editor funktionieren, sobald der Ziel-Tree sauber ist.)
|
||||
- **„Waiting for Improvements" für Planning-Parents (Terminologie):** `taskStatus.waitingForChildren` = „Waiting for Improvements" (en.json:504), `agentStatus.children` dito (503), `childOutcomesLabel` = „IMPROVEMENTS" (191). Seit dem unified-parent-Modell gilt `WaitingForChildren` für Planning **und** Improvement-Parents — „Improvements" ist für einen Planning-Parent falsch. Auf neutrales „Waiting for Subtasks"/„Subtasks" umstellen (en **und** de — Localization.Tests-Parität). Verifiziert §3.
|
||||
- **Kind-Rows aktualisieren nach Parent-Planning-Transitionen nicht live:** Parent-getriebene Änderungen propagieren nicht auf die Kind-`TaskRowViewModel`s ohne Listen-Reload. (a) Nach „Finalize planning" bleiben Kind-Rows auf „Draft" (`IsDraft`) statt „Planned" (`IsPlanned`) — der geänderte `ParentFinalized` kommt nicht über das Parent-`TaskUpdated`-Broadcast an. (b) Nach „Discard" bleiben die (in der DB gelöschten) Draft-Kind-Rows sichtbar, bis die Liste neu geladen wird. Gemeinsame Ursache: die Kinderliste/-zustände werden bei Parent-Transitionen nicht live neu aufgelöst. Doppelt verifiziert §3 (Finalize **und** Discard, 2026-07-24).
|
||||
- **AskUser-Frage erscheint NUR in Mission Control, nicht in der Task-Detail-Insel:** Der `ask_user`-Banner (Frage + Antwort-Eingabe) lebt ausschließlich in `MonitorPaneView` (Mission Control). Die Detail-Insel (`DetailsIslandView`) streamt zwar den Live-Log eines laufenden Tasks, zeigt aber **kein** Frage-Banner — ein Nutzer, der nur die Detailansicht offen hat, sieht nicht, dass der Run auf eine Antwort wartet, und läuft nach 3 min in den Timeout-Fallback. Verifiziert §7 (User schaute Detailansicht, Frage war unsichtbar; erst in Mission Control sichtbar). Fix: Frage-Banner + Inline-Antwort auch in der Detail-Insel für den gebundenen laufenden Task surfacen (VM-Zustand liegt bereits in `TaskMonitorViewModel`, müsste für die Detail-Insel repliziert/geteilt werden). **Von Mika bei der §7-Verifikation ausdrücklich gewünscht** („vermisse diese Interaktion in der Detail-Ansicht"). §7.
|
||||
- **„New session"-Button (Mission Control) unsichtbar — Icon.Plus ist Strich-Only:** `Icon.Plus` = `M12 5v14M5 12h14` (IslandStyles.axaml:61) ist reine Linien-Geometrie ohne Fläche; im `<PathIcon>` (MissionControlView.axaml:38, füllt Geometrie) rendert sie **unsichtbar** → der Ad-hoc-„New session"-Button erscheint leer und ist nicht auffindbar (Feature + VM `OpenAdHocConPtySessionAsync` + Tests funktionieren, nur das Icon fehlt sichtbar). Fix: Icon.Plus als gefüllte Geometrie authoren ODER als gestricheltes `Path` rendern (Icon-Gotcha in CLAUDE.md). **Andere `Icon.Plus`-Verwendungen mitprüfen.** Verifiziert §5.
|
||||
- **Verwaiste git-Worktrees sind für die App unsichtbar** (gefunden 2026-08-06). In `C:\Private\ClaudeDo` waren nach dem Cleanup 22 git-registrierte Worktrees + 26 `claudedo/*`-Branches vorhanden, die ClaudeDo-DB kannte davon nur zwei. Die Worktrees-Übersicht listet ausschließlich Zeilen aus `worktrees`, also kann der Nutzer diese Reste nicht über die App entfernen — und es gibt keinen Sweep dafür (`OrphanRecovery` räumt nur Task-Zeilen auf, keine Worktrees). Wunsch: entweder ein Startup-Abgleich `git worktree list` ↔ DB, der Unbekannte als „untracked" in die Übersicht aufnimmt, oder mindestens eine Warnung mit Anzahl.
|
||||
|
||||
## UX / Nits (verifiziert 2026-07-24)
|
||||
## UX / Nits (offen)
|
||||
|
||||
- **„Resume planning session" ist grundsätzlich kaputt (Session-Id wird nie erfasst) + Fehler wird verschluckt:** Im UnfinishedPlanning-Modal macht **Resume** nichts. Ursache: `PlanningSessionManager.ResumeAsync` (PlanningSessionManager.cs:238) bricht mit `InvalidOperationException("No Claude session ID captured yet; cannot resume.")` ab, wenn `task.PlanningSessionId` leer ist — und der Setter `TaskRepository.UpdatePlanningSessionIdAsync` (TaskRepository.cs:322) hat **keinen einzigen Aufrufer** im Worker, d.h. `planning_session_id` wird nie befüllt (verifiziert an `verif §3 resume-discard`: `planning_session_id=NULL`, Token gesetzt, Drafts angelegt). Resume kann also **nie** erfolgreich sein. Zusätzlich verschluckt `TasksIslandViewModel.ResumePlanningSessionAsync` den ganzen Block in einem leeren `catch { }` (~Zeile 870) → der Button ist ein **stummer No-op**, kein Footer-Fehler, kein Dialog (verstößt gegen `feedback_ui_error_surfacing`). Hintergrund: die wt-Planning-Session ist interaktiv; ClaudeDo erfasst die claude-Session-Id dort nicht (analog zur bewusst nicht persistierten ConPTY-Session-Id, §5). Fix-Optionen: (a) beim Planning-Start/-Lauf die claude-Session-Id erfassen und via `UpdatePlanningSessionIdAsync` persistieren, damit Resume echt resumen kann; ODER (b) Resume entfernen/deaktivieren, wenn keine Session-Id vorliegt; in JEDEM Fall den Fehler statt des leeren `catch` surfacen. Verifiziert §3. §3.
|
||||
- **Attachments: erster Drag&Drop der Session schlug einmalig fehl („An error occurred"):** Beim allerersten Drop-to-attach einer UI-Session zeigte die `DropStatus`-Zeile inline einen generischen Fehler und es wurde nichts persistiert (kein File, keine DB-Row); alle folgenden Drops derselben Session + der „Add file…"-Picker + Remove funktionierten fehlerfrei. Nicht reproduzierbar nach dem ersten Mal (App-Neustart nötig, um die „erster-Drop"-Bedingung wiederherzustellen). Kandidaten: transiente SQLite-Contention (der UI-Prozess schreibt `todo.db` direkt via `new TaskAttachmentRepository`, während der Worker dieselbe DB hält) ODER ein Fehler im Drop-Stream-Pfad (`IStorageFile.OpenReadAsync` im Code-Behind, außerhalb des `try` in `AddFilesAsync`). Falls es erneut auftritt: `AddFilesAsync`/`OnDrop` mit robusterem Error-Logging versehen (die genaue Exception-Message fehlt, weil `DropStatus` nur `{fileName}: {ex.Message}` zeigt). Verifiziert §9 (einmalig beobachtet). §9.
|
||||
- **Agent-Settings-Gear weicht vom übrigen Settings-Icon ab:** Der Agent-Settings-Flyout-Button (`TaskHeaderBar.axaml:68`) rendert ein Unicode-Glyph `⚙` (`<TextBlock Text="⚙">`), während die Listen-Nav (`ListsIslandView.axaml:56`) und die Listen-Settings (`TasksIslandView.axaml:47`) das gefüllte `Icon.Settings`-PathIcon (Gear-StreamGeometry, IslandStyles.axaml:110) nutzen → optisch ein anderes Zahnrad. Angleichen: den `⚙`-TextBlock durch `<PathIcon Data="{StaticResource Icon.Settings}" .../>` ersetzen. Verifiziert §8 (User-Sichtprüfung). §8.
|
||||
- **Session-Skills-Tab hat keinen Empty-State:** Bei 0 installierten Skills zeigt der Skills-Tab (Settings) nur eine nackte leere Fläche unter der Install-Zeile — kein erklärender Hinweis (z.B. „Noch keine Skills installiert — GitHub-URL oben einfügen"). Verifiziert §8 (frischer Zustand vor ponytail-Install). Kleiner Empty-State-Text ergänzen.
|
||||
- **Planning-aktiver Parent zeigt weiter „Idle":** Parent in `planning_phase=active` hat `Status=Idle` (korrekt im Modell), aber `TaskRowViewModel.StatusLabel` (Zeile 132) kennt nur `HasInteractiveSession`/`IsParked` als Overrides — der `PlanningBadge` ersetzt den Chip nicht sichtbar → liest sich wie ein normaler Idle-Task. Wunsch: klarer „Planning/Draft aktiv"-Zustand, der Idle überschreibt. (Kinder zeigen korrekt „Draft".)
|
||||
- **Blocked-by-Kette nicht sichtbar:** Nach Finalize ist die sequentielle Kette korrekt gesetzt (child[i] blocked-by child[i-1]), aber die UI stellt Reihenfolge/Abhängigkeit nicht dar — es gibt nur den „waiting"-Chip. Wunsch: Kette visualisieren (z.B. „wartet auf <Vorgänger>").
|
||||
- **Dequeue-„X" fehlt auf wartenden (blockierten) Kettengliedern:** `CanRemoveFromQueue = IsQueued || HasQueuedSubtasks` (TaskRowViewModel.cs:99), `IsQueued` verlangt leeres `blocked_by`. Ein gequeuetes, aber blockiertes Kind (`IsWaiting`) bekommt daher kein Remove-from-queue-X — nur Parent + erstes (entsperrtes) Kind.
|
||||
- **Session-Skills-Tab hat keinen Empty-State:** Bei 0 installierten Skills zeigt der Skills-Tab (Settings) nur eine nackte leere Fläche unter der Install-Zeile — kein erklärender Hinweis (z.B. „Noch keine Skills installiert — GitHub-URL oben einfügen"). Im `sessionSkillsTab`-Locale-Namespace (en.json:674) gibt es keinen `empty`-Key.
|
||||
- **Modal-Bodies sind unten abgeschnitten** (Sichtprüfung 2026-08-06, beobachtet in den Listen-Settings: der Hinweistext unter „Verify command" ist mitten in der Zeile vom Footer weggeschnitten und lässt sich nicht herunterscrollen; laut Mika „fast überall" in den Settings-Modals). Gemeinsames Muster: `<ScrollViewer Padding="20,16">` als Modal-Body — in `ListSettingsModalView:36`, `MergeModalView:33`, `WorktreesOverviewModalView:131`, `AboutModalView:20`, `MergeHelperSelectionModal:47`, `RepoImportModalView:45`, `LogVisualizerView:45` sowie `WorkConsole:295/395`. **Vermutete** Ursache (nicht verifiziert): das untere `Padding` des ScrollViewers zählt nicht zum scrollbaren Extent, die letzten Pixel sind also unerreichbar. Erst am echten Fall nachmessen, dann ggf. einheitlich das Padding vom ScrollViewer auf ein `Margin` des inneren Inhalts umziehen.
|
||||
- **Usage-Monitor-Modal: Analyse-Tabellen ausbaufähig** (Sichtprüfung 2026-08-06; Gauges, Info-Bänder, Presets/Custom-Range und Refresh-Button sind in Ordnung). Models-Tab: die Spaltenköpfe „OTHER CACHE" und „SHARE" kollidieren zu `OTHER CACHISHARE`; `claude-haiku-4-5-20251001` läuft in die IN-Spalte; alle Zahlen linksbündig und ohne Tausendertrenner (`506830708`). Tasks-Tab: Task-Titel wird ohne Ellipse hart an der LIST-Spalte abgeschnitten, der LIST-Text ebenfalls; MODEL ist bei Runs von vor der `task_runs.model`-Spalte leer (besser „—"). Wunsch: Zahlen rechtsbündig + `#,##0` (oder k/M), Spaltenbreiten/Truncation fixen.
|
||||
- **Conflict-Resolver: mehrere Konfliktdateien schlecht erkennbar:** Beim 2-Datei-Konflikt schwer zu sehen, dass zwei Dateien betroffen sind (File-Switcher/Anzahl zu unauffällig). Prominentere Datei-Liste / „x von y Dateien".
|
||||
- **Attachments: erster Drag&Drop der Session schlug einmalig fehl („An error occurred"):** Beim allerersten Drop-to-attach einer UI-Session zeigte die `DropStatus`-Zeile inline einen generischen Fehler und es wurde nichts persistiert (kein File, keine DB-Row); alle folgenden Drops derselben Session + der „Add file…"-Picker + Remove funktionierten fehlerfrei. Nicht reproduzierbar nach dem ersten Mal. Kandidaten: transiente SQLite-Contention (der UI-Prozess schreibt `todo.db` direkt via `new TaskAttachmentRepository`, während der Worker dieselbe DB hält) ODER ein Fehler im Drop-Stream-Pfad (`IStorageFile.OpenReadAsync` im Code-Behind, außerhalb des `try` in `AddFilesAsync`). Falls es erneut auftritt: `AddFilesAsync`/`OnDrop` mit robusterem Error-Logging versehen.
|
||||
|
||||
- **Diff-Viewer: reiner Rename schwach dargestellt:** Rename korrekt erkannt (`UnifiedDiffParser` → `DiffFileStatus.Renamed`, Badge `StatusCode="R"`), aber der File-Tree zeigt nur den neuen Namen + „+0 −0" ohne „alt → neu"-Pfad; liest sich wie „keine Änderung". Optional: alten Pfad + „renamed"-Label anzeigen. §1.
|
||||
- **Header-TurnsText zeigt `0/max` für abgeschlossene Runs:** `TurnsText => {Turns}/{EffectiveMaxTurns}` — `Turns` wird beim Laden eines terminalen Tasks nicht aus `task_runs.turnCount` restauriert (nur live gefüllt). Kosmetisch. §1.
|
||||
- **Conflict-Resolver: Continue/Merge-Button klickbar trotz offener Konflikte:** Gate greift funktional (merged erst wenn alle Konflikte in allen Dateien gelöst), aber der Button ist nicht disabled/gegraut → früher Klick ist ein stummer No-op. Besser: disabled bis `AllResolved`, oder Hinweis „N Konflikte in M Dateien offen". §4.
|
||||
- **Conflict-Resolver: mehrere Konfliktdateien schlecht erkennbar:** Beim 2-Datei-Konflikt schwer zu sehen, dass zwei Dateien betroffen sind (File-Switcher/Anzahl zu unauffällig). Prominentere Datei-Liste / „x von y Dateien". §4.
|
||||
- **Planning-aktiver Parent zeigt weiter „Idle":** Parent in `planning_phase=active` hat `Status=Idle` (korrekt im Modell), aber der Row-Status-Chip zeigt „Idle"; der `PlanningBadge` ersetzt das nicht sichtbar → liest sich wie ein normaler Idle-Task. Wunsch: klarer „Planning/Draft aktiv"-Zustand, der Idle überschreibt. (Kinder zeigen korrekt „Draft".) §3.
|
||||
- **Blocked-by-Kette nicht sichtbar:** Nach Finalize ist die sequentielle Kette korrekt gesetzt (child[i] blocked-by child[i-1]), aber die UI stellt die Reihenfolge/Abhängigkeit nicht dar. Wunsch: Kette visualisieren (z.B. „wartet auf <Vorgänger>"), nicht nur der „waiting"-Chip nach dem Queueen. §3.
|
||||
- **Dequeue-„X" fehlt auf wartenden (blockierten) Kettengliedern:** `CanRemoveFromQueue = IsQueued || HasQueuedSubtasks`, `IsQueued` verlangt leeres `blocked_by`. Ein gequeuetes, aber blockiertes Kind (`IsWaiting`) bekommt daher kein Remove-from-queue-X — nur Parent + erstes (entsperrtes) Kind. §3.
|
||||
- **„Open ConPTY session" erneut = Prompt wird neu gesendet, kein Resume:** Da die ConPTY-Session-Id bewusst nicht persistiert wird, startet ein erneutes „Open ConPTY session" auf demselben Task eine frische Session und sendet den Task-Prompt erneut (re-runt die Arbeit im selben Worktree) statt zu resumen. So designt, aber UX-Falle — evtl. „Resume"-Affordance oder Re-Open-Warnung. §5.
|
||||
## Feature-Wünsche
|
||||
|
||||
## Feature-Wünsche (aus der Session)
|
||||
- **Conflict-Resolver: farbliches Hervorheben eingefügter Zeilen im Result-Pane** (grüner „flow" der übernommenen Zeilen).
|
||||
- **Die Handler-Session soll „Submit for review" selbst auslösen können** (Sichtprüfung 2026-08-06). Aktuell ist der Abschluss eines List-Handler-Laufs ein reiner Handgriff über die Schaltfläche in der Mission-Control-Kachel — die Session selbst hat kein Werkzeug dafür, obwohl sie am besten weiß, wann sie fertig ist. Wunsch: ein MCP-Tool auf der In-Task-Oberfläche, das denselben Pfad wie der Button nimmt.
|
||||
- **Handoff-Kachel besser beschriften** (Sichtprüfung 2026-08-06). Die zweite Kachel heißt `Merge Helper — <Liste> (Handoff)` bzw. „(Übergabe)" (`missionControl.mergeHelperHandoffTitleSuffix`, en.json/de.json:297). „Handoff" ist internes Vokabular und sagt nicht, was die Session tut. Besser etwas, das die Rolle nennt (Ausführen + Mergen der verbliebenen Tasks, Phasen 3-5).
|
||||
|
||||
- **Conflict-Resolver: farbliches Hervorheben eingefügter Zeilen im Result-Pane** (grüner „flow" der übernommenen Zeilen). §4.
|
||||
- Als ClaudeDo-Tasks in Liste „Claude do" erfasst: **Approve erzwingt Diff/Review vor Merge** (koppelt den blocked-Merge-Silent-Fail-Fix) und **interaktive Planning-Session über embedded ConPTY statt externem wt-Fenster** (koppelt den Planning-Permission-Prompt).
|
||||
## Design-Entscheidungen (27.07.-Batch, Sichtprüfung am 2026-08-06 abgeschlossen)
|
||||
|
||||
## Sichtprüfung offen (2026-07-27, Batch „Claude do"-Liste)
|
||||
|
||||
Alles gebaut + unit-getestet, aber **nicht visuell verifiziert** — Mika prüft:
|
||||
|
||||
- **Ctrl+K fokussiert die Suche, `#` ist wieder tippbar** (Binding war `OemQuestion` = `#` auf DE-Layout).
|
||||
- **Titel-Edit in der Detail-Insel persistiert** (400 ms debounced, wie die Beschreibung); Row-Titel + Merge-Kontext ziehen mit.
|
||||
- **Spinner beim ConPTY-Start:** Tile erscheint sofort mit „Sitzung wird gestartet…" und deckt Launch-Spec-Roundtrip + Spawn ab. **Nicht** abgedeckt: die Sekunden, die die claude-TUI danach zum ersten Frame braucht (dafür bräuchte es einen Hook auf `TerminalControl.DataReceived`). Bei Launch-Fehler bleibt das Tile jetzt mit Inline-Banner stehen statt zu verschwinden.
|
||||
- **Spinner beim Refine** ersetzt den Refine-Button in der Row, solange der Run läuft.
|
||||
- **Interactive-Chip** statt „Parked" auf Tasks mit offener ConPTY-Session; Klick öffnet Mission Control und fokussiert die Pane. Accent-Tint (bewusst dieselbe „live"-Familie wie Running, klar unterschieden vom slate-blauen Parked).
|
||||
- **Diff-Viewer:** rechter Abstand der `+n −n`-Zahlen im File-Tree.
|
||||
- **Manual-Tasks:** MANUAL-Badge, Kontextmenü-Toggle, Listen-Checkbox „Manuelle Liste"; Queue/Refine/Planning ausgeblendet, ConPTY bleibt.
|
||||
- **Settings → Allgemein:** Tabelle „Vorgaben pro Modell" (Effort + Max. Durchläufe je haiku/sonnet/opus/fable). Ersetzt das einzelne globale „Max. Durchläufe"-Feld.
|
||||
|
||||
Offene Entscheidungen dazu:
|
||||
Der Sichtprüfungs-Block dieses Batches (Ctrl+K/`#`, Titel-Edit, ConPTY-/Refine-Spinner,
|
||||
Interactive-Chip, Diff-Viewer-Abstände, Manual-Tasks, Vorgaben-pro-Modell-Tabelle) ist von Mika
|
||||
verifiziert und deshalb hier entfernt. Es bleiben die Entscheidungen, die daran hängen:
|
||||
|
||||
- Interaktive ConPTY-Sessions bekommen `--effort`, aber **kein** `--model` — die Session läuft weiter unter dem Modell aus Mikas Claude-Config, der Effort kommt aus dem Preset des Modells, das ClaudeDo für die Task auflösen würde. Falls ClaudeDo auch interaktiv das Modell erzwingen soll, ist das ein Folge-Task.
|
||||
- `AppSettings.DefaultMaxTurns` ist jetzt tatsächlich verdrahtet (`ModelPresets.For(..., global.DefaultMaxTurns)` in `TaskRunner.ResolveConfigAsync`): reiner Fallback für ein Modell, das auch nach `ModelRegistry.TryNormalizeAlias` auf keine Preset-Zeile trifft — vorher war das Feld tot (hartkodierte 30). Hat weiterhin keinen eigenen Editor mehr (nur die Preset-Tabelle pro Modell); Spalte könnte später entfallen, falls das nie zutrifft.
|
||||
@@ -55,6 +40,7 @@ Offene Entscheidungen dazu:
|
||||
## Beobachtung (offen — Entscheidung Mika)
|
||||
|
||||
- **`--permission-mode auto` + Modell `haiku` → Writes werden denied:** Kontrolliert verifiziert (CLI 2.1.207): unter dem Default-Mode `auto` bekommt **sonnet** Writes auto-approved (`permission_denials:[]`), **haiku** wird `denied` (`permission_denials:[Write]`, keine Datei) — eine haiku-Task macht unter `auto` still nichts und landet ohne Änderung in `WaitingForReview`. Normalbetrieb (Default = sonnet) nicht betroffen. KEINE CLI-Regression, sondern modellabhängiges `auto`-Verhalten. Optionen falls es nervt: haiku aus der Auswahl nehmen, ODER Runner auf `acceptEdits`/`bypassPermissions` (modell-unabhängig). Mika: erstmal beobachten. Siehe Memory `auto_permission_haiku_footgun`.
|
||||
- ~~**`QueueServiceTests.UsageGate_TransitionLogging_FiresOncePerChange` ist zeitbasiert flaky**~~ — **am 2026-08-06 gefixt**: der feste `Task.Delay(200)` ist durch die schon im selben File vorhandene `AssertStableCountAsync` ersetzt (pollt bis der erste Backstop-Tick geloggt hat, wartet dann eine Karenzzeit und stellt sicher, dass kein weiterer Tick nachlegt). Historischer Befund zur Einordnung: Schlägt reproduzierbar fehl (`Expected 1, Actual 0` Warn-Log-Aufrufe), sowohl solo (`--filter`) als auch im Vollauf, auf einem sauberen `git worktree add` gegen `main` (bdee731) — also **kein** durch diese Abschluss-Session verursachter Regress (die Session hat keine `.cs`-Datei angefasst). Ursache: der Test verlässt sich auf einen festen `Task.Delay(200)`, um mehrere 50-ms-Backstop-Ticks abzuwarten (Kommentar im Test: „Several backstop ticks (50ms interval) all observe the same blocked state"); auf einer stark ausgelasteten Maschine (hier: viele parallele ClaudeDo-Worktrees/Builds) reicht das Fenster nicht immer. Zum Vergleich: derselbe Test lief in einer zweiten, isolierten Verifikation (Scratch-Merge für den Environment-Checks-Task) sauber durch (876/876). Fix wäre ein Poll-basiertes Warten statt fixem Sleep — aber außerhalb des Scopes dieser Doku/Verifikations-Session (keine Code-Änderung angefasst).
|
||||
|
||||
---
|
||||
|
||||
@@ -62,27 +48,28 @@ Offene Entscheidungen dazu:
|
||||
|
||||
- **List handler (2026-07-27)** — visual pass: the Broom button is gone from the lists footer,
|
||||
the context-menu item appears only on lists with a working dir, and the selection dialog has no
|
||||
LIST column. Plus a real-Claude smoke run of the five phases (dedupe questions, enhancements
|
||||
landing in task descriptions, queued execution, merges).
|
||||
LIST column. (Der real-Claude-Smoke-Run der fünf Phasen ist am 2026-07-29 gelaufen — 6/6 sauber
|
||||
gemerged; nur die Sichtprüfung ist noch offen.)
|
||||
|
||||
## Offene Verifikation (2026-08-05)
|
||||
|
||||
- **List handler owns a task (2026-08-05)** — build + unit tests all green, but **not visually
|
||||
verified**: start "Let Claude handle it" on a list, confirm exactly one new task appears in that
|
||||
list (`Idle`, MANUAL badge, title "List handler: <list>"), the Mission Control tile is
|
||||
task-based (Submit for review button present), Submit for review flips it to
|
||||
`WaitingForReview`, and the detail pane's diff/merge card shows the full range of everything the
|
||||
run merged to main (via the new `HandlerBaseCommit`/`HandlerHeadCommit` fallback — no
|
||||
`WorktreeEntity` is created for this task, so the diff comes from `list.WorkingDir` directly).
|
||||
- **Roadblock reply field (2026-08-05)** — build + unit tests all green, but **not visually
|
||||
verified**: on a `Done` (or `WaitingForReview`/`Failed`/`Cancelled`) task with a reported
|
||||
roadblock, the ROADBLOCK card shows a reply textbox + Send button under the roadblock text.
|
||||
Check layout/spacing against the AskUser question card it's modeled on, Enter-to-send, the
|
||||
disabled state + hint text when there's no session ID to resume, and that an override-slot-busy
|
||||
error shows up in the footer log strip (not a modal) with the typed text still in the field.
|
||||
Approve should go straight to `Done` with no merge attempt. Design choice: the review range
|
||||
lives as two new nullable columns directly on `TaskEntity` (not a phantom `WorktreeEntity` row),
|
||||
specifically so `list_worktrees`/the Worktrees overview never see it.
|
||||
- **List handler owns a task (2026-08-05)** — **visuell verifiziert am 2026-08-06** (Lauf über die
|
||||
Liste `ClaudeDoTests` mit 4 Tasks, davon 2 absichtliche Dubletten): genau ein neuer Task, Mission-
|
||||
Control-Tile task-basiert mit „Submit for review", Dedupe hat die Dublette erkannt,
|
||||
Beschreibungen wurden angereichert, Submit → `WaitingForReview`, und die Diff-Karte zeigte alle
|
||||
drei Dateien des Laufs. Korrektur zur Erwartung oben: der Task-Chip liest sich **„Interactive"**,
|
||||
nicht `Idle` — das ist korrekt so (`HasInteractiveSession` überschreibt den Status-Chip, solange
|
||||
die ConPTY-Session läuft). Die drei dabei gefundenen Punkte stehen unter „Bugs" bzw.
|
||||
„Feature-Wünsche".
|
||||
- **Roadblock reply field (2026-08-05)** — **Kernfunktion am 2026-08-06 verifiziert**: auf einem
|
||||
Task mit gemeldetem Roadblock erscheint das Antwortfeld unter dem Roadblock-Text, die Antwort
|
||||
setzt die Session fort, und der Task landet danach sauber auf `WaitingForReview`. Dabei ist der
|
||||
Bug oben aufgefallen (Feld deaktiviert, solange der Task seit vor dem Lauf offen ist). Layout der
|
||||
Roadblock-Karte ist in Ordnung. **Noch offen:** Enter-zum-Senden, und dass ein „Override-Slot
|
||||
besetzt"-Fehler im Footer-Strip landet (nicht als Modal) mit dem getippten Text weiterhin im
|
||||
Feld. Design-Entscheidung dazu: der Review-Bereich lebt als zwei nullable Spalten
|
||||
direkt auf `TaskEntity` (kein Phantom-`WorktreeEntity`), damit `list_worktrees`/die
|
||||
Worktrees-Übersicht ihn nie sehen.
|
||||
|
||||
- **Post-merge verify gate (2026-08-05)** — build + unit tests all green (incl. real-process
|
||||
`VerifyCommandRunner` exit-code/output/timeout tests and `TaskMergeService` success/failure/
|
||||
@@ -94,12 +81,60 @@ Offene Entscheidungen dazu:
|
||||
`dotnet test` invocation as the configured command) — only fast synthetic commands (`exit N`,
|
||||
`ping` for timeout) were exercised.
|
||||
|
||||
## Offene Verifikation (2026-08-06, Fix-Batch aus der Sichtprüfung)
|
||||
|
||||
Fünf Findings der Sichtprüfung sind gefixt, Build + Tests grün, aber **noch nicht in der App
|
||||
nachgeprüft** (der laufende Build ist älter — erst nach Neuinstallation testbar):
|
||||
|
||||
- **NumericUpDown-Leeren wirft nicht mehr:** neuer `KeepLastNumberConverter` bildet beim
|
||||
ConvertBack `null` auf `BindingOperations.DoNothing` ab, an allen acht nicht-nullbaren
|
||||
NumericUpDowns im Settings-Modal verdrahtet. Prüfen: Wert löschen und neu tippen — keine
|
||||
Exception, alter Wert bleibt stehen, bis eine echte Zahl kommt. Damit ist auch der offene Rest
|
||||
von „MaxTurnsCeiling ändern → Hinweistexte ziehen mit" nachholbar.
|
||||
- **Verify-Gate greift jetzt auch ohne Worktree:** Approve auf einem List-Handler-Task mit
|
||||
fehlschlagendem `VerifyCommand` muss `verify_failed` liefern und den Task aus `Done` halten
|
||||
(vorher ging er kommentarlos auf `Done`).
|
||||
- **`verify_failed` im Merge-Modal und in der Worktrees-Übersicht:** Modal zeigt statt „Unknown
|
||||
status" den echten Fehlertext und schließt sich **nicht** automatisch; die Batch-Übersicht
|
||||
zeigt `VerifyFailed` und markiert die Zeile trotzdem als `Merged` (der Merge ist ja gelandet).
|
||||
- **Roadblock-Reply/Continue live:** Task offen lassen, während er in den Roadblock läuft — das
|
||||
Antwortfeld und Continue müssen **ohne** erneutes Öffnen aktiv werden.
|
||||
- **Cleanup-Log:** ein Worktree-Cleanup über veraltete DB-Zeilen darf keine WARN-Flut mehr im
|
||||
Footer erzeugen (die „schon erledigt"-Fälle gehen auf Debug).
|
||||
- **Handoff schließt die alte Kachel:** beim Übergang von Phase 2 auf Phase 3 muss die alte
|
||||
Merge-Helper-Kachel verschwinden und die neue an ihrer Stelle stehen — keine leere Geister-Pane
|
||||
mehr. (Entscheidung Mika 2026-08-06: automatisch schließen statt Output erhalten.)
|
||||
|
||||
## Offene Verifikation (2026-08-06, Resume planning session)
|
||||
|
||||
`planning_session_id` wurde nie befüllt (der Setter hatte keinen Aufrufer), weil die interaktive
|
||||
Planning-Session ihre claude-Session-Id nie zurückmeldet — Resume konnte deshalb nie
|
||||
funktionieren. `PlanningSessionManager.ResumeAsync` liest die Id jetzt beim ersten Resume aus dem
|
||||
Transkript, das Claude Code unter `~/.claude/projects/<encodiertes cwd>/<sessionId>.jsonl` für den
|
||||
Planning-Worktree ablegt (`PlanningTranscriptLocator`), und persistiert sie. Unit-Tests grün,
|
||||
**nicht visuell verifiziert**:
|
||||
|
||||
- Planning-Session starten, Fenster/Pane schließen, Task erneut öffnen → „Resume" muss die
|
||||
ConPTY-Pane mit der **fortgesetzten** Unterhaltung öffnen (nicht mit leerem Verlauf).
|
||||
- Ohne Transkript (z. B. Session nie wirklich gestartet): Fehlermeldung „No Claude session
|
||||
transcript found…" landet sichtbar im Footer-Error-Strip (`Terminal.StartError` →
|
||||
`ErrorReported`), nicht still.
|
||||
- **Risiko:** das Verzeichnis-Encoding (`~/.claude/projects/`, jedes Nicht-Alphanumerische wird
|
||||
`-`) ist undokumentiertes CLI-Verhalten — ändert es sich, findet der Locator nichts und Resume
|
||||
meldet sauber „cannot resume" (fail-safe, kein falscher Resume).
|
||||
|
||||
## Offene Verifikation (2026-08-05, Usage Monitor)
|
||||
|
||||
- **Visueller Pass Usage-Pill** (Footer **und** Mission-Control-Header): Text/Tooltip,
|
||||
Dot-Zustände normal/warn/stale/blocked, Dark/Light.
|
||||
- **Visueller Pass Usage-Monitor-Modal**: Gauges (dynamisch aus `limits[]`), Modell-/Task-
|
||||
Tabellen, Info-Bänder (stale/gate-blocked), 7d/30d-Presets + Custom-Range, Dark/Light.
|
||||
> **Kein Light-Theme.** `App.axaml:6` setzt `RequestedThemeVariant="Dark"` fest, es gibt keinen
|
||||
> Umschalter und `Tokens.axaml` kennt keine Light-Variante. „Dark/Light"-Checks sind deshalb
|
||||
> überall aus dieser Datei entfernt — sie waren nie erfüllbar.
|
||||
|
||||
- **Visueller Pass Usage-Pill** (Footer **und** Mission-Control-Header) — **am 2026-08-06
|
||||
verifiziert**: Text/Tooltip lesbar, Dot-Zustände plausibel, Pill in Footer und
|
||||
Mission-Control-Header identisch.
|
||||
- **Visueller Pass Usage-Monitor-Modal** — **am 2026-08-06 verifiziert**: Gauges (dynamisch aus
|
||||
`limits[]`), Info-Bänder (der Throttle-Hinweis „Queue throttled: 1/3 slots (7d)" stand real an),
|
||||
7d/30d-Presets + Custom-Range. Die Analyse-Tabellen sind ausbaufähig → siehe „UX / Nits".
|
||||
- **E2E Gate**: das Gate greift real, sobald ein Bucket (`five_hour`/`seven_day`) die
|
||||
konfigurierte Schwelle reißt — Queue-Nachschub pausiert, laufende Runs/`RunNow`/ConPTY/
|
||||
Planning/Prime bleiben unberührt — und die Queue nimmt nach dem Reset selbstständig wieder
|
||||
@@ -108,6 +143,84 @@ Offene Entscheidungen dazu:
|
||||
undokumentiert und kann sich ändern; bei Ausfall/Formatänderung ist das Gate wirkungslos
|
||||
(fail-open by design — kein Blocker, aber der Schutz fällt dann aus, ohne dass es auffällt).
|
||||
|
||||
### Nachtrag 2026-08-05: 429-Fix (Poll-Kadenz + Refresh-Button)
|
||||
|
||||
Der 60s-Poll lief in 429s. Neu: aktivitätsabhängige Kadenz (5 Min. solange ein Task `Running`
|
||||
ist, sonst 15 Min.), 429-Backoff mit `Retry-After`, und ein „Jetzt aktualisieren"-Button im
|
||||
Usage-Monitor-Modal (`RefreshUsage` → `UsageMonitorService.RefreshNowAsync`, 10s-Cooldown).
|
||||
Unit-Tests grün, **offen**:
|
||||
|
||||
- **Visueller Pass Refresh-Button** im Modal — **am 2026-08-06 verifiziert** (Button, Hinweiszeile
|
||||
„Polled every 5 min while a task runs, otherwise every 15 min.").
|
||||
- **E2E:** über ≥20 Min. mit und ohne laufenden Task beobachten, dass keine 429s mehr im
|
||||
Worker-Log auftauchen und die Pill trotzdem aktuell bleibt.
|
||||
- **Beachten:** die Pill wird jetzt erst nach 3× 15 Min. als `stale` markiert — ein echter
|
||||
Endpoint-Ausfall fällt vorher nur über `LastError` auf (der `IsStale` sofort setzt).
|
||||
|
||||
## Offene Verifikation (2026-08-05, Max-Turns-Ceiling)
|
||||
|
||||
Build + unit tests grün (`ResolveMaxTurns`-Klemmung, Repository-Backfill von `model_presets`,
|
||||
Migration `AddMaxTurnsCeiling` gegen eine Scratch-DB angewendet), aber **nicht visuell
|
||||
verifiziert**:
|
||||
|
||||
- Agent-Settings-Editor (Task **und** Liste): Max-Turns-Feld auf einen Wert über der Ceiling
|
||||
(Default 80) setzen, Hinweistext unter dem `NumericUpDown` erscheint ("Runs are capped at
|
||||
{N} turns…").
|
||||
- Settings → Allgemein → Vorgaben pro Modell: eine Zeile über 80 setzen, derselbe Hinweistext
|
||||
erscheint unter der Zeile.
|
||||
- `MaxTurnsCeiling` ist seit `2975f90` selbst editierbar (Settings → Allgemein,
|
||||
`SettingsModalView.axaml`) — Feld prüfen: Wert ändern, speichern, Hinweistexte oben ziehen mit.
|
||||
|
||||
## Offene Verifikation (2026-08-05, Environment Checks / SystemCheckPage)
|
||||
|
||||
Checks + SystemCheckPage (`claudedo/06aca9b3…`) und das ExecutableResolver-Wiring im Worker
|
||||
(`claudedo/40272c0b…`) sind seit 2026-08-06 auf `main` gemerged; die frühere „erst mergen"-
|
||||
Voraussetzung ist erledigt. Details → `installer-preflight` in `docs/explore-notes/README.md`
|
||||
und der Abschnitt „Environment Checks" in `src/ClaudeDo.Installer/CLAUDE.md`.
|
||||
|
||||
**Update (2026-08-06):** beide Folge-Features sind jetzt implementiert und auf `main`.
|
||||
|
||||
- Diagnose-Sektion (Config-Modus/`SettingsWindow`): `Pages/DiagnosePage/` + geteilte
|
||||
`Checks/CheckListViewModel.cs`/`Checks/CheckListView.xaml` (auch von `SystemCheckPage`
|
||||
genutzt, keine zweite Implementierung). Unit-getestet
|
||||
(`tests/ClaudeDo.Installer.Tests/Pages/DiagnosePage/DiagnosePageViewModelTests.cs`).
|
||||
- „Claude Help Me"-Button: `Core/ClaudeHelpLauncher.cs` + `SystemCheckPageViewModel`/-View,
|
||||
unit-getestet (`tests/ClaudeDo.Installer.Tests/Core/ClaudeHelpLauncherTests.cs`).
|
||||
|
||||
Beides **nicht visuell verifiziert**:
|
||||
|
||||
- [x] Help-Me-Button ist deaktiviert, wenn `claude-cli` nicht `Ok` ist oder `claude-auth`
|
||||
`Failed` ist (bleibt aktiv bei `Unknown`), mit erklärendem Tooltip — unit-getestet.
|
||||
- [ ] „Claude Help Me" öffnet tatsächlich ein Terminal mit laufender Claude-Session, und die
|
||||
Session hat den Diagnose-Report gelesen — **nicht verifiziert** (der eigentliche
|
||||
Terminal-Start/`wt.exe`-Zusammenspiel und die Session-Qualität sind nur über die
|
||||
injizierte `IProcessLauncher`-Fake getestet, nie mit einem echten Terminal/CLI).
|
||||
- [ ] Platzierung des Help-Me-Buttons: er sitzt seit dem Merge der Diagnose-Sektion in einer
|
||||
**eigenen Zeile unter** dem geteilten Check-Listen-Footer (der reservierte Slot *im*
|
||||
Footer entfiel mit der Extraktion nach `CheckListView.xaml`). Optisch prüfen, ob das
|
||||
so bleiben soll oder ob der Button in den geteilten Footer gehört.
|
||||
- [ ] Diagnose-Sektion im Config-Modus: Öffnen von SettingsWindow löst keinen Prüflauf aus, Klick
|
||||
auf „Erneut prüfen" schon; zeigt die echten installierten Pfade/Ports (nicht die
|
||||
InstallContext-Defaults), und der laufende Worker auf dem konfigurierten SignalR-Port gilt
|
||||
nicht als Konflikt — **unit-verifiziert, visueller Durchlauf noch offen.**
|
||||
|
||||
Weitere Punkte, gebaut + unit-getestet auf `main`, aber **nicht visuell verifiziert**:
|
||||
|
||||
- [ ] SystemCheckPage: Layout, Icon-/Farbwirkung der vier Status (Ok grün / Warnung orange /
|
||||
Fehler rot / Unbekannt grau — `StatusGreenBrush`/`StatusOrangeBrush`/`StatusRedBrush`/
|
||||
`StatusGrayBrush`), Lesbarkeit der Hint-Texte, DE und EN.
|
||||
- [ ] Weiter-Button gesperrt bei einem echten blockierenden Fehler (z. B. `claude` nicht im
|
||||
PATH → `claude-cli` Error/Failed), und der Grund ist in der Zusammenfassungszeile
|
||||
sichtbar (nennt den/die blockierenden Check(s) namentlich).
|
||||
- [ ] „Erneut prüfen" wechselt einen Status live (z. B. git-Identity setzen → Warnung
|
||||
verschwindet), ohne dass ein zweiter paralleler Lauf startet, wenn währenddessen erneut
|
||||
geklickt wird.
|
||||
- [ ] Update-Modus zeigt die SystemCheckPage **nicht** (Wizard bleibt Welcome + Install).
|
||||
- [ ] Auf einem Rechner mit npm-installiertem `claude.cmd`: `claude-cli`-Check findet es
|
||||
(Detail-Text „Resolved via a shim…"), und ein Task läuft im Worker durch (bestätigt, dass
|
||||
`ClaudeProcess`/`ClaudeCliPreflight` den Shim über `cmd.exe /c` tatsächlich startet, nicht
|
||||
nur, dass der Check ihn findet).
|
||||
|
||||
---
|
||||
|
||||
## Bewusst verworfen (nicht erneut vorschlagen)
|
||||
|
||||
@@ -35,7 +35,7 @@ the engine.
|
||||
| A1 | Diff viewing | `DiffModalViewModel` (worktree + commit-range), `WorktreeModalViewModel` (file-tree + per-file), `PlanningDiffViewModel` (per-subtask + integration) | `UnifiedDiffParser`, `DiffLinesView` (good) |
|
||||
| A2 | Agent-config editing | `ListSettingsModalViewModel` (list scope), `AgentSettingsSectionViewModel` (task scope); global lives in `SettingsModalViewModel` | `InheritanceResolver`, `InheritedBadge` (good) |
|
||||
| A3 | Worktree actions | `WorktreesOverviewModalViewModel` per-row cmds (Merge/Discard/Keep/ForceRemove/ShowDiff/Jump) vs `MergeSectionViewModel` (Merge/OpenDiff) | same `IWorkerClient` calls |
|
||||
| A4 | Merge display | `AgentStripView` re-displays `MergeSectionViewModel` state | — |
|
||||
| A4 | Merge display | ~~`AgentStripView` re-displays `MergeSectionViewModel` state~~ — resolved 2026-08-06: `AgentStripView` was dead (never bound after the task-detail redesign); deleted instead of unified | — |
|
||||
|
||||
### Bucket B — entry-point sprawl (one backend, many hand-wired doors)
|
||||
|
||||
@@ -43,7 +43,7 @@ the engine.
|
||||
|---|---|---|---|
|
||||
| B1 | Conflict-resolution seam | 5 copies of `Func<string,string,Task>? RequestConflictResolution` | `WorktreesOverviewModalViewModel.cs:83`, `DiffModalViewModel.cs:75`, `MergeModalViewModel.cs:33`, `MergeSectionViewModel.cs:51`, `DetailsIslandViewModel.cs:347` (delegates). Threaded through `MainWindow.axaml.cs:81`, `IslandsShellViewModel.cs:49/202`, `DiffModalViewModel.cs:103`, `MergeSectionViewModel.cs:159` |
|
||||
| B2 | Diff (open) | 3–4 | MergeSection "Open Diff", TaskHeaderBar "Review Merged Diff", WorktreesOverview "Show Diff", Planning "Review Combined" |
|
||||
| B3 | List Settings dialog | 3 | Lists context menu, Tasks header button, shell bridge `IslandsShellViewModel.cs:190-194` |
|
||||
| B3 | List Settings dialog | 2 (was 3 — Lists context menu entry removed 2026-08-06) | Tasks header button, double-click on a list row, shell bridge `IslandsShellViewModel.cs:190-194` |
|
||||
| B4 | Worktrees Overview | 2–3 | Repos menu (global), Lists context menu (per-list) |
|
||||
| B5 | Repo Import | 2 | Repos menu, Lists footer button |
|
||||
|
||||
|
||||
@@ -0,0 +1,235 @@
|
||||
# Planning-Chain Children: Fork Base Commit (Design Options, No Implementation)
|
||||
|
||||
**Date:** 2026-08-06
|
||||
**Status:** Options evaluated, recommendation given — decision pending (Mika)
|
||||
**Scope:** Analysis only. No production code changed by this document.
|
||||
|
||||
## Problem
|
||||
|
||||
The planning chain gives **ordering**, not **code inheritance**. `PlanningChainCoordinator.SetupChainAsync`
|
||||
(`src/ClaudeDo.Worker/Planning/PlanningChainCoordinator.cs:36-72`) links children via
|
||||
`BlockedByTaskId` (child[i] blocks on child[i-1]) so they run one at a time. But each child's
|
||||
worktree is still created from **main's current HEAD**, not from the predecessor's branch, so
|
||||
child N+1 never sees child N's (unmerged) work.
|
||||
|
||||
**Observed failure (unit `44241dcb`, "Installer: Environment-Checks", 2026-08-05):** 7 of 9
|
||||
children succeeded; the two that depended on a sibling's output could not:
|
||||
|
||||
- `4e196058` ("Claude Help Me" button) — reported: *"the task spec assumes an existing
|
||||
SystemCheckPage/EnvironmentCheckReport/ExecutableResolver, but that code only exists on an
|
||||
unmerged sibling branch (claudedo/06aca9b3...). A fast-forward merge of that branch was
|
||||
attempted ... but was denied twice by the auto-mode permission classifier. No implementation
|
||||
work was done."*
|
||||
- `c22cdd06` (Diagnose section) — *"Blocked before implementation could start."*
|
||||
|
||||
Both correctly reported `CLAUDEDO_BLOCKED` and committed nothing (`ahead: 0`). A second-order
|
||||
symptom of the same root cause: `ExecutableResolver.cs` was independently created **twice**
|
||||
(`40272c0b`, `06aca9b3`, near-identical) and collided as an add/add conflict at unit-merge time.
|
||||
|
||||
## Ist-Zustand (verified against commit `816f247`, 2026-08-06)
|
||||
|
||||
### Where the base commit is chosen
|
||||
|
||||
`WorktreeManager.ResolveBaseCommitAsync` — `src/ClaudeDo.Worker/Runner/WorktreeManager.cs:191-207`:
|
||||
|
||||
```csharp
|
||||
private async Task<string> ResolveBaseCommitAsync(TaskEntity task, string workingDir, CancellationToken ct)
|
||||
{
|
||||
if (task.ParentTaskId is not null)
|
||||
{
|
||||
var parent = ...;
|
||||
if (parent is not null && parent.PlanningPhase == PlanningPhase.None)
|
||||
{
|
||||
var parentWt = await new WorktreeRepository(ctx).GetByTaskIdAsync(task.ParentTaskId, ct);
|
||||
var parentHead = parentWt?.HeadCommit ?? parentWt?.BaseCommit;
|
||||
if (parentHead is not null)
|
||||
return parentHead;
|
||||
}
|
||||
}
|
||||
return await _git.RevParseHeadAsync(workingDir, ct); // planning children land here
|
||||
}
|
||||
```
|
||||
|
||||
**A "don't fork from main" mechanism already exists** — but only for *improvement* children
|
||||
(a non-planning parent's own follow-up subtasks), which base off `task.ParentTaskId`'s worktree
|
||||
HEAD. The guard `parent.PlanningPhase == PlanningPhase.None` explicitly **excludes** planning
|
||||
children: their `ParentTaskId` points at the planning parent (which has no worktree of its own),
|
||||
not at a sibling, so this branch never fires for them and they fall through to `RevParseHeadAsync`
|
||||
= main HEAD. This is a deliberate exclusion in the existing code, not an oversight — it simply
|
||||
never anticipated that a planning child's *predecessor in the chain* (not its parent) might be
|
||||
the thing to inherit from.
|
||||
|
||||
Called from `WorktreeManager.CreateAsync` (`:35`), invoked by `TaskRunner` (`Runner/TaskRunner.cs:309`)
|
||||
at the moment a task transitions to `Running` — i.e. worktree creation happens per-run, not once
|
||||
at plan-finalize time.
|
||||
|
||||
### Children already run strictly sequentially — this is not a new constraint
|
||||
|
||||
`SetupChainAsync` sets `BlockedByTaskId` on every child but the first
|
||||
(`Planning/PlanningChainCoordinator.cs:61-69`); the queue picker only claims rows with
|
||||
`BlockedByTaskId IS NULL`. `OnChildFinishedAsync` (`:92-120`) unblocks the successor **only**
|
||||
after the predecessor reaches `Done` (and cascades cancellation down the chain on
|
||||
Failed/Cancelled). `OverrideSlotService.RunNow` (`Queue/OverrideSlotService.cs:32-42`) is the one
|
||||
path that bypasses this — it calls `StartRunningAsync` directly with no `BlockedByTaskId` check,
|
||||
so a user can manually force a later child to run out of turn.
|
||||
|
||||
Net: under the normal (queue-driven) path, by the time child N+1's worktree is created, child N
|
||||
is already terminal. Forking N+1 from N's branch tip instead of main's HEAD does **not** introduce
|
||||
any new concurrency constraint on the normal path — the sequencing already exists. It only matters
|
||||
for the `RunNow` bypass edge case (see Option 1 below).
|
||||
|
||||
### The merge side already treats the unit as a sequential chain, twice over
|
||||
|
||||
`PlanningMergeOrchestrator.DrainAsync` (`Planning/PlanningMergeOrchestrator.cs:183-229`) merges
|
||||
`Done` children into `targetBranch` **one at a time, in `SortOrder`**, via
|
||||
`TaskMergeService.MergeAsync`; the first conflict pauses the whole drain
|
||||
(`PlanningMergeConflict`, state kept for `ContinueAsync`/`AbortAsync`), and the first
|
||||
non-conflict failure **aborts the drain outright** — remaining children are never merged and the
|
||||
parent never reaches `Done` (`:208-215`).
|
||||
|
||||
`PlanningAggregator.BuildIntegrationBranchAsync` (`Planning/PlanningAggregator.cs:81-126`) — used
|
||||
for the pre-approve combined-diff preview — does the same thing again: builds a scratch
|
||||
integration branch off `targetBranch` and `MergeNoFfAsync`s each child's branch in, in
|
||||
`SortOrder`, stopping at the first conflict.
|
||||
|
||||
So **both** the actual unit-merge and its preview already model the child set as an ordered
|
||||
sequence where one bad link can stall everything downstream. The only place in the whole pipeline
|
||||
that still treats planning children as N independent forks of main is worktree creation.
|
||||
|
||||
## Options evaluated
|
||||
|
||||
### Option 1 — child forks from the predecessor's branch
|
||||
|
||||
Extend `ResolveBaseCommitAsync` (or a planning-specific sibling of it) to also handle planning
|
||||
children: look up the chain predecessor via `BlockedByTaskId` (not `ParentTaskId` — that points at
|
||||
the planning parent, which has no worktree) and use its `WorktreeEntity.HeadCommit ?? BaseCommit`
|
||||
the same way the improvement-child path already does.
|
||||
|
||||
**What actually breaks, given the Ist-Zustand above:**
|
||||
|
||||
- *Not* a new concurrency constraint (see above) — the chain already serializes execution.
|
||||
- *Not* a new "one failure blocks everyone" behavior at merge time — `DrainAsync` already aborts
|
||||
the whole drain on the first non-conflict failure, and the integration-branch preview already
|
||||
stops at the first conflict. Option 1 brings execution-time behavior in line with what merge-time
|
||||
behavior already is, rather than introducing a new failure mode.
|
||||
- **Genuinely new:** child N+1's branch now contains N's commits as ancestors. When N is later
|
||||
merged into `targetBranch` with `--no-ff` and N+1 is merged afterward, N+1's diff against N's
|
||||
content is empty (identical trees) — merges cleanly as a no-op for that slice, verified by the
|
||||
same mechanism `DrainAsync` already uses. No new conflict class is introduced; if anything this
|
||||
*removes* one: the `ExecutableResolver.cs` add/add collision would not have occurred, since N+1
|
||||
would start from a tree that already has the file.
|
||||
- **Genuinely new edge case:** `OverrideSlotService.RunNow` on a chain member whose predecessor
|
||||
hasn't produced a `WorktreeEntity`/`HeadCommit` yet. Needs an explicit fallback to main HEAD —
|
||||
the same `?? ` pattern the improvement-child branch already uses for a predecessor with no
|
||||
`HeadCommit` (never committed) covers most of this; a predecessor that was never even *run* needs
|
||||
the same fallback-to-`RevParseHeadAsync` the code already falls through to today.
|
||||
- **Genuinely new edge case:** a discarded/failed predecessor's worktree row still carries its last
|
||||
`HeadCommit`/`BaseCommit` (the row isn't deleted on discard, only `State` flips) — same as the
|
||||
improvement-child path already tolerates, so no new handling needed there.
|
||||
- Children still go straight to `Done` with no individual review (Unified Parent Model), so there
|
||||
is no "reject and rewrite a middle child after a successor already forked from it" scenario to
|
||||
worry about — that action doesn't exist in the current state machine.
|
||||
|
||||
**Price:** deeper branch chains (cosmetic — they're squashed away by the sequential merge/prune
|
||||
regardless); one new fallback path for the `RunNow` bypass. Meaningfully smaller than it first
|
||||
looks, because it extends an existing pattern (improvement children) into a lane whose execution
|
||||
and merge sides are *already* sequential — it only fixes the one place that wasn't.
|
||||
|
||||
### Option 2 — merge predecessor to main immediately on success, before the successor starts
|
||||
|
||||
Keeps children independent worktrees (forked from main-as-it-is-now, updated after each merge),
|
||||
but requires a real merge to `main` per child, outside of Approve.
|
||||
|
||||
**Breaks:**
|
||||
|
||||
- Directly contradicts the documented invariant "**Approve is the single review+merge action**"
|
||||
(`src/ClaudeDo.Worker/CLAUDE.md` → Status Model; `docs/explore-notes/review-merge.md` → "Approve
|
||||
= merge the whole unit"). Unreviewed work would land on `main` automatically, mid-chain, before
|
||||
the parent — or the user — ever sees it.
|
||||
- Requires calling `TaskMergeService.MergeAsync` from `OnChildFinishedAsync` (state-transition
|
||||
code), duplicating what `PlanningMergeOrchestrator` already owns, and doing it against a
|
||||
`VerifyCommand`-gated repo N times instead of once — if the gate is configured, a mid-chain
|
||||
verify failure now has to be handled somewhere there's currently no error path for it (parent is
|
||||
still `WaitingForChildren`, not under merge orchestration yet).
|
||||
- Unavoidably touches `TaskStateService`/status-transition semantics, which this task's scope
|
||||
explicitly excludes ("Die Status-Logik oder `TaskStateService` anfassen" is out of scope) — this
|
||||
option cannot be implemented without doing exactly that.
|
||||
|
||||
**Verdict:** rejected outright — it isn't just costly, it conflicts with a stated architectural
|
||||
invariant and this task's own non-goals.
|
||||
|
||||
### Option 3 — child may merge the predecessor's branch into its own worktree if it needs to
|
||||
|
||||
Least invasive to the base-commit mechanism; delegates the decision to the agent at runtime.
|
||||
|
||||
**Breaks:**
|
||||
|
||||
- This is *exactly* what the blocked child in the observed incident already tried, and it was
|
||||
denied twice by the auto-mode permission classifier. The real blocker isn't a missing
|
||||
capability, it's that `git merge` trips the classifier's "risky/hard-to-reverse" heuristic
|
||||
regardless of target — even though a merge confined to the task's own isolated worktree (never
|
||||
touching the shared `workingDir`) is materially lower-risk than a merge on a shared checkout.
|
||||
Fixing this option means carving a narrower permission rule (allow `git merge` only inside a
|
||||
path under the task's own worktree root), which is a security-policy change, not a merge-model
|
||||
change.
|
||||
- Even with permission granted, it still relies on the agent (a) noticing it's missing a
|
||||
prerequisite, (b) correctly identifying which sibling branch has it, and (c) successfully
|
||||
resolving any conflict that surfaces — three separate failure points per occurrence, decided
|
||||
fresh by an LLM each time rather than encoded once in the pipeline.
|
||||
- Doesn't help children that fail *before* they'd even think to look (e.g., a child whose first
|
||||
action is reading a file that doesn't exist yet has no signal to act on).
|
||||
|
||||
**Verdict:** possible as a narrow permission-scope fix layered *on top of* Option 1 (so an agent
|
||||
that still needs something from further back than its immediate predecessor isn't stuck), but not
|
||||
a substitute for it — on its own it reproduces the exact failure mode from the incident, just with
|
||||
one fewer denial.
|
||||
|
||||
### Option 4 — change nothing; require independent children at planning time
|
||||
|
||||
Zero code changes; pushes the constraint into plan quality.
|
||||
|
||||
**Breaks:**
|
||||
|
||||
- No enforcement exists today (`PlanningSessionManager` doesn't validate structural independence
|
||||
between proposed subtasks), and reliably detecting "child B depends on code child A will write"
|
||||
from a plan draft is itself an unsolved code-review problem — not something a prompt tweak
|
||||
guarantees.
|
||||
- A shared-foundation-plus-N-consumers decomposition (exactly the `44241dcb` shape: environment
|
||||
checks feeding two UI consumers) is often the *correct* decomposition, not a planning mistake.
|
||||
Banning it either forces over-merging into one giant child (defeating the purpose of splitting)
|
||||
or requires the planner to reject a structurally sound plan.
|
||||
- Reproduces the observed failure verbatim under the same conditions: nothing about this option
|
||||
would have caught `44241dcb` before it shipped two dead children.
|
||||
|
||||
**Verdict:** cheapest to write down, but doesn't fix the problem — it relocates it to "the plan
|
||||
looked independent but wasn't," which is what already happened.
|
||||
|
||||
## Recommendation
|
||||
|
||||
**Option 1**, with the `RunNow`-bypass fallback described above, and with Option 3's narrower
|
||||
permission-scope fix as an optional follow-up (not a prerequisite) for cases where a child needs
|
||||
something from further back in the chain than its immediate predecessor.
|
||||
|
||||
**Why:** Option 1 is not a new mechanism — it's closing the one gap in a pattern that already
|
||||
exists twice in this codebase: `WorktreeManager.ResolveBaseCommitAsync` already forks improvement
|
||||
children from their parent's HEAD instead of main, and both `PlanningMergeOrchestrator.DrainAsync`
|
||||
and `PlanningAggregator.BuildIntegrationBranchAsync` already treat the child set as an ordered
|
||||
merge sequence where the first failure stalls everything after it. Planning-child worktree
|
||||
creation is the outlier, not the rule. Extending the existing predecessor-lookup pattern (keyed
|
||||
off `BlockedByTaskId` instead of `ParentTaskId` for this lane) makes the "sees predecessor's work"
|
||||
guarantee hold everywhere the chain already implies it, without touching `TaskStateService`, the
|
||||
review model, or introducing a merge-time failure mode that doesn't already exist. Options 2 and 4
|
||||
either conflict with a stated invariant/this task's own non-goals, or fail to address the incident
|
||||
at all; Option 3 alone reproduces the incident's exact failure.
|
||||
|
||||
**Price to pay knowingly:** a chain member that never got a chance to run (no `WorktreeEntity` yet)
|
||||
needs an explicit main-HEAD fallback when its successor is forced via `RunNow` — a few lines,
|
||||
mirroring the null-coalescing fallback the improvement-child path already has. No other new failure
|
||||
surface was found.
|
||||
|
||||
## Explicitly out of scope (per task)
|
||||
|
||||
- Implementing Option 1 or any other option — Mika decides first.
|
||||
- Any change to `TaskStateService` or status-transition logic.
|
||||
- Changing blocked-child visibility/behavior — that's task `001ee94a`, running in parallel; it only
|
||||
touches MCP visibility, no production code overlap with this document.
|
||||
@@ -22,6 +22,7 @@
|
||||
<converters:BoolToItalicConverter x:Key="BoolToItalic"/>
|
||||
<converters:BoolToDraftOpacityConverter x:Key="BoolToDraftOpacity"/>
|
||||
<converters:LogKindForegroundConverter x:Key="LogKindForeground"/>
|
||||
<converters:KeepLastNumberConverter x:Key="KeepLastNumber"/>
|
||||
|
||||
</ResourceDictionary>
|
||||
</Application.Resources>
|
||||
|
||||
@@ -8,19 +8,13 @@ Desktop entry point for the ClaudeDo application. Configures DI, initializes the
|
||||
- `App.axaml` / `App.axaml.cs` — Avalonia application lifecycle, main window creation, static `ServiceProvider` accessor
|
||||
- `ViewLocator.cs` — reflection-based IDataTemplate that maps ViewModels to Views by naming convention
|
||||
|
||||
## Dependencies
|
||||
|
||||
- Avalonia 12.0.0 (Desktop, Fluent theme, Inter fonts)
|
||||
- CommunityToolkit.Mvvm 8.4.1
|
||||
- Microsoft.Extensions.DependencyInjection 8.0.1
|
||||
- Microsoft.AspNetCore.SignalR.Client 8.0.11
|
||||
- Microsoft.Data.Sqlite 8.0.11
|
||||
- Project references: ClaudeDo.Data, ClaudeDo.Ui
|
||||
Project references: `ClaudeDo.Data`, `ClaudeDo.Ui`. Package versions are in the `.csproj` — see
|
||||
the root CLAUDE.md for the tech stack.
|
||||
|
||||
## DI Registration Pattern
|
||||
|
||||
- **Singletons**: `IDbContextFactory`, all Repositories, GitService, WorkerClient, `IReleaseClient`, `UpdateCheckService`, `IPrimeScheduleApi`/`WorkerPrimeScheduleApi`, `INotesApi`/`WorkerNotesApi`, `InstallerLocator` / `WorkerLocator`, the island VMs (`ListsIslandViewModel`, `TasksIslandViewModel`, `DetailsIslandViewModel`) and `IslandsShellViewModel` (the window's DataContext)
|
||||
- **Transients**: modal VMs (`SettingsModalViewModel`, `MergeModalViewModel`, `ListSettingsModalViewModel`, `RepoImportModalViewModel`, `WeeklyReportModalViewModel`, `DiffViewerViewModel`, `WorktreesOverviewModalViewModel`, `PrimeClaudeTabViewModel`), several exposed as `Func<T>` factories for on-demand dialog creation (`Func<DiffViewerViewModel>` for the diff viewer); `ConflictResolverViewModel` via a `Func<string, ConflictResolverViewModel>` factory keyed by taskId (singleton factory, handed to `IslandsShellViewModel.ConflictResolverFactory`)
|
||||
- **Singletons** — `IDbContextFactory`, all repositories, `GitService`, `WorkerClient`, `IReleaseClient`, `UpdateCheckService`, `IPrimeScheduleApi`, `INotesApi`, `InstallerLocator`/`WorkerLocator`, the three island VMs, and `IslandsShellViewModel` (the window's DataContext)
|
||||
- **Transients** — modal VMs, several exposed as `Func<T>` factories for on-demand dialog creation (e.g. `Func<DiffViewerViewModel>`). `ConflictResolverViewModel` uses a `Func<string, ConflictResolverViewModel>` factory keyed by taskId (singleton factory, handed to `IslandsShellViewModel.ConflictResolverFactory`).
|
||||
|
||||
## Notes
|
||||
|
||||
|
||||
+80
-28
@@ -4,52 +4,104 @@ Shared data layer: models, repositories, SQLite infrastructure, and git operatio
|
||||
|
||||
## Models
|
||||
|
||||
- **TaskEntity** — Id, ListId, Title, Description, Status (`Idle|Queued|Running|WaitingForChildren|WaitingForReview|Done|Failed|Cancelled`), PlanningPhase (`None|Active|Finalized` — parent-only), BlockedByTaskId (nullable FK to predecessor in a chain), ScheduledFor, Result, ReviewFeedback (nullable; reviewer's rejection comment, consumed and cleared by the runner on the next re-run), LogPath, timestamps, CommitType, Model / SystemPrompt / AgentPath / MaxTurns (nullable overrides), IsStarred, IsMyDay, IsManual (reminder only the user can do — automation skips it), Notes, ParentTaskId, PlanningSessionId, PlanningSessionToken, PlanningFinalizedAt, CreatedBy, HandlerBaseCommit / HandlerHeadCommit (nullable; review range for a worktree-less "list handler" host task — Mission Control's "Let Claude handle it" — which commits straight into the list's working dir instead of a per-task worktree: `HandlerBaseCommit` is stamped to the list repo's HEAD when the host task is created, `HandlerHeadCommit` when it's submitted for review; the Worker's `SubmitTaskForReview`/`GetTaskDiff` and the Ui's `DetailsIslandViewModel`/`MergeSectionViewModel` fall back to this pair whenever `Worktree` is null). Legacy values `Manual`/`Planning`/`Planned`/`Draft`/`Waiting` were retired; existing rows backfill automatically via the `RetireLegacyTaskStatus` migration.
|
||||
- **TaskEntity** — Id, ListId, Title, Description, Status, PlanningPhase, BlockedByTaskId (FK to predecessor in a chain), ScheduledFor, Result, ReviewFeedback, LogPath, timestamps, CommitType, Model / SystemPrompt / AgentPath / MaxTurns (nullable overrides), IsStarred, IsMyDay, IsManual, Notes, ParentTaskId, PlanningSessionId / PlanningSessionToken / PlanningFinalizedAt, CreatedBy, HandlerBaseCommit / HandlerHeadCommit, InteractiveSessionId.
|
||||
- Status / PlanningPhase / BlockedByTaskId semantics + allowed transitions: `ClaudeDo.Worker/CLAUDE.md` → Status Model.
|
||||
- `HandlerBaseCommit`/`HandlerHeadCommit` = the review range for a **worktree-less "list handler" host task** ("Let Claude handle it"), which commits straight into the list's working dir instead of a per-task worktree. Everything that reads a task's diff falls back to this pair whenever `Worktree` is null → [conpty-sessions](../../docs/explore-notes/conpty-sessions.md).
|
||||
- `InteractiveSessionId` = the claude session id an embedded ConPTY interactive task session runs under, persisted by `InteractiveLaunchSpecService` before launch so a closed/aborted session can be resumed → [conpty-sessions](../../docs/explore-notes/conpty-sessions.md).
|
||||
- Legacy status values `Manual`/`Planning`/`Planned`/`Draft`/`Waiting` were retired; existing rows backfill via the `RetireLegacyTaskStatus` migration.
|
||||
- **ListEntity** — Id, Name, WorkingDir, DefaultCommitType, CreatedAt, IsManual (reminder list — tasks created here default to `IsManual`)
|
||||
- **ListConfigEntity** — ListId (PK, 1:1 with list), Model, SystemPrompt, AgentPath, MaxTurns, SessionSkills, VerifyCommand (all nullable). `VerifyCommand` is an optional post-merge gate (see `ClaudeDo.Worker/CLAUDE.md` → TaskMergeService): null/blank = today's behavior, no gate.
|
||||
- **WorktreeEntity** — TaskId (PK, 1:1 with task), Path, BranchName, BaseCommit, HeadCommit, DiffStat, MergeCommit (nullable; SHA of the merge commit this worktree's branch produced on the target branch, stamped by `TaskMergeService` the moment a merge/continue-merge succeeds — the only thing that makes `revert_merge` possible without heuristically searching `git log`; null for any worktree merged before this field existed), State (Active|Merged|Discarded|Kept)
|
||||
- **TaskRunEntity** — per-run record (session_id, tokens, turns, result, structured output, exit code, log path, nullable `Model` — the model the run actually executed with, column `model`)
|
||||
- **PrimeScheduleEntity** — Id, Days (`[Flags] PrimeDays` weekday bitmask, stored as `days_of_week` int), TimeOfDay, Enabled, LastRunAt, PromptOverride, CreatedAt. Recurs on the selected weekdays; no date range.
|
||||
- **DailyNoteEntity** — Id, Date (DateOnly), Text, SortOrder, CreatedAt → table `daily_notes`
|
||||
- **WeekReportEntity** — Id, StartDate/EndDate (DateOnly), Markdown, GeneratedAt → table `week_reports`, unique index on (start_date, end_date)
|
||||
- **TaskAttachmentEntity** — Id, TaskId (FK to tasks, ON DELETE CASCADE), FileName, ByteSize, CreatedAt → table `task_attachments`
|
||||
- **AppSettingsEntity** also carries `ReportExcludedPaths` (string?, JSON array of excluded path prefixes, column `report_excluded_paths`), `StandupWeekday` (int DayOfWeek, default Wednesday, column `standup_weekday`), and `DailyPrepMaxTasks` (int, default 5, column `daily_prep_max_tasks` — hard cap on how many open tasks the daily-prep / "Prime Claude" feature may place in MyDay) , `ModelPresets` (string?, JSON array of `ModelPreset` rows, column `model_presets`), and `UsageGateFiveHourPct` / `UsageGateSevenDayPct` (int, defaults 80/90, columns `usage_gate_five_hour_pct` / `usage_gate_seven_day_pct` — percentage of the 5h/7d Claude usage window at which the autonomous queue pauses; `0` = gate off; `AppSettingsRepository.UpdateAsync` clamps both to 0..100)
|
||||
- **ModelPresets** / **ModelPreset** — per-model run defaults (`Model`, `Effort`, `MaxTurns`): one row per `ModelRegistry.Aliases` entry, supplying the **global** effort and max-turns defaults. `Parse`/`Serialize` normalize (unknown models dropped, missing aliases filled from `Defaults`, effort validated, turns clamped 1–200) and never throw — a malformed settings row must not stop a run. `For(presets, model, fallbackMaxTurns = 30)` always returns a usable row: `model` is resolved through `ModelRegistry.TryNormalizeAlias` first, so a full CLI model id (e.g. `claude-sonnet-4-6`) still hits its alias's preset row instead of missing every lookup and falling through; only a model that normalizes to nothing recognized falls back to a synthesized row (`EffortRegistry.DefaultLevel` + `fallbackMaxTurns` — callers pass `AppSettings.DefaultMaxTurns` here so that setting has a real effect instead of a hardcoded number). Ship defaults: haiku medium/20, sonnet high/30, opus high/40, fable high/25.
|
||||
- **ModelRegistry.TryNormalizeAlias** — non-throwing counterpart to `NormalizeAlias` for the run path: exact alias match, then substring match against a full model id, else `null`. Never throws, unlike `NormalizeAlias` (which stays the strict, throwing validator for `add_task`/planning model input).
|
||||
- **EffortRegistry** — the `--effort` levels (`low|medium|high|xhigh|max`) + `NormalizeLevel` (blank → null = don't pass the flag)
|
||||
- **SubtaskEntity**, **AppSettingsEntity**, **AgentInfo** — existing helpers / settings / record for scanned agent files
|
||||
- **ListConfigEntity** — ListId (PK, 1:1), Model, SystemPrompt, AgentPath, MaxTurns, SessionSkills, VerifyCommand (all nullable). `VerifyCommand` is an optional post-merge gate; null/blank = no gate → [review-merge](../../docs/explore-notes/review-merge.md).
|
||||
- **WorktreeEntity** — TaskId (PK, 1:1), Path, BranchName, BaseCommit, HeadCommit, DiffStat, MergeCommit (nullable — SHA of the merge commit this branch produced; the only thing making `revert_merge` possible without searching `git log`), State (`Active|Merged|Discarded|Kept`)
|
||||
- **TaskRunEntity** — per-run record: session_id, turns, result, structured output, exit code, log path, nullable `Model` (what the run actually executed with), and `TokensIn`/`TokensOut`/`CacheReadTokens`/`CacheWriteTokens`. ⚠️ Token fields come from the **session transcript**, not the stream-json event, as a per-run delta → [usage-monitoring](../../docs/explore-notes/usage-monitoring.md).
|
||||
- **PrimeScheduleEntity** — Id, Days (`[Flags] PrimeDays` weekday bitmask, column `days_of_week`), TimeOfDay, Enabled, LastRunAt, PromptOverride, CreatedAt. Recurs on selected weekdays; no date range.
|
||||
- **DailyNoteEntity** — Id, Date (DateOnly), Text, SortOrder, CreatedAt → `daily_notes`
|
||||
- **WeekReportEntity** — Id, StartDate/EndDate (DateOnly), Markdown, GeneratedAt → `week_reports`, unique index on (start_date, end_date)
|
||||
- **TaskAttachmentEntity** — Id, TaskId (FK, ON DELETE CASCADE), FileName, ByteSize, CreatedAt → `task_attachments`
|
||||
- **SubtaskEntity**, **AgentInfo** — subtasks / record for scanned agent files
|
||||
|
||||
### AppSettingsEntity
|
||||
|
||||
Beyond the basics it carries:
|
||||
|
||||
| Property | Column | Default | Note |
|
||||
|---|---|---|---|
|
||||
| `DefaultMaxTurns` | `default_max_turns` | **40** | Lowered from 100; `AddMaxTurnsCeiling` backfilled the seeded row. |
|
||||
| `MaxTurnsCeiling` | `max_turns_ceiling` | 80 | Hard ceiling every resolved max-turns value (task/list/global) is clamped to before a run. `UpdateAsync` clamps to min 1. |
|
||||
| `ModelPresets` | `model_presets` | seeded | JSON array of `ModelPreset` rows. ⚠️ `AppSettingsRepository.GetAsync` **backfills shipping defaults on the first read after it's null**, so it's never null once a run has started. |
|
||||
| `UsageGateFiveHourPct` / `UsageGateSevenDayPct` | `usage_gate_*_pct` | 80 / 90 | Queue pause thresholds; `0` = off. |
|
||||
| `UsageThrottleSoftPct` / `UsageThrottleHardPct` | `usage_throttle_*_pct` | 50 / 65 | Staged parallelism below the hard gate; `0` = that stage off. |
|
||||
| `DailyPrepMaxTasks` | `daily_prep_max_tasks` | 5 | Hard cap on MyDay tasks the daily prep may place. |
|
||||
| `ReportExcludedPaths` | `report_excluded_paths` | null | JSON array of excluded path prefixes. |
|
||||
| `StandupWeekday` | `standup_weekday` | Wednesday | int `DayOfWeek`. |
|
||||
|
||||
All four usage percentages are clamped 0..100 by `AppSettingsRepository.UpdateAsync`.
|
||||
Gate/throttle semantics → [usage-monitoring](../../docs/explore-notes/usage-monitoring.md).
|
||||
|
||||
### Model / effort registries
|
||||
|
||||
- **ModelPresets / ModelPreset** — per-model run defaults (`Model`, `Effort`, `MaxTurns`), one row per `ModelRegistry.Aliases` entry, supplying the **global** effort and max-turns defaults. Ship defaults: haiku medium/20, sonnet high/30, opus high/40, fable high/25. `Parse`/`Serialize` normalize (unknown models dropped, missing aliases filled from `Defaults`, effort validated, turns clamped 1–200) and **never throw** — a malformed settings row must not stop a run. `For(presets, model, fallbackMaxTurns)` always returns a usable row; resolution order and the fallback trap → [worker-task-pipeline](../../docs/explore-notes/worker-task-pipeline.md).
|
||||
- **ModelRegistry** — `NormalizeAlias` is the strict, **throwing** validator for `add_task`/planning model input. `TryNormalizeAlias` is the non-throwing counterpart for the run path (exact alias match, then substring match against a full model id, else `null`). `ByCostAscending` = the cost order the prompts use.
|
||||
- **EffortRegistry** — the `--effort` levels (`low|medium|high|xhigh|max`) + `NormalizeLevel` (blank → null = don't pass the flag).
|
||||
|
||||
## Repositories
|
||||
|
||||
All repositories use EF Core LINQ queries via `ClaudeDoDbContext`. The atomic `Queued -> Running` claim lives in the Worker's `QueuePicker` (uses `FromSqlRaw`), not here.
|
||||
All use EF Core LINQ via `ClaudeDoDbContext`. The atomic `Queued → Running` claim lives in the
|
||||
Worker's `QueuePicker` (`FromSqlRaw`), **not** here.
|
||||
|
||||
- **TaskRepository** — CRUD, planning helpers (`CreateChildAsync`, `SetPlanningStartedAsync`, `DiscardPlanningAsync`, `UpdateChildAsync`), `UpdateAgentSettingsAsync` (model / system-prompt / agent-path overrides). Status-mutation primitives `MarkRunningAsync` / `MarkDoneAsync` / `MarkFailedAsync` / `FlipAllRunningToFailedAsync` are `internal` and called only by `TaskStateService` in the worker. `CreateChildAsync` produces children with `Status=Idle, PlanningPhase=None`; once their parent's `PlanningPhase` becomes `Finalized`, the chain coordinator queues them.
|
||||
- **ListRepository** — CRUD, `GetConfigAsync` / `SetConfigAsync` (upsert) / `DeleteConfigAsync` for `list_config`
|
||||
- **WorktreeRepository** — CRUD, `UpdateHeadAsync`, `SetStateAsync`, `SetMergedAsync` (atomically sets State=Merged and stamps MergeCommit in one update — the only writer of MergeCommit)
|
||||
- **TaskRunRepository**, **SubtaskRepository**, **AppSettingsRepository**
|
||||
- **DailyNoteRepository** — `ListByDayAsync`, `ListBetweenAsync`, `AddAsync`, `UpdateAsync`, `DeleteAsync`
|
||||
- **WeekReportRepository** — `GetByRangeAsync`, `UpsertAsync`
|
||||
- **TaskRepository** — CRUD, planning helpers (`CreateChildAsync`, `SetPlanningStartedAsync`, `DiscardPlanningAsync`, `UpdateChildAsync`), `UpdateAgentSettingsAsync`. ⚠️ Status-mutation primitives (`MarkRunningAsync`/`MarkDoneAsync`/`MarkFailedAsync`/`FlipAllRunningToFailedAsync`) are **`internal`** — only the Worker's `TaskStateService` may call them. `CreateChildAsync` produces children with `Status=Idle, PlanningPhase=None`.
|
||||
- **ListRepository** — CRUD, `GetConfigAsync`/`SetConfigAsync` (upsert)/`DeleteConfigAsync` for `list_config`
|
||||
- **WorktreeRepository** — CRUD, `UpdateHeadAsync`, `SetStateAsync`, `SetMergedAsync` (atomically sets State=Merged **and** stamps MergeCommit in one update — the only writer of MergeCommit)
|
||||
- **TaskAttachmentRepository** — `AddAsync`, `UpdateAsync`, `GetAsync(taskId, fileName)`, `ListByTaskIdAsync`, `DeleteAsync(taskId, fileName)`, `DeleteAllForTaskAsync`
|
||||
- **DailyNoteRepository**, **WeekReportRepository**, **TaskRunRepository**, **SubtaskRepository**, **AppSettingsRepository**
|
||||
|
||||
`TaskRepository.DeleteAsync` and `ListRepository.DeleteAsync` also delete the on-disk attachment
|
||||
dir(s) via an optional `AttachmentStore` ctor param (defaults to the production store).
|
||||
|
||||
## Infrastructure
|
||||
|
||||
- **ClaudeDoDbContext** — EF Core DbContext; configured with WAL mode and foreign keys via `UseSqlite` options
|
||||
- **IDbContextFactory<ClaudeDoDbContext>** — registered in DI; used by singleton consumers (e.g. Worker hosted service)
|
||||
- **Paths** — expands `~` and `%USERPROFILE%`, resolves relative paths. App root: `~/.todo-app`
|
||||
- **ClaudeDoDbContext** — EF Core DbContext; WAL mode + foreign keys via `UseSqlite` options
|
||||
- **IDbContextFactory\<ClaudeDoDbContext\>** — registered in DI; used by singleton consumers (e.g. the Worker hosted service)
|
||||
- **Paths** — expands `~` and `%USERPROFILE%`, resolves relative paths. App root `~/.todo-app`
|
||||
- **AppSettings** — loads `~/.todo-app/ui.config.json` (DbPath, SignalRUrl)
|
||||
- **AttachmentStore** — dependency-free file store; default root `~/.todo-app/attachments/<taskId>/`. `SaveAsync` enforces a 5 MB cap and path-traversal/containment guard. Also exposes `DeleteFile`, `DeleteTaskDir`, `TaskDir`, `Root`, and `EnumerateTaskIds` (used by the worker orphan sweep). Attachment files live outside git worktrees intentionally.
|
||||
- **AttachmentStore** — dependency-free file store, default root `~/.todo-app/attachments/<taskId>/`. `SaveAsync` enforces a 5 MB cap and a path-traversal/containment guard. Also `DeleteFile`, `DeleteTaskDir`, `TaskDir`, `Root`, `EnumerateTaskIds` (used by the worker orphan sweep). Attachment files live **outside** git worktrees intentionally.
|
||||
|
||||
## Git
|
||||
|
||||
- **GitService** — async wrapper around git CLI (ProcessStartInfo, no shell). Worktree ops (add — serialized to avoid a commondir race —, remove, prune, list paths for branch), branch ops (current, list local, checkout, delete), staging/commit (status porcelain, add-all, add-path, commit via stdin), diffs (working tree, branch vs base, commit range `base..head` — used to show a merged task's diff after the worktree is gone —, per-file, diff-stat, committed files, has-changes), merge (ff-only, no-ff, abort, mid-merge detection, conflicted files), revert (`RevertMergeCommitAsync` — `git revert --no-edit -m 1 <sha>`, reverts a merge commit against its first parent; `RevertAbortAsync`; `IsMidRevertAsync` — `REVERT_HEAD` presence, mirrors `IsMidMergeAsync`'s `MERGE_HEAD`), `PreviewMergeAsync` (non-destructive mergeability check via `git merge-tree --write-tree`), `CountChangedFilesAsync`, rev-parse, is-git-repo. Revert never resets/rewrites — it always produces a new commit, because the working directory it operates on is shared with other concurrent sessions.
|
||||
**GitService** — async wrapper around the git CLI (`ProcessStartInfo`, no shell):
|
||||
|
||||
- Worktrees: add (**serialized** to avoid a commondir race), remove, prune, list paths for branch
|
||||
- Branches: current, list local, checkout, delete
|
||||
- Staging/commit: status porcelain, add-all, add-path, commit via stdin
|
||||
- Diffs: working tree, branch vs base, commit range `base..head` (shows a merged task's diff after the worktree is gone), per-file, diff-stat, committed files, has-changes
|
||||
- Merge: ff-only, no-ff, abort, mid-merge detection (`MERGE_HEAD`), conflicted files
|
||||
- Revert: `RevertMergeCommitAsync` (`git revert --no-edit -m 1 <sha>`), `RevertAbortAsync`, `IsMidRevertAsync` (`REVERT_HEAD`, mirrors `IsMidMergeAsync`)
|
||||
- `PreviewMergeAsync` (non-destructive check via `git merge-tree --write-tree`), `CountChangedFilesAsync`, rev-parse, is-git-repo
|
||||
|
||||
⚠️ **Revert never resets or rewrites** — it always produces a new commit, because the working
|
||||
directory it operates on is shared with other concurrent sessions.
|
||||
|
||||
## Schema
|
||||
|
||||
Tables: `lists`, `tasks`, `worktrees`, `list_config`, `task_runs`, `subtasks`, `app_settings`, `prime_schedules`, `daily_notes`, `week_reports`, `task_attachments`. Managed by EF Core migrations in the `Migrations/` folder. The `tasks` table holds `status`, `planning_phase` (default `none`), and `blocked_by_task_id` (FK to `tasks.id`, `ON DELETE SET NULL`). Migration `WeeklyReport` added `daily_notes`, `week_reports`, and the two new `app_settings` columns. Migration `DailyPrepMaxTasks` added the `daily_prep_max_tasks` column to `app_settings` (no new tables). Migration `AddTaskAttachments` created the `task_attachments` table. Migration `AddModelPresetsAndManualFlag` added `app_settings.model_presets` plus the `is_manual` flags on `tasks` and `lists`. Migration `AddHandlerCommitRange` added the nullable `handler_base_commit`/`handler_head_commit` columns to `tasks`. Migration `AddUsageGateAndRunModel` added `app_settings.usage_gate_five_hour_pct`/`usage_gate_seven_day_pct` (defaults 80/90) and the nullable `task_runs.model` column. Migration `AddWorktreeMergeCommit` added the nullable `worktrees.merge_commit` column. Migration `AddVerifyCommand` added the nullable `list_config.verify_command` column. `TaskRepository.DeleteAsync` and `ListRepository.DeleteAsync` also delete the on-disk attachment dir(s) via an optional `AttachmentStore` ctor param (defaults to the production store).
|
||||
Tables (one per line so parallel migrations don't collide on the same line):
|
||||
- `lists`
|
||||
- `tasks`
|
||||
- `worktrees`
|
||||
- `list_config`
|
||||
- `task_runs`
|
||||
- `subtasks`
|
||||
- `app_settings`
|
||||
- `prime_schedules`
|
||||
- `daily_notes`
|
||||
- `week_reports`
|
||||
- `task_attachments`
|
||||
|
||||
Managed by EF Core migrations in `Migrations/` — **`ls Migrations/` is the authoritative history**;
|
||||
don't maintain a changelog here. `tasks` holds `status`, `planning_phase` (default `none`), and
|
||||
`blocked_by_task_id` (FK to `tasks.id`, `ON DELETE SET NULL`).
|
||||
|
||||
## Conventions
|
||||
|
||||
- Enum <-> string mapping via EF Core `ValueConverter` (configured in `IEntityTypeConfiguration<T>`)
|
||||
- Entity configurations live in the `Configuration/` folder
|
||||
- Enum ↔ string mapping via EF Core `ValueConverter`, configured in `IEntityTypeConfiguration<T>`
|
||||
- Entity configurations live in `Configuration/`
|
||||
- Primary keys are `init`-only strings (GUIDs assigned at creation)
|
||||
- All methods are async with CancellationToken where applicable
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="ClaudeDo.Worker" />
|
||||
<InternalsVisibleTo Include="ClaudeDo.Worker.Tests" />
|
||||
<InternalsVisibleTo Include="ClaudeDo.Data.Tests" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -18,10 +18,13 @@ public class AppSettingsEntityConfiguration : IEntityTypeConfiguration<AppSettin
|
||||
builder.Property(s => s.DefaultModel)
|
||||
.HasColumnName("default_model").IsRequired().HasDefaultValue("sonnet");
|
||||
builder.Property(s => s.DefaultMaxTurns)
|
||||
.HasColumnName("default_max_turns").IsRequired().HasDefaultValue(30);
|
||||
.HasColumnName("default_max_turns").IsRequired().HasDefaultValue(40);
|
||||
builder.Property(s => s.DefaultPermissionMode)
|
||||
.HasColumnName("default_permission_mode").IsRequired().HasDefaultValue("bypassPermissions");
|
||||
|
||||
builder.Property(s => s.MaxTurnsCeiling)
|
||||
.HasColumnName("max_turns_ceiling").IsRequired().HasDefaultValue(80);
|
||||
|
||||
builder.Property(s => s.MaxParallelExecutions)
|
||||
.HasColumnName("max_parallel_executions").IsRequired().HasDefaultValue(1);
|
||||
|
||||
@@ -52,6 +55,11 @@ public class AppSettingsEntityConfiguration : IEntityTypeConfiguration<AppSettin
|
||||
builder.Property(s => s.UsageGateSevenDayPct)
|
||||
.HasColumnName("usage_gate_seven_day_pct").IsRequired().HasDefaultValue(90);
|
||||
|
||||
builder.Property(s => s.UsageThrottleSoftPct)
|
||||
.HasColumnName("usage_throttle_soft_pct").IsRequired().HasDefaultValue(50);
|
||||
builder.Property(s => s.UsageThrottleHardPct)
|
||||
.HasColumnName("usage_throttle_hard_pct").IsRequired().HasDefaultValue(65);
|
||||
|
||||
builder.HasData(new AppSettingsEntity { Id = AppSettingsEntity.SingletonId });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -95,6 +95,7 @@ public class TaskEntityConfiguration : IEntityTypeConfiguration<TaskEntity>
|
||||
builder.Property(t => t.SessionSkills).HasColumnName("session_skills");
|
||||
builder.Property(t => t.HandlerBaseCommit).HasColumnName("handler_base_commit");
|
||||
builder.Property(t => t.HandlerHeadCommit).HasColumnName("handler_head_commit");
|
||||
builder.Property(t => t.InteractiveSessionId).HasColumnName("interactive_session_id");
|
||||
|
||||
builder.Property(t => t.ParentTaskId).HasColumnName("parent_task_id");
|
||||
builder.Property(t => t.PlanningSessionId).HasColumnName("planning_session_id");
|
||||
|
||||
@@ -24,6 +24,8 @@ public class TaskRunEntityConfiguration : IEntityTypeConfiguration<TaskRunEntity
|
||||
builder.Property(r => r.TurnCount).HasColumnName("turn_count");
|
||||
builder.Property(r => r.TokensIn).HasColumnName("tokens_in");
|
||||
builder.Property(r => r.TokensOut).HasColumnName("tokens_out");
|
||||
builder.Property(r => r.CacheReadTokens).HasColumnName("cache_read_tokens");
|
||||
builder.Property(r => r.CacheWriteTokens).HasColumnName("cache_write_tokens");
|
||||
builder.Property(r => r.LogPath).HasColumnName("log_path");
|
||||
builder.Property(r => r.StartedAt).HasColumnName("started_at");
|
||||
builder.Property(r => r.FinishedAt).HasColumnName("finished_at");
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
using SysEnvironment = System.Environment;
|
||||
|
||||
namespace ClaudeDo.Data.Environment;
|
||||
|
||||
public sealed record ResolvedExecutable(string Path, bool IsShim);
|
||||
|
||||
public sealed record ShimStartInfo(string FileName, string Arguments);
|
||||
|
||||
/// <summary>
|
||||
/// Resolves a command the way Windows' CreateProcess/PATH search does, but also finds
|
||||
/// non-.exe shims (.cmd/.bat/.ps1) that UseShellExecute=false alone would miss.
|
||||
/// </summary>
|
||||
public static class ExecutableResolver
|
||||
{
|
||||
private const string DefaultPathExt = ".COM;.EXE;.BAT;.CMD";
|
||||
|
||||
// Known npm/claude install locations to try when PATH search comes up empty.
|
||||
private static readonly string[] FallbackDirectoryTemplates =
|
||||
{
|
||||
"%APPDATA%\\npm",
|
||||
"%LOCALAPPDATA%\\Programs\\claude",
|
||||
"%USERPROFILE%\\.local\\bin",
|
||||
};
|
||||
|
||||
public static ResolvedExecutable? Resolve(string command, string? pathOverride = null, string? pathExtOverride = null)
|
||||
{
|
||||
var pathExts = ParsePathExt(pathExtOverride);
|
||||
|
||||
if (LooksLikePath(command))
|
||||
{
|
||||
return ResolveAsPath(command, pathExts);
|
||||
}
|
||||
|
||||
var directories = ParsePath(pathOverride);
|
||||
foreach (var dir in directories)
|
||||
{
|
||||
var resolved = ResolveInDirectory(dir, command, pathExts);
|
||||
if (resolved is not null) return resolved;
|
||||
}
|
||||
|
||||
foreach (var template in FallbackDirectoryTemplates)
|
||||
{
|
||||
var dir = SysEnvironment.ExpandEnvironmentVariables(template);
|
||||
var resolved = ResolveInDirectory(dir, command, pathExts);
|
||||
if (resolved is not null) return resolved;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>Expanded fallback directories tried when PATH search comes up empty (for diagnostics).</summary>
|
||||
public static IReadOnlyList<string> FallbackDirectories() =>
|
||||
FallbackDirectoryTemplates.Select(SysEnvironment.ExpandEnvironmentVariables).ToList();
|
||||
|
||||
public static ShimStartInfo BuildShimStartInfo(string shimPath, IReadOnlyList<string> arguments)
|
||||
{
|
||||
var parts = new List<string> { "/c", Quote(shimPath) };
|
||||
parts.AddRange(arguments.Select(Quote));
|
||||
return new ShimStartInfo("cmd.exe", string.Join(' ', parts));
|
||||
}
|
||||
|
||||
private static bool LooksLikePath(string command) =>
|
||||
command.Contains(Path.DirectorySeparatorChar) || command.Contains(Path.AltDirectorySeparatorChar);
|
||||
|
||||
private static ResolvedExecutable? ResolveAsPath(string command, IReadOnlyList<string> pathExts)
|
||||
{
|
||||
if (File.Exists(command)) return new ResolvedExecutable(command, IsShimExtension(Path.GetExtension(command)));
|
||||
|
||||
if (Path.HasExtension(command)) return null;
|
||||
|
||||
foreach (var ext in pathExts)
|
||||
{
|
||||
var candidate = command + ext;
|
||||
if (File.Exists(candidate)) return new ResolvedExecutable(candidate, IsShimExtension(ext));
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static ResolvedExecutable? ResolveInDirectory(string directory, string command, IReadOnlyList<string> pathExts)
|
||||
{
|
||||
if (!Directory.Exists(directory)) return null;
|
||||
|
||||
if (Path.HasExtension(command))
|
||||
{
|
||||
var candidate = Path.Combine(directory, command);
|
||||
return File.Exists(candidate) ? new ResolvedExecutable(candidate, IsShimExtension(Path.GetExtension(candidate))) : null;
|
||||
}
|
||||
|
||||
foreach (var ext in pathExts)
|
||||
{
|
||||
var candidate = Path.Combine(directory, command + ext);
|
||||
if (File.Exists(candidate)) return new ResolvedExecutable(candidate, IsShimExtension(ext));
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static bool IsShimExtension(string extension) =>
|
||||
!extension.Equals(".exe", StringComparison.OrdinalIgnoreCase)
|
||||
&& !extension.Equals(".com", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
private static IReadOnlyList<string> ParsePathExt(string? pathExtOverride)
|
||||
{
|
||||
var raw = pathExtOverride ?? SysEnvironment.GetEnvironmentVariable("PATHEXT") ?? DefaultPathExt;
|
||||
return raw.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string> ParsePath(string? pathOverride)
|
||||
{
|
||||
var raw = pathOverride ?? SysEnvironment.GetEnvironmentVariable("PATH") ?? "";
|
||||
return raw.Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
}
|
||||
|
||||
private static string Quote(string value) => value.Contains(' ') ? $"\"{value}\"" : value;
|
||||
}
|
||||
@@ -26,6 +26,22 @@ public sealed class GitService
|
||||
return stdout.Trim();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// True if <paramref name="ancestorSha"/> is an ancestor of (or equal to) <paramref name="descendantSha"/>,
|
||||
/// via `git merge-base --is-ancestor`. Null means the answer can't be determined (e.g. the commit is
|
||||
/// unknown in this repo) — callers must treat that as "unknown", never as "not an ancestor".
|
||||
/// </summary>
|
||||
public async Task<bool?> IsAncestorAsync(string repoDir, string ancestorSha, string descendantSha, CancellationToken ct = default)
|
||||
{
|
||||
var (exitCode, _, _) = await RunGitAsync(repoDir, ["merge-base", "--is-ancestor", ancestorSha, descendantSha], ct);
|
||||
return exitCode switch
|
||||
{
|
||||
0 => true,
|
||||
1 => false,
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
|
||||
public async Task WorktreeAddAsync(string repoDir, string branchName, string worktreePath, string baseCommit, CancellationToken ct = default)
|
||||
{
|
||||
await WorktreeAddGate.WaitAsync(ct);
|
||||
|
||||
@@ -0,0 +1,843 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using ClaudeDo.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace ClaudeDo.Data.Migrations
|
||||
{
|
||||
[DbContext(typeof(ClaudeDoDbContext))]
|
||||
[Migration("20260805132052_AddMaxTurnsCeiling")]
|
||||
partial class AddMaxTurnsCeiling
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder.HasAnnotation("ProductVersion", "8.0.11");
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.AppSettingsEntity", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<string>("CentralWorktreeRoot")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("central_worktree_root");
|
||||
|
||||
b.Property<int>("DailyPrepMaxTasks")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(5)
|
||||
.HasColumnName("daily_prep_max_tasks");
|
||||
|
||||
b.Property<string>("DefaultClaudeInstructions")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT")
|
||||
.HasDefaultValue("")
|
||||
.HasColumnName("default_claude_instructions");
|
||||
|
||||
b.Property<int>("DefaultMaxTurns")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(40)
|
||||
.HasColumnName("default_max_turns");
|
||||
|
||||
b.Property<string>("DefaultModel")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT")
|
||||
.HasDefaultValue("sonnet")
|
||||
.HasColumnName("default_model");
|
||||
|
||||
b.Property<string>("DefaultPermissionMode")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT")
|
||||
.HasDefaultValue("bypassPermissions")
|
||||
.HasColumnName("default_permission_mode");
|
||||
|
||||
b.Property<int>("MaxParallelExecutions")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(1)
|
||||
.HasColumnName("max_parallel_executions");
|
||||
|
||||
b.Property<int>("MaxTurnsCeiling")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(80)
|
||||
.HasColumnName("max_turns_ceiling");
|
||||
|
||||
b.Property<string>("ModelPresets")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("model_presets");
|
||||
|
||||
b.Property<string>("RepoImportFolders")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("repo_import_folders");
|
||||
|
||||
b.Property<string>("ReportExcludedPaths")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("report_excluded_paths");
|
||||
|
||||
b.Property<string>("SessionSkills")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("session_skills");
|
||||
|
||||
b.Property<int>("StandupWeekday")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(3)
|
||||
.HasColumnName("standup_weekday");
|
||||
|
||||
b.Property<int>("UsageGateFiveHourPct")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(80)
|
||||
.HasColumnName("usage_gate_five_hour_pct");
|
||||
|
||||
b.Property<int>("UsageGateSevenDayPct")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(90)
|
||||
.HasColumnName("usage_gate_seven_day_pct");
|
||||
|
||||
b.Property<int>("WorktreeAutoCleanupDays")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(7)
|
||||
.HasColumnName("worktree_auto_cleanup_days");
|
||||
|
||||
b.Property<bool>("WorktreeAutoCleanupEnabled")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(false)
|
||||
.HasColumnName("worktree_auto_cleanup_enabled");
|
||||
|
||||
b.Property<string>("WorktreeStrategy")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT")
|
||||
.HasDefaultValue("sibling")
|
||||
.HasColumnName("worktree_strategy");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("app_settings", (string)null);
|
||||
|
||||
b.HasData(
|
||||
new
|
||||
{
|
||||
Id = 1,
|
||||
DailyPrepMaxTasks = 5,
|
||||
DefaultClaudeInstructions = "",
|
||||
DefaultMaxTurns = 40,
|
||||
DefaultModel = "sonnet",
|
||||
DefaultPermissionMode = "auto",
|
||||
MaxParallelExecutions = 1,
|
||||
MaxTurnsCeiling = 80,
|
||||
StandupWeekday = 3,
|
||||
UsageGateFiveHourPct = 80,
|
||||
UsageGateSevenDayPct = 90,
|
||||
WorktreeAutoCleanupDays = 7,
|
||||
WorktreeAutoCleanupEnabled = false,
|
||||
WorktreeStrategy = "sibling"
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.DailyNoteEntity", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<DateOnly>("Date")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("note_date");
|
||||
|
||||
b.Property<int>("SortOrder")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("sort_order");
|
||||
|
||||
b.Property<string>("Text")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("text");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Date");
|
||||
|
||||
b.ToTable("daily_notes", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.ListConfigEntity", b =>
|
||||
{
|
||||
b.Property<string>("ListId")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("list_id");
|
||||
|
||||
b.Property<string>("AgentPath")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("agent_path");
|
||||
|
||||
b.Property<int?>("MaxTurns")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("max_turns");
|
||||
|
||||
b.Property<string>("Model")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("model");
|
||||
|
||||
b.Property<string>("SessionSkills")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("session_skills");
|
||||
|
||||
b.Property<string>("SystemPrompt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("system_prompt");
|
||||
|
||||
b.Property<string>("VerifyCommand")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("verify_command");
|
||||
|
||||
b.HasKey("ListId");
|
||||
|
||||
b.ToTable("list_config", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.ListEntity", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<string>("DefaultCommitType")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT")
|
||||
.HasDefaultValue("chore")
|
||||
.HasColumnName("default_commit_type");
|
||||
|
||||
b.Property<bool>("IsManual")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(false)
|
||||
.HasColumnName("is_manual");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("name");
|
||||
|
||||
b.Property<int>("SortOrder")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(0)
|
||||
.HasColumnName("sort_order");
|
||||
|
||||
b.Property<string>("WorkingDir")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("working_dir");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("SortOrder")
|
||||
.HasDatabaseName("idx_lists_sort");
|
||||
|
||||
b.ToTable("lists", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.PrimeScheduleEntity", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<int>("Days")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(31)
|
||||
.HasColumnName("days_of_week");
|
||||
|
||||
b.Property<bool>("Enabled")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(true)
|
||||
.HasColumnName("enabled");
|
||||
|
||||
b.Property<DateTimeOffset?>("LastRunAt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("last_run_at");
|
||||
|
||||
b.Property<string>("PromptOverride")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("prompt_override");
|
||||
|
||||
b.Property<TimeSpan>("TimeOfDay")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("time_of_day");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("prime_schedules", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.SessionSkillEntity", b =>
|
||||
{
|
||||
b.Property<string>("Name")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("name");
|
||||
|
||||
b.Property<DateTimeOffset>("AddedAt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("added_at");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("description");
|
||||
|
||||
b.Property<string>("PinnedRef")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("pinned_ref");
|
||||
|
||||
b.Property<string>("SourceUrl")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("source_url");
|
||||
|
||||
b.Property<string>("Subpath")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("subpath");
|
||||
|
||||
b.HasKey("Name");
|
||||
|
||||
b.ToTable("session_skills", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.SubtaskEntity", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<bool>("Completed")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(false)
|
||||
.HasColumnName("completed");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<int>("OrderNum")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("order_num");
|
||||
|
||||
b.Property<string>("TaskId")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("task_id");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("title");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("TaskId")
|
||||
.HasDatabaseName("idx_subtasks_task_id");
|
||||
|
||||
b.ToTable("subtasks", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.TaskAttachmentEntity", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<long>("ByteSize")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("byte_size");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<string>("FileName")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("file_name");
|
||||
|
||||
b.Property<string>("TaskId")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("task_id");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("TaskId")
|
||||
.HasDatabaseName("idx_task_attachments_task_id");
|
||||
|
||||
b.ToTable("task_attachments", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<string>("AgentPath")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("agent_path");
|
||||
|
||||
b.Property<string>("BlockedByTaskId")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("blocked_by_task_id");
|
||||
|
||||
b.Property<string>("CommitType")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT")
|
||||
.HasDefaultValue("chore")
|
||||
.HasColumnName("commit_type");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<string>("CreatedBy")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("created_by");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("description");
|
||||
|
||||
b.Property<DateTime?>("FinishedAt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("finished_at");
|
||||
|
||||
b.Property<string>("HandlerBaseCommit")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("handler_base_commit");
|
||||
|
||||
b.Property<string>("HandlerHeadCommit")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("handler_head_commit");
|
||||
|
||||
b.Property<bool>("IsManual")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(false)
|
||||
.HasColumnName("is_manual");
|
||||
|
||||
b.Property<bool>("IsMyDay")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(false)
|
||||
.HasColumnName("is_my_day");
|
||||
|
||||
b.Property<bool>("IsStarred")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(false)
|
||||
.HasColumnName("is_starred");
|
||||
|
||||
b.Property<string>("ListId")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("list_id");
|
||||
|
||||
b.Property<string>("LogPath")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("log_path");
|
||||
|
||||
b.Property<int?>("MaxTurns")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("max_turns");
|
||||
|
||||
b.Property<string>("Model")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("model");
|
||||
|
||||
b.Property<string>("Notes")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("notes");
|
||||
|
||||
b.Property<string>("ParentTaskId")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("parent_task_id");
|
||||
|
||||
b.Property<DateTime?>("PlanningFinalizedAt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("planning_finalized_at");
|
||||
|
||||
b.Property<string>("PlanningPhase")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT")
|
||||
.HasDefaultValue("none")
|
||||
.HasColumnName("planning_phase");
|
||||
|
||||
b.Property<string>("PlanningSessionId")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("planning_session_id");
|
||||
|
||||
b.Property<string>("PlanningSessionToken")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("planning_session_token");
|
||||
|
||||
b.Property<string>("Result")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("result");
|
||||
|
||||
b.Property<string>("ReviewFeedback")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("review_feedback");
|
||||
|
||||
b.Property<int>("RoadblockCount")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(0)
|
||||
.HasColumnName("roadblock_count");
|
||||
|
||||
b.Property<DateTime?>("ScheduledFor")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("scheduled_for");
|
||||
|
||||
b.Property<string>("SessionSkills")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("session_skills");
|
||||
|
||||
b.Property<int>("SortOrder")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(0)
|
||||
.HasColumnName("sort_order");
|
||||
|
||||
b.Property<DateTime?>("StartedAt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("started_at");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("status");
|
||||
|
||||
b.Property<string>("SystemPrompt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("system_prompt");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("title");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("BlockedByTaskId")
|
||||
.HasDatabaseName("idx_tasks_blocked_by");
|
||||
|
||||
b.HasIndex("ListId")
|
||||
.HasDatabaseName("idx_tasks_list_id");
|
||||
|
||||
b.HasIndex("ParentTaskId")
|
||||
.HasDatabaseName("idx_tasks_parent_task_id");
|
||||
|
||||
b.HasIndex("Status")
|
||||
.HasDatabaseName("idx_tasks_status");
|
||||
|
||||
b.HasIndex("ListId", "SortOrder")
|
||||
.HasDatabaseName("idx_tasks_list_sort");
|
||||
|
||||
b.ToTable("tasks", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.TaskRunEntity", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<string>("ErrorMarkdown")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("error_markdown");
|
||||
|
||||
b.Property<int?>("ExitCode")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("exit_code");
|
||||
|
||||
b.Property<DateTime?>("FinishedAt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("finished_at");
|
||||
|
||||
b.Property<bool>("IsRetry")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(false)
|
||||
.HasColumnName("is_retry");
|
||||
|
||||
b.Property<string>("LogPath")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("log_path");
|
||||
|
||||
b.Property<string>("Model")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("model");
|
||||
|
||||
b.Property<string>("Prompt")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("prompt");
|
||||
|
||||
b.Property<string>("ResultMarkdown")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("result_markdown");
|
||||
|
||||
b.Property<int>("RunNumber")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("run_number");
|
||||
|
||||
b.Property<string>("SessionId")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("session_id");
|
||||
|
||||
b.Property<DateTime?>("StartedAt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("started_at");
|
||||
|
||||
b.Property<string>("StructuredOutputJson")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("structured_output");
|
||||
|
||||
b.Property<string>("TaskId")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("task_id");
|
||||
|
||||
b.Property<int?>("TokensIn")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("tokens_in");
|
||||
|
||||
b.Property<int?>("TokensOut")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("tokens_out");
|
||||
|
||||
b.Property<int?>("TurnCount")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("turn_count");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("TaskId")
|
||||
.HasDatabaseName("idx_task_runs_task_id");
|
||||
|
||||
b.ToTable("task_runs", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.WeekReportEntity", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<DateOnly>("EndDate")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("end_date");
|
||||
|
||||
b.Property<DateTime>("GeneratedAt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("generated_at");
|
||||
|
||||
b.Property<string>("Markdown")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("markdown");
|
||||
|
||||
b.Property<DateOnly>("StartDate")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("start_date");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("StartDate", "EndDate")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("week_reports", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.WorktreeEntity", b =>
|
||||
{
|
||||
b.Property<string>("TaskId")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("task_id");
|
||||
|
||||
b.Property<string>("BaseCommit")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("base_commit");
|
||||
|
||||
b.Property<string>("BranchName")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("branch_name");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<string>("DiffStat")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("diff_stat");
|
||||
|
||||
b.Property<string>("HeadCommit")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("head_commit");
|
||||
|
||||
b.Property<string>("MergeCommit")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("merge_commit");
|
||||
|
||||
b.Property<string>("Path")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("path");
|
||||
|
||||
b.Property<string>("State")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT")
|
||||
.HasDefaultValue("active")
|
||||
.HasColumnName("state");
|
||||
|
||||
b.HasKey("TaskId");
|
||||
|
||||
b.ToTable("worktrees", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.ListConfigEntity", b =>
|
||||
{
|
||||
b.HasOne("ClaudeDo.Data.Models.ListEntity", "List")
|
||||
.WithOne("Config")
|
||||
.HasForeignKey("ClaudeDo.Data.Models.ListConfigEntity", "ListId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("List");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.SubtaskEntity", b =>
|
||||
{
|
||||
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task")
|
||||
.WithMany("Subtasks")
|
||||
.HasForeignKey("TaskId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Task");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.TaskAttachmentEntity", b =>
|
||||
{
|
||||
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task")
|
||||
.WithMany()
|
||||
.HasForeignKey("TaskId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Task");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b =>
|
||||
{
|
||||
b.HasOne("ClaudeDo.Data.Models.TaskEntity", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("BlockedByTaskId")
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
|
||||
b.HasOne("ClaudeDo.Data.Models.ListEntity", "List")
|
||||
.WithMany("Tasks")
|
||||
.HasForeignKey("ListId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Parent")
|
||||
.WithMany("Children")
|
||||
.HasForeignKey("ParentTaskId")
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
b.Navigation("List");
|
||||
|
||||
b.Navigation("Parent");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.TaskRunEntity", b =>
|
||||
{
|
||||
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task")
|
||||
.WithMany("Runs")
|
||||
.HasForeignKey("TaskId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Task");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.WorktreeEntity", b =>
|
||||
{
|
||||
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task")
|
||||
.WithOne("Worktree")
|
||||
.HasForeignKey("ClaudeDo.Data.Models.WorktreeEntity", "TaskId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Task");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.ListEntity", b =>
|
||||
{
|
||||
b.Navigation("Config");
|
||||
|
||||
b.Navigation("Tasks");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b =>
|
||||
{
|
||||
b.Navigation("Children");
|
||||
|
||||
b.Navigation("Runs");
|
||||
|
||||
b.Navigation("Subtasks");
|
||||
|
||||
b.Navigation("Worktree");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace ClaudeDo.Data.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddMaxTurnsCeiling : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AlterColumn<int>(
|
||||
name: "default_max_turns",
|
||||
table: "app_settings",
|
||||
type: "INTEGER",
|
||||
nullable: false,
|
||||
defaultValue: 40,
|
||||
oldClrType: typeof(int),
|
||||
oldType: "INTEGER",
|
||||
oldDefaultValue: 30);
|
||||
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "max_turns_ceiling",
|
||||
table: "app_settings",
|
||||
type: "INTEGER",
|
||||
nullable: false,
|
||||
defaultValue: 80);
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "app_settings",
|
||||
keyColumn: "id",
|
||||
keyValue: 1,
|
||||
columns: new[] { "default_max_turns", "max_turns_ceiling" },
|
||||
values: new object[] { 40, 80 });
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "max_turns_ceiling",
|
||||
table: "app_settings");
|
||||
|
||||
migrationBuilder.AlterColumn<int>(
|
||||
name: "default_max_turns",
|
||||
table: "app_settings",
|
||||
type: "INTEGER",
|
||||
nullable: false,
|
||||
defaultValue: 30,
|
||||
oldClrType: typeof(int),
|
||||
oldType: "INTEGER",
|
||||
oldDefaultValue: 40);
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "app_settings",
|
||||
keyColumn: "id",
|
||||
keyValue: 1,
|
||||
column: "default_max_turns",
|
||||
value: 100);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,844 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using ClaudeDo.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace ClaudeDo.Data.Migrations
|
||||
{
|
||||
[DbContext(typeof(ClaudeDoDbContext))]
|
||||
[Migration("20260805132801_AddRunCacheTokens")]
|
||||
partial class AddRunCacheTokens
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder.HasAnnotation("ProductVersion", "8.0.11");
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.AppSettingsEntity", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<string>("CentralWorktreeRoot")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("central_worktree_root");
|
||||
|
||||
b.Property<int>("DailyPrepMaxTasks")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(5)
|
||||
.HasColumnName("daily_prep_max_tasks");
|
||||
|
||||
b.Property<string>("DefaultClaudeInstructions")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT")
|
||||
.HasDefaultValue("")
|
||||
.HasColumnName("default_claude_instructions");
|
||||
|
||||
b.Property<int>("DefaultMaxTurns")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(30)
|
||||
.HasColumnName("default_max_turns");
|
||||
|
||||
b.Property<string>("DefaultModel")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT")
|
||||
.HasDefaultValue("sonnet")
|
||||
.HasColumnName("default_model");
|
||||
|
||||
b.Property<string>("DefaultPermissionMode")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT")
|
||||
.HasDefaultValue("bypassPermissions")
|
||||
.HasColumnName("default_permission_mode");
|
||||
|
||||
b.Property<int>("MaxParallelExecutions")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(1)
|
||||
.HasColumnName("max_parallel_executions");
|
||||
|
||||
b.Property<string>("ModelPresets")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("model_presets");
|
||||
|
||||
b.Property<string>("RepoImportFolders")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("repo_import_folders");
|
||||
|
||||
b.Property<string>("ReportExcludedPaths")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("report_excluded_paths");
|
||||
|
||||
b.Property<string>("SessionSkills")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("session_skills");
|
||||
|
||||
b.Property<int>("StandupWeekday")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(3)
|
||||
.HasColumnName("standup_weekday");
|
||||
|
||||
b.Property<int>("UsageGateFiveHourPct")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(80)
|
||||
.HasColumnName("usage_gate_five_hour_pct");
|
||||
|
||||
b.Property<int>("UsageGateSevenDayPct")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(90)
|
||||
.HasColumnName("usage_gate_seven_day_pct");
|
||||
|
||||
b.Property<int>("WorktreeAutoCleanupDays")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(7)
|
||||
.HasColumnName("worktree_auto_cleanup_days");
|
||||
|
||||
b.Property<bool>("WorktreeAutoCleanupEnabled")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(false)
|
||||
.HasColumnName("worktree_auto_cleanup_enabled");
|
||||
|
||||
b.Property<string>("WorktreeStrategy")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT")
|
||||
.HasDefaultValue("sibling")
|
||||
.HasColumnName("worktree_strategy");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("app_settings", (string)null);
|
||||
|
||||
b.HasData(
|
||||
new
|
||||
{
|
||||
Id = 1,
|
||||
DailyPrepMaxTasks = 5,
|
||||
DefaultClaudeInstructions = "",
|
||||
DefaultMaxTurns = 100,
|
||||
DefaultModel = "sonnet",
|
||||
DefaultPermissionMode = "auto",
|
||||
MaxParallelExecutions = 1,
|
||||
StandupWeekday = 3,
|
||||
UsageGateFiveHourPct = 80,
|
||||
UsageGateSevenDayPct = 90,
|
||||
WorktreeAutoCleanupDays = 7,
|
||||
WorktreeAutoCleanupEnabled = false,
|
||||
WorktreeStrategy = "sibling"
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.DailyNoteEntity", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<DateOnly>("Date")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("note_date");
|
||||
|
||||
b.Property<int>("SortOrder")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("sort_order");
|
||||
|
||||
b.Property<string>("Text")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("text");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Date");
|
||||
|
||||
b.ToTable("daily_notes", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.ListConfigEntity", b =>
|
||||
{
|
||||
b.Property<string>("ListId")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("list_id");
|
||||
|
||||
b.Property<string>("AgentPath")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("agent_path");
|
||||
|
||||
b.Property<int?>("MaxTurns")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("max_turns");
|
||||
|
||||
b.Property<string>("Model")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("model");
|
||||
|
||||
b.Property<string>("SessionSkills")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("session_skills");
|
||||
|
||||
b.Property<string>("SystemPrompt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("system_prompt");
|
||||
|
||||
b.Property<string>("VerifyCommand")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("verify_command");
|
||||
|
||||
b.HasKey("ListId");
|
||||
|
||||
b.ToTable("list_config", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.ListEntity", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<string>("DefaultCommitType")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT")
|
||||
.HasDefaultValue("chore")
|
||||
.HasColumnName("default_commit_type");
|
||||
|
||||
b.Property<bool>("IsManual")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(false)
|
||||
.HasColumnName("is_manual");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("name");
|
||||
|
||||
b.Property<int>("SortOrder")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(0)
|
||||
.HasColumnName("sort_order");
|
||||
|
||||
b.Property<string>("WorkingDir")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("working_dir");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("SortOrder")
|
||||
.HasDatabaseName("idx_lists_sort");
|
||||
|
||||
b.ToTable("lists", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.PrimeScheduleEntity", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<int>("Days")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(31)
|
||||
.HasColumnName("days_of_week");
|
||||
|
||||
b.Property<bool>("Enabled")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(true)
|
||||
.HasColumnName("enabled");
|
||||
|
||||
b.Property<DateTimeOffset?>("LastRunAt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("last_run_at");
|
||||
|
||||
b.Property<string>("PromptOverride")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("prompt_override");
|
||||
|
||||
b.Property<TimeSpan>("TimeOfDay")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("time_of_day");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("prime_schedules", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.SessionSkillEntity", b =>
|
||||
{
|
||||
b.Property<string>("Name")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("name");
|
||||
|
||||
b.Property<DateTimeOffset>("AddedAt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("added_at");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("description");
|
||||
|
||||
b.Property<string>("PinnedRef")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("pinned_ref");
|
||||
|
||||
b.Property<string>("SourceUrl")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("source_url");
|
||||
|
||||
b.Property<string>("Subpath")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("subpath");
|
||||
|
||||
b.HasKey("Name");
|
||||
|
||||
b.ToTable("session_skills", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.SubtaskEntity", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<bool>("Completed")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(false)
|
||||
.HasColumnName("completed");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<int>("OrderNum")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("order_num");
|
||||
|
||||
b.Property<string>("TaskId")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("task_id");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("title");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("TaskId")
|
||||
.HasDatabaseName("idx_subtasks_task_id");
|
||||
|
||||
b.ToTable("subtasks", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.TaskAttachmentEntity", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<long>("ByteSize")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("byte_size");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<string>("FileName")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("file_name");
|
||||
|
||||
b.Property<string>("TaskId")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("task_id");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("TaskId")
|
||||
.HasDatabaseName("idx_task_attachments_task_id");
|
||||
|
||||
b.ToTable("task_attachments", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<string>("AgentPath")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("agent_path");
|
||||
|
||||
b.Property<string>("BlockedByTaskId")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("blocked_by_task_id");
|
||||
|
||||
b.Property<string>("CommitType")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT")
|
||||
.HasDefaultValue("chore")
|
||||
.HasColumnName("commit_type");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<string>("CreatedBy")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("created_by");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("description");
|
||||
|
||||
b.Property<DateTime?>("FinishedAt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("finished_at");
|
||||
|
||||
b.Property<string>("HandlerBaseCommit")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("handler_base_commit");
|
||||
|
||||
b.Property<string>("HandlerHeadCommit")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("handler_head_commit");
|
||||
|
||||
b.Property<bool>("IsManual")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(false)
|
||||
.HasColumnName("is_manual");
|
||||
|
||||
b.Property<bool>("IsMyDay")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(false)
|
||||
.HasColumnName("is_my_day");
|
||||
|
||||
b.Property<bool>("IsStarred")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(false)
|
||||
.HasColumnName("is_starred");
|
||||
|
||||
b.Property<string>("ListId")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("list_id");
|
||||
|
||||
b.Property<string>("LogPath")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("log_path");
|
||||
|
||||
b.Property<int?>("MaxTurns")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("max_turns");
|
||||
|
||||
b.Property<string>("Model")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("model");
|
||||
|
||||
b.Property<string>("Notes")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("notes");
|
||||
|
||||
b.Property<string>("ParentTaskId")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("parent_task_id");
|
||||
|
||||
b.Property<DateTime?>("PlanningFinalizedAt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("planning_finalized_at");
|
||||
|
||||
b.Property<string>("PlanningPhase")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT")
|
||||
.HasDefaultValue("none")
|
||||
.HasColumnName("planning_phase");
|
||||
|
||||
b.Property<string>("PlanningSessionId")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("planning_session_id");
|
||||
|
||||
b.Property<string>("PlanningSessionToken")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("planning_session_token");
|
||||
|
||||
b.Property<string>("Result")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("result");
|
||||
|
||||
b.Property<string>("ReviewFeedback")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("review_feedback");
|
||||
|
||||
b.Property<int>("RoadblockCount")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(0)
|
||||
.HasColumnName("roadblock_count");
|
||||
|
||||
b.Property<DateTime?>("ScheduledFor")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("scheduled_for");
|
||||
|
||||
b.Property<string>("SessionSkills")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("session_skills");
|
||||
|
||||
b.Property<int>("SortOrder")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(0)
|
||||
.HasColumnName("sort_order");
|
||||
|
||||
b.Property<DateTime?>("StartedAt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("started_at");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("status");
|
||||
|
||||
b.Property<string>("SystemPrompt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("system_prompt");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("title");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("BlockedByTaskId")
|
||||
.HasDatabaseName("idx_tasks_blocked_by");
|
||||
|
||||
b.HasIndex("ListId")
|
||||
.HasDatabaseName("idx_tasks_list_id");
|
||||
|
||||
b.HasIndex("ParentTaskId")
|
||||
.HasDatabaseName("idx_tasks_parent_task_id");
|
||||
|
||||
b.HasIndex("Status")
|
||||
.HasDatabaseName("idx_tasks_status");
|
||||
|
||||
b.HasIndex("ListId", "SortOrder")
|
||||
.HasDatabaseName("idx_tasks_list_sort");
|
||||
|
||||
b.ToTable("tasks", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.TaskRunEntity", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<int?>("CacheReadTokens")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("cache_read_tokens");
|
||||
|
||||
b.Property<int?>("CacheWriteTokens")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("cache_write_tokens");
|
||||
|
||||
b.Property<string>("ErrorMarkdown")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("error_markdown");
|
||||
|
||||
b.Property<int?>("ExitCode")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("exit_code");
|
||||
|
||||
b.Property<DateTime?>("FinishedAt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("finished_at");
|
||||
|
||||
b.Property<bool>("IsRetry")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(false)
|
||||
.HasColumnName("is_retry");
|
||||
|
||||
b.Property<string>("LogPath")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("log_path");
|
||||
|
||||
b.Property<string>("Model")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("model");
|
||||
|
||||
b.Property<string>("Prompt")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("prompt");
|
||||
|
||||
b.Property<string>("ResultMarkdown")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("result_markdown");
|
||||
|
||||
b.Property<int>("RunNumber")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("run_number");
|
||||
|
||||
b.Property<string>("SessionId")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("session_id");
|
||||
|
||||
b.Property<DateTime?>("StartedAt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("started_at");
|
||||
|
||||
b.Property<string>("StructuredOutputJson")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("structured_output");
|
||||
|
||||
b.Property<string>("TaskId")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("task_id");
|
||||
|
||||
b.Property<int?>("TokensIn")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("tokens_in");
|
||||
|
||||
b.Property<int?>("TokensOut")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("tokens_out");
|
||||
|
||||
b.Property<int?>("TurnCount")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("turn_count");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("TaskId")
|
||||
.HasDatabaseName("idx_task_runs_task_id");
|
||||
|
||||
b.ToTable("task_runs", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.WeekReportEntity", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<DateOnly>("EndDate")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("end_date");
|
||||
|
||||
b.Property<DateTime>("GeneratedAt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("generated_at");
|
||||
|
||||
b.Property<string>("Markdown")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("markdown");
|
||||
|
||||
b.Property<DateOnly>("StartDate")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("start_date");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("StartDate", "EndDate")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("week_reports", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.WorktreeEntity", b =>
|
||||
{
|
||||
b.Property<string>("TaskId")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("task_id");
|
||||
|
||||
b.Property<string>("BaseCommit")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("base_commit");
|
||||
|
||||
b.Property<string>("BranchName")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("branch_name");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<string>("DiffStat")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("diff_stat");
|
||||
|
||||
b.Property<string>("HeadCommit")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("head_commit");
|
||||
|
||||
b.Property<string>("MergeCommit")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("merge_commit");
|
||||
|
||||
b.Property<string>("Path")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("path");
|
||||
|
||||
b.Property<string>("State")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT")
|
||||
.HasDefaultValue("active")
|
||||
.HasColumnName("state");
|
||||
|
||||
b.HasKey("TaskId");
|
||||
|
||||
b.ToTable("worktrees", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.ListConfigEntity", b =>
|
||||
{
|
||||
b.HasOne("ClaudeDo.Data.Models.ListEntity", "List")
|
||||
.WithOne("Config")
|
||||
.HasForeignKey("ClaudeDo.Data.Models.ListConfigEntity", "ListId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("List");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.SubtaskEntity", b =>
|
||||
{
|
||||
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task")
|
||||
.WithMany("Subtasks")
|
||||
.HasForeignKey("TaskId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Task");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.TaskAttachmentEntity", b =>
|
||||
{
|
||||
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task")
|
||||
.WithMany()
|
||||
.HasForeignKey("TaskId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Task");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b =>
|
||||
{
|
||||
b.HasOne("ClaudeDo.Data.Models.TaskEntity", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("BlockedByTaskId")
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
|
||||
b.HasOne("ClaudeDo.Data.Models.ListEntity", "List")
|
||||
.WithMany("Tasks")
|
||||
.HasForeignKey("ListId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Parent")
|
||||
.WithMany("Children")
|
||||
.HasForeignKey("ParentTaskId")
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
b.Navigation("List");
|
||||
|
||||
b.Navigation("Parent");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.TaskRunEntity", b =>
|
||||
{
|
||||
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task")
|
||||
.WithMany("Runs")
|
||||
.HasForeignKey("TaskId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Task");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.WorktreeEntity", b =>
|
||||
{
|
||||
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task")
|
||||
.WithOne("Worktree")
|
||||
.HasForeignKey("ClaudeDo.Data.Models.WorktreeEntity", "TaskId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Task");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.ListEntity", b =>
|
||||
{
|
||||
b.Navigation("Config");
|
||||
|
||||
b.Navigation("Tasks");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b =>
|
||||
{
|
||||
b.Navigation("Children");
|
||||
|
||||
b.Navigation("Runs");
|
||||
|
||||
b.Navigation("Subtasks");
|
||||
|
||||
b.Navigation("Worktree");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace ClaudeDo.Data.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddRunCacheTokens : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "cache_read_tokens",
|
||||
table: "task_runs",
|
||||
type: "INTEGER",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "cache_write_tokens",
|
||||
table: "task_runs",
|
||||
type: "INTEGER",
|
||||
nullable: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "cache_read_tokens",
|
||||
table: "task_runs");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "cache_write_tokens",
|
||||
table: "task_runs");
|
||||
}
|
||||
}
|
||||
}
|
||||
+850
@@ -0,0 +1,850 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using ClaudeDo.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace ClaudeDo.Data.Migrations
|
||||
{
|
||||
[DbContext(typeof(ClaudeDoDbContext))]
|
||||
[Migration("20260805133429_AddUsageThrottleThresholds")]
|
||||
partial class AddUsageThrottleThresholds
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder.HasAnnotation("ProductVersion", "8.0.11");
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.AppSettingsEntity", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<string>("CentralWorktreeRoot")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("central_worktree_root");
|
||||
|
||||
b.Property<int>("DailyPrepMaxTasks")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(5)
|
||||
.HasColumnName("daily_prep_max_tasks");
|
||||
|
||||
b.Property<string>("DefaultClaudeInstructions")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT")
|
||||
.HasDefaultValue("")
|
||||
.HasColumnName("default_claude_instructions");
|
||||
|
||||
b.Property<int>("DefaultMaxTurns")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(30)
|
||||
.HasColumnName("default_max_turns");
|
||||
|
||||
b.Property<string>("DefaultModel")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT")
|
||||
.HasDefaultValue("sonnet")
|
||||
.HasColumnName("default_model");
|
||||
|
||||
b.Property<string>("DefaultPermissionMode")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT")
|
||||
.HasDefaultValue("bypassPermissions")
|
||||
.HasColumnName("default_permission_mode");
|
||||
|
||||
b.Property<int>("MaxParallelExecutions")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(1)
|
||||
.HasColumnName("max_parallel_executions");
|
||||
|
||||
b.Property<string>("ModelPresets")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("model_presets");
|
||||
|
||||
b.Property<string>("RepoImportFolders")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("repo_import_folders");
|
||||
|
||||
b.Property<string>("ReportExcludedPaths")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("report_excluded_paths");
|
||||
|
||||
b.Property<string>("SessionSkills")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("session_skills");
|
||||
|
||||
b.Property<int>("StandupWeekday")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(3)
|
||||
.HasColumnName("standup_weekday");
|
||||
|
||||
b.Property<int>("UsageGateFiveHourPct")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(80)
|
||||
.HasColumnName("usage_gate_five_hour_pct");
|
||||
|
||||
b.Property<int>("UsageGateSevenDayPct")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(90)
|
||||
.HasColumnName("usage_gate_seven_day_pct");
|
||||
|
||||
b.Property<int>("UsageThrottleHardPct")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(65)
|
||||
.HasColumnName("usage_throttle_hard_pct");
|
||||
|
||||
b.Property<int>("UsageThrottleSoftPct")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(50)
|
||||
.HasColumnName("usage_throttle_soft_pct");
|
||||
|
||||
b.Property<int>("WorktreeAutoCleanupDays")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(7)
|
||||
.HasColumnName("worktree_auto_cleanup_days");
|
||||
|
||||
b.Property<bool>("WorktreeAutoCleanupEnabled")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(false)
|
||||
.HasColumnName("worktree_auto_cleanup_enabled");
|
||||
|
||||
b.Property<string>("WorktreeStrategy")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT")
|
||||
.HasDefaultValue("sibling")
|
||||
.HasColumnName("worktree_strategy");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("app_settings", (string)null);
|
||||
|
||||
b.HasData(
|
||||
new
|
||||
{
|
||||
Id = 1,
|
||||
DailyPrepMaxTasks = 5,
|
||||
DefaultClaudeInstructions = "",
|
||||
DefaultMaxTurns = 100,
|
||||
DefaultModel = "sonnet",
|
||||
DefaultPermissionMode = "auto",
|
||||
MaxParallelExecutions = 1,
|
||||
StandupWeekday = 3,
|
||||
UsageGateFiveHourPct = 80,
|
||||
UsageGateSevenDayPct = 90,
|
||||
UsageThrottleHardPct = 65,
|
||||
UsageThrottleSoftPct = 50,
|
||||
WorktreeAutoCleanupDays = 7,
|
||||
WorktreeAutoCleanupEnabled = false,
|
||||
WorktreeStrategy = "sibling"
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.DailyNoteEntity", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<DateOnly>("Date")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("note_date");
|
||||
|
||||
b.Property<int>("SortOrder")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("sort_order");
|
||||
|
||||
b.Property<string>("Text")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("text");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Date");
|
||||
|
||||
b.ToTable("daily_notes", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.ListConfigEntity", b =>
|
||||
{
|
||||
b.Property<string>("ListId")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("list_id");
|
||||
|
||||
b.Property<string>("AgentPath")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("agent_path");
|
||||
|
||||
b.Property<int?>("MaxTurns")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("max_turns");
|
||||
|
||||
b.Property<string>("Model")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("model");
|
||||
|
||||
b.Property<string>("SessionSkills")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("session_skills");
|
||||
|
||||
b.Property<string>("SystemPrompt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("system_prompt");
|
||||
|
||||
b.Property<string>("VerifyCommand")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("verify_command");
|
||||
|
||||
b.HasKey("ListId");
|
||||
|
||||
b.ToTable("list_config", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.ListEntity", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<string>("DefaultCommitType")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT")
|
||||
.HasDefaultValue("chore")
|
||||
.HasColumnName("default_commit_type");
|
||||
|
||||
b.Property<bool>("IsManual")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(false)
|
||||
.HasColumnName("is_manual");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("name");
|
||||
|
||||
b.Property<int>("SortOrder")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(0)
|
||||
.HasColumnName("sort_order");
|
||||
|
||||
b.Property<string>("WorkingDir")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("working_dir");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("SortOrder")
|
||||
.HasDatabaseName("idx_lists_sort");
|
||||
|
||||
b.ToTable("lists", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.PrimeScheduleEntity", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<int>("Days")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(31)
|
||||
.HasColumnName("days_of_week");
|
||||
|
||||
b.Property<bool>("Enabled")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(true)
|
||||
.HasColumnName("enabled");
|
||||
|
||||
b.Property<DateTimeOffset?>("LastRunAt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("last_run_at");
|
||||
|
||||
b.Property<string>("PromptOverride")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("prompt_override");
|
||||
|
||||
b.Property<TimeSpan>("TimeOfDay")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("time_of_day");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("prime_schedules", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.SessionSkillEntity", b =>
|
||||
{
|
||||
b.Property<string>("Name")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("name");
|
||||
|
||||
b.Property<DateTimeOffset>("AddedAt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("added_at");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("description");
|
||||
|
||||
b.Property<string>("PinnedRef")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("pinned_ref");
|
||||
|
||||
b.Property<string>("SourceUrl")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("source_url");
|
||||
|
||||
b.Property<string>("Subpath")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("subpath");
|
||||
|
||||
b.HasKey("Name");
|
||||
|
||||
b.ToTable("session_skills", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.SubtaskEntity", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<bool>("Completed")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(false)
|
||||
.HasColumnName("completed");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<int>("OrderNum")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("order_num");
|
||||
|
||||
b.Property<string>("TaskId")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("task_id");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("title");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("TaskId")
|
||||
.HasDatabaseName("idx_subtasks_task_id");
|
||||
|
||||
b.ToTable("subtasks", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.TaskAttachmentEntity", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<long>("ByteSize")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("byte_size");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<string>("FileName")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("file_name");
|
||||
|
||||
b.Property<string>("TaskId")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("task_id");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("TaskId")
|
||||
.HasDatabaseName("idx_task_attachments_task_id");
|
||||
|
||||
b.ToTable("task_attachments", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<string>("AgentPath")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("agent_path");
|
||||
|
||||
b.Property<string>("BlockedByTaskId")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("blocked_by_task_id");
|
||||
|
||||
b.Property<string>("CommitType")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT")
|
||||
.HasDefaultValue("chore")
|
||||
.HasColumnName("commit_type");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<string>("CreatedBy")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("created_by");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("description");
|
||||
|
||||
b.Property<DateTime?>("FinishedAt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("finished_at");
|
||||
|
||||
b.Property<string>("HandlerBaseCommit")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("handler_base_commit");
|
||||
|
||||
b.Property<string>("HandlerHeadCommit")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("handler_head_commit");
|
||||
|
||||
b.Property<bool>("IsManual")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(false)
|
||||
.HasColumnName("is_manual");
|
||||
|
||||
b.Property<bool>("IsMyDay")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(false)
|
||||
.HasColumnName("is_my_day");
|
||||
|
||||
b.Property<bool>("IsStarred")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(false)
|
||||
.HasColumnName("is_starred");
|
||||
|
||||
b.Property<string>("ListId")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("list_id");
|
||||
|
||||
b.Property<string>("LogPath")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("log_path");
|
||||
|
||||
b.Property<int?>("MaxTurns")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("max_turns");
|
||||
|
||||
b.Property<string>("Model")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("model");
|
||||
|
||||
b.Property<string>("Notes")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("notes");
|
||||
|
||||
b.Property<string>("ParentTaskId")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("parent_task_id");
|
||||
|
||||
b.Property<DateTime?>("PlanningFinalizedAt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("planning_finalized_at");
|
||||
|
||||
b.Property<string>("PlanningPhase")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT")
|
||||
.HasDefaultValue("none")
|
||||
.HasColumnName("planning_phase");
|
||||
|
||||
b.Property<string>("PlanningSessionId")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("planning_session_id");
|
||||
|
||||
b.Property<string>("PlanningSessionToken")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("planning_session_token");
|
||||
|
||||
b.Property<string>("Result")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("result");
|
||||
|
||||
b.Property<string>("ReviewFeedback")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("review_feedback");
|
||||
|
||||
b.Property<int>("RoadblockCount")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(0)
|
||||
.HasColumnName("roadblock_count");
|
||||
|
||||
b.Property<DateTime?>("ScheduledFor")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("scheduled_for");
|
||||
|
||||
b.Property<string>("SessionSkills")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("session_skills");
|
||||
|
||||
b.Property<int>("SortOrder")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(0)
|
||||
.HasColumnName("sort_order");
|
||||
|
||||
b.Property<DateTime?>("StartedAt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("started_at");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("status");
|
||||
|
||||
b.Property<string>("SystemPrompt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("system_prompt");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("title");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("BlockedByTaskId")
|
||||
.HasDatabaseName("idx_tasks_blocked_by");
|
||||
|
||||
b.HasIndex("ListId")
|
||||
.HasDatabaseName("idx_tasks_list_id");
|
||||
|
||||
b.HasIndex("ParentTaskId")
|
||||
.HasDatabaseName("idx_tasks_parent_task_id");
|
||||
|
||||
b.HasIndex("Status")
|
||||
.HasDatabaseName("idx_tasks_status");
|
||||
|
||||
b.HasIndex("ListId", "SortOrder")
|
||||
.HasDatabaseName("idx_tasks_list_sort");
|
||||
|
||||
b.ToTable("tasks", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.TaskRunEntity", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<string>("ErrorMarkdown")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("error_markdown");
|
||||
|
||||
b.Property<int?>("ExitCode")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("exit_code");
|
||||
|
||||
b.Property<DateTime?>("FinishedAt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("finished_at");
|
||||
|
||||
b.Property<bool>("IsRetry")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(false)
|
||||
.HasColumnName("is_retry");
|
||||
|
||||
b.Property<string>("LogPath")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("log_path");
|
||||
|
||||
b.Property<string>("Model")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("model");
|
||||
|
||||
b.Property<string>("Prompt")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("prompt");
|
||||
|
||||
b.Property<string>("ResultMarkdown")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("result_markdown");
|
||||
|
||||
b.Property<int>("RunNumber")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("run_number");
|
||||
|
||||
b.Property<string>("SessionId")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("session_id");
|
||||
|
||||
b.Property<DateTime?>("StartedAt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("started_at");
|
||||
|
||||
b.Property<string>("StructuredOutputJson")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("structured_output");
|
||||
|
||||
b.Property<string>("TaskId")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("task_id");
|
||||
|
||||
b.Property<int?>("TokensIn")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("tokens_in");
|
||||
|
||||
b.Property<int?>("TokensOut")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("tokens_out");
|
||||
|
||||
b.Property<int?>("TurnCount")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("turn_count");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("TaskId")
|
||||
.HasDatabaseName("idx_task_runs_task_id");
|
||||
|
||||
b.ToTable("task_runs", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.WeekReportEntity", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<DateOnly>("EndDate")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("end_date");
|
||||
|
||||
b.Property<DateTime>("GeneratedAt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("generated_at");
|
||||
|
||||
b.Property<string>("Markdown")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("markdown");
|
||||
|
||||
b.Property<DateOnly>("StartDate")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("start_date");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("StartDate", "EndDate")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("week_reports", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.WorktreeEntity", b =>
|
||||
{
|
||||
b.Property<string>("TaskId")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("task_id");
|
||||
|
||||
b.Property<string>("BaseCommit")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("base_commit");
|
||||
|
||||
b.Property<string>("BranchName")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("branch_name");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<string>("DiffStat")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("diff_stat");
|
||||
|
||||
b.Property<string>("HeadCommit")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("head_commit");
|
||||
|
||||
b.Property<string>("MergeCommit")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("merge_commit");
|
||||
|
||||
b.Property<string>("Path")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("path");
|
||||
|
||||
b.Property<string>("State")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT")
|
||||
.HasDefaultValue("active")
|
||||
.HasColumnName("state");
|
||||
|
||||
b.HasKey("TaskId");
|
||||
|
||||
b.ToTable("worktrees", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.ListConfigEntity", b =>
|
||||
{
|
||||
b.HasOne("ClaudeDo.Data.Models.ListEntity", "List")
|
||||
.WithOne("Config")
|
||||
.HasForeignKey("ClaudeDo.Data.Models.ListConfigEntity", "ListId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("List");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.SubtaskEntity", b =>
|
||||
{
|
||||
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task")
|
||||
.WithMany("Subtasks")
|
||||
.HasForeignKey("TaskId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Task");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.TaskAttachmentEntity", b =>
|
||||
{
|
||||
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task")
|
||||
.WithMany()
|
||||
.HasForeignKey("TaskId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Task");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b =>
|
||||
{
|
||||
b.HasOne("ClaudeDo.Data.Models.TaskEntity", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("BlockedByTaskId")
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
|
||||
b.HasOne("ClaudeDo.Data.Models.ListEntity", "List")
|
||||
.WithMany("Tasks")
|
||||
.HasForeignKey("ListId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Parent")
|
||||
.WithMany("Children")
|
||||
.HasForeignKey("ParentTaskId")
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
b.Navigation("List");
|
||||
|
||||
b.Navigation("Parent");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.TaskRunEntity", b =>
|
||||
{
|
||||
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task")
|
||||
.WithMany("Runs")
|
||||
.HasForeignKey("TaskId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Task");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.WorktreeEntity", b =>
|
||||
{
|
||||
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task")
|
||||
.WithOne("Worktree")
|
||||
.HasForeignKey("ClaudeDo.Data.Models.WorktreeEntity", "TaskId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Task");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.ListEntity", b =>
|
||||
{
|
||||
b.Navigation("Config");
|
||||
|
||||
b.Navigation("Tasks");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b =>
|
||||
{
|
||||
b.Navigation("Children");
|
||||
|
||||
b.Navigation("Runs");
|
||||
|
||||
b.Navigation("Subtasks");
|
||||
|
||||
b.Navigation("Worktree");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace ClaudeDo.Data.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddUsageThrottleThresholds : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "usage_throttle_hard_pct",
|
||||
table: "app_settings",
|
||||
type: "INTEGER",
|
||||
nullable: false,
|
||||
defaultValue: 65);
|
||||
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "usage_throttle_soft_pct",
|
||||
table: "app_settings",
|
||||
type: "INTEGER",
|
||||
nullable: false,
|
||||
defaultValue: 50);
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "app_settings",
|
||||
keyColumn: "id",
|
||||
keyValue: 1,
|
||||
columns: new[] { "usage_throttle_hard_pct", "usage_throttle_soft_pct" },
|
||||
values: new object[] { 65, 50 });
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "usage_throttle_hard_pct",
|
||||
table: "app_settings");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "usage_throttle_soft_pct",
|
||||
table: "app_settings");
|
||||
}
|
||||
}
|
||||
}
|
||||
+869
@@ -0,0 +1,869 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using ClaudeDo.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace ClaudeDo.Data.Migrations
|
||||
{
|
||||
[DbContext(typeof(ClaudeDoDbContext))]
|
||||
[Migration("20260806111454_AddInteractiveSessionId")]
|
||||
partial class AddInteractiveSessionId
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder.HasAnnotation("ProductVersion", "8.0.11");
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.AppSettingsEntity", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<string>("CentralWorktreeRoot")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("central_worktree_root");
|
||||
|
||||
b.Property<int>("DailyPrepMaxTasks")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(5)
|
||||
.HasColumnName("daily_prep_max_tasks");
|
||||
|
||||
b.Property<string>("DefaultClaudeInstructions")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT")
|
||||
.HasDefaultValue("")
|
||||
.HasColumnName("default_claude_instructions");
|
||||
|
||||
b.Property<int>("DefaultMaxTurns")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(40)
|
||||
.HasColumnName("default_max_turns");
|
||||
|
||||
b.Property<string>("DefaultModel")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT")
|
||||
.HasDefaultValue("sonnet")
|
||||
.HasColumnName("default_model");
|
||||
|
||||
b.Property<string>("DefaultPermissionMode")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT")
|
||||
.HasDefaultValue("bypassPermissions")
|
||||
.HasColumnName("default_permission_mode");
|
||||
|
||||
b.Property<int>("MaxParallelExecutions")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(1)
|
||||
.HasColumnName("max_parallel_executions");
|
||||
|
||||
b.Property<int>("MaxTurnsCeiling")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(80)
|
||||
.HasColumnName("max_turns_ceiling");
|
||||
|
||||
b.Property<string>("ModelPresets")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("model_presets");
|
||||
|
||||
b.Property<string>("RepoImportFolders")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("repo_import_folders");
|
||||
|
||||
b.Property<string>("ReportExcludedPaths")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("report_excluded_paths");
|
||||
|
||||
b.Property<string>("SessionSkills")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("session_skills");
|
||||
|
||||
b.Property<int>("StandupWeekday")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(3)
|
||||
.HasColumnName("standup_weekday");
|
||||
|
||||
b.Property<int>("UsageGateFiveHourPct")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(80)
|
||||
.HasColumnName("usage_gate_five_hour_pct");
|
||||
|
||||
b.Property<int>("UsageGateSevenDayPct")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(90)
|
||||
.HasColumnName("usage_gate_seven_day_pct");
|
||||
|
||||
b.Property<int>("UsageThrottleHardPct")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(65)
|
||||
.HasColumnName("usage_throttle_hard_pct");
|
||||
|
||||
b.Property<int>("UsageThrottleSoftPct")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(50)
|
||||
.HasColumnName("usage_throttle_soft_pct");
|
||||
|
||||
b.Property<int>("WorktreeAutoCleanupDays")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(7)
|
||||
.HasColumnName("worktree_auto_cleanup_days");
|
||||
|
||||
b.Property<bool>("WorktreeAutoCleanupEnabled")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(false)
|
||||
.HasColumnName("worktree_auto_cleanup_enabled");
|
||||
|
||||
b.Property<string>("WorktreeStrategy")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT")
|
||||
.HasDefaultValue("sibling")
|
||||
.HasColumnName("worktree_strategy");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("app_settings", (string)null);
|
||||
|
||||
b.HasData(
|
||||
new
|
||||
{
|
||||
Id = 1,
|
||||
DailyPrepMaxTasks = 5,
|
||||
DefaultClaudeInstructions = "",
|
||||
DefaultMaxTurns = 40,
|
||||
DefaultModel = "sonnet",
|
||||
DefaultPermissionMode = "auto",
|
||||
MaxParallelExecutions = 1,
|
||||
MaxTurnsCeiling = 80,
|
||||
StandupWeekday = 3,
|
||||
UsageGateFiveHourPct = 80,
|
||||
UsageGateSevenDayPct = 90,
|
||||
UsageThrottleHardPct = 65,
|
||||
UsageThrottleSoftPct = 50,
|
||||
WorktreeAutoCleanupDays = 7,
|
||||
WorktreeAutoCleanupEnabled = false,
|
||||
WorktreeStrategy = "sibling"
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.DailyNoteEntity", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<DateOnly>("Date")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("note_date");
|
||||
|
||||
b.Property<int>("SortOrder")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("sort_order");
|
||||
|
||||
b.Property<string>("Text")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("text");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Date");
|
||||
|
||||
b.ToTable("daily_notes", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.ListConfigEntity", b =>
|
||||
{
|
||||
b.Property<string>("ListId")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("list_id");
|
||||
|
||||
b.Property<string>("AgentPath")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("agent_path");
|
||||
|
||||
b.Property<int?>("MaxTurns")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("max_turns");
|
||||
|
||||
b.Property<string>("Model")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("model");
|
||||
|
||||
b.Property<string>("SessionSkills")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("session_skills");
|
||||
|
||||
b.Property<string>("SystemPrompt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("system_prompt");
|
||||
|
||||
b.Property<string>("VerifyCommand")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("verify_command");
|
||||
|
||||
b.HasKey("ListId");
|
||||
|
||||
b.ToTable("list_config", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.ListEntity", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<string>("DefaultCommitType")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT")
|
||||
.HasDefaultValue("chore")
|
||||
.HasColumnName("default_commit_type");
|
||||
|
||||
b.Property<bool>("IsManual")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(false)
|
||||
.HasColumnName("is_manual");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("name");
|
||||
|
||||
b.Property<int>("SortOrder")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(0)
|
||||
.HasColumnName("sort_order");
|
||||
|
||||
b.Property<string>("WorkingDir")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("working_dir");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("SortOrder")
|
||||
.HasDatabaseName("idx_lists_sort");
|
||||
|
||||
b.ToTable("lists", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.PrimeScheduleEntity", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<int>("Days")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(31)
|
||||
.HasColumnName("days_of_week");
|
||||
|
||||
b.Property<bool>("Enabled")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(true)
|
||||
.HasColumnName("enabled");
|
||||
|
||||
b.Property<DateTimeOffset?>("LastRunAt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("last_run_at");
|
||||
|
||||
b.Property<string>("PromptOverride")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("prompt_override");
|
||||
|
||||
b.Property<TimeSpan>("TimeOfDay")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("time_of_day");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("prime_schedules", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.SessionSkillEntity", b =>
|
||||
{
|
||||
b.Property<string>("Name")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("name");
|
||||
|
||||
b.Property<DateTimeOffset>("AddedAt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("added_at");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("description");
|
||||
|
||||
b.Property<string>("PinnedRef")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("pinned_ref");
|
||||
|
||||
b.Property<string>("SourceUrl")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("source_url");
|
||||
|
||||
b.Property<string>("Subpath")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("subpath");
|
||||
|
||||
b.HasKey("Name");
|
||||
|
||||
b.ToTable("session_skills", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.SubtaskEntity", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<bool>("Completed")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(false)
|
||||
.HasColumnName("completed");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<int>("OrderNum")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("order_num");
|
||||
|
||||
b.Property<string>("TaskId")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("task_id");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("title");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("TaskId")
|
||||
.HasDatabaseName("idx_subtasks_task_id");
|
||||
|
||||
b.ToTable("subtasks", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.TaskAttachmentEntity", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<long>("ByteSize")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("byte_size");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<string>("FileName")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("file_name");
|
||||
|
||||
b.Property<string>("TaskId")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("task_id");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("TaskId")
|
||||
.HasDatabaseName("idx_task_attachments_task_id");
|
||||
|
||||
b.ToTable("task_attachments", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<string>("AgentPath")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("agent_path");
|
||||
|
||||
b.Property<string>("BlockedByTaskId")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("blocked_by_task_id");
|
||||
|
||||
b.Property<string>("CommitType")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT")
|
||||
.HasDefaultValue("chore")
|
||||
.HasColumnName("commit_type");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<string>("CreatedBy")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("created_by");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("description");
|
||||
|
||||
b.Property<DateTime?>("FinishedAt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("finished_at");
|
||||
|
||||
b.Property<string>("HandlerBaseCommit")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("handler_base_commit");
|
||||
|
||||
b.Property<string>("HandlerHeadCommit")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("handler_head_commit");
|
||||
|
||||
b.Property<string>("InteractiveSessionId")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("interactive_session_id");
|
||||
|
||||
b.Property<bool>("IsManual")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(false)
|
||||
.HasColumnName("is_manual");
|
||||
|
||||
b.Property<bool>("IsMyDay")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(false)
|
||||
.HasColumnName("is_my_day");
|
||||
|
||||
b.Property<bool>("IsStarred")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(false)
|
||||
.HasColumnName("is_starred");
|
||||
|
||||
b.Property<string>("ListId")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("list_id");
|
||||
|
||||
b.Property<string>("LogPath")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("log_path");
|
||||
|
||||
b.Property<int?>("MaxTurns")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("max_turns");
|
||||
|
||||
b.Property<string>("Model")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("model");
|
||||
|
||||
b.Property<string>("Notes")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("notes");
|
||||
|
||||
b.Property<string>("ParentTaskId")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("parent_task_id");
|
||||
|
||||
b.Property<DateTime?>("PlanningFinalizedAt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("planning_finalized_at");
|
||||
|
||||
b.Property<string>("PlanningPhase")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT")
|
||||
.HasDefaultValue("none")
|
||||
.HasColumnName("planning_phase");
|
||||
|
||||
b.Property<string>("PlanningSessionId")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("planning_session_id");
|
||||
|
||||
b.Property<string>("PlanningSessionToken")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("planning_session_token");
|
||||
|
||||
b.Property<string>("Result")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("result");
|
||||
|
||||
b.Property<string>("ReviewFeedback")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("review_feedback");
|
||||
|
||||
b.Property<int>("RoadblockCount")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(0)
|
||||
.HasColumnName("roadblock_count");
|
||||
|
||||
b.Property<DateTime?>("ScheduledFor")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("scheduled_for");
|
||||
|
||||
b.Property<string>("SessionSkills")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("session_skills");
|
||||
|
||||
b.Property<int>("SortOrder")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(0)
|
||||
.HasColumnName("sort_order");
|
||||
|
||||
b.Property<DateTime?>("StartedAt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("started_at");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("status");
|
||||
|
||||
b.Property<string>("SystemPrompt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("system_prompt");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("title");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("BlockedByTaskId")
|
||||
.HasDatabaseName("idx_tasks_blocked_by");
|
||||
|
||||
b.HasIndex("ListId")
|
||||
.HasDatabaseName("idx_tasks_list_id");
|
||||
|
||||
b.HasIndex("ParentTaskId")
|
||||
.HasDatabaseName("idx_tasks_parent_task_id");
|
||||
|
||||
b.HasIndex("Status")
|
||||
.HasDatabaseName("idx_tasks_status");
|
||||
|
||||
b.HasIndex("ListId", "SortOrder")
|
||||
.HasDatabaseName("idx_tasks_list_sort");
|
||||
|
||||
b.ToTable("tasks", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.TaskRunEntity", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<int?>("CacheReadTokens")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("cache_read_tokens");
|
||||
|
||||
b.Property<int?>("CacheWriteTokens")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("cache_write_tokens");
|
||||
|
||||
b.Property<string>("ErrorMarkdown")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("error_markdown");
|
||||
|
||||
b.Property<int?>("ExitCode")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("exit_code");
|
||||
|
||||
b.Property<DateTime?>("FinishedAt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("finished_at");
|
||||
|
||||
b.Property<bool>("IsRetry")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(false)
|
||||
.HasColumnName("is_retry");
|
||||
|
||||
b.Property<string>("LogPath")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("log_path");
|
||||
|
||||
b.Property<string>("Model")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("model");
|
||||
|
||||
b.Property<string>("Prompt")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("prompt");
|
||||
|
||||
b.Property<string>("ResultMarkdown")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("result_markdown");
|
||||
|
||||
b.Property<int>("RunNumber")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("run_number");
|
||||
|
||||
b.Property<string>("SessionId")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("session_id");
|
||||
|
||||
b.Property<DateTime?>("StartedAt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("started_at");
|
||||
|
||||
b.Property<string>("StructuredOutputJson")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("structured_output");
|
||||
|
||||
b.Property<string>("TaskId")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("task_id");
|
||||
|
||||
b.Property<int?>("TokensIn")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("tokens_in");
|
||||
|
||||
b.Property<int?>("TokensOut")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("tokens_out");
|
||||
|
||||
b.Property<int?>("TurnCount")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("turn_count");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("TaskId")
|
||||
.HasDatabaseName("idx_task_runs_task_id");
|
||||
|
||||
b.ToTable("task_runs", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.WeekReportEntity", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<DateOnly>("EndDate")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("end_date");
|
||||
|
||||
b.Property<DateTime>("GeneratedAt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("generated_at");
|
||||
|
||||
b.Property<string>("Markdown")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("markdown");
|
||||
|
||||
b.Property<DateOnly>("StartDate")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("start_date");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("StartDate", "EndDate")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("week_reports", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.WorktreeEntity", b =>
|
||||
{
|
||||
b.Property<string>("TaskId")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("task_id");
|
||||
|
||||
b.Property<string>("BaseCommit")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("base_commit");
|
||||
|
||||
b.Property<string>("BranchName")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("branch_name");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<string>("DiffStat")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("diff_stat");
|
||||
|
||||
b.Property<string>("HeadCommit")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("head_commit");
|
||||
|
||||
b.Property<string>("MergeCommit")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("merge_commit");
|
||||
|
||||
b.Property<string>("Path")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("path");
|
||||
|
||||
b.Property<string>("State")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT")
|
||||
.HasDefaultValue("active")
|
||||
.HasColumnName("state");
|
||||
|
||||
b.HasKey("TaskId");
|
||||
|
||||
b.ToTable("worktrees", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.ListConfigEntity", b =>
|
||||
{
|
||||
b.HasOne("ClaudeDo.Data.Models.ListEntity", "List")
|
||||
.WithOne("Config")
|
||||
.HasForeignKey("ClaudeDo.Data.Models.ListConfigEntity", "ListId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("List");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.SubtaskEntity", b =>
|
||||
{
|
||||
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task")
|
||||
.WithMany("Subtasks")
|
||||
.HasForeignKey("TaskId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Task");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.TaskAttachmentEntity", b =>
|
||||
{
|
||||
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task")
|
||||
.WithMany()
|
||||
.HasForeignKey("TaskId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Task");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b =>
|
||||
{
|
||||
b.HasOne("ClaudeDo.Data.Models.TaskEntity", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("BlockedByTaskId")
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
|
||||
b.HasOne("ClaudeDo.Data.Models.ListEntity", "List")
|
||||
.WithMany("Tasks")
|
||||
.HasForeignKey("ListId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Parent")
|
||||
.WithMany("Children")
|
||||
.HasForeignKey("ParentTaskId")
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
b.Navigation("List");
|
||||
|
||||
b.Navigation("Parent");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.TaskRunEntity", b =>
|
||||
{
|
||||
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task")
|
||||
.WithMany("Runs")
|
||||
.HasForeignKey("TaskId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Task");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.WorktreeEntity", b =>
|
||||
{
|
||||
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task")
|
||||
.WithOne("Worktree")
|
||||
.HasForeignKey("ClaudeDo.Data.Models.WorktreeEntity", "TaskId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Task");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.ListEntity", b =>
|
||||
{
|
||||
b.Navigation("Config");
|
||||
|
||||
b.Navigation("Tasks");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b =>
|
||||
{
|
||||
b.Navigation("Children");
|
||||
|
||||
b.Navigation("Runs");
|
||||
|
||||
b.Navigation("Subtasks");
|
||||
|
||||
b.Navigation("Worktree");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace ClaudeDo.Data.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddInteractiveSessionId : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "interactive_session_id",
|
||||
table: "tasks",
|
||||
type: "TEXT",
|
||||
nullable: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "interactive_session_id",
|
||||
table: "tasks");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -43,7 +43,7 @@ namespace ClaudeDo.Data.Migrations
|
||||
b.Property<int>("DefaultMaxTurns")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(30)
|
||||
.HasDefaultValue(40)
|
||||
.HasColumnName("default_max_turns");
|
||||
|
||||
b.Property<string>("DefaultModel")
|
||||
@@ -66,6 +66,12 @@ namespace ClaudeDo.Data.Migrations
|
||||
.HasDefaultValue(1)
|
||||
.HasColumnName("max_parallel_executions");
|
||||
|
||||
b.Property<int>("MaxTurnsCeiling")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(80)
|
||||
.HasColumnName("max_turns_ceiling");
|
||||
|
||||
b.Property<string>("ModelPresets")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("model_presets");
|
||||
@@ -100,6 +106,18 @@ namespace ClaudeDo.Data.Migrations
|
||||
.HasDefaultValue(90)
|
||||
.HasColumnName("usage_gate_seven_day_pct");
|
||||
|
||||
b.Property<int>("UsageThrottleHardPct")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(65)
|
||||
.HasColumnName("usage_throttle_hard_pct");
|
||||
|
||||
b.Property<int>("UsageThrottleSoftPct")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(50)
|
||||
.HasColumnName("usage_throttle_soft_pct");
|
||||
|
||||
b.Property<int>("WorktreeAutoCleanupDays")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
@@ -129,13 +147,16 @@ namespace ClaudeDo.Data.Migrations
|
||||
Id = 1,
|
||||
DailyPrepMaxTasks = 5,
|
||||
DefaultClaudeInstructions = "",
|
||||
DefaultMaxTurns = 100,
|
||||
DefaultMaxTurns = 40,
|
||||
DefaultModel = "sonnet",
|
||||
DefaultPermissionMode = "auto",
|
||||
MaxParallelExecutions = 1,
|
||||
MaxTurnsCeiling = 80,
|
||||
StandupWeekday = 3,
|
||||
UsageGateFiveHourPct = 80,
|
||||
UsageGateSevenDayPct = 90,
|
||||
UsageThrottleHardPct = 65,
|
||||
UsageThrottleSoftPct = 50,
|
||||
WorktreeAutoCleanupDays = 7,
|
||||
WorktreeAutoCleanupEnabled = false,
|
||||
WorktreeStrategy = "sibling"
|
||||
@@ -442,6 +463,10 @@ namespace ClaudeDo.Data.Migrations
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("handler_head_commit");
|
||||
|
||||
b.Property<string>("InteractiveSessionId")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("interactive_session_id");
|
||||
|
||||
b.Property<bool>("IsManual")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
@@ -576,6 +601,14 @@ namespace ClaudeDo.Data.Migrations
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<int?>("CacheReadTokens")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("cache_read_tokens");
|
||||
|
||||
b.Property<int?>("CacheWriteTokens")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("cache_write_tokens");
|
||||
|
||||
b.Property<string>("ErrorMarkdown")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("error_markdown");
|
||||
|
||||
@@ -8,9 +8,13 @@ public sealed class AppSettingsEntity
|
||||
|
||||
public string DefaultClaudeInstructions { get; set; } = string.Empty;
|
||||
public string DefaultModel { get; set; } = "sonnet";
|
||||
public int DefaultMaxTurns { get; set; } = 100;
|
||||
public int DefaultMaxTurns { get; set; } = 40;
|
||||
public string DefaultPermissionMode { get; set; } = "auto";
|
||||
|
||||
// Hard ceiling every resolved max-turns value (task/list/global) is clamped to before a run
|
||||
// starts. Guards against runaway sessions regardless of what a task/list override requests.
|
||||
public int MaxTurnsCeiling { get; set; } = 80;
|
||||
|
||||
public int MaxParallelExecutions { get; set; } = 1;
|
||||
|
||||
public string WorktreeStrategy { get; set; } = "sibling";
|
||||
@@ -38,4 +42,9 @@ public sealed class AppSettingsEntity
|
||||
// Percentage of the 5h/7d Claude usage window at which the autonomous queue pauses. 0 = gate off.
|
||||
public int UsageGateFiveHourPct { get; set; } = 80;
|
||||
public int UsageGateSevenDayPct { get; set; } = 90;
|
||||
|
||||
// Percentage of the 5h/7d Claude usage window at which the queue starts throttling
|
||||
// parallelism ahead of the hard gate above. 0 = that stage off.
|
||||
public int UsageThrottleSoftPct { get; set; } = 50;
|
||||
public int UsageThrottleHardPct { get; set; } = 65;
|
||||
}
|
||||
|
||||
@@ -60,6 +60,12 @@ public sealed class TaskEntity
|
||||
public string? HandlerBaseCommit { get; set; }
|
||||
public string? HandlerHeadCommit { get; set; }
|
||||
|
||||
// The claude session id an embedded ConPTY interactive task session is (or was last)
|
||||
// running under -- generated up front and persisted before launch so a closed/aborted
|
||||
// session can be resumed even if the process never got past startup. Cleared implicitly
|
||||
// whenever the task's worktree is recreated (a fresh worktree has nothing to resume into).
|
||||
public string? InteractiveSessionId { get; set; }
|
||||
|
||||
public string? ParentTaskId { get; set; }
|
||||
public string? PlanningSessionId { get; set; }
|
||||
public string? PlanningSessionToken { get; set; }
|
||||
|
||||
@@ -15,6 +15,8 @@ public sealed class TaskRunEntity
|
||||
public int? TurnCount { get; set; }
|
||||
public int? TokensIn { get; set; }
|
||||
public int? TokensOut { get; set; }
|
||||
public int? CacheReadTokens { get; set; }
|
||||
public int? CacheWriteTokens { get; set; }
|
||||
public string? LogPath { get; set; }
|
||||
public DateTime? StartedAt { get; set; }
|
||||
public DateTime? FinishedAt { get; set; }
|
||||
|
||||
@@ -11,16 +11,16 @@ public static class Paths
|
||||
if (string.IsNullOrWhiteSpace(path))
|
||||
throw new ArgumentException("Path must not be empty.", nameof(path));
|
||||
|
||||
var expanded = Environment.ExpandEnvironmentVariables(path);
|
||||
var expanded = System.Environment.ExpandEnvironmentVariables(path);
|
||||
|
||||
if (expanded.StartsWith("~", StringComparison.Ordinal))
|
||||
{
|
||||
var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
|
||||
var home = System.Environment.GetFolderPath(System.Environment.SpecialFolder.UserProfile);
|
||||
expanded = home + expanded[1..];
|
||||
}
|
||||
|
||||
if (!Path.IsPathRooted(expanded))
|
||||
expanded = Path.GetFullPath(expanded, baseDir ?? Environment.CurrentDirectory);
|
||||
expanded = Path.GetFullPath(expanded, baseDir ?? System.Environment.CurrentDirectory);
|
||||
|
||||
return Path.GetFullPath(expanded);
|
||||
}
|
||||
|
||||
@@ -1,39 +1,185 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace ClaudeDo.Data;
|
||||
|
||||
public enum PromptKind { System, Planning, PlanningInitial, Retry, DailyPrep, WeeklyReport, ImprovementChild, Refine, MergeHelper, MergeHelperInitial }
|
||||
public enum PromptKind { System, Planning, PlanningInitial, Retry, DailyPrep, WeeklyReport, ImprovementChild, Refine, MergeHelper, MergeHelperInitial, MergeHelperHandoff }
|
||||
|
||||
/// <summary>
|
||||
/// How a prompt kind's on-disk override (if any) relates to the bundled default.
|
||||
/// </summary>
|
||||
public enum PromptFileState
|
||||
{
|
||||
/// <summary>No override file — the bundled default is in effect.</summary>
|
||||
Missing,
|
||||
/// <summary>File exists and is byte-for-byte (normalized) the current default.</summary>
|
||||
MatchesCurrentDefault,
|
||||
/// <summary>File exists, differs from today's default, but was recorded as an unedited copy of a past default — safe to reconcile away.</summary>
|
||||
MatchesKnownPastDefault,
|
||||
/// <summary>File exists and diverges from the default with no matching recorded hash — a real user customization.</summary>
|
||||
Edited
|
||||
}
|
||||
|
||||
public static class PromptFiles
|
||||
{
|
||||
public static string Root => Path.Combine(Paths.AppDataRoot(), "prompts");
|
||||
|
||||
public static string PathFor(PromptKind kind) => kind switch
|
||||
public static string PathFor(PromptKind kind, string? root = null) =>
|
||||
Path.Combine(root ?? Root, FileNameFor(kind));
|
||||
|
||||
private static string FileNameFor(PromptKind kind) => kind switch
|
||||
{
|
||||
PromptKind.System => Path.Combine(Root, "system.md"),
|
||||
PromptKind.Planning => Path.Combine(Root, "planning-system.md"),
|
||||
PromptKind.PlanningInitial => Path.Combine(Root, "planning-initial.md"),
|
||||
PromptKind.Retry => Path.Combine(Root, "retry.md"),
|
||||
PromptKind.DailyPrep => Path.Combine(Root, "daily-prep.md"),
|
||||
PromptKind.WeeklyReport => Path.Combine(Root, "weekly-report.md"),
|
||||
PromptKind.ImprovementChild => Path.Combine(Root, "improvement-child.md"),
|
||||
PromptKind.Refine => Path.Combine(Root, "refine.md"),
|
||||
PromptKind.MergeHelper => Path.Combine(Root, "merge-helper-system.md"),
|
||||
PromptKind.MergeHelperInitial => Path.Combine(Root, "merge-helper-initial.md"),
|
||||
PromptKind.System => "system.md",
|
||||
PromptKind.Planning => "planning-system.md",
|
||||
PromptKind.PlanningInitial => "planning-initial.md",
|
||||
PromptKind.Retry => "retry.md",
|
||||
PromptKind.DailyPrep => "daily-prep.md",
|
||||
PromptKind.WeeklyReport => "weekly-report.md",
|
||||
PromptKind.ImprovementChild => "improvement-child.md",
|
||||
PromptKind.Refine => "refine.md",
|
||||
PromptKind.MergeHelper => "merge-helper-system.md",
|
||||
PromptKind.MergeHelperInitial => "merge-helper-initial.md",
|
||||
PromptKind.MergeHelperHandoff => "merge-helper-handoff.md",
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(kind))
|
||||
};
|
||||
|
||||
public static void EnsureExists(PromptKind kind)
|
||||
/// <summary>Classify an override file against the bundled default and the recorded default-hash log.</summary>
|
||||
public static PromptFileState Classify(PromptKind kind, string? root = null)
|
||||
{
|
||||
Directory.CreateDirectory(Root);
|
||||
var path = PathFor(kind);
|
||||
if (File.Exists(path)) return;
|
||||
File.WriteAllText(path, DefaultFor(kind));
|
||||
var effectiveRoot = root ?? Root;
|
||||
var path = PathFor(kind, effectiveRoot);
|
||||
if (!File.Exists(path)) return PromptFileState.Missing;
|
||||
|
||||
var normalized = Normalize(File.ReadAllText(path));
|
||||
if (normalized == Normalize(DefaultFor(kind))) return PromptFileState.MatchesCurrentDefault;
|
||||
|
||||
var hashes = LoadDefaultHashes(effectiveRoot);
|
||||
if (hashes.TryGetValue(kind.ToString(), out var recorded) && recorded == HashOf(normalized))
|
||||
return PromptFileState.MatchesKnownPastDefault;
|
||||
|
||||
return PromptFileState.Edited;
|
||||
}
|
||||
|
||||
public static string? ReadOrNull(PromptKind kind)
|
||||
/// <summary>Write an explicit override. If the content matches today's default, its hash is recorded so a
|
||||
/// later default change can reconcile it away automatically instead of freezing it forever.</summary>
|
||||
public static void Save(PromptKind kind, string content, string? root = null)
|
||||
{
|
||||
var path = PathFor(kind);
|
||||
var effectiveRoot = root ?? Root;
|
||||
Directory.CreateDirectory(effectiveRoot);
|
||||
File.WriteAllText(PathFor(kind, effectiveRoot), content);
|
||||
|
||||
var normalized = Normalize(content);
|
||||
var hashes = LoadDefaultHashes(effectiveRoot);
|
||||
if (normalized == Normalize(DefaultFor(kind)))
|
||||
hashes[kind.ToString()] = HashOf(normalized);
|
||||
else
|
||||
hashes.Remove(kind.ToString());
|
||||
SaveDefaultHashes(effectiveRoot, hashes);
|
||||
}
|
||||
|
||||
/// <summary>Delete the override file (if any) so the bundled default takes effect again.</summary>
|
||||
public static void ResetToDefault(PromptKind kind, string? root = null)
|
||||
{
|
||||
var effectiveRoot = root ?? Root;
|
||||
var path = PathFor(kind, effectiveRoot);
|
||||
if (File.Exists(path)) File.Delete(path);
|
||||
|
||||
var hashes = LoadDefaultHashes(effectiveRoot);
|
||||
if (hashes.Remove(kind.ToString())) SaveDefaultHashes(effectiveRoot, hashes);
|
||||
}
|
||||
|
||||
/// <summary>Startup sweep: any override file that only ever matched a past (now superseded) default,
|
||||
/// and was never actually edited, is dropped so the current default reaches real runs again.</summary>
|
||||
public static void ReconcileStaleDefaults(string? root = null)
|
||||
{
|
||||
var effectiveRoot = root ?? Root;
|
||||
foreach (var kind in Enum.GetValues<PromptKind>())
|
||||
if (Classify(kind, effectiveRoot) == PromptFileState.MatchesKnownPastDefault)
|
||||
ResetToDefault(kind, effectiveRoot);
|
||||
}
|
||||
|
||||
/// <summary>Startup sweep: moves any *.md file under the prompts root that doesn't match a known
|
||||
/// <see cref="PromptKind"/> path (leftovers from a retired naming scheme) into a "_orphans" subfolder.
|
||||
/// Never deletes them outright. Returns the destination paths of files it moved.</summary>
|
||||
public static IReadOnlyList<string> QuarantineOrphans(string? root = null)
|
||||
{
|
||||
var effectiveRoot = root ?? Root;
|
||||
if (!Directory.Exists(effectiveRoot)) return Array.Empty<string>();
|
||||
|
||||
var known = Enum.GetValues<PromptKind>()
|
||||
.Select(k => PathFor(k, effectiveRoot))
|
||||
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
var moved = new List<string>();
|
||||
foreach (var file in Directory.EnumerateFiles(effectiveRoot, "*.md", SearchOption.TopDirectoryOnly))
|
||||
{
|
||||
if (known.Contains(file)) continue;
|
||||
|
||||
var orphansDir = Path.Combine(effectiveRoot, "_orphans");
|
||||
Directory.CreateDirectory(orphansDir);
|
||||
var dest = Path.Combine(orphansDir, Path.GetFileName(file));
|
||||
if (File.Exists(dest))
|
||||
dest = Path.Combine(orphansDir,
|
||||
$"{Path.GetFileNameWithoutExtension(file)}-{HashOf(file)[..8]}{Path.GetExtension(file)}");
|
||||
File.Move(file, dest);
|
||||
moved.Add(dest);
|
||||
}
|
||||
return moved;
|
||||
}
|
||||
|
||||
/// <summary>Compact, non-LCS diff (common prefix/suffix trimmed, differing middle shown +/-) between the
|
||||
/// bundled default and the on-disk override, for surfacing a customization in the Files settings tab.</summary>
|
||||
public static string DiffAgainstDefault(PromptKind kind, string? root = null)
|
||||
{
|
||||
var effectiveRoot = root ?? Root;
|
||||
var defaultLines = Normalize(DefaultFor(kind)).Split('\n');
|
||||
var fileLines = Normalize(ReadOrNull(kind, effectiveRoot) ?? "").Split('\n');
|
||||
|
||||
var prefix = 0;
|
||||
while (prefix < defaultLines.Length && prefix < fileLines.Length && defaultLines[prefix] == fileLines[prefix])
|
||||
prefix++;
|
||||
var suffix = 0;
|
||||
while (suffix < defaultLines.Length - prefix && suffix < fileLines.Length - prefix &&
|
||||
defaultLines[^(suffix + 1)] == fileLines[^(suffix + 1)])
|
||||
suffix++;
|
||||
|
||||
var sb = new StringBuilder();
|
||||
for (var i = prefix; i < defaultLines.Length - suffix; i++) sb.AppendLine("- " + defaultLines[i]);
|
||||
for (var i = prefix; i < fileLines.Length - suffix; i++) sb.AppendLine("+ " + fileLines[i]);
|
||||
return sb.ToString().TrimEnd();
|
||||
}
|
||||
|
||||
internal static string Normalize(string s) => s.Replace("\r\n", "\n").Trim();
|
||||
|
||||
internal static string HashOf(string content) =>
|
||||
Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(content)));
|
||||
|
||||
private static string DefaultsHashPath(string root) => Path.Combine(root, ".defaults.json");
|
||||
|
||||
private static Dictionary<string, string> LoadDefaultHashes(string root)
|
||||
{
|
||||
var path = DefaultsHashPath(root);
|
||||
if (!File.Exists(path)) return new();
|
||||
try
|
||||
{
|
||||
return JsonSerializer.Deserialize<Dictionary<string, string>>(File.ReadAllText(path)) ?? new();
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return new();
|
||||
}
|
||||
}
|
||||
|
||||
private static void SaveDefaultHashes(string root, Dictionary<string, string> hashes)
|
||||
{
|
||||
Directory.CreateDirectory(root);
|
||||
File.WriteAllText(DefaultsHashPath(root), JsonSerializer.Serialize(hashes));
|
||||
}
|
||||
|
||||
public static string? ReadOrNull(PromptKind kind, string? root = null)
|
||||
{
|
||||
var path = PathFor(kind, root ?? Root);
|
||||
if (!File.Exists(path)) return null;
|
||||
var content = File.ReadAllText(path).Trim();
|
||||
return string.IsNullOrEmpty(content) ? null : content;
|
||||
@@ -67,6 +213,7 @@ public static class PromptFiles
|
||||
PromptKind.Refine => RefineDefault,
|
||||
PromptKind.MergeHelper => MergeHelperDefault,
|
||||
PromptKind.MergeHelperInitial => MergeHelperInitialDefault,
|
||||
PromptKind.MergeHelperHandoff => MergeHelperHandoffDefault,
|
||||
_ => ""
|
||||
};
|
||||
|
||||
@@ -98,10 +245,30 @@ public static class PromptFiles
|
||||
just restate the code.
|
||||
- Validate only at real boundaries (user input, external APIs).
|
||||
|
||||
## Reading efficiently
|
||||
- Locate before reading: use Grep/Glob to find the relevant spot instead of
|
||||
opening files to look around.
|
||||
- Read narrowly: pass `offset`/`limit` for the relevant section. Read a whole file
|
||||
only when you already know you need all of it — for files over ~400 lines that's
|
||||
rarely the case.
|
||||
- Don't re-read: check whether the content is already in context before reading it
|
||||
again.
|
||||
- For orientation questions ("where is X", "how does Y work", "which files touch
|
||||
Z"), dispatch an exploration subagent instead of reading broadly yourself — the
|
||||
file dump stays in its context, only the summary comes back. Skip the subagent
|
||||
for a single targeted read or edit; the overhead isn't worth it there.
|
||||
|
||||
## Finishing
|
||||
- Before claiming done, verify: run the build and relevant tests, confirm they
|
||||
pass, and report what you ran. If you couldn't verify something, say so plainly.
|
||||
- Make focused commits using the repository's existing commit-message convention.
|
||||
You are on this task's own branch in its own worktree — a commit here is the
|
||||
deliverable, not an unrequested auto-commit; a rule against auto-committing
|
||||
protects `main` and shared checkouts, neither of which is this. Still never
|
||||
push, never commit on `main`, and never `git add -A` or a bare `git commit`
|
||||
in a checkout other sessions share.
|
||||
- Report the real outcome, including the commit SHA if you made one. Don't say
|
||||
no commit was made when there was.
|
||||
|
||||
## Safety
|
||||
- Never force-push, hard-reset, or delete branches/files beyond the task's scope
|
||||
@@ -140,7 +307,10 @@ public static class PromptFiles
|
||||
- Touch as few files as possible. Do not restructure unrelated code.
|
||||
- Do NOT file further improvements — improvements are one layer deep.
|
||||
- Verify the build and relevant tests before finishing, and report what you ran.
|
||||
- Make one focused commit using the repository's commit-message convention.
|
||||
- Make one focused commit in this task's own worktree using the repository's
|
||||
commit-message convention — that commit is the deliverable, not an unrequested auto-commit.
|
||||
Report the real outcome, including the commit SHA; don't say no commit was
|
||||
made when there was.
|
||||
""";
|
||||
|
||||
private const string PlanningSystemDefault = """
|
||||
@@ -267,6 +437,9 @@ public static class PromptFiles
|
||||
|
||||
If a task visibly bundles several independent features, or has a blocker that is not resolved by anything in its own description, do not force it into one description. Propose splitting it to the user; if they agree, create the pieces with add_task/add_subtask and only move the pieces the user confirmed into "surviving tasks" for the phases below. Split only what the task already asks for — the "do not invent requirements" rule still applies.
|
||||
|
||||
## Handoff
|
||||
Once every surviving task is enhanced, call handoff_list_handler with this session's task id and the surviving task ids, in the order you intend to run them. That opens a fresh session to carry out phases 3–5 with just that list, without dragging along this session's dedupe/rewrite context. Say a short goodbye line, then stop — do not continue into phase 3 yourself.
|
||||
|
||||
## Phase 3 — Run
|
||||
Do NOT use run_task_now for a batch — there is a single override slot and the second call fails with "override slot busy".
|
||||
|
||||
@@ -327,6 +500,21 @@ public static class PromptFiles
|
||||
When every task is handled, print the summary.
|
||||
""";
|
||||
|
||||
private const string MergeHelperHandoffDefault = """
|
||||
# List handler handoff
|
||||
|
||||
Scope: {scope}
|
||||
Repo: {repo}
|
||||
|
||||
A prior session already read, deduped and enhanced this list's tasks. Pick up at phase 3
|
||||
for the tasks below — their descriptions are already sharpened, so don't redo phases 0–2.
|
||||
|
||||
{tasks}
|
||||
|
||||
Start with phase 3 (run), continuing through review/merge and the summary as your
|
||||
instructions describe.
|
||||
""";
|
||||
|
||||
private const string WeeklyReportDefault = """
|
||||
You are generating a concise weekly standup report for a software developer,
|
||||
covering {start} to {end}.
|
||||
|
||||
@@ -14,25 +14,45 @@ public sealed class AppSettingsRepository
|
||||
{
|
||||
var row = await _context.AppSettings.AsNoTracking()
|
||||
.FirstOrDefaultAsync(s => s.Id == AppSettingsEntity.SingletonId, ct);
|
||||
if (row is not null) return row;
|
||||
if (row is null)
|
||||
{
|
||||
row = new AppSettingsEntity { Id = AppSettingsEntity.SingletonId, ModelPresets = ModelPresets.SerializeDefaults() };
|
||||
_context.AppSettings.Add(row);
|
||||
try
|
||||
{
|
||||
await _context.SaveChangesAsync(ct);
|
||||
_context.Entry(row).State = EntityState.Detached;
|
||||
}
|
||||
catch (DbUpdateException)
|
||||
{
|
||||
// Concurrent process already inserted the singleton — discard our attempt and re-read.
|
||||
_context.Entry(row).State = EntityState.Detached;
|
||||
row = await _context.AppSettings.AsNoTracking()
|
||||
.FirstAsync(s => s.Id == AppSettingsEntity.SingletonId, ct);
|
||||
}
|
||||
return row;
|
||||
}
|
||||
|
||||
// First read after upgrading from a null model_presets column: persist the shipping
|
||||
// defaults so the Settings UI shows real, editable rows instead of a code-only fallback.
|
||||
if (row.ModelPresets is null)
|
||||
row.ModelPresets = await BackfillModelPresetsAsync(ct);
|
||||
|
||||
row = new AppSettingsEntity { Id = AppSettingsEntity.SingletonId };
|
||||
_context.AppSettings.Add(row);
|
||||
try
|
||||
{
|
||||
await _context.SaveChangesAsync(ct);
|
||||
_context.Entry(row).State = EntityState.Detached;
|
||||
}
|
||||
catch (DbUpdateException)
|
||||
{
|
||||
// Concurrent process already inserted the singleton — discard our attempt and re-read.
|
||||
_context.Entry(row).State = EntityState.Detached;
|
||||
row = await _context.AppSettings.AsNoTracking()
|
||||
.FirstAsync(s => s.Id == AppSettingsEntity.SingletonId, ct);
|
||||
}
|
||||
return row;
|
||||
}
|
||||
|
||||
private async Task<string> BackfillModelPresetsAsync(CancellationToken ct)
|
||||
{
|
||||
var defaults = ModelPresets.SerializeDefaults();
|
||||
var tracked = await GetOrCreateTrackedRowAsync(ct);
|
||||
if (tracked.ModelPresets is null)
|
||||
{
|
||||
tracked.ModelPresets = defaults;
|
||||
await _context.SaveChangesAsync(ct);
|
||||
}
|
||||
return defaults;
|
||||
}
|
||||
|
||||
private async Task<AppSettingsEntity> GetOrCreateTrackedRowAsync(CancellationToken ct)
|
||||
{
|
||||
var row = await _context.AppSettings
|
||||
@@ -52,6 +72,7 @@ public sealed class AppSettingsRepository
|
||||
row.DefaultClaudeInstructions = updated.DefaultClaudeInstructions ?? string.Empty;
|
||||
row.DefaultModel = string.IsNullOrWhiteSpace(updated.DefaultModel) ? "sonnet" : updated.DefaultModel;
|
||||
row.DefaultMaxTurns = updated.DefaultMaxTurns;
|
||||
row.MaxTurnsCeiling = updated.MaxTurnsCeiling < 1 ? 1 : updated.MaxTurnsCeiling;
|
||||
row.DefaultPermissionMode = string.IsNullOrWhiteSpace(updated.DefaultPermissionMode)
|
||||
? "auto" : updated.DefaultPermissionMode;
|
||||
row.MaxParallelExecutions = updated.MaxParallelExecutions < 1 ? 1 : updated.MaxParallelExecutions;
|
||||
@@ -67,6 +88,8 @@ public sealed class AppSettingsRepository
|
||||
row.SessionSkills = string.IsNullOrWhiteSpace(updated.SessionSkills) ? null : updated.SessionSkills;
|
||||
row.UsageGateFiveHourPct = Math.Clamp(updated.UsageGateFiveHourPct, 0, 100);
|
||||
row.UsageGateSevenDayPct = Math.Clamp(updated.UsageGateSevenDayPct, 0, 100);
|
||||
row.UsageThrottleSoftPct = Math.Clamp(updated.UsageThrottleSoftPct, 0, 100);
|
||||
row.UsageThrottleHardPct = Math.Clamp(updated.UsageThrottleHardPct, 0, 100);
|
||||
|
||||
await _context.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
@@ -402,6 +402,19 @@ public sealed class TaskRepository
|
||||
.SetProperty(t => t.HandlerHeadCommit, headCommit), ct);
|
||||
}
|
||||
|
||||
// Persists the claude session id a fresh embedded ConPTY interactive task session will run
|
||||
// under, written BEFORE launch so a closed/aborted session still leaves a resumable id.
|
||||
public async Task SetInteractiveSessionIdAsync(
|
||||
string taskId,
|
||||
string? sessionId,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
await _context.Tasks
|
||||
.Where(t => t.Id == taskId)
|
||||
.ExecuteUpdateAsync(s => s
|
||||
.SetProperty(t => t.InteractiveSessionId, sessionId), ct);
|
||||
}
|
||||
|
||||
public async Task<TaskEntity?> FindByPlanningTokenAsync(
|
||||
string token,
|
||||
CancellationToken ct = default)
|
||||
|
||||
@@ -4,13 +4,18 @@ using System.Linq;
|
||||
using System.Net.Http;
|
||||
using System.Reflection;
|
||||
using System.Windows;
|
||||
using ClaudeDo.Installer.Checks;
|
||||
using ClaudeDo.Installer.Checks.Interfaces;
|
||||
using ClaudeDo.Installer.Core;
|
||||
using ClaudeDo.Installer.Core.Interfaces;
|
||||
using ClaudeDo.Installer.Localization;
|
||||
using ClaudeDo.Localization;
|
||||
using ClaudeDo.Releases;
|
||||
using ClaudeDo.Installer.Pages.DiagnosePage;
|
||||
using ClaudeDo.Installer.Pages.InstallPage;
|
||||
using ClaudeDo.Installer.Pages.PathsPage;
|
||||
using ClaudeDo.Installer.Pages.ServicePage;
|
||||
using ClaudeDo.Installer.Pages.SystemCheckPage;
|
||||
using ClaudeDo.Installer.Pages.UiSettingsPage;
|
||||
using ClaudeDo.Installer.Pages.WelcomePage;
|
||||
using ClaudeDo.Installer.Steps;
|
||||
@@ -121,12 +126,40 @@ public partial class App : Application
|
||||
sc.AddSingleton<IReleaseClient>(sp => new ReleaseClient(sp.GetRequiredService<HttpClient>()));
|
||||
sc.AddSingleton<InstallModeDetector>();
|
||||
|
||||
// Environment checks — stateless, so their infrastructure is shared; ClaudeCliLookup is
|
||||
// rebuilt per EnvironmentCheckService instance so a re-check doesn't reuse a stale result.
|
||||
sc.AddSingleton<IProcessRunner, ProcessRunnerAdapter>();
|
||||
sc.AddSingleton<IProcessLauncher, ProcessLauncher>();
|
||||
sc.AddSingleton<IPortOwnerResolver, NetstatPortOwnerResolver>();
|
||||
sc.AddSingleton<ClaudeHelpLauncher>();
|
||||
sc.AddTransient<Func<EnvironmentCheckService>>(sp => () =>
|
||||
{
|
||||
var processRunner = sp.GetRequiredService<IProcessRunner>();
|
||||
var claudeLookup = new ClaudeCliLookup(processRunner);
|
||||
return new EnvironmentCheckService(new IEnvironmentCheck[]
|
||||
{
|
||||
new GitCheck(processRunner),
|
||||
new GitIdentityCheck(processRunner),
|
||||
new PortCheck(sp.GetRequiredService<IPortOwnerResolver>()),
|
||||
new WriteAccessCheck(),
|
||||
new ClaudeCliCheck(claudeLookup),
|
||||
new ClaudeVersionCheck(claudeLookup),
|
||||
new ClaudeAuthCheck(claudeLookup),
|
||||
new PermissionModeAutoCheck(claudeLookup),
|
||||
});
|
||||
});
|
||||
|
||||
// Pages
|
||||
sc.AddSingleton<IInstallerPage, WelcomePageViewModel>();
|
||||
sc.AddSingleton<IInstallerPage, SystemCheckPageViewModel>();
|
||||
sc.AddSingleton<IInstallerPage, PathsPageViewModel>();
|
||||
sc.AddSingleton<IInstallerPage, ServicePageViewModel>();
|
||||
sc.AddSingleton<IInstallerPage, UiSettingsPageViewModel>();
|
||||
sc.AddSingleton<IInstallerPage, InstallPageViewModel>();
|
||||
sc.AddSingleton<IInstallerPage>(sp => new DiagnosePageViewModel(
|
||||
sp.GetRequiredService<InstallContext>(),
|
||||
sp.GetRequiredService<Func<EnvironmentCheckService>>(),
|
||||
InstallerWorkerConfig.Load));
|
||||
|
||||
// Steps — execution order matters for the FreshInstall pipeline (IEnumerable<IInstallStep>).
|
||||
// Double-registered as both IInstallStep and concrete type so the Update pipeline
|
||||
|
||||
@@ -21,20 +21,18 @@ Note: this is the one project where `System.Windows` is correct (WPF, not Avalon
|
||||
3. Open `WizardWindow` (FreshInstall / Update) or `SettingsWindow` (Config)
|
||||
|
||||
The installer does **not** self-update. Each release ships a stable-named
|
||||
`ClaudeDo.Installer.exe` asset (permanent URL
|
||||
`…/releases/latest/download/ClaudeDo.Installer.exe`); the installer never checks for or
|
||||
replaces itself on launch. The in-app "Update" button relaunches the on-disk installer to
|
||||
run the app update — the installer binary itself only changes when the user downloads a
|
||||
fresh copy. App-update detection is unaffected: `WriteInstallManifestStep` records
|
||||
`ctx.InstalledVersion` (the release tag from `DownloadAndExtractStep`), which
|
||||
`InstallModeDetector` compares against the latest tag.
|
||||
`ClaudeDo.Installer.exe` asset (permanent URL `…/releases/latest/download/ClaudeDo.Installer.exe`);
|
||||
the binary only changes when the user downloads a fresh copy. The in-app "Update" button relaunches
|
||||
the on-disk installer to run the *app* update. App-update detection is unaffected:
|
||||
`WriteInstallManifestStep` records `ctx.InstalledVersion` (the release tag from
|
||||
`DownloadAndExtractStep`), which `InstallModeDetector` compares against the latest tag.
|
||||
|
||||
## Modes (`Core/InstallerMode.cs`)
|
||||
|
||||
| Mode | Condition | Window |
|
||||
|---|---|---|
|
||||
| `FreshInstall` | No `install.json` | Full wizard (all pages) |
|
||||
| `Update` | `install.json` present + newer release available | Wizard — Welcome + Install pages only |
|
||||
| `FreshInstall` | No `install.json` | Full wizard: Welcome → **SystemCheck** → Paths → Service → UiSettings → Install |
|
||||
| `Update` | `install.json` present + newer release available | Wizard — Welcome + Install pages only (SystemCheck **not** shown) |
|
||||
| `Config` | Current version, or Gitea API unreachable | `SettingsWindow` (settings / repair / uninstall) |
|
||||
|
||||
## Install Pipelines
|
||||
@@ -62,7 +60,8 @@ Installer/
|
||||
ConfigModels, InstallerService, UninstallRunner, PageResolver,
|
||||
AutostartShortcut, ShortcutFactory, ProcessRunner, DarkTitleBar
|
||||
Interfaces/ — IInstallStep + StepResult/StepStatus/StepProgress, IInstallerPage
|
||||
Pages/ — WelcomePage, PathsPage, ServicePage, UiSettingsPage, InstallPage
|
||||
Checks/ — environment preflight checks, see "Environment Checks" below
|
||||
Pages/ — WelcomePage, SystemCheckPage, PathsPage, ServicePage, UiSettingsPage, InstallPage
|
||||
(each: ViewModel + View.xaml)
|
||||
Views/ — WizardWindow(+WizardViewModel), SettingsWindow(+SettingsViewModel)
|
||||
```
|
||||
@@ -76,13 +75,18 @@ claude mcp add --transport http --scope user claudedo http://127.0.0.1:{External
|
||||
```
|
||||
Non-fatal if `claude` CLI is missing or too old (prints the manual command). Server name: `claudedo`.
|
||||
|
||||
**`RegisterAutostartStep`** — creates a per-user Startup-folder shortcut `ClaudeDo Worker.lnk` (`Environment.SpecialFolder.Startup`). Also migrates away from legacy mechanisms:
|
||||
**`RegisterAutostartStep`** — creates a per-user Startup-folder shortcut `ClaudeDo Worker.lnk` (`Environment.SpecialFolder.Startup`); `AutostartShortcut.Install` skips the rewrite (and reports it) when the shortcut already points at the current worker exe, so update/repair runs don't touch it needlessly. Also migrates away from legacy mechanisms, unconditionally on every run (no cached "already migrated" flag — see the comment in the step):
|
||||
- Deletes legacy Windows service: `sc.exe stop/delete ClaudeDoWorker`
|
||||
- Deletes legacy scheduled task: `schtasks /Delete /TN ClaudeDoWorker`
|
||||
|
||||
No new service or scheduled task is created. Rationale: the worker must run in the user's interactive session so Claude CLI auth works.
|
||||
|
||||
**`DownloadAndExtractStep`** — fetches `checksums.txt` first and only touches the install dir after the zip verifies. The zip is cached in `%TEMP%\ClaudeDo-download-cache` (ctor takes an override for tests) and reused on a retry when its SHA-256 still matches, so a failed attempt doesn't cost another full download; it is dropped after a successful install, a bad download is deleted immediately, and zips of other versions are pruned. `app\`/`worker\` are stashed to `*.bak` before extraction and restored if extraction fails.
|
||||
**`DownloadAndExtractStep`** — fetches `checksums.txt` first and only touches the install dir after
|
||||
the zip verifies. The zip is cached in `%TEMP%\ClaudeDo-download-cache` (ctor takes an override for
|
||||
tests) and reused on a retry when its SHA-256 still matches, so a failed attempt doesn't cost
|
||||
another full download. Cache is dropped after a successful install, a bad download is deleted
|
||||
immediately, other versions are pruned. `app\`/`worker\` are stashed to `*.bak` before extraction
|
||||
and restored if it fails.
|
||||
|
||||
### Gotcha: the installer must never run from inside the install dir
|
||||
|
||||
@@ -121,3 +125,63 @@ No new service or scheduled task is created. Rationale: the worker must run in t
|
||||
| `%APPDATA%\Microsoft\Windows\Start Menu\Programs\Startup\ClaudeDo Worker.lnk` | Worker autostart |
|
||||
|
||||
The Apps & Features uninstall string and "Rerun Installer" both point at `<InstallDir>\uninstaller\ClaudeDo.Installer.exe` with no `/uninstall` flag — Config mode is detected from `install.json`.
|
||||
|
||||
## Environment Checks
|
||||
|
||||
`Checks/` holds one `IEnvironmentCheck` per concern, run in parallel by `EnvironmentCheckService.RunAllAsync`:
|
||||
|
||||
| Check | Severity | What it verifies |
|
||||
|---|---|---|
|
||||
| `GitCheck` | Error | `git` resolvable (via `ExecutableResolver`) and runs |
|
||||
| `WriteAccessCheck` | Error | install dir + `%APPDATA%` (or first existing parent) are writable |
|
||||
| `ClaudeCliCheck` | Error | `claude` resolvable on PATH, including npm `.cmd`/`.bat`/`.ps1` shims |
|
||||
| `ClaudeVersionCheck` | Error | resolved `claude --version` ≥ `ClaudeVersionCheck.MinimumVersion` (currently `2.1.220`) |
|
||||
| `ClaudeAuthCheck` | Error | `claude auth status --json` reports `loggedIn: true` (never sends a prompt) |
|
||||
| `GitIdentityCheck` | Warning | `git config user.name`/`user.email` are set |
|
||||
| `PortCheck` | Warning | `SignalRPort`/`ExternalMcpPort` are free, or already owned by a running `ClaudeDo.Worker` |
|
||||
| `PermissionModeAutoCheck` | Warning | CLI's `--help` still lists `auto` as a `--permission-mode` choice |
|
||||
|
||||
Each check returns a `CheckResult` with `CheckStatus` (`Ok` / `Failed` / `Unknown`). A check that
|
||||
throws is caught by `EnvironmentCheckService` and turned into `Unknown`, never a crash.
|
||||
|
||||
**Gating rule:** `EnvironmentCheckReport.HasBlockingError` is true only when a check with
|
||||
`Severity == Error` has `Status == Failed`. Warnings never block, and `Unknown` never blocks
|
||||
regardless of severity (an indeterminate result — e.g. the CLI not found, so version/auth/auto-mode
|
||||
can't be checked — must not strand the user; the underlying `Error`-severity check for the CLI
|
||||
itself, `ClaudeCliCheck`, is what blocks in that case).
|
||||
|
||||
The check-row rendering and the check-run logic (busy state, summary text, Recheck command) live
|
||||
in one place — `Checks/CheckListViewModel.cs` + `Checks/CheckListView.xaml` — composed by every
|
||||
page that hosts a check list, not duplicated per page.
|
||||
|
||||
`SystemCheckPage` (`Pages/SystemCheckPage/`) hosts the check list in the **FreshInstall** wizard
|
||||
only, registered via `PageResolver` at `Order = 1` (directly after `WelcomePage`); `WizardViewModel`
|
||||
filters it back out in `Update` mode along with Paths/Service/UiSettings. Checks run automatically
|
||||
on page entry (`LoadAsync`, guarded against double-entry). "Next" is disabled via
|
||||
`IInstallerPage.BlocksNavigation` (`IsRunning || HasBlockingError`) — `WizardViewModel.CanGoNext`
|
||||
subscribes to `PropertyChanged` on the current page so a live recheck can flip it back. A "Recheck"
|
||||
button re-runs `EnvironmentCheckService.RunAllAsync` (disabled while already running).
|
||||
|
||||
**"Claude Help Me" button** (`Core/ClaudeHelpLauncher.cs`) — a second button below the shared
|
||||
check-list footer, enabled only when `claude-cli` is `Ok` and `claude-auth` is not `Failed` (`Unknown`
|
||||
stays enabled — an indeterminate login state shouldn't block the one feature that could help
|
||||
diagnose it). `BuildReportAsync` renders all check results (Id/Severity/Status/Message table,
|
||||
plus the full `Detail` of any `Failed` check) and system info (OS, `dotnet --list-runtimes`,
|
||||
resolved `git`/`claude` messages, planned install dir/ports) into
|
||||
`%TEMP%\claudedo-setup-diagnose.md` — English and hardcoded (an AI assistant reads it, not the
|
||||
user) and deliberately excludes credentials/tokens/env-var dumps. `LaunchTerminal` then opens
|
||||
`wt.exe -d %TEMP% cmd.exe /k <claude> <prompt>` (or `cmd.exe /k <claude> <prompt>` if `wt.exe`
|
||||
isn't resolvable) via the injectable `IProcessLauncher`, pointing the initial prompt at that
|
||||
report file. Errors from either step surface as `ClaudeHelpError` on the page, never an
|
||||
exception. It reads the last report via `CheckListViewModel.LastReport`, so it re-evaluates on
|
||||
every check run.
|
||||
|
||||
`DiagnosePage` (`Pages/DiagnosePage/`) hosts the same `CheckListView` in `SettingsWindow` (Config
|
||||
mode only, `ShowInSettings = true` / `ShowInWizard = false`, `Order = 5` — after UiSettings).
|
||||
Nothing here blocks navigation and checks do **not** auto-run on load — only on a "Recheck" click.
|
||||
Unlike the wizard, its `InstallContext` is built from the **installed** configuration
|
||||
(`InstallerWorkerConfig.Load()` for `ClaudeBin`/`SignalRPort`, the shared `InstallContext` for
|
||||
`InstallDirectory`/`ExternalMcpPort`), refreshed on every `LoadAsync()` — not the wizard-default
|
||||
`InstallContext` the DI container hands out, which is only populated once a page's `ApplyAsync`
|
||||
(i.e. Save) runs. `DiagnosePage` intentionally has **no** "Claude Help Me" button — that one is
|
||||
wizard-only.
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
<UserControl x:Class="ClaudeDo.Installer.Checks.CheckListView"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:local="clr-namespace:ClaudeDo.Installer.Checks"
|
||||
xmlns:loc="clr-namespace:ClaudeDo.Installer.Localization"
|
||||
d:DataContext="{d:DesignInstance local:CheckListViewModel}"
|
||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
mc:Ignorable="d">
|
||||
|
||||
<Grid>
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="*"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
</Grid.RowDefinitions>
|
||||
|
||||
<!-- Check list -->
|
||||
<ScrollViewer Grid.Row="0" VerticalScrollBarVisibility="Auto">
|
||||
<ItemsControl ItemsSource="{Binding Rows}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate DataType="{x:Type local:CheckRowViewModel}">
|
||||
<Border Margin="0,0,0,6" Padding="10,8"
|
||||
Background="{StaticResource IslandBgBrush}"
|
||||
BorderBrush="{StaticResource BorderSubtleBrush}"
|
||||
BorderThickness="1" CornerRadius="4">
|
||||
<StackPanel>
|
||||
<Grid>
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
<ColumnDefinition Width="*"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
|
||||
<Ellipse Grid.Column="0" Width="10" Height="10" Margin="0,0,10,0"
|
||||
VerticalAlignment="Top"
|
||||
Fill="{Binding StatusBrush}"/>
|
||||
|
||||
<StackPanel Grid.Column="1">
|
||||
<TextBlock Text="{Binding Title}" FontSize="13" FontWeight="SemiBold"/>
|
||||
<TextBlock Text="{Binding Message}" FontSize="12"
|
||||
Foreground="{StaticResource TextSecondaryBrush}"
|
||||
TextWrapping="Wrap" Margin="0,2,0,0"/>
|
||||
|
||||
<TextBlock Text="{Binding Hint}" FontSize="11"
|
||||
Foreground="{StaticResource TextMutedBrush}"
|
||||
TextWrapping="Wrap" Margin="0,4,0,0"
|
||||
Visibility="{Binding Hint, Converter={StaticResource NullToCollapsedConverter}}"/>
|
||||
|
||||
<Button Content="{Binding HelpUrl}"
|
||||
HorizontalAlignment="Left" Margin="-16,4,0,0"
|
||||
Background="Transparent" BorderThickness="0"
|
||||
Foreground="{StaticResource AccentLightBrush}"
|
||||
Cursor="Hand"
|
||||
Command="{Binding DataContext.OpenHelpUrlCommand, RelativeSource={RelativeSource AncestorType=ItemsControl}}"
|
||||
CommandParameter="{Binding HelpUrl}"
|
||||
Visibility="{Binding HelpUrl, Converter={StaticResource NullToCollapsedConverter}}"/>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</ScrollViewer>
|
||||
|
||||
<!-- Progress + summary -->
|
||||
<StackPanel Grid.Row="1" Margin="0,8,0,0">
|
||||
<ProgressBar IsIndeterminate="True" Margin="0,0,0,8"
|
||||
Visibility="{Binding IsRunning, Converter={StaticResource BoolToVisConverter}}"/>
|
||||
<TextBlock Text="{Binding Summary}" FontSize="12" TextWrapping="Wrap"
|
||||
Foreground="{StaticResource TextSecondaryBrush}">
|
||||
<TextBlock.Style>
|
||||
<Style TargetType="TextBlock">
|
||||
<Style.Triggers>
|
||||
<DataTrigger Binding="{Binding HasBlockingError}" Value="True">
|
||||
<Setter Property="Foreground" Value="{StaticResource ErrorBrush}"/>
|
||||
</DataTrigger>
|
||||
</Style.Triggers>
|
||||
</Style>
|
||||
</TextBlock.Style>
|
||||
</TextBlock>
|
||||
</StackPanel>
|
||||
|
||||
<!-- Footer -->
|
||||
<StackPanel Grid.Row="2" Orientation="Horizontal" HorizontalAlignment="Right" Margin="0,12,0,0">
|
||||
<Button Content="{loc:Tr installer.systemCheck.recheck}"
|
||||
Command="{Binding RunChecksCommand}"/>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</UserControl>
|
||||
@@ -0,0 +1,8 @@
|
||||
using System.Windows.Controls;
|
||||
|
||||
namespace ClaudeDo.Installer.Checks;
|
||||
|
||||
public partial class CheckListView : UserControl
|
||||
{
|
||||
public CheckListView() => InitializeComponent();
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Diagnostics;
|
||||
using System.Windows;
|
||||
using System.Windows.Media;
|
||||
using ClaudeDo.Installer.Core;
|
||||
using ClaudeDo.Installer.Localization;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
|
||||
namespace ClaudeDo.Installer.Checks;
|
||||
|
||||
public sealed class CheckRowViewModel
|
||||
{
|
||||
public CheckRowViewModel(CheckResult result)
|
||||
{
|
||||
var loc = TrExtension.Localizer;
|
||||
Status = result.Status;
|
||||
Severity = result.Severity;
|
||||
Title = loc?[result.TitleKey] ?? result.TitleKey;
|
||||
Message = result.Message;
|
||||
Hint = result.HintKey is not null ? loc?[result.HintKey] ?? result.HintKey : null;
|
||||
HelpUrl = result.HelpUrl;
|
||||
StatusBrush = ResolveBrush(Status, Severity);
|
||||
}
|
||||
|
||||
public CheckStatus Status { get; }
|
||||
public CheckSeverity Severity { get; }
|
||||
public string Title { get; }
|
||||
public string Message { get; }
|
||||
public string? Hint { get; }
|
||||
public string? HelpUrl { get; }
|
||||
public Brush StatusBrush { get; }
|
||||
|
||||
private static Brush ResolveBrush(CheckStatus status, CheckSeverity severity)
|
||||
{
|
||||
var key = status switch
|
||||
{
|
||||
CheckStatus.Ok => "StatusGreenBrush",
|
||||
CheckStatus.Unknown => "StatusGrayBrush",
|
||||
CheckStatus.Failed when severity == CheckSeverity.Error => "StatusRedBrush",
|
||||
CheckStatus.Failed => "StatusOrangeBrush",
|
||||
_ => "StatusGrayBrush",
|
||||
};
|
||||
return Application.Current?.Resources[key] as Brush ?? Brushes.Gray;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Shared presentation logic for the environment check list: running the check pass, tracking
|
||||
/// busy/summary state, and the row collection. Composed (not subclassed) by every page that
|
||||
/// hosts a check list — <see cref="Pages.SystemCheckPage.SystemCheckPageViewModel"/> and
|
||||
/// <see cref="Pages.DiagnosePage.DiagnosePageViewModel"/> — so the run logic exists exactly once.
|
||||
/// </summary>
|
||||
public partial class CheckListViewModel : ObservableObject
|
||||
{
|
||||
private readonly InstallContext _context;
|
||||
private readonly Func<EnvironmentCheckService> _checkServiceFactory;
|
||||
|
||||
public ObservableCollection<CheckRowViewModel> Rows { get; } = [];
|
||||
|
||||
/// <summary>Report of the most recent run, null before the first one. Hosts that need the raw
|
||||
/// results (the wizard's "Claude Help Me" button) read it instead of running the checks again.</summary>
|
||||
public EnvironmentCheckReport? LastReport { get; private set; }
|
||||
|
||||
[ObservableProperty] private bool _isRunning;
|
||||
[ObservableProperty] private bool _hasRun;
|
||||
[ObservableProperty] private bool _hasBlockingError;
|
||||
[ObservableProperty] private string _summary = string.Empty;
|
||||
|
||||
public CheckListViewModel(InstallContext context, Func<EnvironmentCheckService> checkServiceFactory)
|
||||
{
|
||||
_context = context;
|
||||
_checkServiceFactory = checkServiceFactory;
|
||||
}
|
||||
|
||||
partial void OnIsRunningChanged(bool value) => RunChecksCommand.NotifyCanExecuteChanged();
|
||||
|
||||
[RelayCommand(CanExecute = nameof(CanRunChecks))]
|
||||
private async Task RunChecksAsync()
|
||||
{
|
||||
if (IsRunning) return;
|
||||
|
||||
IsRunning = true;
|
||||
Summary = TrExtension.Localizer?["installer.systemCheck.summary.running"] ?? "Checking your system…";
|
||||
|
||||
try
|
||||
{
|
||||
var service = _checkServiceFactory();
|
||||
var report = await service.RunAllAsync(_context, CancellationToken.None);
|
||||
|
||||
Rows.Clear();
|
||||
foreach (var result in report.Results)
|
||||
Rows.Add(new CheckRowViewModel(result));
|
||||
|
||||
LastReport = report;
|
||||
OnPropertyChanged(nameof(LastReport));
|
||||
HasBlockingError = report.HasBlockingError;
|
||||
Summary = BuildSummary(report);
|
||||
HasRun = true;
|
||||
}
|
||||
finally
|
||||
{
|
||||
IsRunning = false;
|
||||
}
|
||||
}
|
||||
|
||||
private bool CanRunChecks() => !IsRunning;
|
||||
|
||||
private static string BuildSummary(EnvironmentCheckReport report)
|
||||
{
|
||||
var loc = TrExtension.Localizer;
|
||||
|
||||
var blocking = report.Results
|
||||
.Where(r => r.Severity == CheckSeverity.Error && r.Status == CheckStatus.Failed)
|
||||
.ToList();
|
||||
if (blocking.Count > 0)
|
||||
{
|
||||
var names = string.Join(", ", blocking.Select(r => loc?[r.TitleKey] ?? r.TitleKey));
|
||||
return loc?.Get("installer.systemCheck.summary.blocking", blocking.Count, names)
|
||||
?? $"{blocking.Count} problem(s) must be fixed: {names}";
|
||||
}
|
||||
|
||||
var warnings = report.Results.Count(r => r.Severity == CheckSeverity.Warning && r.Status == CheckStatus.Failed);
|
||||
if (warnings > 0)
|
||||
{
|
||||
return loc?.Get("installer.systemCheck.summary.warnings", warnings)
|
||||
?? $"{warnings} warning(s) found.";
|
||||
}
|
||||
|
||||
return loc?["installer.systemCheck.summary.ok"] ?? "Everything looks good.";
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private static void OpenHelpUrl(string? url)
|
||||
{
|
||||
if (string.IsNullOrEmpty(url)) return;
|
||||
Process.Start(new ProcessStartInfo(url) { UseShellExecute = true });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
namespace ClaudeDo.Installer.Checks;
|
||||
|
||||
public enum CheckSeverity { Warning, Error }
|
||||
|
||||
public enum CheckStatus { Ok, Failed, Unknown }
|
||||
|
||||
public sealed record CheckResult(
|
||||
string Id,
|
||||
CheckSeverity Severity,
|
||||
CheckStatus Status,
|
||||
string TitleKey,
|
||||
string Message,
|
||||
string? HintKey,
|
||||
string? HelpUrl,
|
||||
string? Detail)
|
||||
{
|
||||
public static CheckResult Ok(string id, CheckSeverity severity, string titleKey, string message, string? detail = null) =>
|
||||
new(id, severity, CheckStatus.Ok, titleKey, message, HintKey: null, HelpUrl: null, detail);
|
||||
|
||||
public static CheckResult Fail(string id, CheckSeverity severity, string titleKey, string message, string? hintKey = null, string? helpUrl = null, string? detail = null) =>
|
||||
new(id, severity, CheckStatus.Failed, titleKey, message, hintKey, helpUrl, detail);
|
||||
|
||||
public static CheckResult Unknown(string id, CheckSeverity severity, string titleKey, string message, string? detail = null) =>
|
||||
new(id, severity, CheckStatus.Unknown, titleKey, message, HintKey: null, HelpUrl: null, detail);
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
using System.Text.Json;
|
||||
using ClaudeDo.Installer.Core;
|
||||
|
||||
namespace ClaudeDo.Installer.Checks;
|
||||
|
||||
/// <summary>
|
||||
/// Checks login via `claude auth status --json` — never sends a prompt (costs tokens, can hang
|
||||
/// on a usage-limited account). See docs/explore-notes/installer-preflight.md §4.
|
||||
/// </summary>
|
||||
public sealed class ClaudeAuthCheck : IEnvironmentCheck
|
||||
{
|
||||
public const string CheckId = "claude-auth";
|
||||
|
||||
private readonly ClaudeCliLookup _lookup;
|
||||
|
||||
public ClaudeAuthCheck(ClaudeCliLookup lookup)
|
||||
{
|
||||
_lookup = lookup;
|
||||
}
|
||||
|
||||
public string Id => CheckId;
|
||||
public CheckSeverity Severity => CheckSeverity.Error;
|
||||
|
||||
public async Task<CheckResult> RunAsync(InstallContext ctx, CancellationToken ct)
|
||||
{
|
||||
var lookup = await _lookup.ResolveAsync(ctx, ct);
|
||||
if (lookup.Resolved is null)
|
||||
{
|
||||
return CheckResult.Unknown(Id, Severity, "checks.claudeAuth.title",
|
||||
"The claude CLI was not found — login could not be checked.");
|
||||
}
|
||||
|
||||
var (fileName, arguments) = ProcessCommand.For(lookup.Resolved, "auth", "status", "--json");
|
||||
var (exitCode, output) = await _lookup.ProcessRunner.RunAsync(fileName, arguments, null, ct);
|
||||
|
||||
if (exitCode != 0)
|
||||
{
|
||||
return CheckResult.Unknown(Id, Severity, "checks.claudeAuth.title",
|
||||
"Login status could not be determined.", output);
|
||||
}
|
||||
|
||||
var loggedIn = TryParseLoggedIn(output);
|
||||
if (loggedIn is null)
|
||||
{
|
||||
return CheckResult.Unknown(Id, Severity, "checks.claudeAuth.title",
|
||||
"Login status could not be determined.", output);
|
||||
}
|
||||
|
||||
return loggedIn.Value
|
||||
? CheckResult.Ok(Id, Severity, "checks.claudeAuth.title", "Logged in.")
|
||||
: CheckResult.Fail(Id, Severity, "checks.claudeAuth.title", "Not logged in.", "checks.claudeAuth.hint");
|
||||
}
|
||||
|
||||
private static bool? TryParseLoggedIn(string output)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(output);
|
||||
if (doc.RootElement.TryGetProperty("loggedIn", out var prop) &&
|
||||
prop.ValueKind is JsonValueKind.True or JsonValueKind.False)
|
||||
{
|
||||
return prop.GetBoolean();
|
||||
}
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
// fall through to null — treated as Unknown
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
using System.IO;
|
||||
using ClaudeDo.Data.Environment;
|
||||
using ClaudeDo.Installer.Core;
|
||||
|
||||
namespace ClaudeDo.Installer.Checks;
|
||||
|
||||
/// <summary>Without the claude CLI, no task can ever run — blocking.</summary>
|
||||
public sealed class ClaudeCliCheck : IEnvironmentCheck
|
||||
{
|
||||
public const string CheckId = "claude-cli";
|
||||
|
||||
private readonly ClaudeCliLookup _lookup;
|
||||
|
||||
public ClaudeCliCheck(ClaudeCliLookup lookup)
|
||||
{
|
||||
_lookup = lookup;
|
||||
}
|
||||
|
||||
public string Id => CheckId;
|
||||
public CheckSeverity Severity => CheckSeverity.Error;
|
||||
|
||||
public async Task<CheckResult> RunAsync(InstallContext ctx, CancellationToken ct)
|
||||
{
|
||||
var lookup = await _lookup.ResolveAsync(ctx, ct);
|
||||
|
||||
if (lookup.Resolved is null)
|
||||
{
|
||||
var searched = string.Join(", ", SearchedLocations());
|
||||
return CheckResult.Fail(Id, Severity, "checks.claudeCli.title",
|
||||
$"'{ctx.ClaudeBin}' was not found on PATH. Searched: {searched}.",
|
||||
"checks.claudeCli.hint");
|
||||
}
|
||||
|
||||
if (lookup.ExitCode != 0)
|
||||
{
|
||||
return CheckResult.Fail(Id, Severity, "checks.claudeCli.title",
|
||||
$"'{lookup.Resolved.Path}' exited with code {lookup.ExitCode}.",
|
||||
"checks.claudeCli.hint", detail: lookup.Output);
|
||||
}
|
||||
|
||||
var version = lookup.ParsedVersion?.ToString() ?? lookup.Output.Trim();
|
||||
var detail = lookup.Resolved.IsShim
|
||||
? "Resolved via a shim (.cmd/.bat/.ps1), not a native .exe."
|
||||
: null;
|
||||
return CheckResult.Ok(Id, Severity, "checks.claudeCli.title", $"{lookup.Resolved.Path} — {version}", detail);
|
||||
}
|
||||
|
||||
private IEnumerable<string> SearchedLocations()
|
||||
{
|
||||
var pathVar = _lookup.PathOverride ?? System.Environment.GetEnvironmentVariable("PATH") ?? "";
|
||||
var pathEntries = pathVar.Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
return pathEntries.Concat(ExecutableResolver.FallbackDirectories());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
using System.Text.RegularExpressions;
|
||||
using ClaudeDo.Data.Environment;
|
||||
using ClaudeDo.Installer.Core;
|
||||
using ClaudeDo.Installer.Core.Interfaces;
|
||||
|
||||
namespace ClaudeDo.Installer.Checks;
|
||||
|
||||
public sealed record ClaudeCliLookupResult(
|
||||
ResolvedExecutable? Resolved,
|
||||
int ExitCode,
|
||||
string Output,
|
||||
Version? ParsedVersion);
|
||||
|
||||
/// <summary>
|
||||
/// Resolves the claude CLI and runs `--version` at most once per install run, shared across
|
||||
/// ClaudeCliCheck, ClaudeVersionCheck, ClaudeAuthCheck, and PermissionModeAutoCheck.
|
||||
/// </summary>
|
||||
public sealed class ClaudeCliLookup
|
||||
{
|
||||
private static readonly Regex VersionPattern = new(@"\d+(\.\d+){1,3}", RegexOptions.Compiled);
|
||||
|
||||
private readonly SemaphoreSlim _gate = new(1, 1);
|
||||
private ClaudeCliLookupResult? _cached;
|
||||
|
||||
public ClaudeCliLookup(IProcessRunner processRunner, string? pathOverride = null, string? pathExtOverride = null)
|
||||
{
|
||||
ProcessRunner = processRunner;
|
||||
PathOverride = pathOverride;
|
||||
PathExtOverride = pathExtOverride;
|
||||
}
|
||||
|
||||
public IProcessRunner ProcessRunner { get; }
|
||||
public string? PathOverride { get; }
|
||||
public string? PathExtOverride { get; }
|
||||
|
||||
public async Task<ClaudeCliLookupResult> ResolveAsync(InstallContext ctx, CancellationToken ct)
|
||||
{
|
||||
if (_cached is not null) return _cached;
|
||||
|
||||
await _gate.WaitAsync(ct);
|
||||
try
|
||||
{
|
||||
_cached ??= await ResolveCoreAsync(ctx, ct);
|
||||
return _cached;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_gate.Release();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<ClaudeCliLookupResult> ResolveCoreAsync(InstallContext ctx, CancellationToken ct)
|
||||
{
|
||||
var resolved = ExecutableResolver.Resolve(ctx.ClaudeBin, PathOverride, PathExtOverride);
|
||||
if (resolved is null)
|
||||
{
|
||||
return new ClaudeCliLookupResult(null, ExitCode: -1, Output: string.Empty, ParsedVersion: null);
|
||||
}
|
||||
|
||||
var (fileName, arguments) = ProcessCommand.For(resolved, "--version");
|
||||
var (exitCode, output) = await ProcessRunner.RunAsync(fileName, arguments, null, ct);
|
||||
var parsedVersion = exitCode == 0 ? ParseVersion(output) : null;
|
||||
|
||||
return new ClaudeCliLookupResult(resolved, exitCode, output, parsedVersion);
|
||||
}
|
||||
|
||||
private static Version? ParseVersion(string output) =>
|
||||
VersionPattern.Match(output) is { Success: true } match && Version.TryParse(match.Value, out var version)
|
||||
? version
|
||||
: null;
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
using ClaudeDo.Installer.Core;
|
||||
|
||||
namespace ClaudeDo.Installer.Checks;
|
||||
|
||||
/// <summary>Gates the CLI flags ClaudeArgsBuilder relies on — blocking.</summary>
|
||||
public sealed class ClaudeVersionCheck : IEnvironmentCheck
|
||||
{
|
||||
public const string CheckId = "claude-version";
|
||||
|
||||
/// <summary>
|
||||
/// Newest version confirmed to work end-to-end with every flag ClaudeDo uses (--permission-mode
|
||||
/// auto, --effort, --agents, --json-schema, --append-system-prompt, --output-format stream-json
|
||||
/// --verbose, --resume, mcp add --transport http --scope user). A verified floor, not a proven
|
||||
/// theoretical minimum — see docs/explore-notes/installer-preflight.md §3.
|
||||
/// </summary>
|
||||
public static readonly Version MinimumVersion = new(2, 1, 220);
|
||||
|
||||
private readonly ClaudeCliLookup _lookup;
|
||||
|
||||
public ClaudeVersionCheck(ClaudeCliLookup lookup)
|
||||
{
|
||||
_lookup = lookup;
|
||||
}
|
||||
|
||||
public string Id => CheckId;
|
||||
public CheckSeverity Severity => CheckSeverity.Error;
|
||||
|
||||
public async Task<CheckResult> RunAsync(InstallContext ctx, CancellationToken ct)
|
||||
{
|
||||
var lookup = await _lookup.ResolveAsync(ctx, ct);
|
||||
|
||||
if (lookup.Resolved is null)
|
||||
{
|
||||
return CheckResult.Unknown(Id, Severity, "checks.claudeVersion.title",
|
||||
"The claude CLI was not found — version could not be checked.");
|
||||
}
|
||||
|
||||
if (lookup.ExitCode != 0 || lookup.ParsedVersion is null)
|
||||
{
|
||||
return CheckResult.Unknown(Id, Severity, "checks.claudeVersion.title",
|
||||
$"Could not parse a version from '{lookup.Output.Trim()}'.");
|
||||
}
|
||||
|
||||
if (lookup.ParsedVersion < MinimumVersion)
|
||||
{
|
||||
return CheckResult.Fail(Id, Severity, "checks.claudeVersion.title",
|
||||
$"claude {lookup.ParsedVersion} is older than the required {MinimumVersion}.",
|
||||
"checks.claudeVersion.hint");
|
||||
}
|
||||
|
||||
return CheckResult.Ok(Id, Severity, "checks.claudeVersion.title",
|
||||
$"claude {lookup.ParsedVersion} meets the minimum ({MinimumVersion}).");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
using ClaudeDo.Installer.Core;
|
||||
|
||||
namespace ClaudeDo.Installer.Checks;
|
||||
|
||||
public sealed record EnvironmentCheckReport(IReadOnlyList<CheckResult> Results)
|
||||
{
|
||||
public bool HasBlockingError => Results.Any(r => r.Severity == CheckSeverity.Error && r.Status == CheckStatus.Failed);
|
||||
}
|
||||
|
||||
public sealed class EnvironmentCheckService
|
||||
{
|
||||
private readonly IReadOnlyList<IEnvironmentCheck> _checks;
|
||||
|
||||
public EnvironmentCheckService(IEnumerable<IEnvironmentCheck> checks)
|
||||
{
|
||||
_checks = checks.ToList();
|
||||
}
|
||||
|
||||
public async Task<EnvironmentCheckReport> RunAllAsync(InstallContext ctx, CancellationToken ct)
|
||||
{
|
||||
var tasks = _checks.Select(check => RunSafeAsync(check, ctx, ct)).ToArray();
|
||||
var results = await Task.WhenAll(tasks);
|
||||
return new EnvironmentCheckReport(results);
|
||||
}
|
||||
|
||||
private static async Task<CheckResult> RunSafeAsync(IEnvironmentCheck check, InstallContext ctx, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
return await check.RunAsync(ctx, ct);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return CheckResult.Unknown(check.Id, check.Severity, string.Empty, ex.Message, ex.Message);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
using System.Text.RegularExpressions;
|
||||
using ClaudeDo.Data.Environment;
|
||||
using ClaudeDo.Installer.Core;
|
||||
using ClaudeDo.Installer.Core.Interfaces;
|
||||
|
||||
namespace ClaudeDo.Installer.Checks;
|
||||
|
||||
/// <summary>Without git, no task can run (worktrees) — blocking.</summary>
|
||||
public sealed class GitCheck : IEnvironmentCheck
|
||||
{
|
||||
public const string CheckId = "git";
|
||||
private static readonly Regex VersionPattern = new(@"\d+(\.\d+){1,3}", RegexOptions.Compiled);
|
||||
|
||||
private readonly IProcessRunner _processRunner;
|
||||
private readonly string? _pathOverride;
|
||||
private readonly string? _pathExtOverride;
|
||||
|
||||
public GitCheck(IProcessRunner processRunner, string? pathOverride = null, string? pathExtOverride = null)
|
||||
{
|
||||
_processRunner = processRunner;
|
||||
_pathOverride = pathOverride;
|
||||
_pathExtOverride = pathExtOverride;
|
||||
}
|
||||
|
||||
public string Id => CheckId;
|
||||
public CheckSeverity Severity => CheckSeverity.Error;
|
||||
|
||||
public async Task<CheckResult> RunAsync(InstallContext ctx, CancellationToken ct)
|
||||
{
|
||||
var resolved = ExecutableResolver.Resolve("git", _pathOverride, _pathExtOverride);
|
||||
if (resolved is null)
|
||||
{
|
||||
return CheckResult.Fail(Id, Severity, "checks.git.title", "git was not found on PATH.",
|
||||
"checks.git.hint", "https://git-scm.com/downloads");
|
||||
}
|
||||
|
||||
var (fileName, arguments) = ProcessCommand.For(resolved, "--version");
|
||||
var (exitCode, output) = await _processRunner.RunAsync(fileName, arguments, null, ct);
|
||||
|
||||
if (exitCode != 0)
|
||||
{
|
||||
return CheckResult.Fail(Id, Severity, "checks.git.title", $"'{resolved.Path}' exited with code {exitCode}.",
|
||||
"checks.git.hint", "https://git-scm.com/downloads", output);
|
||||
}
|
||||
|
||||
var version = VersionPattern.Match(output) is { Success: true } match ? match.Value : output.Trim();
|
||||
return CheckResult.Ok(Id, Severity, "checks.git.title", $"{resolved.Path} — {version}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
using ClaudeDo.Data.Environment;
|
||||
using ClaudeDo.Installer.Core;
|
||||
using ClaudeDo.Installer.Core.Interfaces;
|
||||
|
||||
namespace ClaudeDo.Installer.Checks;
|
||||
|
||||
/// <summary>Trivial to fix after the fact (git config --global), and the Claude help-me button can resolve it — a warning, not a blocker.</summary>
|
||||
public sealed class GitIdentityCheck : IEnvironmentCheck
|
||||
{
|
||||
public const string CheckId = "git-identity";
|
||||
|
||||
private readonly IProcessRunner _processRunner;
|
||||
private readonly string? _pathOverride;
|
||||
private readonly string? _pathExtOverride;
|
||||
|
||||
public GitIdentityCheck(IProcessRunner processRunner, string? pathOverride = null, string? pathExtOverride = null)
|
||||
{
|
||||
_processRunner = processRunner;
|
||||
_pathOverride = pathOverride;
|
||||
_pathExtOverride = pathExtOverride;
|
||||
}
|
||||
|
||||
public string Id => CheckId;
|
||||
public CheckSeverity Severity => CheckSeverity.Warning;
|
||||
|
||||
public async Task<CheckResult> RunAsync(InstallContext ctx, CancellationToken ct)
|
||||
{
|
||||
var resolved = ExecutableResolver.Resolve("git", _pathOverride, _pathExtOverride);
|
||||
if (resolved is null)
|
||||
{
|
||||
return CheckResult.Unknown(Id, Severity, "checks.gitIdentity.title", "git was not found — identity could not be checked.");
|
||||
}
|
||||
|
||||
var name = await ReadConfigAsync(resolved, "user.name", ct);
|
||||
var email = await ReadConfigAsync(resolved, "user.email", ct);
|
||||
var hasName = !string.IsNullOrWhiteSpace(name);
|
||||
var hasEmail = !string.IsNullOrWhiteSpace(email);
|
||||
|
||||
if (hasName && hasEmail)
|
||||
{
|
||||
return CheckResult.Ok(Id, Severity, "checks.gitIdentity.title", $"{name} <{email}>");
|
||||
}
|
||||
|
||||
var missing = (hasName, hasEmail) switch
|
||||
{
|
||||
(false, false) => "user.name, user.email",
|
||||
(false, true) => "user.name",
|
||||
_ => "user.email",
|
||||
};
|
||||
|
||||
return CheckResult.Fail(Id, Severity, "checks.gitIdentity.title", $"Missing git identity: {missing}.", "checks.gitIdentity.hint");
|
||||
}
|
||||
|
||||
private async Task<string> ReadConfigAsync(ResolvedExecutable resolved, string key, CancellationToken ct)
|
||||
{
|
||||
var (fileName, arguments) = ProcessCommand.For(resolved, "config", "--get", key);
|
||||
var (_, output) = await _processRunner.RunAsync(fileName, arguments, null, ct);
|
||||
return output.Trim();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
using ClaudeDo.Installer.Core;
|
||||
|
||||
namespace ClaudeDo.Installer.Checks;
|
||||
|
||||
public interface IEnvironmentCheck
|
||||
{
|
||||
string Id { get; }
|
||||
CheckSeverity Severity { get; }
|
||||
Task<CheckResult> RunAsync(InstallContext ctx, CancellationToken ct);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace ClaudeDo.Installer.Checks.Interfaces;
|
||||
|
||||
public interface IPortOwnerResolver
|
||||
{
|
||||
Task<string?> FindOwningProcessNameAsync(int port, CancellationToken ct);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
using System.Diagnostics;
|
||||
using ClaudeDo.Installer.Checks.Interfaces;
|
||||
using ClaudeDo.Installer.Core.Interfaces;
|
||||
|
||||
namespace ClaudeDo.Installer.Checks;
|
||||
|
||||
public sealed class NetstatPortOwnerResolver : IPortOwnerResolver
|
||||
{
|
||||
private readonly IProcessRunner _processRunner;
|
||||
|
||||
public NetstatPortOwnerResolver(IProcessRunner processRunner) => _processRunner = processRunner;
|
||||
|
||||
public async Task<string?> FindOwningProcessNameAsync(int port, CancellationToken ct)
|
||||
{
|
||||
var (exitCode, output) = await _processRunner.RunAsync("netstat.exe", "-ano -p TCP", null, ct);
|
||||
if (exitCode != 0) return null;
|
||||
|
||||
var suffix = $":{port}";
|
||||
foreach (var line in output.Split('\n'))
|
||||
{
|
||||
var parts = line.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries);
|
||||
if (parts.Length < 5) continue;
|
||||
if (!parts[0].Equals("TCP", StringComparison.OrdinalIgnoreCase)) continue;
|
||||
if (!parts[1].EndsWith(suffix, StringComparison.Ordinal)) continue;
|
||||
if (!int.TryParse(parts[^1], out var pid)) continue;
|
||||
|
||||
try { return Process.GetProcessById(pid).ProcessName; }
|
||||
catch { return null; }
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
using System.Text.RegularExpressions;
|
||||
using ClaudeDo.Installer.Core;
|
||||
|
||||
namespace ClaudeDo.Installer.Checks;
|
||||
|
||||
/// <summary>
|
||||
/// Static-only check per docs/explore-notes/installer-preflight.md §2: real auto-mode eligibility
|
||||
/// (org/model/plan) has no cheap detectable signal, so this only confirms the CLI's argument
|
||||
/// parser still recognizes "auto" as a --permission-mode choice. A warning, not a blocker — a
|
||||
/// too-old CLI just degrades to acceptEdits/default with more prompts.
|
||||
/// </summary>
|
||||
public sealed class PermissionModeAutoCheck : IEnvironmentCheck
|
||||
{
|
||||
public const string CheckId = "claude-permission-mode-auto";
|
||||
|
||||
private static readonly Regex AutoWord = new(@"\bauto\b", RegexOptions.Compiled);
|
||||
private const int SearchWindow = 300;
|
||||
|
||||
private readonly ClaudeCliLookup _lookup;
|
||||
|
||||
public PermissionModeAutoCheck(ClaudeCliLookup lookup)
|
||||
{
|
||||
_lookup = lookup;
|
||||
}
|
||||
|
||||
public string Id => CheckId;
|
||||
public CheckSeverity Severity => CheckSeverity.Warning;
|
||||
|
||||
public async Task<CheckResult> RunAsync(InstallContext ctx, CancellationToken ct)
|
||||
{
|
||||
var lookup = await _lookup.ResolveAsync(ctx, ct);
|
||||
if (lookup.Resolved is null)
|
||||
{
|
||||
return CheckResult.Unknown(Id, Severity, "checks.permissionModeAuto.title",
|
||||
"The claude CLI was not found — permission-mode support could not be checked.");
|
||||
}
|
||||
|
||||
var (fileName, arguments) = ProcessCommand.For(lookup.Resolved, "--help");
|
||||
var (exitCode, output) = await _lookup.ProcessRunner.RunAsync(fileName, arguments, null, ct);
|
||||
|
||||
if (exitCode != 0 || string.IsNullOrWhiteSpace(output))
|
||||
{
|
||||
return CheckResult.Unknown(Id, Severity, "checks.permissionModeAuto.title",
|
||||
"Could not determine whether --permission-mode auto is supported.");
|
||||
}
|
||||
|
||||
if (AutoIsListed(output))
|
||||
{
|
||||
return CheckResult.Ok(Id, Severity, "checks.permissionModeAuto.title",
|
||||
"--permission-mode auto is recognized by this CLI.");
|
||||
}
|
||||
|
||||
return CheckResult.Fail(Id, Severity, "checks.permissionModeAuto.title",
|
||||
"This claude CLI version does not list 'auto' as a --permission-mode choice.",
|
||||
"checks.permissionModeAuto.hint");
|
||||
}
|
||||
|
||||
private static bool AutoIsListed(string helpOutput)
|
||||
{
|
||||
var index = helpOutput.IndexOf("--permission-mode", StringComparison.Ordinal);
|
||||
if (index < 0) return false;
|
||||
|
||||
var windowEnd = Math.Min(helpOutput.Length, index + SearchWindow);
|
||||
return AutoWord.IsMatch(helpOutput[index..windowEnd]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using ClaudeDo.Installer.Checks.Interfaces;
|
||||
using ClaudeDo.Installer.Core;
|
||||
|
||||
namespace ClaudeDo.Installer.Checks;
|
||||
|
||||
/// <summary>Both ports are configurable in the installer settings, so a conflict is only a warning.</summary>
|
||||
public sealed class PortCheck : IEnvironmentCheck
|
||||
{
|
||||
public const string CheckId = "ports";
|
||||
|
||||
private readonly IPortOwnerResolver _portOwnerResolver;
|
||||
|
||||
public PortCheck(IPortOwnerResolver portOwnerResolver) => _portOwnerResolver = portOwnerResolver;
|
||||
|
||||
public string Id => CheckId;
|
||||
public CheckSeverity Severity => CheckSeverity.Warning;
|
||||
|
||||
public async Task<CheckResult> RunAsync(InstallContext ctx, CancellationToken ct)
|
||||
{
|
||||
var ports = new (string Label, int Port)[]
|
||||
{
|
||||
("SignalR", ctx.SignalRPort),
|
||||
("External MCP", ctx.ExternalMcpPort),
|
||||
};
|
||||
|
||||
var blocked = new List<string>();
|
||||
var ownWorker = new List<string>();
|
||||
|
||||
foreach (var (label, port) in ports)
|
||||
{
|
||||
if (IsFree(port)) continue;
|
||||
|
||||
var owner = await _portOwnerResolver.FindOwningProcessNameAsync(port, ct);
|
||||
if (owner is not null && owner.Contains("ClaudeDo.Worker", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
ownWorker.Add($"{label} ({port})");
|
||||
continue;
|
||||
}
|
||||
|
||||
blocked.Add(owner is null ? $"{label} port {port} is in use." : $"{label} port {port} is in use by '{owner}'.");
|
||||
}
|
||||
|
||||
if (blocked.Count > 0)
|
||||
{
|
||||
return CheckResult.Fail(Id, Severity, "checks.ports.title", string.Join(" ", blocked), "checks.ports.hint");
|
||||
}
|
||||
|
||||
if (ownWorker.Count > 0)
|
||||
{
|
||||
return CheckResult.Ok(Id, Severity, "checks.ports.title", $"In use by the running ClaudeDo Worker: {string.Join(", ", ownWorker)}.");
|
||||
}
|
||||
|
||||
return CheckResult.Ok(Id, Severity, "checks.ports.title", "Ports are available.");
|
||||
}
|
||||
|
||||
private static bool IsFree(int port)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var listener = new TcpListener(IPAddress.Loopback, port);
|
||||
listener.Start();
|
||||
return true;
|
||||
}
|
||||
catch (SocketException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
using ClaudeDo.Data.Environment;
|
||||
|
||||
namespace ClaudeDo.Installer.Checks;
|
||||
|
||||
/// <summary>Builds a (FileName, Arguments) pair for a resolved executable, routing shims through cmd.exe.</summary>
|
||||
internal static class ProcessCommand
|
||||
{
|
||||
public static (string FileName, string Arguments) For(ResolvedExecutable resolved, params string[] args)
|
||||
{
|
||||
if (resolved.IsShim)
|
||||
{
|
||||
var shim = ExecutableResolver.BuildShimStartInfo(resolved.Path, args);
|
||||
return (shim.FileName, shim.Arguments);
|
||||
}
|
||||
|
||||
return (resolved.Path, string.Join(' ', args.Select(Quote)));
|
||||
}
|
||||
|
||||
private static string Quote(string value) => value.Contains(' ') ? $"\"{value}\"" : value;
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
using System.IO;
|
||||
using ClaudeDo.Data;
|
||||
using ClaudeDo.Installer.Core;
|
||||
|
||||
namespace ClaudeDo.Installer.Checks;
|
||||
|
||||
/// <summary>Without write access nothing can be installed — blocking.</summary>
|
||||
public sealed class WriteAccessCheck : IEnvironmentCheck
|
||||
{
|
||||
public const string CheckId = "write-access";
|
||||
|
||||
public string Id => CheckId;
|
||||
public CheckSeverity Severity => CheckSeverity.Error;
|
||||
|
||||
public Task<CheckResult> RunAsync(InstallContext ctx, CancellationToken ct)
|
||||
{
|
||||
foreach (var target in new[] { ctx.InstallDirectory, Paths.AppDataRoot() })
|
||||
{
|
||||
var error = TryWrite(target);
|
||||
if (error is not null)
|
||||
{
|
||||
return Task.FromResult(CheckResult.Fail(Id, Severity, "checks.writeAccess.title",
|
||||
$"Cannot write to '{target}': {error}", "checks.writeAccess.hint"));
|
||||
}
|
||||
}
|
||||
|
||||
return Task.FromResult(CheckResult.Ok(Id, Severity, "checks.writeAccess.title", "Install directory and data directory are writable."));
|
||||
}
|
||||
|
||||
private static string? TryWrite(string path)
|
||||
{
|
||||
var probeDir = FirstExistingParent(path);
|
||||
var probeFile = Path.Combine(probeDir, $".claudedo-write-check-{Guid.NewGuid():N}.tmp");
|
||||
|
||||
try
|
||||
{
|
||||
File.WriteAllText(probeFile, string.Empty);
|
||||
return null;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return ex.Message;
|
||||
}
|
||||
finally
|
||||
{
|
||||
try { File.Delete(probeFile); } catch { /* best-effort cleanup */ }
|
||||
}
|
||||
}
|
||||
|
||||
private static string FirstExistingParent(string path)
|
||||
{
|
||||
var current = Path.GetFullPath(path);
|
||||
while (!Directory.Exists(current))
|
||||
{
|
||||
var parent = Path.GetDirectoryName(current);
|
||||
if (string.IsNullOrEmpty(parent) || parent == current) break;
|
||||
current = parent;
|
||||
}
|
||||
|
||||
return current;
|
||||
}
|
||||
}
|
||||
@@ -11,13 +11,26 @@ public static class AutostartShortcut
|
||||
|
||||
public static string PathIn(string startupDir) => Path.Combine(startupDir, FileName);
|
||||
|
||||
public static void Install(string startupDir, string workerExe)
|
||||
/// <summary>Creates or updates the Startup shortcut. Returns false if it already pointed at <paramref name="workerExe"/> and was left untouched.</summary>
|
||||
public static bool Install(string startupDir, string workerExe)
|
||||
{
|
||||
Directory.CreateDirectory(startupDir);
|
||||
var shortcutPath = PathIn(startupDir);
|
||||
if (File.Exists(shortcutPath))
|
||||
{
|
||||
var existingTarget = ShortcutFactory.TryGetTarget(shortcutPath);
|
||||
if (existingTarget is not null && PathsEqual(existingTarget, workerExe))
|
||||
return false;
|
||||
}
|
||||
|
||||
var workingDir = Path.GetDirectoryName(workerExe) ?? startupDir;
|
||||
ShortcutFactory.CreateShortcut(PathIn(startupDir), workerExe, workingDir, "ClaudeDo background worker");
|
||||
ShortcutFactory.CreateShortcut(shortcutPath, workerExe, workingDir, "ClaudeDo background worker");
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool PathsEqual(string a, string b) =>
|
||||
string.Equals(Path.GetFullPath(a), Path.GetFullPath(b), StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
public static void Remove(string startupDir)
|
||||
{
|
||||
var path = PathIn(startupDir);
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Text;
|
||||
using ClaudeDo.Data.Environment;
|
||||
using ClaudeDo.Installer.Checks;
|
||||
using ClaudeDo.Installer.Core.Interfaces;
|
||||
|
||||
namespace ClaudeDo.Installer.Core;
|
||||
|
||||
public sealed record ClaudeHelpLaunchResult(bool Success, string? ErrorMessage);
|
||||
|
||||
/// <summary>
|
||||
/// Builds a setup-diagnose report from the environment check results and opens an interactive
|
||||
/// claude session pointed at it, so a stuck user can get live help finishing setup.
|
||||
/// </summary>
|
||||
public sealed class ClaudeHelpLauncher
|
||||
{
|
||||
public const string ReportFileName = "claudedo-setup-diagnose.md";
|
||||
|
||||
private readonly IProcessRunner _processRunner;
|
||||
private readonly IProcessLauncher _processLauncher;
|
||||
private readonly string? _pathOverride;
|
||||
private readonly string? _pathExtOverride;
|
||||
|
||||
public ClaudeHelpLauncher(
|
||||
IProcessRunner processRunner,
|
||||
IProcessLauncher processLauncher,
|
||||
string? pathOverride = null,
|
||||
string? pathExtOverride = null)
|
||||
{
|
||||
_processRunner = processRunner;
|
||||
_processLauncher = processLauncher;
|
||||
_pathOverride = pathOverride;
|
||||
_pathExtOverride = pathExtOverride;
|
||||
}
|
||||
|
||||
public async Task<string> BuildReportAsync(EnvironmentCheckReport report, InstallContext ctx, CancellationToken ct)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
|
||||
sb.AppendLine("# ClaudeDo setup diagnose");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine(
|
||||
"ClaudeDo is a desktop task manager that runs the `claude` CLI autonomously in git " +
|
||||
"worktrees. The person you're talking to is stuck partway through the setup wizard. " +
|
||||
"Explain the likely cause of each failing check below in plain language, propose the " +
|
||||
"concrete command(s) to fix it, and ask a clarifying question if something here is " +
|
||||
"ambiguous.");
|
||||
sb.AppendLine();
|
||||
|
||||
sb.AppendLine("## Check results");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine("| Id | Severity | Status | Message |");
|
||||
sb.AppendLine("|---|---|---|---|");
|
||||
foreach (var result in report.Results)
|
||||
{
|
||||
sb.AppendLine($"| {result.Id} | {result.Severity} | {result.Status} | {EscapeCell(result.Message)} |");
|
||||
}
|
||||
sb.AppendLine();
|
||||
|
||||
var failedDetails = report.Results
|
||||
.Where(r => r.Status == CheckStatus.Failed && !string.IsNullOrWhiteSpace(r.Detail))
|
||||
.ToList();
|
||||
if (failedDetails.Count > 0)
|
||||
{
|
||||
sb.AppendLine("## Details of failed checks");
|
||||
foreach (var result in failedDetails)
|
||||
{
|
||||
sb.AppendLine();
|
||||
sb.AppendLine($"### {result.Id}");
|
||||
sb.AppendLine("```");
|
||||
sb.AppendLine(result.Detail);
|
||||
sb.AppendLine("```");
|
||||
}
|
||||
sb.AppendLine();
|
||||
}
|
||||
|
||||
sb.AppendLine("## System info");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine($"- Windows: {RuntimeInformation.OSDescription}");
|
||||
sb.AppendLine($"- Architecture: {RuntimeInformation.OSArchitecture}");
|
||||
sb.AppendLine($"- git: {FindMessage(report, GitCheck.CheckId)}");
|
||||
sb.AppendLine($"- claude: {FindMessage(report, ClaudeCliCheck.CheckId)}");
|
||||
sb.AppendLine($"- Planned install directory: {ctx.InstallDirectory}");
|
||||
sb.AppendLine($"- Planned ports: SignalR {ctx.SignalRPort}, MCP {ctx.ExternalMcpPort}");
|
||||
sb.AppendLine("- `dotnet --list-runtimes`:");
|
||||
sb.AppendLine("```");
|
||||
sb.AppendLine(await RunDotnetListRuntimesAsync(ct));
|
||||
sb.AppendLine("```");
|
||||
sb.AppendLine();
|
||||
|
||||
// No secrets: never include credentials.json content, tokens, or a raw environment
|
||||
// variable dump here — only resolved paths and version numbers, since this file exists
|
||||
// to be read by an AI assistant, not to stay private.
|
||||
sb.AppendLine(
|
||||
"No secrets are included above (no credentials file contents, no tokens, no raw " +
|
||||
"environment variable dump) — only resolved paths and version numbers.");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine("Please start with the blocking errors above.");
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
public async Task<ClaudeHelpLaunchResult> LaunchAsync(EnvironmentCheckReport report, InstallContext ctx, CancellationToken ct)
|
||||
{
|
||||
string reportPath;
|
||||
try
|
||||
{
|
||||
var content = await BuildReportAsync(report, ctx, ct);
|
||||
reportPath = Path.Combine(Path.GetTempPath(), ReportFileName);
|
||||
await File.WriteAllTextAsync(reportPath, content, ct);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new ClaudeHelpLaunchResult(false, ex.Message);
|
||||
}
|
||||
|
||||
return LaunchTerminal(reportPath, ctx);
|
||||
}
|
||||
|
||||
public ClaudeHelpLaunchResult LaunchTerminal(string reportPath, InstallContext ctx)
|
||||
{
|
||||
var claude = ExecutableResolver.Resolve(ctx.ClaudeBin, _pathOverride, _pathExtOverride);
|
||||
if (claude is null)
|
||||
{
|
||||
return new ClaudeHelpLaunchResult(false, $"'{ctx.ClaudeBin}' was not found on PATH.");
|
||||
}
|
||||
|
||||
var tempDir = Path.GetTempPath();
|
||||
var promptText = $"Lies {reportPath} und hilf mir, mein ClaudeDo-Setup zum Laufen zu bringen.";
|
||||
var claudeCommand = $"{Quote(claude.Path)} {Quote(promptText)}";
|
||||
|
||||
var wt = ExecutableResolver.Resolve("wt", _pathOverride, _pathExtOverride);
|
||||
var startInfo = wt is not null
|
||||
? new ProcessStartInfo(wt.Path)
|
||||
{
|
||||
Arguments = $"-d {QuoteDirectory(tempDir)} cmd.exe /k {claudeCommand}",
|
||||
WorkingDirectory = tempDir,
|
||||
UseShellExecute = false,
|
||||
}
|
||||
: new ProcessStartInfo("cmd.exe")
|
||||
{
|
||||
Arguments = $"/k {claudeCommand}",
|
||||
WorkingDirectory = tempDir,
|
||||
UseShellExecute = false,
|
||||
};
|
||||
|
||||
try
|
||||
{
|
||||
_processLauncher.Start(startInfo);
|
||||
return new ClaudeHelpLaunchResult(true, null);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new ClaudeHelpLaunchResult(false, ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<string> RunDotnetListRuntimesAsync(CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
var (_, output) = await _processRunner.RunAsync("dotnet", "--list-runtimes", null, ct);
|
||||
return output.Trim();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return $"(could not run 'dotnet --list-runtimes': {ex.Message})";
|
||||
}
|
||||
}
|
||||
|
||||
private static string? FindMessage(EnvironmentCheckReport report, string checkId) =>
|
||||
report.Results.FirstOrDefault(r => r.Id == checkId)?.Message;
|
||||
|
||||
private static string EscapeCell(string value) =>
|
||||
value.Replace("|", "\\|").Replace("\r", " ").Replace("\n", " ");
|
||||
|
||||
private static string Quote(string value) => value.Contains(' ') ? $"\"{value}\"" : value;
|
||||
|
||||
private static string QuoteDirectory(string directory) => Quote(directory.TrimEnd('\\', '/'));
|
||||
}
|
||||
@@ -52,6 +52,9 @@ public sealed class InstallerWorkerConfig
|
||||
[JsonPropertyName("claude_bin")]
|
||||
public string ClaudeBin { get; set; } = "claude";
|
||||
|
||||
[JsonPropertyName("external_mcp_port")]
|
||||
public int ExternalMcpPort { get; set; } = 47_822;
|
||||
|
||||
private static readonly JsonSerializerOptions ReadOpts = new()
|
||||
{
|
||||
ReadCommentHandling = JsonCommentHandling.Skip,
|
||||
|
||||
@@ -13,4 +13,7 @@ public interface IInstallerPage
|
||||
Task LoadAsync();
|
||||
Task ApplyAsync();
|
||||
bool Validate();
|
||||
|
||||
/// <summary>True while this page wants "Next" disabled (e.g. a check run in progress or a blocking error).</summary>
|
||||
bool BlocksNavigation => false;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
using System.Diagnostics;
|
||||
|
||||
namespace ClaudeDo.Installer.Core.Interfaces;
|
||||
|
||||
/// <summary>Starts a detached process without waiting for it to exit or capturing its output.</summary>
|
||||
public interface IProcessLauncher
|
||||
{
|
||||
void Start(ProcessStartInfo startInfo);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace ClaudeDo.Installer.Core.Interfaces;
|
||||
|
||||
public interface IProcessRunner
|
||||
{
|
||||
Task<(int ExitCode, string Output)> RunAsync(string fileName, string arguments, string? workingDirectory, CancellationToken ct);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
using System.Diagnostics;
|
||||
using ClaudeDo.Installer.Core.Interfaces;
|
||||
|
||||
namespace ClaudeDo.Installer.Core;
|
||||
|
||||
public sealed class ProcessLauncher : IProcessLauncher
|
||||
{
|
||||
public void Start(ProcessStartInfo startInfo) => Process.Start(startInfo);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
using ClaudeDo.Installer.Core.Interfaces;
|
||||
|
||||
namespace ClaudeDo.Installer.Core;
|
||||
|
||||
public sealed class ProcessRunnerAdapter : IProcessRunner
|
||||
{
|
||||
public Task<(int ExitCode, string Output)> RunAsync(string fileName, string arguments, string? workingDirectory, CancellationToken ct) =>
|
||||
ProcessRunner.RunAsync(fileName, arguments, workingDirectory, progress: null, ct);
|
||||
}
|
||||
@@ -6,16 +6,52 @@ namespace ClaudeDo.Installer.Core;
|
||||
|
||||
public static class ShortcutFactory
|
||||
{
|
||||
private const int SlgpRawPath = 0x4;
|
||||
|
||||
// Both helpers release the ShellLink RCW explicitly. IPersistFile.Load/Save keeps a handle on
|
||||
// the .lnk for as long as the COM object lives, so leaving it to the GC lets a read immediately
|
||||
// followed by a write to the same path fail with "used by another process" — exactly what
|
||||
// AutostartShortcut.Install does when the recorded target changed.
|
||||
public static void CreateShortcut(string shortcutPath, string targetPath, string workingDir, string description)
|
||||
{
|
||||
var link = (IShellLink)new ShellLink();
|
||||
link.SetPath(targetPath);
|
||||
link.SetWorkingDirectory(workingDir);
|
||||
link.SetDescription(description);
|
||||
link.SetIconLocation(targetPath, 0);
|
||||
try
|
||||
{
|
||||
link.SetPath(targetPath);
|
||||
link.SetWorkingDirectory(workingDir);
|
||||
link.SetDescription(description);
|
||||
link.SetIconLocation(targetPath, 0);
|
||||
|
||||
var file = (IPersistFile)link;
|
||||
file.Save(shortcutPath, false);
|
||||
var file = (IPersistFile)link;
|
||||
file.Save(shortcutPath, false);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Marshal.FinalReleaseComObject(link);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Reads the target path of an existing .lnk, or null if it can't be read (missing/corrupt).</summary>
|
||||
public static string? TryGetTarget(string shortcutPath)
|
||||
{
|
||||
IShellLink? link = null;
|
||||
try
|
||||
{
|
||||
link = (IShellLink)new ShellLink();
|
||||
((IPersistFile)link).Load(shortcutPath, 0);
|
||||
var sb = new StringBuilder(260);
|
||||
link.GetPath(sb, sb.Capacity, IntPtr.Zero, SlgpRawPath);
|
||||
var path = sb.ToString();
|
||||
return path.Length == 0 ? null : path;
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (link is not null) Marshal.FinalReleaseComObject(link);
|
||||
}
|
||||
}
|
||||
|
||||
[ComImport]
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
<UserControl x:Class="ClaudeDo.Installer.Pages.DiagnosePage.DiagnosePageView"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:local="clr-namespace:ClaudeDo.Installer.Pages.DiagnosePage"
|
||||
xmlns:checks="clr-namespace:ClaudeDo.Installer.Checks"
|
||||
xmlns:loc="clr-namespace:ClaudeDo.Installer.Localization"
|
||||
d:DataContext="{d:DesignInstance local:DiagnosePageViewModel}"
|
||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
mc:Ignorable="d">
|
||||
|
||||
<Grid>
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="*"/>
|
||||
</Grid.RowDefinitions>
|
||||
|
||||
<!-- Header -->
|
||||
<StackPanel Grid.Row="0" Margin="0,0,0,16">
|
||||
<TextBlock Text="{loc:Tr installer.diagnose.title}" FontSize="18" FontWeight="SemiBold" Margin="0,0,0,4"/>
|
||||
<TextBlock Text="{loc:Tr installer.diagnose.subtitle}"
|
||||
Foreground="{StaticResource TextSecondaryBrush}" TextWrapping="Wrap"/>
|
||||
</StackPanel>
|
||||
|
||||
<checks:CheckListView Grid.Row="1" DataContext="{Binding Checks}"/>
|
||||
</Grid>
|
||||
</UserControl>
|
||||
@@ -0,0 +1,8 @@
|
||||
using System.Windows.Controls;
|
||||
|
||||
namespace ClaudeDo.Installer.Pages.DiagnosePage;
|
||||
|
||||
public partial class DiagnosePageView : UserControl
|
||||
{
|
||||
public DiagnosePageView() => InitializeComponent();
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
using System.Windows.Controls;
|
||||
using ClaudeDo.Installer.Checks;
|
||||
using ClaudeDo.Installer.Core;
|
||||
using ClaudeDo.Installer.Localization;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
|
||||
namespace ClaudeDo.Installer.Pages.DiagnosePage;
|
||||
|
||||
/// <summary>
|
||||
/// Re-runs the same environment checks as the wizard's SystemCheckPage, but against the
|
||||
/// installed configuration (worker.config.json + the detected install directory) instead of
|
||||
/// wizard defaults, and nothing here blocks navigation or auto-runs on load.
|
||||
/// </summary>
|
||||
public partial class DiagnosePageViewModel : ObservableObject, IInstallerPage
|
||||
{
|
||||
private readonly InstallContext _sharedContext;
|
||||
private readonly Func<InstallerWorkerConfig> _loadWorkerConfig;
|
||||
private readonly InstallContext _installedContext = new();
|
||||
private DiagnosePageView? _view;
|
||||
|
||||
public string Title => TrExtension.Localizer?["installer.diagnose.title"] ?? "Diagnose";
|
||||
public string Icon => "";
|
||||
public int Order => 5;
|
||||
public bool ShowInWizard => false;
|
||||
public bool ShowInSettings => true;
|
||||
public UserControl View => _view ??= new DiagnosePageView { DataContext = this };
|
||||
|
||||
public CheckListViewModel Checks { get; }
|
||||
|
||||
public DiagnosePageViewModel(InstallContext sharedContext, Func<EnvironmentCheckService> checkServiceFactory, Func<InstallerWorkerConfig> loadWorkerConfig)
|
||||
{
|
||||
_sharedContext = sharedContext;
|
||||
_loadWorkerConfig = loadWorkerConfig;
|
||||
Checks = new CheckListViewModel(_installedContext, checkServiceFactory);
|
||||
}
|
||||
|
||||
public Task LoadAsync()
|
||||
{
|
||||
var cfg = _loadWorkerConfig();
|
||||
_installedContext.InstallDirectory = _sharedContext.InstallDirectory;
|
||||
_installedContext.ClaudeBin = cfg.ClaudeBin;
|
||||
_installedContext.SignalRPort = cfg.SignalRPort;
|
||||
_installedContext.ExternalMcpPort = _sharedContext.ExternalMcpPort;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task ApplyAsync() => Task.CompletedTask;
|
||||
|
||||
public bool Validate() => true;
|
||||
}
|
||||
@@ -58,6 +58,7 @@ public partial class InstallPageViewModel : ObservableObject, IInstallerPage
|
||||
Steps.Add(new StepViewModel("Stop Worker"));
|
||||
Steps.Add(new StepViewModel("Download and Extract"));
|
||||
Steps.Add(new StepViewModel("Register Autostart"));
|
||||
Steps.Add(new StepViewModel("Register MCP with Claude"));
|
||||
Steps.Add(new StepViewModel("Start Worker"));
|
||||
Steps.Add(new StepViewModel("Write Install Manifest"));
|
||||
Steps.Add(new StepViewModel("Register in Add/Remove Programs"));
|
||||
@@ -67,6 +68,7 @@ public partial class InstallPageViewModel : ObservableObject, IInstallerPage
|
||||
Steps.Add(new StepViewModel("Download and Extract"));
|
||||
Steps.Add(new StepViewModel("Write Configuration"));
|
||||
Steps.Add(new StepViewModel("Initialize Database"));
|
||||
Steps.Add(new StepViewModel("Register MCP with Claude"));
|
||||
Steps.Add(new StepViewModel("Register Autostart"));
|
||||
Steps.Add(new StepViewModel("Create Shortcuts"));
|
||||
Steps.Add(new StepViewModel("Register in Add/Remove Programs"));
|
||||
|
||||
@@ -12,7 +12,7 @@ public partial class PathsPageViewModel : ObservableObject, IInstallerPage
|
||||
|
||||
public string Title => TrExtension.Localizer?["installer.paths.title"] ?? "Paths";
|
||||
public string Icon => "\uE8B7";
|
||||
public int Order => 1;
|
||||
public int Order => 2;
|
||||
public bool ShowInWizard => true;
|
||||
public bool ShowInSettings => true;
|
||||
public UserControl View => _view ??= new PathsPageView { DataContext = this };
|
||||
|
||||
@@ -14,7 +14,7 @@ public partial class ServicePageViewModel : ObservableObject, IInstallerPage
|
||||
|
||||
public string Title => TrExtension.Localizer?["installer.service.title"] ?? "Service";
|
||||
public string Icon => "\uE912";
|
||||
public int Order => 2;
|
||||
public int Order => 3;
|
||||
public bool ShowInWizard => true;
|
||||
public bool ShowInSettings => true;
|
||||
public UserControl View => _view ??= new ServicePageView { DataContext = this };
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
<UserControl x:Class="ClaudeDo.Installer.Pages.SystemCheckPage.SystemCheckPageView"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:local="clr-namespace:ClaudeDo.Installer.Pages.SystemCheckPage"
|
||||
xmlns:checks="clr-namespace:ClaudeDo.Installer.Checks"
|
||||
xmlns:loc="clr-namespace:ClaudeDo.Installer.Localization"
|
||||
d:DataContext="{d:DesignInstance local:SystemCheckPageViewModel}"
|
||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
mc:Ignorable="d">
|
||||
|
||||
<Grid>
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="*"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
</Grid.RowDefinitions>
|
||||
|
||||
<!-- Header -->
|
||||
<StackPanel Grid.Row="0" Margin="0,0,0,16">
|
||||
<TextBlock Text="{loc:Tr installer.systemCheck.title}" FontSize="18" FontWeight="SemiBold" Margin="0,0,0,4"/>
|
||||
<TextBlock Text="{loc:Tr installer.systemCheck.subtitle}"
|
||||
Foreground="{StaticResource TextSecondaryBrush}" TextWrapping="Wrap"/>
|
||||
</StackPanel>
|
||||
|
||||
<checks:CheckListView Grid.Row="1" DataContext="{Binding Checks}"/>
|
||||
|
||||
<!-- Wizard-only footer: "Claude Help Me" (the Recheck button lives in the shared list) -->
|
||||
<StackPanel Grid.Row="2" Margin="0,8,0,0">
|
||||
<TextBlock Text="{Binding ClaudeHelpError}" FontSize="12" TextWrapping="Wrap"
|
||||
Foreground="{StaticResource ErrorBrush}" Margin="0,0,0,8"
|
||||
Visibility="{Binding ClaudeHelpError, Converter={StaticResource NullToCollapsedConverter}}"/>
|
||||
<Button Content="{loc:Tr installer.systemCheck.claudeHelp.button}"
|
||||
HorizontalAlignment="Right"
|
||||
ToolTip="{Binding ClaudeHelpTooltip}"
|
||||
Command="{Binding StartClaudeHelpCommand}"/>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</UserControl>
|
||||
@@ -0,0 +1,8 @@
|
||||
using System.Windows.Controls;
|
||||
|
||||
namespace ClaudeDo.Installer.Pages.SystemCheckPage;
|
||||
|
||||
public partial class SystemCheckPageView : UserControl
|
||||
{
|
||||
public SystemCheckPageView() => InitializeComponent();
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Windows.Controls;
|
||||
using ClaudeDo.Installer.Checks;
|
||||
using ClaudeDo.Installer.Core;
|
||||
using ClaudeDo.Installer.Localization;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
|
||||
namespace ClaudeDo.Installer.Pages.SystemCheckPage;
|
||||
|
||||
public partial class SystemCheckPageViewModel : ObservableObject, IInstallerPage
|
||||
{
|
||||
private readonly InstallContext _context;
|
||||
private readonly ClaudeHelpLauncher _claudeHelpLauncher;
|
||||
private SystemCheckPageView? _view;
|
||||
private bool _hasStarted;
|
||||
|
||||
public string Title => TrExtension.Localizer?["installer.systemCheck.title"] ?? "System Check";
|
||||
public string Icon => "";
|
||||
public int Order => 1;
|
||||
public bool ShowInWizard => true;
|
||||
public bool ShowInSettings => false;
|
||||
public UserControl View => _view ??= new SystemCheckPageView { DataContext = this };
|
||||
|
||||
public CheckListViewModel Checks { get; }
|
||||
|
||||
// Pass-through to the shared check-run logic — kept so this page's UI/tests can bind
|
||||
// directly on it, matching the pre-extraction API.
|
||||
public ObservableCollection<CheckRowViewModel> Rows => Checks.Rows;
|
||||
public bool IsRunning => Checks.IsRunning;
|
||||
public bool HasRun => Checks.HasRun;
|
||||
public bool HasBlockingError => Checks.HasBlockingError;
|
||||
public string Summary => Checks.Summary;
|
||||
public IAsyncRelayCommand RunChecksCommand => Checks.RunChecksCommand;
|
||||
|
||||
public bool BlocksNavigation => Checks.IsRunning || Checks.HasBlockingError;
|
||||
|
||||
[ObservableProperty] private string? _claudeHelpError;
|
||||
|
||||
public bool ClaudeCliOk =>
|
||||
Checks.LastReport?.Results.FirstOrDefault(r => r.Id == ClaudeCliCheck.CheckId)?.Status == CheckStatus.Ok;
|
||||
|
||||
public bool ClaudeAuthFailed =>
|
||||
Checks.LastReport?.Results.FirstOrDefault(r => r.Id == ClaudeAuthCheck.CheckId)?.Status == CheckStatus.Failed;
|
||||
|
||||
public bool CanStartClaudeHelp => ClaudeCliOk && !ClaudeAuthFailed;
|
||||
|
||||
public string ClaudeHelpTooltip
|
||||
{
|
||||
get
|
||||
{
|
||||
var loc = TrExtension.Localizer;
|
||||
if (!ClaudeCliOk)
|
||||
return loc?["installer.systemCheck.claudeHelp.tooltip.cliMissing"] ?? "The Claude CLI was not found.";
|
||||
if (ClaudeAuthFailed)
|
||||
return loc?["installer.systemCheck.claudeHelp.tooltip.notLoggedIn"] ?? "Claude is not logged in.";
|
||||
return loc?["installer.systemCheck.claudeHelp.tooltip.ready"]
|
||||
?? "Start an interactive Claude session to help troubleshoot your setup.";
|
||||
}
|
||||
}
|
||||
|
||||
public SystemCheckPageViewModel(
|
||||
InstallContext context,
|
||||
Func<EnvironmentCheckService> checkServiceFactory,
|
||||
ClaudeHelpLauncher claudeHelpLauncher)
|
||||
{
|
||||
_context = context;
|
||||
_claudeHelpLauncher = claudeHelpLauncher;
|
||||
Checks = new CheckListViewModel(context, checkServiceFactory);
|
||||
// The wizard listens for PropertyChanged on this page to re-evaluate "Next" — bubble
|
||||
// any change from the composed check list up so a live recheck can flip it back, and so
|
||||
// the Claude-Help gating re-reads the fresh report.
|
||||
Checks.PropertyChanged += (_, _) =>
|
||||
{
|
||||
OnPropertyChanged(nameof(BlocksNavigation));
|
||||
OnPropertyChanged(nameof(ClaudeCliOk));
|
||||
OnPropertyChanged(nameof(ClaudeAuthFailed));
|
||||
OnPropertyChanged(nameof(CanStartClaudeHelp));
|
||||
OnPropertyChanged(nameof(ClaudeHelpTooltip));
|
||||
StartClaudeHelpCommand.NotifyCanExecuteChanged();
|
||||
};
|
||||
}
|
||||
|
||||
public Task LoadAsync()
|
||||
{
|
||||
if (!_hasStarted)
|
||||
{
|
||||
_hasStarted = true;
|
||||
_ = Checks.RunChecksCommand.ExecuteAsync(null);
|
||||
}
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task ApplyAsync() => Task.CompletedTask;
|
||||
|
||||
public bool Validate() => !HasBlockingError;
|
||||
|
||||
[RelayCommand(CanExecute = nameof(CanStartClaudeHelp))]
|
||||
private async Task StartClaudeHelpAsync()
|
||||
{
|
||||
if (Checks.LastReport is null) return;
|
||||
|
||||
ClaudeHelpError = null;
|
||||
var result = await _claudeHelpLauncher.LaunchAsync(Checks.LastReport, _context, CancellationToken.None);
|
||||
if (!result.Success)
|
||||
{
|
||||
ClaudeHelpError = TrExtension.Localizer?.Get("installer.systemCheck.claudeHelp.error", result.ErrorMessage ?? "")
|
||||
?? $"Could not start the Claude session: {result.ErrorMessage}";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -12,7 +12,7 @@ public partial class UiSettingsPageViewModel : ObservableObject, IInstallerPage
|
||||
|
||||
public string Title => TrExtension.Localizer?["installer.uiSettings.title"] ?? "UI Settings";
|
||||
public string Icon => "\uE771";
|
||||
public int Order => 3;
|
||||
public int Order => 4;
|
||||
public bool ShowInWizard => true;
|
||||
public bool ShowInSettings => true;
|
||||
public UserControl View => _view ??= new UiSettingsPageView { DataContext = this };
|
||||
|
||||
@@ -16,6 +16,11 @@ public sealed class RegisterAutostartStep : IInstallStep
|
||||
if (!File.Exists(workerExe))
|
||||
return StepResult.Fail($"Worker executable not found: {workerExe}");
|
||||
|
||||
// Legacy service/task cleanup below runs unconditionally on every install/update/repair,
|
||||
// even though it's a no-op once migrated. A cached "already migrated" flag could go stale
|
||||
// (e.g. a user re-adds the legacy service) and strand them with it still running; two
|
||||
// extra process starts per run is the price for that migration safety net.
|
||||
|
||||
// 1) Migrate away the legacy Windows service if present.
|
||||
progress.Report("Checking for legacy worker service...");
|
||||
var (queryExit, _) = await ProcessRunner.RunAsync("sc.exe", $"query {LegacyServiceName}", null, progress, ct);
|
||||
@@ -38,10 +43,11 @@ public sealed class RegisterAutostartStep : IInstallStep
|
||||
await ProcessRunner.RunAsync("schtasks.exe", $"/Delete /TN \"{LegacyTaskName}\" /F", null, progress, ct);
|
||||
|
||||
// 3) Register per-user autostart via a Startup-folder shortcut.
|
||||
progress.Report("Creating Startup shortcut...");
|
||||
progress.Report("Checking Startup shortcut...");
|
||||
try
|
||||
{
|
||||
AutostartShortcut.Install(AutostartShortcut.DefaultStartupDir, workerExe);
|
||||
var created = AutostartShortcut.Install(AutostartShortcut.DefaultStartupDir, workerExe);
|
||||
progress.Report(created ? "Startup shortcut created." : "Startup shortcut already up to date.");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
|
||||
@@ -5,9 +5,23 @@ namespace ClaudeDo.Installer.Steps;
|
||||
public sealed class RegisterMcpStep : IInstallStep
|
||||
{
|
||||
private const string ServerName = "claudedo";
|
||||
private readonly Func<InstallerWorkerConfig> _loadWorkerConfig;
|
||||
|
||||
public RegisterMcpStep(Func<InstallerWorkerConfig>? loadWorkerConfig = null)
|
||||
{
|
||||
_loadWorkerConfig = loadWorkerConfig ?? InstallerWorkerConfig.Load;
|
||||
}
|
||||
|
||||
public string Name => "Register MCP with Claude";
|
||||
|
||||
// Resolves the URL from the persisted worker.config.json rather than ctx.ExternalMcpPort:
|
||||
// an Update run never repopulates ctx from the existing installation, so ctx would still
|
||||
// hold the wizard default (47822) even when the installed config has a different port.
|
||||
// Returns null when the port is 0 — WorkerConfig treats that as "external listener off",
|
||||
// so registering a URL against it would just point Claude at nothing.
|
||||
public static string? ResolveUrl(InstallerWorkerConfig cfg) =>
|
||||
cfg.ExternalMcpPort == 0 ? null : $"http://127.0.0.1:{cfg.ExternalMcpPort}/mcp";
|
||||
|
||||
public async Task<StepResult> ExecuteAsync(InstallContext ctx, IProgress<string> progress, CancellationToken ct)
|
||||
{
|
||||
if (!ctx.RegisterMcpWithClaude)
|
||||
@@ -16,7 +30,12 @@ public sealed class RegisterMcpStep : IInstallStep
|
||||
return StepResult.Ok();
|
||||
}
|
||||
|
||||
var url = $"http://127.0.0.1:{ctx.ExternalMcpPort}/mcp";
|
||||
var url = ResolveUrl(_loadWorkerConfig());
|
||||
if (url is null)
|
||||
{
|
||||
progress.Report("Skipped (external_mcp_port is 0 — the external MCP listener is disabled).");
|
||||
return StepResult.Ok();
|
||||
}
|
||||
|
||||
// Drop any prior registration first so a re-run (e.g. update, changed port)
|
||||
// overwrites cleanly instead of erroring on a duplicate name.
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using System.ComponentModel;
|
||||
using System.Linq;
|
||||
using System.Windows;
|
||||
using ClaudeDo.Installer.Core;
|
||||
@@ -32,10 +33,12 @@ public partial class WizardViewModel : ObservableObject
|
||||
[NotifyPropertyChangedFor(nameof(IsLastPage))]
|
||||
[NotifyPropertyChangedFor(nameof(NextButtonText))]
|
||||
[NotifyPropertyChangedFor(nameof(CurrentPage))]
|
||||
[NotifyPropertyChangedFor(nameof(CanGoNext))]
|
||||
private int _currentPageIndex;
|
||||
|
||||
public IInstallerPage CurrentPage => Pages[CurrentPageIndex];
|
||||
public bool CanGoBack => CurrentPageIndex > 0;
|
||||
public bool CanGoNext => !CurrentPage.BlocksNavigation;
|
||||
public bool IsLastPage => CurrentPageIndex == Pages.Count - 1;
|
||||
public string NextButtonText => IsLastPage
|
||||
? (_localizer["installer.nav.install"])
|
||||
@@ -58,6 +61,14 @@ public partial class WizardViewModel : ObservableObject
|
||||
|| p is InstallPageViewModel).ToList()
|
||||
: all;
|
||||
|
||||
// A page's blocking state (e.g. a running/failed system check) can change while it's
|
||||
// displayed; re-evaluate CanGoNext whenever any page raises a property change.
|
||||
foreach (var page in Pages)
|
||||
{
|
||||
if (page is INotifyPropertyChanged notifying)
|
||||
notifying.PropertyChanged += (_, _) => OnPropertyChanged(nameof(CanGoNext));
|
||||
}
|
||||
|
||||
if (Pages.Count > 0)
|
||||
_ = InitAsync();
|
||||
}
|
||||
|
||||
@@ -100,6 +100,7 @@
|
||||
|
||||
<Button Grid.Column="2" Content="{Binding NextButtonText}"
|
||||
Command="{Binding GoNextCommand}"
|
||||
IsEnabled="{Binding CanGoNext}"
|
||||
Style="{StaticResource AccentButton}"
|
||||
MinWidth="100"/>
|
||||
</Grid>
|
||||
|
||||
@@ -18,6 +18,9 @@
|
||||
"effort": "Effort",
|
||||
"modelPresets": "Vorgaben pro Modell",
|
||||
"modelPresetsHint": "Effort und Durchlauf-Budget, mit denen eine Sitzung unter diesem Modell läuft. Overrides auf Listen- und Aufgabenebene gewinnen weiterhin.",
|
||||
"maxTurnsCeiling": "Max. Durchläufe – Obergrenze",
|
||||
"maxTurnsCeilingHint": "Harte Obergrenze, auf die jeder aufgelöste Max-Durchläufe-Wert geklemmt wird — Task-, Listen- und Modell-Einstellungen können sie nicht überschreiten.",
|
||||
"maxTurnsCeilingInvalid": "Die Obergrenze für Max. Durchläufe muss mindestens 1 sein.",
|
||||
"permission": "Berechtigung",
|
||||
"maxParallelExecutions": "Max. parallele Ausführungen",
|
||||
"maxParallelExecutionsHint": "Wie viele Aufgaben aus der Warteschlange der Worker gleichzeitig ausführt.",
|
||||
@@ -62,7 +65,10 @@
|
||||
"retryPrompt": "Wiederholung",
|
||||
"dailyPrepPrompt": "Tagesplanung",
|
||||
"weeklyReportPrompt": "Wochenbericht",
|
||||
"openInEditor": "Im Editor öffnen"
|
||||
"viewPrompt": "Ansehen",
|
||||
"customizedSection": "ANGEPASST",
|
||||
"customizedHint": "Diese Prompts weichen vom mitgelieferten Standard ab und werden unverändert beibehalten. Zurücksetzen, um künftige Standard-Verbesserungen zu erhalten.",
|
||||
"resetToDefault": "Auf Standard zurücksetzen"
|
||||
},
|
||||
"prime": {
|
||||
"description": "Bereite dein Claude-Nutzungsfenster vor, indem an den von dir gewählten Tagen zu einer bestimmten Zeit ein einzelner nicht-interaktiver Ping ausgelöst wird. Läuft nur, solange ClaudeDo geöffnet ist. Wenn die App innerhalb von 30 Minuten vor der Zielzeit startet, wird der Ping sofort ausgelöst.",
|
||||
@@ -103,6 +109,7 @@
|
||||
"overrideBadge": "überschrieben",
|
||||
"resetToInherited": "Auf geerbt zurücksetzen"
|
||||
},
|
||||
"turnsCeilingHint": "Läufe sind auf {0} Durchläufe gedeckelt — dieser Wert wird geklemmt.",
|
||||
"agentEditor": {
|
||||
"model": "Modell",
|
||||
"maxTurns": "Max. Durchläufe",
|
||||
@@ -126,6 +133,7 @@
|
||||
"tasks": {
|
||||
"showCompletedTip": "Abgeschlossene anzeigen",
|
||||
"letClaudeTip": "Claude machen lassen",
|
||||
"quickClaudeTip": "Schnelle Claude-Sitzung",
|
||||
"listSettingsTip": "Listeneinstellungen",
|
||||
"addPlaceholder": "Aufgabe hinzufügen…",
|
||||
"enterKey": "ENTER",
|
||||
@@ -141,7 +149,7 @@
|
||||
"ctxMarkAs": "Markieren als",
|
||||
"ctxMarkDone": "Erledigt",
|
||||
"ctxMarkCancelled": "Abgebrochen",
|
||||
"ctxOpenConPtySession": "ConPTY-Sitzung öffnen",
|
||||
"ctxOpenConPtySession": "Interaktive Sitzung öffnen",
|
||||
"ctxOpenPlanningSession": "Planungssitzung öffnen",
|
||||
"ctxResumePlanningSession": "Planungssitzung fortsetzen",
|
||||
"ctxFinalizePlanningSession": "Plan finalisieren",
|
||||
@@ -186,11 +194,9 @@
|
||||
"settingsTip": "Einstellungen",
|
||||
"smartListsLabel": "INTELLIGENTE LISTEN",
|
||||
"myListsLabel": "MEINE LISTEN",
|
||||
"contextSettings": "Einstellungen...",
|
||||
"contextWorktrees": "Worktrees…",
|
||||
"contextOpenExplorer": "Im Explorer öffnen",
|
||||
"contextOpenTerminal": "Im Terminal öffnen",
|
||||
"contextLetClaude": "Claude machen lassen",
|
||||
"newList": "Neue Liste",
|
||||
"addReposTip": "Repos als Listen hinzufügen"
|
||||
},
|
||||
@@ -243,21 +249,7 @@
|
||||
}
|
||||
},
|
||||
"agent": {
|
||||
"stopTip": "Agent stoppen",
|
||||
"sendToQueue": "In Warteschlange einreihen",
|
||||
"sendToQueueTip": "Diese Aufgabe einreihen, damit der Worker sie übernimmt",
|
||||
"removeFromQueue": "Aus Warteschlange entfernen",
|
||||
"removeFromQueueTip": "Diese Aufgabe wieder aus der Warteschlange nehmen",
|
||||
"worktreeLabel": "WORKTREE",
|
||||
"copyPathTip": "Pfad kopieren",
|
||||
"diffLabel": "DIFF",
|
||||
"openDiff": "Diff öffnen",
|
||||
"worktreeBtn": "Worktree",
|
||||
"openWorktreeTip": "Worktree im Datei-Explorer öffnen",
|
||||
"continue": "Fortsetzen",
|
||||
"continueTip": "Die letzte Sitzung fortsetzen und weitermachen",
|
||||
"resetAndRetry": "Zurücksetzen & erneut versuchen",
|
||||
"resetAndRetryTip": "Den Worktree verwerfen und die Aufgabe erneut einreihen, um von vorn zu beginnen"
|
||||
"openWorktreeTip": "Worktree im Datei-Explorer öffnen"
|
||||
},
|
||||
"notes": {
|
||||
"today": "Heute",
|
||||
@@ -284,14 +276,17 @@
|
||||
"focusMode": "Fokus",
|
||||
"overviewMode": "Übersicht",
|
||||
"closeSession": "Sitzung schließen",
|
||||
"conptyLaunchFailed": "ConPTY-Sitzung konnte nicht geöffnet werden: {0}",
|
||||
"conptyLaunchFailed": "Interaktive Sitzung konnte nicht geöffnet werden: {0}",
|
||||
"conptyStarting": "Sitzung wird gestartet…",
|
||||
"mergeHelperTitle": "Merge-Helfer",
|
||||
"mergeHelperHandoffTitleSuffix": " (Übergabe)",
|
||||
"mergeHelperTaskTitle": "Listen-Handler: {0}",
|
||||
"mergeHelperTaskDescriptionHeader": "Von diesem Lauf bearbeitete Tasks:",
|
||||
"submitForReviewFailed": "Einreichen zum Review fehlgeschlagen: {0}",
|
||||
"submitForReview": "Zum Review einreichen",
|
||||
"submitForReviewTip": "Diesen Worktree committen und den Task ins Review bringen, damit er gemergt werden kann",
|
||||
"retry": "Erneut versuchen",
|
||||
"retryTip": "Diese Sitzung erneut starten",
|
||||
"planningTitleSuffix": " (Planung)",
|
||||
"question": {
|
||||
"title": "Claude fragt nach",
|
||||
@@ -306,6 +301,10 @@
|
||||
"refresh": "Aktualisieren",
|
||||
"empty": "Keine Logs in den letzten 30 Minuten.",
|
||||
"count": "{0} Einträge",
|
||||
"copyLast": "Letzte 40 kopieren",
|
||||
"copyLastTooltip": "Kopiert die letzten 40 sichtbaren Zeilen in die Zwischenablage",
|
||||
"copied": "{0} Zeilen kopiert",
|
||||
"copyFailed": "Kopieren fehlgeschlagen",
|
||||
"footerHint": "logs",
|
||||
"openTooltip": "Aktuelle Worker-Logs anzeigen"
|
||||
},
|
||||
@@ -427,9 +426,12 @@
|
||||
"title": "USAGE MONITOR",
|
||||
"windowTitle": "Usage Monitor",
|
||||
"noGauges": "Noch keine Nutzungslimits gemeldet.",
|
||||
"refresh": "Jetzt aktualisieren",
|
||||
"refreshHint": "Abruf alle 5 Min., während ein Task läuft, sonst alle 15 Min.",
|
||||
"staleFormat": "Werte veraltet (Stand {0})",
|
||||
"staleGateHint": "Das Gate greift in diesem Zustand nicht.",
|
||||
"gateBlockedFormat": "Queue pausiert — {0}",
|
||||
"throttleFormat": "Queue gedrosselt: {0}/{1} Slots ({2})",
|
||||
"resetIn": "Reset in {0}",
|
||||
"gaugeSession": "Session (5 Std.)",
|
||||
"gaugeWeeklyAll": "Woche (alle Modelle)",
|
||||
@@ -474,6 +476,26 @@
|
||||
"registerMcp": "MCP-Server bei Claude registrieren",
|
||||
"registerMcpHint": "Führt 'claude mcp add' aus, damit Claude deine ClaudeDo-Aufgaben sehen und verwalten kann. Du kannst dies später ändern."
|
||||
},
|
||||
"systemCheck": {
|
||||
"title": "Systemprüfung",
|
||||
"subtitle": "ClaudeDo prüft dein System vor der Installation.",
|
||||
"recheck": "Erneut prüfen",
|
||||
"summary": {
|
||||
"running": "System wird geprüft…",
|
||||
"ok": "Alles in Ordnung.",
|
||||
"warnings": "{0} Warnung(en) gefunden.",
|
||||
"blocking": "{0} Problem(e) müssen behoben werden: {1}"
|
||||
},
|
||||
"claudeHelp": {
|
||||
"button": "Claude Help Me",
|
||||
"error": "Claude-Session konnte nicht gestartet werden: {0}",
|
||||
"tooltip": {
|
||||
"cliMissing": "Claude CLI wurde nicht gefunden — installiere sie zuerst (siehe Hinweis zur Claude CLI oben).",
|
||||
"notLoggedIn": "Claude ist nicht eingeloggt — führe einmal 'claude' aus und schließe den Login-Vorgang ab.",
|
||||
"ready": "Öffnet eine interaktive Claude-Session, die dir beim Einrichten hilft."
|
||||
}
|
||||
}
|
||||
},
|
||||
"paths": {
|
||||
"title": "Datenpfade",
|
||||
"subtitle": "Lege fest, wo ClaudeDo seine Daten speichert.",
|
||||
@@ -505,17 +527,50 @@
|
||||
"subtitle": "Klicke auf Installieren, um ClaudeDo zu erstellen und bereitzustellen.",
|
||||
"launch": "ClaudeDo starten"
|
||||
},
|
||||
"diagnose": {
|
||||
"title": "Diagnose",
|
||||
"subtitle": "Führt die Systemprüfungen erneut aus, gegen deine installierte Konfiguration."
|
||||
},
|
||||
"settings": {
|
||||
"removeUserData": "Benutzerdaten entfernen (Aufgaben, Logs, Konfigurationen in ~/.todo-app)",
|
||||
"uninstall": "Deinstallieren",
|
||||
"repair": "Reparieren",
|
||||
"save": "Speichern",
|
||||
"close": "Schließen"
|
||||
}
|
||||
},
|
||||
"checks": {
|
||||
"git": {
|
||||
"title": "Git",
|
||||
"hint": "Installiere Git und stelle sicher, dass es im PATH liegt."
|
||||
},
|
||||
"selfUpdate": {
|
||||
"heading": "Ein neuerer Installer ist verfügbar",
|
||||
"update": "Aktualisieren",
|
||||
"continueAnyway": "Trotzdem fortfahren"
|
||||
"gitIdentity": {
|
||||
"title": "Git-Identität",
|
||||
"hint": "Lege deine Git-Identität fest: git config --global user.name \"...\" und user.email \"...\"."
|
||||
},
|
||||
"ports": {
|
||||
"title": "Ports",
|
||||
"hint": "Gib den Port frei oder ändere ihn nach der Installation auf der Worker-Seite."
|
||||
},
|
||||
"writeAccess": {
|
||||
"title": "Schreibzugriff",
|
||||
"hint": "Wähle ein anderes Installationsverzeichnis oder starte den Installer als Administrator."
|
||||
},
|
||||
"claudeCli": {
|
||||
"title": "Claude CLI",
|
||||
"hint": "Installiere die Claude CLI und stelle sicher, dass sie im PATH liegt."
|
||||
},
|
||||
"claudeVersion": {
|
||||
"title": "Claude-CLI-Version",
|
||||
"hint": "Aktualisiere die Claude CLI auf eine neuere Version."
|
||||
},
|
||||
"claudeAuth": {
|
||||
"title": "Claude-Anmeldung",
|
||||
"hint": "Führe 'claude auth login' aus, um dich anzumelden."
|
||||
},
|
||||
"permissionModeAuto": {
|
||||
"title": "Berechtigungsmodus",
|
||||
"hint": "Aktualisiere die Claude CLI auf eine Version, die --permission-mode auto unterstützt."
|
||||
}
|
||||
},
|
||||
"planning": {
|
||||
@@ -576,6 +631,13 @@
|
||||
"available": "Update verfügbar: v",
|
||||
"updateNow": "Jetzt aktualisieren",
|
||||
"dismiss": "Ausblenden"
|
||||
},
|
||||
"staleWorker": {
|
||||
"message": "Der Worker läuft auf einem älteren Stand als der gemergte main-Branch dieses Repos — Neustart nötig, damit gemergte Änderungen wirken."
|
||||
},
|
||||
"externalMerge": {
|
||||
"banner": "Eine Claude-Session löst gerade einen Merge-Konflikt auf — bitte keine Dateien im Repository bearbeiten.",
|
||||
"open": "Resolver öffnen"
|
||||
}
|
||||
},
|
||||
"vm": {
|
||||
@@ -585,22 +647,26 @@
|
||||
"taskStatus": { "idle": "Leerlauf", "queued": "In Warteschlange", "running": "Läuft", "waitingForReview": "Wartet auf Prüfung", "waitingForChildren": "Wartet auf Teilaufgaben", "done": "Fertig", "failed": "Fehlgeschlagen", "cancelled": "Abgebrochen", "parked": "Geparkt", "interactive": "Interaktiv" },
|
||||
"planningBadge": { "active": "PLANUNG", "finalized": "GEPLANT" },
|
||||
"taskRow": { "createdPrefix": "Erstellt {0}", "stepsText": "{0}/{1} Schritte" },
|
||||
"tasksIsland": { "completedHeader": "ABGESCHLOSSEN", "completedHeaderCount": "ABGESCHLOSSEN · {0}", "planningOpenFailed": "Planungssitzung konnte nicht geöffnet werden: {0}", "planningResumeFailed": "Planungssitzung konnte nicht fortgesetzt werden: {0}", "approveFailed": "Genehmigen & Mergen fehlgeschlagen: {0}", "moveRunningRejected": "Ein laufender Task kann nicht in eine andere Liste verschoben werden.", "moveWorktreeRejected": "Verschieben nicht möglich — dieser Task hat einen aktiven Worktree, der auf sein aktuelles Repo zeigt.", "moveRepoConfirm": "Unterschiedliche Repos — {0} → {1}. Task trotzdem verschieben?", "moveConfirmUnavailable": "Verschieben nicht möglich — der Bestätigungsdialog ist nicht verfügbar." },
|
||||
"tasksIsland": { "completedHeader": "ABGESCHLOSSEN", "completedHeaderCount": "ABGESCHLOSSEN · {0}", "planningOpenFailed": "Planungssitzung konnte nicht geöffnet werden: {0}", "planningResumeFailed": "Planungssitzung konnte nicht fortgesetzt werden: {0}", "approveFailed": "Genehmigen & Mergen fehlgeschlagen: {0}", "cancelReviewFailed": "Prüfung abbrechen fehlgeschlagen: {0}", "sendToQueueFailed": "In die Warteschlange stellen fehlgeschlagen: {0}", "queuePlanBlockedInteractive": "Plan kann nicht in die Warteschlange gestellt werden — {0} hat eine offene interaktive Sitzung und muss zuerst geschlossen werden.", "moveRunningRejected": "Ein laufender Task kann nicht in eine andere Liste verschoben werden.", "moveWorktreeRejected": "Verschieben nicht möglich — dieser Task hat einen aktiven Worktree, der auf sein aktuelles Repo zeigt.", "moveRepoConfirm": "Unterschiedliche Repos — {0} → {1}. Task trotzdem verschieben?", "moveConfirmUnavailable": "Verschieben nicht möglich — der Bestätigungsdialog ist nicht verfügbar.", "quickClaudeNoWorkingDir": "Für diese Liste ist kein Arbeitsverzeichnis konfiguriert.", "quickClaudeDirMissing": "Arbeitsverzeichnis existiert nicht mehr: {0}" },
|
||||
"diff": { "loadFailed": "Diff konnte nicht geladen werden: {0}", "noChanges": "Keine Änderungen anzuzeigen.", "unavailable": "Diff nicht mehr verfügbar — Commit-Bereich unvollständig." },
|
||||
"planningDiff": { "hubError": "Kombinierte Vorschau konnte nicht erstellt werden (Hub-Fehler).", "conflict": "Kombinierte Vorschau nicht möglich: Teilaufgabe {0} steht im Konflikt mit einer früheren Teilaufgabe ({1} Dateien).", "buildFailed": "Kombinierte Vorschau konnte nicht erstellt werden: {0}" },
|
||||
"merge": { "commitMessage": "Merge-Aufgabe: {0}", "workerOfflineBranches": "Worker offline — Branches können nicht aufgelistet werden.", "loadBranchesFailed": "Branches konnten nicht geladen werden: {0}", "merged": "Zusammengeführt.", "conflict": "Merge-Konflikt — Ziel-Branch wiederhergestellt. Manuell oder über Fortsetzen lösen, dann erneut versuchen.", "blocked": "Blockiert: {0}", "unknownStatus": "Unbekannter Status: {0}", "mergeFailed": "Merge fehlgeschlagen: {0}" },
|
||||
"merge": { "commitMessage": "Merge-Aufgabe: {0}", "workerOfflineBranches": "Worker offline — Branches können nicht aufgelistet werden.", "loadBranchesFailed": "Branches konnten nicht geladen werden: {0}", "merged": "Zusammengeführt.", "conflict": "Merge-Konflikt — Ziel-Branch wiederhergestellt. Manuell oder über Fortsetzen lösen, dann erneut versuchen.", "blocked": "Blockiert: {0}", "verifyFailed": "Merge ist gelandet, aber das Verify-Kommando der Liste ist fehlgeschlagen — die Aufgabe wurde nicht auf Erledigt gesetzt.", "unknownStatus": "Unbekannter Status: {0}", "mergeFailed": "Merge fehlgeschlagen: {0}" },
|
||||
"conflictResolution": { "vsCodeError": "VS Code konnte nicht gestartet werden: {0}. Die Pfade sind oben aufgeführt — kopiere sie manuell.", "subtaskPrefix": "Konflikte in Teilaufgabe: {0}", "targetPrefix": "Zusammenführen in: {0}" },
|
||||
"settingsModal": { "workerOffline": "Worker offline — Einstellungen schreibgeschützt.", "saveFailed": "Speichern fehlgeschlagen: {0}" },
|
||||
"onlineInbox": { "workerOffline": "Worker offline — Konfiguration kann nicht geladen werden.", "saved": "Konfiguration gespeichert.", "saveFailed": "Speichern fehlgeschlagen: {0}", "signedIn": "Erfolgreich angemeldet.", "signedInNoRole": "Angemeldet, aber diesem Konto fehlt die Rolle 'user' in Zitadel — die Online-Synchronisierung wird abgelehnt, bis die Rolle im ClaudeDo-Projekt zugewiesen wird.", "signInFailed": "Anmeldung fehlgeschlagen: {0}", "signedOut": "Abgemeldet.", "signOutFailed": "Abmeldung fehlgeschlagen: {0}" },
|
||||
"weeklyReport": { "invalidRange": "Ungültiger Datumsbereich.", "generating": "Bericht wird erstellt…", "error": "Fehler: {0}" },
|
||||
"usageMonitor": { "loadFailed": "Nutzungsdaten konnten nicht geladen werden: {0}" },
|
||||
"filesTab": { "workerOffline": "Worker offline.", "noneBundled": "Keine Standard-Agenten mitgeliefert.", "allPresent": "Alle Standard-Agenten bereits vorhanden.", "restored": "{0} Standard-Agent(en) wiederhergestellt.", "restoreFailed": "Wiederherstellung fehlgeschlagen: {0}", "openFailed": "Öffnen fehlgeschlagen: {0}" },
|
||||
"usageMonitor": {
|
||||
"loadFailed": "Nutzungsdaten konnten nicht geladen werden: {0}",
|
||||
"refreshFailed": "Nutzung konnte nicht aktualisiert werden: {0}"
|
||||
},
|
||||
"filesTab": { "workerOffline": "Worker offline.", "noneBundled": "Keine Standard-Agenten mitgeliefert.", "allPresent": "Alle Standard-Agenten bereits vorhanden.", "restored": "{0} Standard-Agent(en) wiederhergestellt.", "restoreFailed": "Wiederherstellung fehlgeschlagen: {0}", "resetToDefault": "Auf den mitgelieferten Standard zurückgesetzt." },
|
||||
"sessionSkillsTab": { "installed": "Installiert: {0}", "installFailed": "Installation fehlgeschlagen: {0}", "updated": "Aktualisiert.", "updateFailed": "Aktualisierung fehlgeschlagen: {0}", "removed": "Entfernt.", "removeFailed": "Entfernen fehlgeschlagen: {0}" },
|
||||
"worktreesTab": { "workerOffline": "Worker offline.", "removed": "{0} Worktree(s) entfernt.", "blocked": "Zwangsentfernung nicht möglich: {0} Aufgabe(n) laufen noch. Brich sie zuerst ab.", "removedFrom": "{0} Worktree(s) von {1} Aufgabe(n) entfernt.", "cleanupFailed": "Aufräumen fehlgeschlagen: {0}", "resetFailed": "Zurücksetzen fehlgeschlagen: {0}" },
|
||||
"worktreesOverview": { "titleAll": "Worktrees", "titleList": "Worktrees — {0}", "listFallback": "Liste", "cleanupFailed": "Aufräumen fehlgeschlagen.", "cleanupFailedDetailed": "Aufräumen fehlgeschlagen: {0}", "removed": "{0} Worktree(s) entfernt.", "discardFailed": "Worktree konnte nicht verworfen werden.", "keepFailed": "Worktree konnte nicht behalten werden.", "cannotForceRunning": "Eine laufende Aufgabe kann nicht zwangsweise entfernt werden.", "forceRemoveFailed": "Zwangsentfernung fehlgeschlagen.", "forceRemoveFailedDetailed": "Zwangsentfernung fehlgeschlagen: {0}", "batchProgress": "Merge {0}/{1}…", "batchDone": "{0} gemergt, {1} zu lösen." },
|
||||
"listSettings": { "untitled": "Unbenannt" },
|
||||
"detailsIsland": { "verifyFailed": "Merge ist erfolgt, aber das Verifikationskommando der Liste ist fehlgeschlagen — die Aufgabe wurde nicht auf 'Erledigt' gesetzt." },
|
||||
"lists": { "localSuffix": "{0} / lokal", "smartMyDay": "Mein Tag", "smartImportant": "Wichtig", "smartPlanned": "Geplant", "virtualQueue": "Warteschlange", "virtualRunning": "Läuft", "virtualReview": "Prüfung", "newList": "Neue Liste" }
|
||||
"lists": { "localSuffix": "{0} / lokal", "smartMyDay": "Mein Tag", "smartImportant": "Wichtig", "smartPlanned": "Geplant", "virtualQueue": "Warteschlange", "virtualRunning": "Läuft", "virtualReview": "Prüfung", "newList": "Neue Liste" },
|
||||
"repoImport": { "loadFailed": "Gespeicherte Ordner konnten nicht geladen werden: {0}", "saveFailed": "Ordner konnten nicht gespeichert werden: {0}" }
|
||||
},
|
||||
"usage": {
|
||||
"pill": {
|
||||
@@ -615,7 +681,42 @@
|
||||
"durationMinutes": "{0} m",
|
||||
"blockedReason": "Blockiert: {0}",
|
||||
"stale": "veraltet (Stand {0})",
|
||||
"lastError": "Letzter Fehler: {0}"
|
||||
"lastError": "Letzter Fehler: {0}",
|
||||
"throttled": "Gedrosselt: {0}/{1} Slots ({2})"
|
||||
}
|
||||
},
|
||||
"checks": {
|
||||
"git": {
|
||||
"title": "Git",
|
||||
"hint": "Installiere Git von https://git-scm.com/downloads und starte den Installer neu."
|
||||
},
|
||||
"gitIdentity": {
|
||||
"title": "Git-Identität",
|
||||
"hint": "Führe aus: git config --global user.name \"Dein Name\" und git config --global user.email \"du@example.com\""
|
||||
},
|
||||
"ports": {
|
||||
"title": "Ports",
|
||||
"hint": "Ändere die SignalR-/MCP-Ports auf der Dienste-Seite, oder beende den blockierenden Prozess."
|
||||
},
|
||||
"writeAccess": {
|
||||
"title": "Schreibzugriff",
|
||||
"hint": "Wähle ein anderes Installationsverzeichnis oder starte den Installer mit ausreichenden Rechten (z. B. als Administrator)."
|
||||
},
|
||||
"claudeCli": {
|
||||
"title": "Claude CLI",
|
||||
"hint": "Installiere die Claude CLI (npm install -g @anthropic-ai/claude-code) und starte danach dein Terminal/deine Anmeldesitzung neu, damit der aktualisierte PATH greift."
|
||||
},
|
||||
"claudeVersion": {
|
||||
"title": "Claude-CLI-Version",
|
||||
"hint": "Aktualisiere die CLI: npm update -g @anthropic-ai/claude-code"
|
||||
},
|
||||
"claudeAuth": {
|
||||
"title": "Claude-CLI-Login",
|
||||
"hint": "Starte 'claude' einmal und schließe den Login-Vorgang ab."
|
||||
},
|
||||
"permissionModeAuto": {
|
||||
"title": "Unterstützung für --permission-mode auto",
|
||||
"hint": "Aktualisiere die CLI: npm update -g @anthropic-ai/claude-code. ClaudeDo funktioniert auch im Modus acceptEdits/default, dann mit mehr Rückfragen."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,9 @@
|
||||
"effort": "Effort",
|
||||
"modelPresets": "Per-model defaults",
|
||||
"modelPresetsHint": "Reasoning effort and turn budget used when a session runs under that model. List- and task-level max-turn overrides still win.",
|
||||
"maxTurnsCeiling": "Max turns ceiling",
|
||||
"maxTurnsCeilingHint": "Hard cap every resolved max-turns value is clamped to — task, list, and per-model settings can't exceed this.",
|
||||
"maxTurnsCeilingInvalid": "Max turns ceiling must be at least 1.",
|
||||
"permission": "Permission",
|
||||
"maxParallelExecutions": "Max parallel executions",
|
||||
"maxParallelExecutionsHint": "How many queued tasks the worker runs at once.",
|
||||
@@ -62,7 +65,10 @@
|
||||
"retryPrompt": "Retry",
|
||||
"dailyPrepPrompt": "Daily prep",
|
||||
"weeklyReportPrompt": "Weekly report",
|
||||
"openInEditor": "Open in editor"
|
||||
"viewPrompt": "View",
|
||||
"customizedSection": "CUSTOMIZED",
|
||||
"customizedHint": "These prompts differ from the bundled default and are kept as-is. Reset to pick up future default improvements.",
|
||||
"resetToDefault": "Reset to default"
|
||||
},
|
||||
"prime": {
|
||||
"description": "Prime your Claude usage window by firing a single non-interactive ping on the days you choose, at a chosen time. Only runs while ClaudeDo is open. If the app starts within 30 minutes of the target time, the ping fires immediately.",
|
||||
@@ -103,6 +109,7 @@
|
||||
"overrideBadge": "override",
|
||||
"resetToInherited": "Reset to inherited"
|
||||
},
|
||||
"turnsCeilingHint": "Runs are capped at {0} turns — this value will be clamped.",
|
||||
"agentEditor": {
|
||||
"model": "Model",
|
||||
"maxTurns": "Max turns",
|
||||
@@ -126,6 +133,7 @@
|
||||
"tasks": {
|
||||
"showCompletedTip": "Show completed",
|
||||
"letClaudeTip": "Let Claude handle it",
|
||||
"quickClaudeTip": "Quick Claude session",
|
||||
"listSettingsTip": "List settings",
|
||||
"addPlaceholder": "Add a task…",
|
||||
"enterKey": "ENTER",
|
||||
@@ -141,7 +149,7 @@
|
||||
"ctxMarkAs": "Mark as",
|
||||
"ctxMarkDone": "Done",
|
||||
"ctxMarkCancelled": "Cancelled",
|
||||
"ctxOpenConPtySession": "Open ConPTY session",
|
||||
"ctxOpenConPtySession": "Open interactive session",
|
||||
"ctxOpenPlanningSession": "Open planning Session",
|
||||
"ctxResumePlanningSession": "Resume planning Session",
|
||||
"ctxFinalizePlanningSession": "Finalize plan",
|
||||
@@ -186,11 +194,9 @@
|
||||
"settingsTip": "Settings",
|
||||
"smartListsLabel": "SMART LISTS",
|
||||
"myListsLabel": "MY LISTS",
|
||||
"contextSettings": "Settings...",
|
||||
"contextWorktrees": "Worktrees…",
|
||||
"contextOpenExplorer": "Open in Explorer",
|
||||
"contextOpenTerminal": "Open in Terminal",
|
||||
"contextLetClaude": "Let Claude handle it",
|
||||
"newList": "New list",
|
||||
"addReposTip": "Add repos as lists"
|
||||
},
|
||||
@@ -243,21 +249,7 @@
|
||||
}
|
||||
},
|
||||
"agent": {
|
||||
"stopTip": "Stop agent",
|
||||
"sendToQueue": "Send to queue",
|
||||
"sendToQueueTip": "Queue this task for the worker to pick up",
|
||||
"removeFromQueue": "Remove from queue",
|
||||
"removeFromQueueTip": "Take this task back out of the queue",
|
||||
"worktreeLabel": "WORKTREE",
|
||||
"copyPathTip": "Copy path",
|
||||
"diffLabel": "DIFF",
|
||||
"openDiff": "Open diff",
|
||||
"worktreeBtn": "Worktree",
|
||||
"openWorktreeTip": "Open worktree in file explorer",
|
||||
"continue": "Continue",
|
||||
"continueTip": "Resume the last session and keep going",
|
||||
"resetAndRetry": "Reset & retry",
|
||||
"resetAndRetryTip": "Discard the worktree and re-queue the task to run from scratch"
|
||||
"openWorktreeTip": "Open worktree in file explorer"
|
||||
},
|
||||
"notes": {
|
||||
"today": "Today",
|
||||
@@ -284,14 +276,17 @@
|
||||
"focusMode": "Focus",
|
||||
"overviewMode": "Overview",
|
||||
"closeSession": "Close session",
|
||||
"conptyLaunchFailed": "Couldn't open ConPTY session: {0}",
|
||||
"conptyLaunchFailed": "Couldn't open interactive session: {0}",
|
||||
"conptyStarting": "Starting session…",
|
||||
"mergeHelperTitle": "Merge Helper",
|
||||
"mergeHelperHandoffTitleSuffix": " (Handoff)",
|
||||
"mergeHelperTaskTitle": "List handler: {0}",
|
||||
"mergeHelperTaskDescriptionHeader": "Tasks handled by this run:",
|
||||
"submitForReviewFailed": "Couldn't submit for review: {0}",
|
||||
"submitForReview": "Submit for review",
|
||||
"submitForReviewTip": "Commit this worktree and move the task to review so it can be merged",
|
||||
"retry": "Retry",
|
||||
"retryTip": "Try launching this session again",
|
||||
"planningTitleSuffix": " (Planning)",
|
||||
"question": {
|
||||
"title": "Claude is asking",
|
||||
@@ -307,7 +302,11 @@
|
||||
"empty": "No logs in the last 30 minutes.",
|
||||
"count": "{0} entries",
|
||||
"footerHint": "logs",
|
||||
"openTooltip": "View recent worker logs"
|
||||
"openTooltip": "View recent worker logs",
|
||||
"copyLast": "Copy last 40",
|
||||
"copyLastTooltip": "Copy the last 40 visible lines to the clipboard",
|
||||
"copied": "{0} lines copied",
|
||||
"copyFailed": "Copy failed"
|
||||
},
|
||||
"about": {
|
||||
"title": "ABOUT",
|
||||
@@ -427,9 +426,12 @@
|
||||
"title": "USAGE MONITOR",
|
||||
"windowTitle": "Usage Monitor",
|
||||
"noGauges": "No usage limits reported yet.",
|
||||
"refresh": "Refresh now",
|
||||
"refreshHint": "Polled every 5 min while a task runs, otherwise every 15 min.",
|
||||
"staleFormat": "Values stale (as of {0})",
|
||||
"staleGateHint": "The gate does not apply while values are stale.",
|
||||
"gateBlockedFormat": "Queue paused — {0}",
|
||||
"throttleFormat": "Queue throttled: {0}/{1} slots ({2})",
|
||||
"resetIn": "Reset in {0}",
|
||||
"gaugeSession": "Session (5h)",
|
||||
"gaugeWeeklyAll": "Week (all models)",
|
||||
@@ -474,6 +476,26 @@
|
||||
"registerMcp": "Register MCP server with Claude",
|
||||
"registerMcpHint": "Runs 'claude mcp add' so Claude can view and manage your ClaudeDo tasks. You can change this later."
|
||||
},
|
||||
"systemCheck": {
|
||||
"title": "System Check",
|
||||
"subtitle": "ClaudeDo checks your system before installing.",
|
||||
"recheck": "Recheck",
|
||||
"summary": {
|
||||
"running": "Checking your system…",
|
||||
"ok": "Everything looks good.",
|
||||
"warnings": "{0} warning(s) found.",
|
||||
"blocking": "{0} problem(s) must be fixed: {1}"
|
||||
},
|
||||
"claudeHelp": {
|
||||
"button": "Claude Help Me",
|
||||
"error": "Could not start the Claude session: {0}",
|
||||
"tooltip": {
|
||||
"cliMissing": "The Claude CLI was not found — install it first (see the Claude CLI hint above).",
|
||||
"notLoggedIn": "Claude is not logged in — run 'claude' once and complete the login flow.",
|
||||
"ready": "Open an interactive Claude session to help fix your setup."
|
||||
}
|
||||
}
|
||||
},
|
||||
"paths": {
|
||||
"title": "Data Paths",
|
||||
"subtitle": "Configure where ClaudeDo stores its data.",
|
||||
@@ -505,17 +527,50 @@
|
||||
"subtitle": "Click Install to build and deploy ClaudeDo.",
|
||||
"launch": "Launch ClaudeDo"
|
||||
},
|
||||
"diagnose": {
|
||||
"title": "Diagnose",
|
||||
"subtitle": "Re-run the environment checks against your installed configuration."
|
||||
},
|
||||
"settings": {
|
||||
"removeUserData": "Remove user data (tasks, logs, configs in ~/.todo-app)",
|
||||
"uninstall": "Uninstall",
|
||||
"repair": "Repair",
|
||||
"save": "Save",
|
||||
"close": "Close"
|
||||
}
|
||||
},
|
||||
"checks": {
|
||||
"git": {
|
||||
"title": "Git",
|
||||
"hint": "Install Git and make sure it is on PATH."
|
||||
},
|
||||
"selfUpdate": {
|
||||
"heading": "A newer installer is available",
|
||||
"update": "Update",
|
||||
"continueAnyway": "Continue anyway"
|
||||
"gitIdentity": {
|
||||
"title": "Git Identity",
|
||||
"hint": "Set your git identity: git config --global user.name \"...\" and user.email \"...\"."
|
||||
},
|
||||
"ports": {
|
||||
"title": "Ports",
|
||||
"hint": "Free the port, or change it on the Worker page after install."
|
||||
},
|
||||
"writeAccess": {
|
||||
"title": "Write Access",
|
||||
"hint": "Choose a different install directory, or run the installer as administrator."
|
||||
},
|
||||
"claudeCli": {
|
||||
"title": "Claude CLI",
|
||||
"hint": "Install the Claude CLI and make sure it is on PATH."
|
||||
},
|
||||
"claudeVersion": {
|
||||
"title": "Claude CLI Version",
|
||||
"hint": "Update the Claude CLI to a newer version."
|
||||
},
|
||||
"claudeAuth": {
|
||||
"title": "Claude Login",
|
||||
"hint": "Run 'claude auth login' to sign in."
|
||||
},
|
||||
"permissionModeAuto": {
|
||||
"title": "Permission Mode",
|
||||
"hint": "Update the Claude CLI to a version that supports --permission-mode auto."
|
||||
}
|
||||
},
|
||||
"planning": {
|
||||
@@ -576,6 +631,13 @@
|
||||
"available": "Update available: v",
|
||||
"updateNow": "Update now",
|
||||
"dismiss": "Dismiss"
|
||||
},
|
||||
"staleWorker": {
|
||||
"message": "The worker is running an older build than this repo's merged main — restart it so your merged changes take effect."
|
||||
},
|
||||
"externalMerge": {
|
||||
"banner": "A Claude session is resolving a merge conflict — don't edit files in the repository.",
|
||||
"open": "Open resolver"
|
||||
}
|
||||
},
|
||||
"vm": {
|
||||
@@ -585,22 +647,26 @@
|
||||
"taskStatus": { "idle": "Idle", "queued": "Queued", "running": "Running", "waitingForReview": "Waiting for Review", "waitingForChildren": "Waiting for Subtasks", "done": "Done", "failed": "Failed", "cancelled": "Cancelled", "parked": "Parked", "interactive": "Interactive" },
|
||||
"planningBadge": { "active": "PLANNING", "finalized": "PLANNED" },
|
||||
"taskRow": { "createdPrefix": "Created {0}", "stepsText": "{0}/{1} steps" },
|
||||
"tasksIsland": { "completedHeader": "COMPLETED", "completedHeaderCount": "COMPLETED · {0}", "planningOpenFailed": "Couldn't open planning session: {0}", "planningResumeFailed": "Couldn't resume planning session: {0}", "approveFailed": "Approve & merge failed: {0}", "moveRunningRejected": "Can't move a running task to another list.", "moveWorktreeRejected": "Can't move — this task has an active worktree pointing at its current repo.", "moveRepoConfirm": "Different repos — {0} → {1}. Move the task anyway?", "moveConfirmUnavailable": "Can't move — the confirmation dialog isn't available." },
|
||||
"tasksIsland": { "completedHeader": "COMPLETED", "completedHeaderCount": "COMPLETED · {0}", "planningOpenFailed": "Couldn't open planning session: {0}", "planningResumeFailed": "Couldn't resume planning session: {0}", "approveFailed": "Approve & merge failed: {0}", "cancelReviewFailed": "Cancel review failed: {0}", "sendToQueueFailed": "Send to queue failed: {0}", "queuePlanBlockedInteractive": "Can't queue the plan — {0} has an open interactive session and must be closed first.", "moveRunningRejected": "Can't move a running task to another list.", "moveWorktreeRejected": "Can't move — this task has an active worktree pointing at its current repo.", "moveRepoConfirm": "Different repos — {0} → {1}. Move the task anyway?", "moveConfirmUnavailable": "Can't move — the confirmation dialog isn't available.", "quickClaudeNoWorkingDir": "This list has no working directory configured.", "quickClaudeDirMissing": "Working directory no longer exists: {0}" },
|
||||
"diff": { "loadFailed": "Failed to load diff: {0}", "noChanges": "No changes to show.", "unavailable": "Diff no longer available — commit range incomplete." },
|
||||
"planningDiff": { "hubError": "Could not build combined preview (hub error).", "conflict": "Cannot build combined preview: subtask {0} conflicts with an earlier subtask ({1} files).", "buildFailed": "Could not build combined preview: {0}" },
|
||||
"merge": { "commitMessage": "Merge task: {0}", "workerOfflineBranches": "Worker offline — cannot list branches.", "loadBranchesFailed": "Failed to load branches: {0}", "merged": "Merged.", "conflict": "Merge conflict — target branch restored. Resolve manually or via Continue, then retry.", "blocked": "Blocked: {0}", "unknownStatus": "Unknown status: {0}", "mergeFailed": "Merge failed: {0}" },
|
||||
"merge": { "commitMessage": "Merge task: {0}", "workerOfflineBranches": "Worker offline — cannot list branches.", "loadBranchesFailed": "Failed to load branches: {0}", "merged": "Merged.", "conflict": "Merge conflict — target branch restored. Resolve manually or via Continue, then retry.", "blocked": "Blocked: {0}", "verifyFailed": "Merge landed, but the list's verify command failed — the task was kept out of Done.", "unknownStatus": "Unknown status: {0}", "mergeFailed": "Merge failed: {0}" },
|
||||
"conflictResolution": { "vsCodeError": "Could not launch VS Code: {0}. Paths are listed above — copy them manually.", "subtaskPrefix": "Conflicts in subtask: {0}", "targetPrefix": "Merging into: {0}" },
|
||||
"settingsModal": { "workerOffline": "Worker offline — settings read-only.", "saveFailed": "Save failed: {0}" },
|
||||
"onlineInbox": { "workerOffline": "Worker offline — cannot load config.", "saved": "Config saved.", "saveFailed": "Save failed: {0}", "signedIn": "Signed in successfully.", "signedInNoRole": "Signed in, but this account is missing the 'user' role in Zitadel — online sync will be rejected until the role is granted in the ClaudeDo project.", "signInFailed": "Sign-in failed: {0}", "signedOut": "Signed out.", "signOutFailed": "Sign-out failed: {0}" },
|
||||
"weeklyReport": { "invalidRange": "Invalid date range.", "generating": "Generating report…", "error": "Error: {0}" },
|
||||
"usageMonitor": { "loadFailed": "Couldn't load usage data: {0}" },
|
||||
"filesTab": { "workerOffline": "Worker offline.", "noneBundled": "No default agents bundled.", "allPresent": "All default agents already present.", "restored": "Restored {0} default agent(s).", "restoreFailed": "Restore failed: {0}", "openFailed": "Open failed: {0}" },
|
||||
"usageMonitor": {
|
||||
"loadFailed": "Couldn't load usage data: {0}",
|
||||
"refreshFailed": "Couldn't refresh usage: {0}"
|
||||
},
|
||||
"filesTab": { "workerOffline": "Worker offline.", "noneBundled": "No default agents bundled.", "allPresent": "All default agents already present.", "restored": "Restored {0} default agent(s).", "restoreFailed": "Restore failed: {0}", "resetToDefault": "Reset to the bundled default." },
|
||||
"sessionSkillsTab": { "installed": "Installed: {0}", "installFailed": "Install failed: {0}", "updated": "Updated.", "updateFailed": "Update failed: {0}", "removed": "Removed.", "removeFailed": "Remove failed: {0}" },
|
||||
"worktreesTab": { "workerOffline": "Worker offline.", "removed": "Removed {0} worktree(s).", "blocked": "Cannot force-remove: {0} task(s) still running. Cancel them first.", "removedFrom": "Removed {0} worktree(s) from {1} task(s).", "cleanupFailed": "Cleanup failed: {0}", "resetFailed": "Reset failed: {0}" },
|
||||
"worktreesOverview": { "titleAll": "Worktrees", "titleList": "Worktrees — {0}", "listFallback": "list", "cleanupFailed": "Cleanup failed.", "cleanupFailedDetailed": "Cleanup failed: {0}", "removed": "Removed {0} worktree(s).", "discardFailed": "Failed to discard worktree.", "keepFailed": "Failed to keep worktree.", "cannotForceRunning": "Cannot force-remove a running task.", "forceRemoveFailed": "Force remove failed.", "forceRemoveFailedDetailed": "Force remove failed: {0}", "batchProgress": "Merging {0}/{1}…", "batchDone": "Merged {0}, {1} need resolution." },
|
||||
"listSettings": { "untitled": "Untitled" },
|
||||
"detailsIsland": { "verifyFailed": "Merge landed, but the list's verify command failed — the task was kept out of Done." },
|
||||
"lists": { "localSuffix": "{0} / local", "smartMyDay": "My Day", "smartImportant": "Important", "smartPlanned": "Planned", "virtualQueue": "Queue", "virtualRunning": "Running", "virtualReview": "Review", "newList": "New list" }
|
||||
"lists": { "localSuffix": "{0} / local", "smartMyDay": "My Day", "smartImportant": "Important", "smartPlanned": "Planned", "virtualQueue": "Queue", "virtualRunning": "Running", "virtualReview": "Review", "newList": "New list" },
|
||||
"repoImport": { "loadFailed": "Couldn't load remembered folders: {0}", "saveFailed": "Couldn't save folders: {0}" }
|
||||
},
|
||||
"usage": {
|
||||
"pill": {
|
||||
@@ -615,7 +681,42 @@
|
||||
"durationMinutes": "{0} m",
|
||||
"blockedReason": "Blocked: {0}",
|
||||
"stale": "stale (as of {0})",
|
||||
"lastError": "Last error: {0}"
|
||||
"lastError": "Last error: {0}",
|
||||
"throttled": "Throttled: {0}/{1} slots ({2})"
|
||||
}
|
||||
},
|
||||
"checks": {
|
||||
"git": {
|
||||
"title": "Git",
|
||||
"hint": "Install Git from https://git-scm.com/downloads, then restart the installer."
|
||||
},
|
||||
"gitIdentity": {
|
||||
"title": "Git identity",
|
||||
"hint": "Run: git config --global user.name \"Your Name\" and git config --global user.email \"you@example.com\""
|
||||
},
|
||||
"ports": {
|
||||
"title": "Ports",
|
||||
"hint": "Change the SignalR/MCP ports on the Service page, or stop the process using them."
|
||||
},
|
||||
"writeAccess": {
|
||||
"title": "Write access",
|
||||
"hint": "Choose a different install directory, or run the installer with sufficient permissions (e.g. as administrator)."
|
||||
},
|
||||
"claudeCli": {
|
||||
"title": "Claude CLI",
|
||||
"hint": "Install the Claude CLI (npm install -g @anthropic-ai/claude-code), then restart your terminal/login session so the updated PATH takes effect."
|
||||
},
|
||||
"claudeVersion": {
|
||||
"title": "Claude CLI version",
|
||||
"hint": "Update the CLI: npm update -g @anthropic-ai/claude-code"
|
||||
},
|
||||
"claudeAuth": {
|
||||
"title": "Claude CLI login",
|
||||
"hint": "Run 'claude' once and complete the login flow."
|
||||
},
|
||||
"permissionModeAuto": {
|
||||
"title": "--permission-mode auto support",
|
||||
"hint": "Update the CLI: npm update -g @anthropic-ai/claude-code. ClaudeDo still works in acceptEdits/default mode, with more prompts."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+68
-35
@@ -2,17 +2,22 @@
|
||||
|
||||
Avalonia UI layer: views, viewmodels, converters, and the SignalR client.
|
||||
|
||||
Deeper detail: [review-merge](../../docs/explore-notes/review-merge.md) (diff stack + conflict
|
||||
resolver) · [conpty-sessions](../../docs/explore-notes/conpty-sessions.md) (Mission Control
|
||||
tiles) · [usage-monitoring](../../docs/explore-notes/usage-monitoring.md) (usage pill + modal).
|
||||
|
||||
## Pattern
|
||||
|
||||
MVVM with CommunityToolkit.Mvvm source generators:
|
||||
- `[ObservableProperty]` for bindable properties
|
||||
- `[RelayCommand]` for commands (supports async and CanExecute)
|
||||
- `[ObservableProperty]` for bindable properties, `[RelayCommand]` for commands
|
||||
- All ViewModels inherit `ViewModelBase` (extends `ObservableObject`)
|
||||
- All views use compiled bindings (`x:DataType`)
|
||||
|
||||
## Layout: Islands
|
||||
|
||||
`MainWindow` hosts three "islands" (lists | tasks | details). There is no MainWindowViewModel, StatusBarView, or task/list editor modal — the root coordinator is **IslandsShellViewModel**, and task/list editing happens inline in the islands.
|
||||
`MainWindow` hosts three "islands" (lists | tasks | details). There is **no**
|
||||
MainWindowViewModel, StatusBarView, or task/list editor modal — the root coordinator is
|
||||
`IslandsShellViewModel`, and task/list editing happens inline in the islands.
|
||||
|
||||
```
|
||||
ViewModels/
|
||||
@@ -23,52 +28,80 @@ ViewModels/
|
||||
Modals/ — About, DiffViewer (+ DiffModels), ListSettings, Merge, MergeHelperSelection,
|
||||
RepoImport, Settings (+ Settings/ tab VMs), UnfinishedPlanning, WeeklyReport,
|
||||
WorkerConnection, WorktreesOverview, UnifiedDiffParser
|
||||
Conflicts/ — ConflictResolverViewModel + ConflictModels (MergeFile/MergeFileSegment/MergeConflictBlock)
|
||||
Views/ — mirrors the VM layout; Islands/Detail/ holds TaskHeaderBar,
|
||||
DescriptionStepsCard, WorkConsole; plus AgentStripView, SessionTerminalView
|
||||
Views/Controls/ — MarkdownView, ModalShell, ThemedDatePicker, DiffLinesView, InheritedBadge, AgentConfigEditor
|
||||
Design/ — Tokens.axaml (design tokens; merged before styles) + IslandStyles.axaml
|
||||
(component styles + the filled icon geometry library)
|
||||
Conflicts/ — ConflictResolverViewModel + ConflictModels
|
||||
Views/ — mirrors the VM layout; Islands/Detail/ holds TaskHeaderBar,
|
||||
DescriptionStepsCard, WorkConsole; plus SessionTerminalView
|
||||
Views/Controls/ — MarkdownView, ModalShell, ThemedDatePicker, DiffLinesView, InheritedBadge,
|
||||
AgentConfigEditor
|
||||
Design/ — Tokens.axaml (design tokens; merged before styles)
|
||||
+ IslandStyles.axaml (component styles + the filled icon geometry library)
|
||||
```
|
||||
|
||||
## ViewModels
|
||||
## Core ViewModels
|
||||
|
||||
- **IslandsShellViewModel** — root coordinator; owns the three island VMs and the `WorkerClient`, wires cross-island events (selection, notes/prep mode, conflict resolution), owns connection state, the update banner, the inline worker-log strip (clickable → Log Visualizer overlay via `OpenLogVisualizerCommand`; `FlashFooterError` surfaces UI-action failures + the worker's Serilog Warn/Error there), responsive-layout flags (`ShowLists`/`ShowDetails` by window width), `PrimeStatus` flash, and the modal openers (About, RepoImport, WeeklyReport, WorktreesOverview, WorkerConnection help, LogVisualizer) plus `RestartWorkerAsync`/`CheckForUpdatesAsync`. Hosts `UpdateCheckService`.
|
||||
- **IslandsShellViewModel** — root coordinator. Owns the three island VMs and the `WorkerClient`, wires cross-island events (selection, notes/prep mode, conflict resolution), connection state, the update banner, the inline worker-log strip (clickable → Log Visualizer overlay; `FlashFooterError` surfaces UI-action failures + the worker's Warn/Error there), responsive-layout flags (`ShowLists`/`ShowDetails` by window width), `PrimeStatus` flash, the modal openers, and `RestartWorkerAsync`/`CheckForUpdatesAsync`. Hosts `UpdateCheckService`.
|
||||
- **ListsIslandViewModel** — smart lists (My Day, Important, Planned, virtual queued/running/review), user lists, selection, list CRUD, drag-reorder, badge counts, opens list settings / repo import / worktrees overview, `OpenInExplorer`/`OpenInTerminal`.
|
||||
- **TasksIslandViewModel** — open/overdue/completed groups for the selected list with hierarchy-aware regrouping; task CRUD, drag-reorder, toggle done/star, schedule, enqueue/dequeue, cancel; review actions (approve, reject-rerun, reject-park, cancel); planning session lifecycle (open/resume/discard/finalize, `QueuePlanningSubtasksAsync`); `RefineTask`, `OpenConPtySessionRequested` (embedded ConPTY terminal), `ToggleManual` (per-task manual flag) and `SyncInteractiveSessions` (mirrors Mission Control's open ConPTY panes onto the rows); MyDay extras (`IsMyDayList`, `ClearDayCommand`, `ShowPrepLogCommand`) and the pinned Notes pseudo-row (`ShowNotesRow`, `OpenNotesCommand`). Raises `NotesRequested`/`PrepRequested` events consumed by the shell.
|
||||
- **DetailsIslandViewModel** — the detail pane for a bound `TaskRowViewModel`. Owns live-log streaming (`Log` via `StreamLineFormatter`), debounced title/description editing, subtasks, session-outcome/roadblock split (splits `Result` at the roadblock marker into two cards) — the ROADBLOCK card also has a reply field (`RoadblockReplyDraft`/`SendRoadblockReplyCommand`, gated by `CanReplyToRoadblock` on `LatestRunSessionId`) that resumes the session via the same `ContinueTaskAsync` transport as `ContinueCommand` but with the user's own text instead of the fixed re-run prompt; failures raise `ErrorReported`, wired by the shell into `FlashFooterError`, the three-tab work console (`output`/`git`/`session`), child surfacing (`ChildOutcomes` rows plus `ChildrenNeedingAttention`/`HasChildrenNeedingAttention` — children that failed, were cancelled, await review, or reported roadblocks — drive an attention band on the Session tab, which is only visible when `HasChildOutcomes`), and the modes: `IsNotesMode` (hosts `NotesEditorViewModel`), `IsPrepMode`, computed `IsTaskDetailVisible = !IsNotesMode && !IsPrepMode`. Three concerns are extracted into section VMs exposed as properties: **AgentConfigEditorViewModel** (scope=Task; per-task Model/MaxTurns/AgentPath overrides with `InheritedBadge` + `InheritanceResolver`, additive SystemPrompt, debounced auto-save; exposed as `AgentSettings`), **MergeSectionViewModel** (merge-target selection, mergeability indicator via `MergePreviewPresenter` over `PreviewMergeAsync`, `OpenDiffAsync` and `ReviewCombinedDiffCommand` — both build a `DiffViewerViewModel`, call `ShowDiffViewer`, and fire the `DiffViewed` callback; `HasReviewableDiff` reports whether anything is inspectable, feeding the review gate), **PrepPanelViewModel** (daily-prep panel: `PrepLog`, `PlanDayCommand` → `RunDailyPrepNowAsync`, persisted last run via `GetLastPrepLogAsync`). Attachments: `Attachments` (`ObservableCollection<AttachmentRowViewModel>`), `IsDragOver`, `DropStatus`, `CanAcceptDrop`, `AddFilesAsync`, `RemoveAttachmentCommand`; loads on task change; `ComposedPreview` includes attachment paths. Writes directly via `new AttachmentStore()` + `new TaskAttachmentRepository(ctx)`. Helper rows (`ChildOutcomeRowViewModel`, `SubtaskRowViewModel`, `LogLineViewModel`, `AttachmentRowViewModel`) live in the same file.
|
||||
- **TaskRowViewModel** / **ListNavItemViewModel** — lightweight display VMs (task row: status, planning phase, parent/blocked links, roadblock count, computed `IsDraft`/`IsPlanned`/`IsChild`/`IsPlanningParent`/`CanRefine`; plus `IsManual` (→ MANUAL badge; suppresses `CanSendToQueue`/`CanRefine`/`CanOpenPlanningSession`) and `HasInteractiveSession` (→ accent "Interactive" chip instead of "Parked"; tapping it jumps to that Mission Control pane); list row: kind Smart/Virtual/User, count, icon/dot keys, drop hints, `IsManual`).
|
||||
- **NotesEditorViewModel** — day navigator + bullet CRUD for daily notes via `INotesApi`.
|
||||
- **UsagePillViewModel** — one shared instance backs the `UsagePill` control hosted in both the footer and the Mission Control header; loads via `GetUsageSnapshotAsync` and updates live off `IWorkerClient.UsageUpdatedEvent`; derives display text, tooltip, and dot state (normal/warn/stale/blocked, mutually exclusive priority blocked > stale > warn > normal) from the shared `UsageSnapshotDto`.
|
||||
- **Modal VMs** — `SettingsModalViewModel` (four tabs: General, Worktrees, Files prompt-paths, Prime Claude incl. `DailyPrepMaxTasks` + prime-schedule rows). General hosts the per-model preset table (`ModelPresets` → `ModelPresetRowViewModel`: effort + max turns per alias) which **replaced** the single global "Max turns" field, `ListSettingsModalViewModel` (name, working dir, commit type, "manual list" flag, `VerifyCommand` (optional post-merge verify gate, own field/section — not part of `AgentConfigEditorViewModel`), delete list; hosts shared `AgentConfigEditorViewModel` as `Agent` property (scope=List) — save delegates to `Agent.SaveAsync(verifyCommand)`, since both fields land in the same `list_config` row via one `UpdateListConfig` call and would otherwise clobber each other), `RepoImportModalViewModel` (bulk-create lists from git repos found under chosen parents; already-wired repos disabled), `WeeklyReportModalViewModel` (range pickers default "since last standup weekday → today", cached per range, markdown via MarkdownView), `MergeModalViewModel` (single-task merge form, called from the diff modal), `WorktreesOverviewModalViewModel` (global/per-list worktree rows, batch merge + state ops), `UnfinishedPlanningModalViewModel` (Resume/FinalizeNow/Discard for a draft planning session), `MergeHelperSelectionModalViewModel` ("Let Claude handle it": checkbox picker over one list's non-terminal, non-manual tasks, pre-ticks the actionable ones; list-scoped only — `Configure(listId, listName)`, no global scope. Opened from the list row's context menu, which is hidden when the list has no working dir; on confirm `ListsIslandViewModel` raises `LetClaudeHandleRequested` → shell → `MissionControlViewModel.OpenMergeHelperConPtySessionAsync`, which first calls `IWorkerClient.CreateMergeHelperTaskAsync` to create one new ClaudeDo task per run in that list — `Idle`/`IsManual=true` (never queued), title/description localized (`missionControl.mergeHelperTaskTitle`/`mergeHelperTaskDescriptionHeader`), `TaskEntity.HandlerBaseCommit` stamped to the list repo's current HEAD — then opens a **task-based** ConPTY tile for it (deduped by `TaskId` like `OpenConPtySessionAsync`, not `CreateAdHoc`) running the five-phase handler prompt. The handler still merges the tasks it handles itself; the host task never gets a worktree of its own, so "Submit for review" stamps `HandlerHeadCommit` instead of committing a worktree, and the detail pane's `MergeSectionViewModel` falls back to `HandlerBaseCommit`/`HandlerHeadCommit` over the list's working dir for its diff — see `TaskEntity` in `ClaudeDo.Data/CLAUDE.md`), `WorkerConnectionModalViewModel` (offline help), `AboutModalViewModel`, `LogVisualizerViewModel` (worker logs, last 30 min, all levels + a warn/error-only filter; loads via `GetRecentLogsAsync`), `UsageMonitorModalViewModel` (opened from the usage pill; renders one gauge per row in `UsageSnapshotDto.Limits` — **dynamic**, since the fixed `seven_day_opus`/`seven_day_sonnet`-style buckets the raw Anthropic API can return are plan-dependent and come back `null` on plans that don't have them, so a fixed gauge layout would break; also shows model usage (`GetModelUsageAsync`, ClaudeDo-vs-Other split per model) and top-task usage (`GetTaskUsageAsync`) tables over a 7d/30d preset or custom date range).
|
||||
- **Diff stack** — `UnifiedDiffParser` (static; parses `git diff` output into `DiffFileViewModel`s, detecting added/deleted/renamed/binary files and per-line numbers; `Flatten` injects file-header rows for a combined single-pane view). `DiffModels.cs` holds shared types: `DiffLineViewModel`, `DiffFileViewModel`, `DiffLineKind`, `DiffFileStatus`, `SubtaskDiffRow`, `DiffTreeNodeViewModel`, `DiffTree`. `DiffViewerViewModel` is a single unified read-only diff viewer with two modes: **Files** (dirty worktree / branch-vs-base / commit-range — loads via GitService, shows a folder file-tree on the left + per-file diff pane on the right, Merge button for live branch source) and **Planning** (per-subtask diffs via `GetPlanningAggregateAsync`, subtask list left + flat diff right, combined integration-branch toggle). The Merge button opens the merge form, which routes to `ConflictResolverViewModel` on conflict. `DiffLinesView` renders per-file diff content with binary/empty placeholders.
|
||||
- **Conflicts** — `ConflictResolverViewModel` (in-app **Rider-style 3-pane merge editor** for both single-task and planning unit-merge conflicts: single-task starts the conflict merge, parses each conflicted file into stable/conflict `MergeFileSegment`s via the worker's `GetMergeConflictDocuments`; exposes the active file's three reconstructed documents — `ActiveOursText` / `ActiveResultText` / `ActiveTheirsText` (from `MergeFile.OursText/ResultText/TheirsText`; Result seeds unresolved conflicts with Ours) — plus `ActiveFile`/`SelectFileCommand` (multi-file switcher), `Current`/`Next`/`Previous` (focused-conflict nav), a per-active-file `PositionText` readout, per-block `AcceptOurs/Theirs/Both/Base` + `MergeFile.Compose`, and `CanContinue` gated on every file resolved + no binary; writes each file via `WriteConflictResolution`, continue/abort; **planning mode** via `OpenForPlanningAsync(parentId, subtaskId)` loads the current subtask's mid-merge conflicts without re-starting the merge and routes continue/abort to `ContinuePlanningMerge`/`AbortPlanningMerge`, so a unit-merge conflict re-opens the editor per subtask via the `PlanningMergeConflict` broadcast). The view (`Views/Conflicts/ConflictResolverView`) shows the whole file in three **AvaloniaEdit** panes — MAIN/ours (read-only) | editable Result | INCOMING/theirs (read-only) — with TextMate highlighting by extension (theme `StyleInclude` in `App.axaml`); a code-behind `IBackgroundRenderer` tints each conflict block (unresolved/resolved) across panes, an `IReadOnlySectionProvider` + `TextAnchor` regions keep only conflict spans editable in Result (edits flow back to the block); each unresolved conflict starts EMPTY (a thin marker bar); the between-pane gutter controls **toggle** each side in/out of the result — `›`/`‹` add MAIN/INCOMING in click order (first pick on top), clicking again removes that side — so a conflict can take main, incoming, both, or neither; a `FilesSummary` readout shows how many files still have conflicts, and the three panes share a proportional synced vertical scroll. A conflict overview ruler right of the Result pane (`ConflictMap`) maps every conflict in the file proportionally (click a tick to jump) — handy for long files. Conflict block tints live in `Tokens.axaml` (`Merge*TintBrush`). The editor is reached from review **Approve** on conflict and from the **Merge** button in the Diff window (a conflicting `MergeTask` hands off to the resolver via `RequestConflictResolution`).
|
||||
- **TasksIslandViewModel** — open/overdue/completed groups for the selected list with hierarchy-aware regrouping; task CRUD, drag-reorder, toggle done/star, schedule, enqueue/dequeue, cancel; review actions; planning session lifecycle; `RefineTask`, `OpenConPtySessionRequested`, `ToggleManual`, `SyncInteractiveSessions`; MyDay extras (`IsMyDayList`, `ClearDayCommand`, `ShowPrepLogCommand`) and the pinned Notes pseudo-row. Raises `NotesRequested`/`PrepRequested` for the shell.
|
||||
- **DetailsIslandViewModel** — the detail pane for a bound `TaskRowViewModel`. Owns live-log streaming (`Log` via `StreamLineFormatter`), debounced title/description editing, subtasks, the session-outcome/roadblock split, the three-tab work console (`output`/`git`/`session`), child surfacing (`ChildOutcomes` + `ChildrenNeedingAttention` drive an attention band on the Session tab), attachments, and the modes `IsNotesMode`/`IsPrepMode`/computed `IsTaskDetailVisible`. Failures raise `ErrorReported`, wired by the shell into `FlashFooterError`.
|
||||
- Three concerns are extracted into section VMs exposed as properties: `AgentSettings` (`AgentConfigEditorViewModel`, scope=Task), `MergeSectionViewModel`, `PrepPanelViewModel`. Helper rows live in the same file.
|
||||
- The ROADBLOCK card's reply field (`RoadblockReplyDraft`/`SendRoadblockReplyCommand`, gated by `CanReplyToRoadblock` on `LatestRunSessionId`) resumes the session over the same `ContinueTaskAsync` transport as `ContinueCommand`, but with the user's own text.
|
||||
- Attachments write directly via `new AttachmentStore()` + `new TaskAttachmentRepository(ctx)`; `ComposedPreview` includes attachment paths.
|
||||
- **TaskRowViewModel** / **ListNavItemViewModel** — lightweight display VMs. Task row: status, planning phase, parent/blocked links, roadblock count, computed `IsDraft`/`IsPlanned`/`IsChild`/`IsPlanningParent`/`CanRefine`, plus `IsManual` (→ MANUAL badge; suppresses `CanSendToQueue`/`CanRefine`/`CanOpenPlanningSession`) and `HasInteractiveSession` (→ accent "Interactive" chip instead of "Parked"; tapping jumps to that Mission Control pane). List row: kind Smart/Virtual/User, count, icon/dot keys, drop hints, `IsManual`.
|
||||
- **NotesEditorViewModel** — day navigator + bullet CRUD via `INotesApi`.
|
||||
- **UsagePillViewModel** — one shared instance backs the `UsagePill` in both the footer and the Mission Control header → [usage-monitoring](../../docs/explore-notes/usage-monitoring.md).
|
||||
|
||||
## Modal VMs
|
||||
|
||||
| VM | Notes |
|
||||
|---|---|
|
||||
| `SettingsModalViewModel` | Four tabs: General, Worktrees, Files (prompt paths), Prime Claude. General hosts the per-model preset table (`ModelPresetRowViewModel`: effort + max turns per alias) which **replaced** the single global "Max turns" field. |
|
||||
| `ListSettingsModalViewModel` | Name, working dir, commit type, "manual list" flag, `VerifyCommand`, delete. Hosts the shared `AgentConfigEditorViewModel` as `Agent` (scope=List) — ⚠️ save delegates to `Agent.SaveAsync(verifyCommand)` because both land in the same `list_config` row via one `UpdateListConfig` call and would otherwise clobber each other. |
|
||||
| `WeeklyReportModalViewModel` | Range pickers default "since last standup weekday → today", cached per range. |
|
||||
| `MergeHelperSelectionModalViewModel` | "Let Claude handle it" picker → [conpty-sessions](../../docs/explore-notes/conpty-sessions.md). |
|
||||
| `UsageMonitorModalViewModel` | Opened from the usage pill; gauges are **dynamic** per `UsageSnapshotDto.Limits` row. |
|
||||
|
||||
Self-explanatory: `RepoImportModalViewModel` (bulk-create lists from git repos; already-wired
|
||||
repos disabled), `MergeModalViewModel`, `WorktreesOverviewModalViewModel`,
|
||||
`UnfinishedPlanningModalViewModel`, `LogVisualizerViewModel` (last 30 min, all levels + a
|
||||
warn/error filter), `WorkerConnectionModalViewModel`, `AboutModalViewModel`.
|
||||
|
||||
## Diff & Conflicts
|
||||
|
||||
`UnifiedDiffParser` (static) + `DiffModels.cs` shared types + `DiffViewerViewModel` (one unified
|
||||
read-only viewer, Files and Planning modes) + `DiffLinesView`.
|
||||
`ConflictResolverViewModel` is an in-app Rider-style 3-pane AvaloniaEdit merge editor for both
|
||||
single-task and planning unit-merge conflicts. Full detail →
|
||||
[review-merge](../../docs/explore-notes/review-merge.md).
|
||||
|
||||
## Services
|
||||
|
||||
- **WorkerClient** / **IWorkerClient** — SignalR client connecting to `http://127.0.0.1:47821/hub`, auto-reconnect with exponential backoff. The surface tracks `WorkerHub` (see `src/ClaudeDo.Worker/CLAUDE.md` for the canonical method/event list); groups: task execution (RunNow/Cancel/Continue/Reset/SetTaskStatus), review (`ApproveReviewAsync(taskId, targetBranch) -> MergeResultDto`, reject-to-queue/idle, cancel review, `PreviewMergeAsync -> MergePreviewDto`), planning sessions (start/resume/discard/finalize, queue subtasks, pending draft count, refine), embedded ConPTY launch specs (`GetInteractiveLaunchSpecAsync`/`GetAdHocLaunchSpecAsync`), planning aggregate/integration-branch diffs, unit-merge continue/abort, single-task conflict resolving (start/get-conflict-documents/write-resolution/continue/abort), worktrees (overview, set state, force remove, cleanup, reset all), agents, app settings, lists/config, weekly report, daily notes, daily prep (`RunDailyPrepNowAsync`, `ClearMyDayAsync`, `GetLastPrepLogAsync`), prime schedules, recent worker logs (`GetRecentLogsAsync`), usage monitoring (`GetUsageSnapshotAsync -> UsageSnapshotDto?`, `GetModelUsageAsync(from, to)`, `GetTaskUsageAsync(from, to)`). Events mirror `HubBroadcaster` (task/worktree/list/run updates, prep events, planning-merge events, refine events, worker log, `UsageUpdatedEvent`). Lifecycle (`StartAsync`/`StopAsync`) and a few admin methods live only on the concrete `WorkerClient`.
|
||||
- **INotesApi** / **WorkerNotesApi** — daily-note CRUD (`ListAsync(day)`, `AddAsync`, `UpdateAsync`, `DeleteAsync`); UI DTO `DailyNoteDto(Id, Date, Text, SortOrder)`.
|
||||
- **IPrimeScheduleApi** — prime-schedule CRUD (`ListAsync`, `UpsertAsync`, `DeleteAsync`).
|
||||
- **UpdateCheckService** — polls releases, exposes `LastCheckStatus`/`LatestVersion`/`CheckNowAsync` (feeds the shell's update banner).
|
||||
- **WorkerClient / IWorkerClient** — SignalR client on `http://127.0.0.1:47821/hub`, auto-reconnect with exponential backoff. The surface **tracks `WorkerHub`** — treat `src/ClaudeDo.Worker/Hub/WorkerHub.cs` as the canonical method list rather than duplicating it here. Events mirror `HubBroadcaster`. Lifecycle (`StartAsync`/`StopAsync`) and a few admin methods live only on the concrete `WorkerClient`.
|
||||
- **INotesApi / WorkerNotesApi** — daily-note CRUD; UI DTO `DailyNoteDto(Id, Date, Text, SortOrder)`.
|
||||
- **IPrimeScheduleApi** — prime-schedule CRUD.
|
||||
- **UpdateCheckService** — polls releases; `LastCheckStatus`/`LatestVersion`/`CheckNowAsync` feed the shell's update banner.
|
||||
- **InheritanceResolver** — resolves the task → list → global override chain to `(value, source)` for the inherited badges.
|
||||
- **RepoScanner**, **InstallArtifactLocator**/**InstallerLocator**/**WorkerLocator**, **ForegroundHelper** (Win32 foreground before launching a terminal), **FocusClearing**.
|
||||
|
||||
## Converters
|
||||
|
||||
`StatusColorConverter` (+ `ConnectionColorConverter` in the same file), `WorkerLogLevelToBrushConverter`, `DotBrushConverter`, `EqStatusConverter`, `IconKeyConverter`, `CheckboxBorderConverter`, `StrikeIfTrueConverter`, `BoolToItalicConverter`, `BoolToDraftOpacityConverter`, `NotNullToBoolConverter`, `UpperCaseConverter`, `DateOnlyToDateTimeConverter`.
|
||||
In `Converters/` — grep rather than list: log-level brush, dot brush,
|
||||
status equality, icon key, strike/italic/opacity toggles, null→bool,
|
||||
uppercase.
|
||||
|
||||
## Dialog Pattern
|
||||
|
||||
Modals use `TaskCompletionSource` results behind the reusable `ModalShell` control — the dialog sets the result on save/cancel, and the caller awaits the TCS.
|
||||
Modals use `TaskCompletionSource` results behind the reusable `ModalShell` control — the dialog
|
||||
sets the result on save/cancel, the caller awaits the TCS.
|
||||
|
||||
## Notes
|
||||
## Gotchas
|
||||
|
||||
- Context menus exist on both list rows and task rows; right-click selects before opening the menu
|
||||
- "Run Now" CanExecute re-evaluates when worker connection state changes
|
||||
- Icon gotcha: `PathIcon` fills geometry. Line-art/stroke icons must be defined as filled geometry or rendered as a stroked `Path` (e.g. `Icon.PlanDay` via the `Path.plan-icon` style); a pure stroke path used with `PathIcon` is invisible.
|
||||
- Window key bindings live on `MainWindow`: `Ctrl+K` focuses search, `Ctrl+N` the add-task box. Do **not** bind bare punctuation gestures — `OemQuestion` used to hold search focus and silently swallowed `#` app-wide on a German layout.
|
||||
- `FocusClearing` also clears focus from a TextBox on Escape, mirroring its click-outside behavior — but the KeyDown handler is scoped to `MainWindow` specifically (`AddClassHandler<MainWindow>`, not `<TopLevel>`). Modal windows (`AboutModalView` etc.) each bind their own `Window.KeyBindings` Escape → close; since modals are separate `Window` instances, this handler never runs there, so Escape still closes them unchanged. Mission Control's ConPTY tiles (`InteractiveTerminalView`) live in `MissionControlWindow`, also unaffected — Escape always reaches the PTY there.
|
||||
- `Ellipse.spinner` (IslandStyles) is the shared indeterminate spinner: used for a starting ConPTY pane (`InteractiveTerminalViewModel.IsStarting`) and in place of the refine button while `TaskRowViewModel.IsRefining`.
|
||||
- `ConPtyPaneViewModel` resolves its own launch spec (ctor takes a descriptor **factory**; the host wires handlers and then calls `Start()`), so the Mission Control tile appears immediately with its spinner while the worker is still preparing the worktree. A failed launch keeps the tile with its inline error banner instead of never appearing.
|
||||
- `SessionTerminalView` is the reusable log terminal (StyledProperties `Entries`, `Label`, `IsRunning`, `IsDone`, `IsFailed`) used for both the task `Log` and the prep `PrepLog`.
|
||||
- `DetailsIslandView` is a pane-wide drag-and-drop file target (`DragDrop.AllowDrop`, Avalonia 12 `DataFormat.File`) with a "Drop to attach" hover overlay. `DescriptionStepsCard` shows an Attachments list (file name, size, remove button), an "Add file…" picker, and an explicit `DropStatus` confirmation line. Keys use the `details.attachments.*` localization namespace (en + de).
|
||||
- **`PathIcon` *fills* its geometry.** Line-art/stroke icons must be authored as filled geometry or rendered with a stroked `Path` (e.g. `Icon.PlanDay` via the `Path.plan-icon` style). A pure stroke path in a `PathIcon` is **invisible**.
|
||||
- **`NumericUpDown.Value` is `decimal?` and goes null while the box is empty** — i.e. every time the user clears a value to type a new one. Bound TwoWay to a non-nullable `int`/`decimal`, that null throws `InvalidCastException`. Either bind a `decimal?` property (as `AgentConfigEditorViewModel.MaxTurns` does) or add `Converter={StaticResource KeepLastNumber}`, which drops the null via `BindingOperations.DoNothing`.
|
||||
- **Never bind bare punctuation gestures.** Window key bindings live on `MainWindow` (`Ctrl+K` search, `Ctrl+N` add-task). `OemQuestion` once held search focus and silently swallowed `#` app-wide on a German layout.
|
||||
- **`FocusClearing`'s Escape handler is scoped to `MainWindow`** (`AddClassHandler<MainWindow>`, not `<TopLevel>`) — it clears focus from a TextBox on Escape, mirroring click-outside. Modals are separate `Window` instances that bind their own Escape → close, so it never runs there. Mission Control's ConPTY tiles are in `MissionControlWindow`, also unaffected, so **Escape always reaches the PTY**.
|
||||
- **Review gate:** Approve & Merge stays disabled until the diff has been opened once, and re-locks per run → [review-merge](../../docs/explore-notes/review-merge.md).
|
||||
- Context menus exist on both list and task rows; right-click selects before opening the menu.
|
||||
- "Run Now" CanExecute re-evaluates when worker connection state changes.
|
||||
- `Ellipse.spinner` (IslandStyles) is the shared indeterminate spinner (starting ConPTY pane, refining task row).
|
||||
- `SessionTerminalView` is the reusable log terminal (StyledProperties `Entries`, `Label`, `IsRunning`, `IsDone`, `IsFailed`) — used for both the task `Log` and the prep `PrepLog`.
|
||||
- `DetailsIslandView` is a pane-wide drag-and-drop file target (`DragDrop.AllowDrop`, Avalonia 12 `DataFormat.File`) with a "Drop to attach" overlay; `DescriptionStepsCard` shows the attachments list, an "Add file…" picker, and an explicit `DropStatus` line. Keys use the `details.attachments.*` locale namespace (en + de).
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
using System;
|
||||
using System.Globalization;
|
||||
using Avalonia.Data.Converters;
|
||||
using Avalonia.Media;
|
||||
|
||||
namespace ClaudeDo.Ui.Converters;
|
||||
|
||||
public sealed class CheckboxBorderConverter : IValueConverter
|
||||
{
|
||||
public static readonly CheckboxBorderConverter Instance = new();
|
||||
|
||||
private static readonly ISolidColorBrush Gray = new SolidColorBrush(Color.Parse("#475569"));
|
||||
private static readonly ISolidColorBrush Orange = new SolidColorBrush(Color.Parse("#e67e22"));
|
||||
private static readonly ISolidColorBrush Green = new SolidColorBrush(Color.Parse("#3d9474"));
|
||||
private static readonly ISolidColorBrush Red = new SolidColorBrush(Color.Parse("#ef4444"));
|
||||
|
||||
public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
|
||||
{
|
||||
return value?.ToString()?.ToLowerInvariant() switch
|
||||
{
|
||||
"running" => Orange,
|
||||
"done" => Green,
|
||||
"failed" => Red,
|
||||
_ => Gray, // manual, queued
|
||||
};
|
||||
}
|
||||
|
||||
public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
|
||||
=> throw new NotSupportedException();
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
using System.Globalization;
|
||||
using Avalonia.Data.Converters;
|
||||
|
||||
namespace ClaudeDo.Ui.Converters;
|
||||
|
||||
public sealed class DateOnlyToDateTimeConverter : IValueConverter
|
||||
{
|
||||
public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
|
||||
{
|
||||
if (value is DateOnly d)
|
||||
return d.ToDateTime(TimeOnly.MinValue);
|
||||
return null;
|
||||
}
|
||||
|
||||
public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
|
||||
{
|
||||
if (value is DateTime dt)
|
||||
return DateOnly.FromDateTime(dt);
|
||||
if (value is DateTimeOffset dto)
|
||||
return DateOnly.FromDateTime(dto.LocalDateTime);
|
||||
return DateOnly.FromDateTime(DateTime.Today);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
using System.Globalization;
|
||||
using Avalonia.Data;
|
||||
using Avalonia.Data.Converters;
|
||||
|
||||
namespace ClaudeDo.Ui.Converters;
|
||||
|
||||
/// <summary>
|
||||
/// For <c>NumericUpDown.Value</c> bound to a non-nullable numeric property. The control's Value is
|
||||
/// <c>decimal?</c> and goes null the moment the text box is empty — which is exactly what happens
|
||||
/// while the user clears a value to type a new one. Writing that null into an <c>int</c>/<c>decimal</c>
|
||||
/// target throws <see cref="InvalidCastException"/>, so swallow it and leave the source untouched
|
||||
/// until a real number arrives.
|
||||
/// </summary>
|
||||
public class KeepLastNumberConverter : IValueConverter
|
||||
{
|
||||
public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
|
||||
=> value is null ? null : System.Convert.ToDecimal(value, culture);
|
||||
|
||||
public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
|
||||
{
|
||||
if (value is null) return BindingOperations.DoNothing;
|
||||
return System.Convert.ChangeType(value, Nullable.GetUnderlyingType(targetType) ?? targetType, culture);
|
||||
}
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
using System.Globalization;
|
||||
using Avalonia.Data.Converters;
|
||||
using Avalonia.Media;
|
||||
|
||||
namespace ClaudeDo.Ui.Converters;
|
||||
|
||||
public class StatusColorConverter : IValueConverter
|
||||
{
|
||||
public static StatusColorConverter Instance { get; } = new();
|
||||
|
||||
public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
|
||||
{
|
||||
var status = value?.ToString()?.ToLowerInvariant();
|
||||
return status switch
|
||||
{
|
||||
"queued" => Brushes.DodgerBlue,
|
||||
"running" => Brushes.Orange,
|
||||
"waitingforreview" => Brushes.MediumPurple,
|
||||
"waiting_for_review" => Brushes.MediumPurple,
|
||||
"waitingforchildren" => Brushes.DarkOrange,
|
||||
"done" => Brushes.Green,
|
||||
"failed" => Brushes.Red,
|
||||
"manual" => Brushes.Gray,
|
||||
_ => Brushes.Transparent,
|
||||
};
|
||||
}
|
||||
|
||||
public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
|
||||
=> throw new NotSupportedException();
|
||||
}
|
||||
|
||||
public class ConnectionColorConverter : IValueConverter
|
||||
{
|
||||
public static ConnectionColorConverter Instance { get; } = new();
|
||||
|
||||
public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
|
||||
{
|
||||
var text = value?.ToString();
|
||||
return text == "Online" ? Brushes.Green : Brushes.Red;
|
||||
}
|
||||
|
||||
public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
|
||||
=> throw new NotSupportedException();
|
||||
}
|
||||
@@ -8,7 +8,6 @@
|
||||
<Button Classes="icon-btn"> — 24×24 icon button
|
||||
<Button Classes="btn primary"> — rounded-rect button
|
||||
<TextBlock Classes="eyebrow"> — uppercase mono label
|
||||
<Border Classes="agent-strip running"> — agent status strip
|
||||
<Border Classes="terminal"> — terminal/log window
|
||||
-->
|
||||
<Styles xmlns="https://github.com/avaloniaui"
|
||||
@@ -489,45 +488,6 @@
|
||||
<Setter Property="Stroke" Value="{StaticResource TextDimBrush}" />
|
||||
</Style>
|
||||
|
||||
<!-- ============================================================ -->
|
||||
<!-- AGENT STRIP -->
|
||||
<!-- ============================================================ -->
|
||||
<Style Selector="Border.agent-strip">
|
||||
<Setter Property="Padding" Value="12,10" />
|
||||
<Setter Property="CornerRadius" Value="10" />
|
||||
<Setter Property="Background" Value="{StaticResource Surface2Brush}" />
|
||||
<Setter Property="BorderThickness" Value="1" />
|
||||
<Setter Property="BorderBrush" Value="{StaticResource LineBrush}" />
|
||||
</Style>
|
||||
<Style Selector="Border.agent-strip.running">
|
||||
<Setter Property="Background" Value="{StaticResource RunningTintBrush}" />
|
||||
<Setter Property="BorderBrush" Value="{StaticResource RunningTintBorderBrush}" />
|
||||
</Style>
|
||||
<Style Selector="Border.agent-strip.review">
|
||||
<Setter Property="Background" Value="{StaticResource ReviewTintBrush}" />
|
||||
<Setter Property="BorderBrush" Value="{StaticResource ReviewTintBorderBrush}" />
|
||||
</Style>
|
||||
<Style Selector="Border.agent-strip.children">
|
||||
<Setter Property="Background" Value="#332A1A" />
|
||||
<Setter Property="BorderBrush" Value="#4D3A1A" />
|
||||
</Style>
|
||||
<Style Selector="Border.agent-strip.error">
|
||||
<Setter Property="Background" Value="{StaticResource ErrorTintBrush}" />
|
||||
<Setter Property="BorderBrush" Value="{StaticResource ErrorTintBorderBrush}" />
|
||||
</Style>
|
||||
|
||||
<!-- queued → Sage tint -->
|
||||
<Style Selector="Border.agent-strip.queued">
|
||||
<Setter Property="Background" Value="{StaticResource QueuedTintBrush}" />
|
||||
<Setter Property="BorderBrush" Value="{StaticResource QueuedTintBorderBrush}" />
|
||||
</Style>
|
||||
|
||||
<!-- idle → neutral (same as base, explicit for clarity) -->
|
||||
<Style Selector="Border.agent-strip.idle">
|
||||
<Setter Property="Background" Value="{StaticResource Surface2Brush}" />
|
||||
<Setter Property="BorderBrush" Value="{StaticResource LineBrush}" />
|
||||
</Style>
|
||||
|
||||
<!-- ============================================================ -->
|
||||
<!-- TERMINAL / LOG -->
|
||||
<!-- ============================================================ -->
|
||||
@@ -959,23 +919,6 @@
|
||||
<Setter Property="Foreground" Value="{StaticResource TextDimBrush}" />
|
||||
</Style>
|
||||
|
||||
<!-- ============================================================ -->
|
||||
<!-- DIFF METER -->
|
||||
<!-- ============================================================ -->
|
||||
<!-- Outer track (full width, line-bright bg) -->
|
||||
<Style Selector="Border.diff-meter-track">
|
||||
<Setter Property="Height" Value="4" />
|
||||
<Setter Property="CornerRadius" Value="2" />
|
||||
<Setter Property="Background" Value="{StaticResource LineBrightBrush}" />
|
||||
<Setter Property="ClipToBounds" Value="True" />
|
||||
</Style>
|
||||
<!-- Filled portion (moss; width set via ScaleTransform or Width binding in view) -->
|
||||
<Style Selector="Rectangle.diff-meter-fill">
|
||||
<Setter Property="Height" Value="4" />
|
||||
<Setter Property="Fill" Value="{StaticResource MossBrightBrush}" />
|
||||
<Setter Property="HorizontalAlignment" Value="Left" />
|
||||
</Style>
|
||||
|
||||
<!-- ============================================================ -->
|
||||
<!-- SUBTASK ROW -->
|
||||
<!-- ============================================================ -->
|
||||
|
||||
@@ -25,13 +25,20 @@ public interface IWorkerClient : INotifyPropertyChanged
|
||||
/// <summary>A pending question was answered, timed out, or the run ended: (taskId, questionId).</summary>
|
||||
event Action<string, string>? TaskQuestionResolvedEvent;
|
||||
|
||||
/// <summary>A running list-handler session called handoff_list_handler at the end of Phase 2:
|
||||
/// (handlerTaskId, survivingTaskIds). The UI opens a second ConPTY tile for the same task.</summary>
|
||||
event Action<string, IReadOnlyList<string>>? HandoffRequestedEvent;
|
||||
|
||||
event Action? PrepStartedEvent;
|
||||
event Action<string>? PrepLineEvent;
|
||||
event Action<bool>? PrepFinishedEvent;
|
||||
|
||||
event Action<string, string>? PlanningMergeStartedEvent;
|
||||
event Action<string, string>? PlanningSubtaskMergedEvent;
|
||||
event Action<string, string, IReadOnlyList<string>>? PlanningMergeConflictEvent;
|
||||
/// <summary>(planningTaskId, subtaskId, conflictedFiles, externallyDriven). externallyDriven
|
||||
/// is true when an MCP session (not the UI) started the unit merge — the resolver must not
|
||||
/// auto-open in that case.</summary>
|
||||
event Action<string, string, IReadOnlyList<string>, bool>? PlanningMergeConflictEvent;
|
||||
event Action<string>? PlanningMergeAbortedEvent;
|
||||
event Action<string>? PlanningCompletedEvent;
|
||||
|
||||
@@ -50,11 +57,20 @@ public interface IWorkerClient : INotifyPropertyChanged
|
||||
Task<PendingQuestionDto?> GetPendingQuestionAsync(string taskId);
|
||||
Task ResetTaskAsync(string taskId);
|
||||
Task CancelTaskAsync(string taskId);
|
||||
/// <summary>Deletes a task via the worker (mirrors the MCP delete_task tool), so a deleted
|
||||
/// child correctly advances a WaitingForChildren parent. Returns (false, message) instead of
|
||||
/// throwing when the task has children or is running, preserving the FK-error UX.</summary>
|
||||
Task<(bool Ok, string? Error)> DeleteTaskAsync(string taskId);
|
||||
Task<List<AgentInfo>> GetAgentsAsync();
|
||||
Task RefreshAgentsAsync();
|
||||
Task<SeedResultDto?> RestoreDefaultAgentsAsync();
|
||||
Task<ListConfigDto?> GetListConfigAsync(string listId);
|
||||
Task UpdateTaskAgentSettingsAsync(UpdateTaskAgentSettingsDto dto);
|
||||
/// <summary>Repo-import folders remembered for the "Add repos as lists" dialog. Throws on
|
||||
/// a failed hub call — the caller surfaces the failure rather than showing an empty list.</summary>
|
||||
Task<List<string>> GetRepoImportFoldersAsync();
|
||||
/// <summary>Persists the repo-import folder list. Throws on a failed hub call.</summary>
|
||||
Task SetRepoImportFoldersAsync(List<string> folders);
|
||||
Task<List<SessionSkillDto>> GetSessionSkillsAsync();
|
||||
Task<List<string>> InstallSessionSkillAsync(string url);
|
||||
Task UpdateSessionSkillAsync(string sourceUrl);
|
||||
@@ -90,6 +106,10 @@ public interface IWorkerClient : INotifyPropertyChanged
|
||||
/// never queued) so the ConPTY tile can be task-based instead of ad-hoc. Returns the new task id.</summary>
|
||||
Task<string> CreateMergeHelperTaskAsync(
|
||||
IReadOnlyList<string> taskIds, string listId, string title, string descriptionHeader, CancellationToken ct = default);
|
||||
/// <summary>Launch spec for the fresh ConPTY session a merge-helper run hands off to once Phase 2
|
||||
/// is done -- reuses the SAME handler task id (no new task created).</summary>
|
||||
Task<LaunchSpec> GetMergeHelperHandoffLaunchSpecAsync(
|
||||
string taskId, IReadOnlyList<string> survivingTaskIds, CancellationToken ct = default);
|
||||
/// <summary>Starts a planning session and returns the launch spec for an embedded ConPTY
|
||||
/// planning terminal (replaces StartPlanningSessionAsync's external wt window).</summary>
|
||||
Task<LaunchSpec> GetPlanningStartLaunchSpecAsync(string taskId, CancellationToken ct = default);
|
||||
@@ -104,6 +124,9 @@ public interface IWorkerClient : INotifyPropertyChanged
|
||||
Task<CombinedDiffResultDto?> BuildPlanningIntegrationBranchAsync(string planningTaskId, string targetBranch);
|
||||
Task ContinuePlanningMergeAsync(string planningTaskId);
|
||||
Task AbortPlanningMergeAsync(string planningTaskId);
|
||||
/// <summary>Unit merges currently paused on a conflict that an MCP session started. Called
|
||||
/// on (re)connect to recover the "don't auto-open" banner state after a UI restart.</summary>
|
||||
Task<IReadOnlyList<PlanningMergeConflictStateDto>> GetActiveExternalPlanningMergeConflictsAsync();
|
||||
Task QueuePlanningSubtasksAsync(string parentTaskId, CancellationToken ct = default);
|
||||
Task<string?> GetWeekReportAsync(DateOnly start, DateOnly end);
|
||||
Task<string> GenerateWeekReportAsync(DateOnly start, DateOnly end);
|
||||
@@ -122,6 +145,9 @@ public interface IWorkerClient : INotifyPropertyChanged
|
||||
Task<string> GetLastPrepLogAsync();
|
||||
Task<IReadOnlyList<WorkerLogEntry>> GetRecentLogsAsync();
|
||||
|
||||
/// <summary>Git SHA the running worker was built from (null when offline or the build isn't stamped).</summary>
|
||||
Task<WorkerBuildInfoDto?> GetWorkerBuildInfoAsync();
|
||||
|
||||
Task<List<PrimeScheduleDto>> GetPrimeSchedulesAsync();
|
||||
Task<PrimeScheduleDto?> UpsertPrimeScheduleAsync(PrimeScheduleDto dto);
|
||||
Task DeletePrimeScheduleAsync(Guid id);
|
||||
@@ -143,6 +169,9 @@ public interface IWorkerClient : INotifyPropertyChanged
|
||||
/// <summary>Raised whenever the worker's usage poller ticks (success or failure).</summary>
|
||||
event Action<UsageSnapshotDto>? UsageUpdatedEvent;
|
||||
Task<UsageSnapshotDto?> GetUsageSnapshotAsync();
|
||||
|
||||
/// <summary>Forces an out-of-band usage poll on the worker and returns the fresh snapshot.</summary>
|
||||
Task<UsageSnapshotDto?> RefreshUsageAsync();
|
||||
Task<IReadOnlyList<ModelUsageRowDto>> GetModelUsageAsync(DateOnly from, DateOnly to);
|
||||
Task<IReadOnlyList<TaskUsageRowDto>> GetTaskUsageAsync(DateOnly from, DateOnly to);
|
||||
}
|
||||
|
||||
@@ -19,6 +19,28 @@ namespace ClaudeDo.Ui.Services;
|
||||
/// </summary>
|
||||
public sealed class PtyTerminalSession : IDisposable
|
||||
{
|
||||
// Guards the set-env + LaunchProcess critical section below: two sessions starting
|
||||
// back-to-back (e.g. planning sessions for two different tasks) could otherwise interleave
|
||||
// their SetEnvironmentVariable calls before either LaunchProcess() forks, so one process
|
||||
// inherits the other's env (e.g. CLAUDEDO_PLANNING_TOKEN, breaking that session's own MCP
|
||||
// auth). Process-wide env leakage AFTER a launch has forked remains a documented limitation
|
||||
// — Porta.Pty has no per-launch env seam, so the vars stay set on the whole UI process.
|
||||
// The wait is bounded (see WaitForLaunchGateAsync) — a hung launch (slow disk, AV scanning
|
||||
// claude.exe, a Porta.Pty/ConPTY hiccup) must not freeze every other pane open behind it.
|
||||
private static readonly SemaphoreSlim s_launchGate = new(1, 1);
|
||||
private static readonly TimeSpan s_launchGateTimeout = TimeSpan.FromSeconds(30);
|
||||
|
||||
/// <summary>
|
||||
/// Waits on <paramref name="gate"/> for at most <paramref name="timeout"/>, throwing
|
||||
/// <see cref="TimeoutException"/> instead of blocking forever. Never acquires the gate on
|
||||
/// timeout, so callers must not release it in that case.
|
||||
/// </summary>
|
||||
internal static async Task WaitForLaunchGateAsync(SemaphoreSlim gate, TimeSpan timeout, CancellationToken ct)
|
||||
{
|
||||
if (!await gate.WaitAsync(timeout, ct))
|
||||
throw new TimeoutException("Another terminal launch is still starting up. Please retry in a moment.");
|
||||
}
|
||||
|
||||
private TerminalControl? _control;
|
||||
private bool _disposed;
|
||||
|
||||
@@ -38,14 +60,29 @@ public sealed class PtyTerminalSession : IDisposable
|
||||
_control = control;
|
||||
control.ProcessExited += OnControlProcessExited;
|
||||
|
||||
foreach (var (key, value) in descriptor.Env)
|
||||
Environment.SetEnvironmentVariable(key, value);
|
||||
|
||||
control.Process = descriptor.Exe;
|
||||
control.Args = new List<string>(descriptor.Args);
|
||||
control.StartingDirectory = descriptor.Cwd;
|
||||
|
||||
await control.LaunchProcess();
|
||||
await WaitForLaunchGateAsync(s_launchGate, s_launchGateTimeout, ct);
|
||||
try
|
||||
{
|
||||
foreach (var (key, value) in descriptor.Env)
|
||||
Environment.SetEnvironmentVariable(key, value);
|
||||
|
||||
await control.LaunchProcess();
|
||||
}
|
||||
finally
|
||||
{
|
||||
s_launchGate.Release();
|
||||
}
|
||||
|
||||
// Permanent reparent mode: TerminalView.OnDetachedFromLogicalTree kills the child
|
||||
// process unless BeginReparent() suppressed it, and Mission Control detaches pane
|
||||
// views routinely (overview-grid rebuilds on pane add/remove, focus-mode tab
|
||||
// switches). ClaudeDo owns teardown explicitly instead — ConPtyPaneViewModel.Dispose
|
||||
// calls Kill() when a pane closes — so EndReparent is deliberately never called.
|
||||
control.BeginReparent();
|
||||
IsRunning = true;
|
||||
}
|
||||
|
||||
|
||||
@@ -4,23 +4,72 @@ public sealed record RepoCandidate(string Name, string FullPath);
|
||||
|
||||
public static class RepoScanner
|
||||
{
|
||||
private const int MaxDepth = 5;
|
||||
|
||||
private static readonly HashSet<string> SkipDirNames = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"node_modules", "bin", "obj", ".git", ".vs", "packages",
|
||||
};
|
||||
|
||||
public static IReadOnlyList<RepoCandidate> Scan(string parentFolder)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(parentFolder) || !Directory.Exists(parentFolder))
|
||||
return Array.Empty<RepoCandidate>();
|
||||
|
||||
var result = new List<RepoCandidate>();
|
||||
IEnumerable<string> subdirs;
|
||||
try { subdirs = Directory.EnumerateDirectories(parentFolder); }
|
||||
try
|
||||
{
|
||||
var info = new DirectoryInfo(parentFolder);
|
||||
if (info.Attributes.HasFlag(FileAttributes.ReparsePoint))
|
||||
return Array.Empty<RepoCandidate>();
|
||||
}
|
||||
catch (Exception e) when (e is IOException or UnauthorizedAccessException)
|
||||
{ return Array.Empty<RepoCandidate>(); }
|
||||
|
||||
foreach (var dir in subdirs)
|
||||
{
|
||||
var gitPath = Path.Combine(dir, ".git");
|
||||
if (Directory.Exists(gitPath) || File.Exists(gitPath))
|
||||
result.Add(new RepoCandidate(Path.GetFileName(dir), dir));
|
||||
}
|
||||
var result = new List<RepoCandidate>();
|
||||
ScanDirectory(parentFolder, depth: 0, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
private static void ScanDirectory(string dir, int depth, List<RepoCandidate> result)
|
||||
{
|
||||
if (IsRepo(dir))
|
||||
{
|
||||
result.Add(new RepoCandidate(Path.GetFileName(dir), dir));
|
||||
return;
|
||||
}
|
||||
|
||||
if (depth >= MaxDepth)
|
||||
return;
|
||||
|
||||
IEnumerable<string> subdirs;
|
||||
try { subdirs = Directory.EnumerateDirectories(dir); }
|
||||
catch (Exception e) when (e is IOException or UnauthorizedAccessException)
|
||||
{ return; }
|
||||
|
||||
foreach (var subdir in subdirs)
|
||||
{
|
||||
var name = Path.GetFileName(subdir);
|
||||
if (SkipDirNames.Contains(name))
|
||||
continue;
|
||||
|
||||
try
|
||||
{
|
||||
var info = new DirectoryInfo(subdir);
|
||||
if (info.Attributes.HasFlag(FileAttributes.ReparsePoint))
|
||||
continue;
|
||||
}
|
||||
catch (Exception e) when (e is IOException or UnauthorizedAccessException)
|
||||
{ continue; }
|
||||
|
||||
try { ScanDirectory(subdir, depth + 1, result); }
|
||||
catch (Exception e) when (e is IOException or UnauthorizedAccessException)
|
||||
{ }
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsRepo(string dir)
|
||||
{
|
||||
var gitPath = Path.Combine(dir, ".git");
|
||||
return Directory.Exists(gitPath) || File.Exists(gitPath);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,6 +49,7 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
|
||||
public event Action<string>? TaskUpdatedEvent;
|
||||
public event Action<string, string, string>? TaskQuestionAskedEvent;
|
||||
public event Action<string, string>? TaskQuestionResolvedEvent;
|
||||
public event Action<string, IReadOnlyList<string>>? HandoffRequestedEvent;
|
||||
public event Action? ConnectionRestoredEvent;
|
||||
public event Action<string>? WorktreeUpdatedEvent;
|
||||
public event Action<string>? ListUpdatedEvent;
|
||||
@@ -65,7 +66,7 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
|
||||
|
||||
public event Action<string, string>? PlanningMergeStartedEvent;
|
||||
public event Action<string, string>? PlanningSubtaskMergedEvent;
|
||||
public event Action<string, string, IReadOnlyList<string>>? PlanningMergeConflictEvent;
|
||||
public event Action<string, string, IReadOnlyList<string>, bool>? PlanningMergeConflictEvent;
|
||||
public event Action<string>? PlanningMergeAbortedEvent;
|
||||
public event Action<string>? PlanningCompletedEvent;
|
||||
|
||||
@@ -150,6 +151,11 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
|
||||
Dispatcher.UIThread.Post(() => TaskQuestionResolvedEvent?.Invoke(taskId, questionId));
|
||||
});
|
||||
|
||||
_hub.On<string, IReadOnlyList<string>>("HandoffRequested", (taskId, survivingTaskIds) =>
|
||||
{
|
||||
Dispatcher.UIThread.Post(() => HandoffRequestedEvent?.Invoke(taskId, survivingTaskIds));
|
||||
});
|
||||
|
||||
_hub.On<string>("WorktreeUpdated", taskId =>
|
||||
{
|
||||
Dispatcher.UIThread.Post(() => WorktreeUpdatedEvent?.Invoke(taskId));
|
||||
@@ -176,9 +182,9 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
|
||||
Dispatcher.UIThread.Post(() => PlanningSubtaskMergedEvent?.Invoke(planningTaskId, subtaskId));
|
||||
});
|
||||
|
||||
_hub.On<string, string, IReadOnlyList<string>>("PlanningMergeConflict", (planningTaskId, subtaskId, conflictedFiles) =>
|
||||
_hub.On<string, string, IReadOnlyList<string>, bool>("PlanningMergeConflict", (planningTaskId, subtaskId, conflictedFiles, externallyDriven) =>
|
||||
{
|
||||
Dispatcher.UIThread.Post(() => PlanningMergeConflictEvent?.Invoke(planningTaskId, subtaskId, conflictedFiles));
|
||||
Dispatcher.UIThread.Post(() => PlanningMergeConflictEvent?.Invoke(planningTaskId, subtaskId, conflictedFiles, externallyDriven));
|
||||
});
|
||||
|
||||
_hub.On<string>("PlanningMergeAborted", planningTaskId =>
|
||||
@@ -321,6 +327,24 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
|
||||
await _hub.InvokeAsync("CancelTask", taskId);
|
||||
}
|
||||
|
||||
public async Task<(bool Ok, string? Error)> DeleteTaskAsync(string taskId)
|
||||
{
|
||||
try
|
||||
{
|
||||
await _hub.InvokeAsync("DeleteTask", taskId, CancellationToken.None);
|
||||
return (true, null);
|
||||
}
|
||||
catch (HubException ex)
|
||||
{
|
||||
return (false, ex.Message);
|
||||
}
|
||||
catch (InvalidOperationException)
|
||||
{
|
||||
// Hub connection is not active (worker offline / not yet connected).
|
||||
return (false, "Worker is offline. Reconnect and try again.");
|
||||
}
|
||||
}
|
||||
|
||||
public async Task WakeQueueAsync()
|
||||
{
|
||||
await _hub.InvokeAsync("WakeQueue");
|
||||
@@ -417,6 +441,9 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
|
||||
public async Task<string> GetLastPrepLogAsync()
|
||||
=> await TryInvokeAsync<string>("GetLastPrepLog") ?? string.Empty;
|
||||
|
||||
public Task<WorkerBuildInfoDto?> GetWorkerBuildInfoAsync()
|
||||
=> TryInvokeAsync<WorkerBuildInfoDto>("GetWorkerBuildInfo");
|
||||
|
||||
public async Task<IReadOnlyList<WorkerLogEntry>> GetRecentLogsAsync()
|
||||
=> await TryInvokeAsync<List<WorkerLogEntry>>("GetRecentLogs") ?? new List<WorkerLogEntry>();
|
||||
|
||||
@@ -438,6 +465,12 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
|
||||
await _hub.InvokeAsync("UpdateTaskAgentSettings", dto);
|
||||
}
|
||||
|
||||
public async Task<List<string>> GetRepoImportFoldersAsync()
|
||||
=> await _hub.InvokeAsync<List<string>>("GetRepoImportFolders");
|
||||
|
||||
public Task SetRepoImportFoldersAsync(List<string> folders)
|
||||
=> _hub.InvokeAsync("SetRepoImportFolders", folders);
|
||||
|
||||
public async Task<List<SessionSkillDto>> GetSessionSkillsAsync()
|
||||
=> await TryInvokeAsync<List<SessionSkillDto>>("GetSessionSkills") ?? [];
|
||||
|
||||
@@ -531,6 +564,10 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
|
||||
IReadOnlyList<string> taskIds, string listId, string title, string descriptionHeader, CancellationToken ct = default)
|
||||
=> await _hub.InvokeAsync<string>("CreateMergeHelperTask", taskIds, listId, title, descriptionHeader, ct);
|
||||
|
||||
public async Task<LaunchSpec> GetMergeHelperHandoffLaunchSpecAsync(
|
||||
string taskId, IReadOnlyList<string> survivingTaskIds, CancellationToken ct = default)
|
||||
=> await _hub.InvokeAsync<LaunchSpec>("GetMergeHelperHandoffLaunchSpec", taskId, survivingTaskIds, ct);
|
||||
|
||||
public async Task<LaunchSpec> GetPlanningStartLaunchSpecAsync(string taskId, CancellationToken ct = default)
|
||||
=> await _hub.InvokeAsync<LaunchSpec>("GetPlanningStartLaunchSpec", taskId, ct);
|
||||
|
||||
@@ -562,6 +599,10 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
|
||||
await _hub.InvokeAsync("AbortPlanningMerge", planningTaskId);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<PlanningMergeConflictStateDto>> GetActiveExternalPlanningMergeConflictsAsync()
|
||||
=> await TryInvokeAsync<List<PlanningMergeConflictStateDto>>("GetActiveExternalPlanningMergeConflicts")
|
||||
?? [];
|
||||
|
||||
public async Task QueuePlanningSubtasksAsync(string parentTaskId, CancellationToken ct = default)
|
||||
{
|
||||
await _hub.InvokeAsync("QueuePlanningSubtasksAsync", parentTaskId, ct);
|
||||
@@ -582,6 +623,9 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
|
||||
public Task<UsageSnapshotDto?> GetUsageSnapshotAsync()
|
||||
=> TryInvokeAsync<UsageSnapshotDto>("GetUsageSnapshot");
|
||||
|
||||
public Task<UsageSnapshotDto?> RefreshUsageAsync()
|
||||
=> TryInvokeAsync<UsageSnapshotDto>("RefreshUsage");
|
||||
|
||||
public async Task<IReadOnlyList<ModelUsageRowDto>> GetModelUsageAsync(DateOnly from, DateOnly to)
|
||||
=> await TryInvokeAsync<List<ModelUsageRowDto>>("GetModelUsage", from, to) ?? [];
|
||||
|
||||
@@ -625,7 +669,8 @@ public sealed record AppSettingsDto(
|
||||
List<string>? SessionSkills = null,
|
||||
List<ModelPresetDto>? ModelPresets = null,
|
||||
int UsageGateFiveHourPct = 80,
|
||||
int UsageGateSevenDayPct = 90);
|
||||
int UsageGateSevenDayPct = 90,
|
||||
int MaxTurnsCeiling = 80);
|
||||
|
||||
// Per-model run defaults (effort + turn budget) edited in Settings → General.
|
||||
public sealed record ModelPresetDto(string Model, string Effort, int MaxTurns);
|
||||
@@ -672,7 +717,9 @@ public sealed record LaunchSpec(
|
||||
IReadOnlyDictionary<string, string> Env);
|
||||
|
||||
public sealed record ForceRemoveResultDto(bool Removed, string? Reason);
|
||||
public sealed record PlanningMergeConflictStateDto(string PlanningTaskId, string SubtaskId);
|
||||
public sealed record PendingQuestionDto(string TaskId, string QuestionId, string Question);
|
||||
public sealed record WorkerBuildInfoDto(string? BuildSha);
|
||||
|
||||
public sealed record OnlineInboxStateDto(
|
||||
bool Enabled,
|
||||
@@ -714,7 +761,10 @@ public sealed record UsageSnapshotDto(
|
||||
string? GateReason,
|
||||
DateTime? FetchedAtUtc,
|
||||
bool IsStale,
|
||||
string? LastError);
|
||||
string? LastError,
|
||||
int ConfiguredSlots,
|
||||
int EffectiveSlots,
|
||||
string? ThrottleBucket);
|
||||
|
||||
public sealed record ModelUsageRowDto(
|
||||
DateOnly Date,
|
||||
|
||||
@@ -43,6 +43,7 @@ public sealed partial class AgentConfigEditorViewModel : ViewModelBase, IDisposa
|
||||
[ObservableProperty] private string _modelInheritedHint = "";
|
||||
[ObservableProperty] private string _turnsBadge = "";
|
||||
[ObservableProperty] private string _turnsInheritedHint = "";
|
||||
[ObservableProperty] private string _turnsCeilingHint = "";
|
||||
[ObservableProperty] private string _agentBadge = "";
|
||||
[ObservableProperty] private string _effectiveSystemPromptHint = "";
|
||||
|
||||
@@ -50,6 +51,7 @@ public sealed partial class AgentConfigEditorViewModel : ViewModelBase, IDisposa
|
||||
// The global max-turns default is per-model (Settings -> General), so it moves with whichever
|
||||
// model actually ends up in effect here.
|
||||
private IReadOnlyList<ModelPreset> _presets = ModelPresets.Defaults;
|
||||
private int _maxTurnsCeiling = 80;
|
||||
private string EffectiveModel => Model ?? _listModel ?? _globalModel;
|
||||
private int GlobalMaxTurns => ModelPresets.For(_presets, EffectiveModel).MaxTurns;
|
||||
private string? _listModel; // Task scope only
|
||||
@@ -145,6 +147,9 @@ public sealed partial class AgentConfigEditorViewModel : ViewModelBase, IDisposa
|
||||
: InheritanceResolver.ResolveList(own, GlobalMaxTurns.ToString());
|
||||
TurnsInheritedHint = value;
|
||||
TurnsBadge = BadgeFor(source, MaxTurns is not null);
|
||||
TurnsCeilingHint = MaxTurns is decimal t && (int)t > _maxTurnsCeiling
|
||||
? Loc.T("settings.turnsCeilingHint", _maxTurnsCeiling)
|
||||
: "";
|
||||
}
|
||||
|
||||
private void RecomputeAgentBadge()
|
||||
@@ -301,6 +306,7 @@ public sealed partial class AgentConfigEditorViewModel : ViewModelBase, IDisposa
|
||||
_presets = app?.ModelPresets is { Count: > 0 } rows
|
||||
? rows.Select(r => new ModelPreset(r.Model, r.Effort, r.MaxTurns)).ToList()
|
||||
: ModelPresets.Defaults;
|
||||
_maxTurnsCeiling = app?.MaxTurnsCeiling ?? 80;
|
||||
}
|
||||
|
||||
private void ApplyConfig(string? model, int? maxTurns, string? systemPrompt, string? agentPath)
|
||||
|
||||
@@ -13,9 +13,18 @@ namespace ClaudeDo.Ui.ViewModels;
|
||||
/// </summary>
|
||||
public sealed partial class InteractiveTerminalViewModel : ViewModelBase, IDisposable
|
||||
{
|
||||
// A process that dies within this window of becoming "running" is treated as a startup
|
||||
// failure (see OnSessionProcessExited) rather than a normal session end, even though the
|
||||
// spawn itself succeeded — e.g. `claude --session-id <guid>` exiting immediately on an
|
||||
// auth/network hiccup. Long enough to clear normal CLI startup, short enough not to
|
||||
// mistake a real crash-after-use for a start failure.
|
||||
private static readonly TimeSpan StartupGraceWindow = TimeSpan.FromSeconds(5);
|
||||
|
||||
private readonly PtyTerminalSession _session = new();
|
||||
private readonly Func<DateTime> _utcNow;
|
||||
private TerminalControl? _control;
|
||||
private TerminalLaunchDescriptor? _pendingDescriptor;
|
||||
private DateTime? _startedAtUtc;
|
||||
|
||||
[ObservableProperty] private bool _isRunning;
|
||||
[ObservableProperty] private bool _hasExited;
|
||||
@@ -27,12 +36,23 @@ public sealed partial class InteractiveTerminalViewModel : ViewModelBase, IDispo
|
||||
/// can show a spinner instead of an empty black pane.</summary>
|
||||
public bool IsStarting => !IsRunning && !HasExited && StartError is null;
|
||||
|
||||
partial void OnIsRunningChanged(bool value) => OnPropertyChanged(nameof(IsStarting));
|
||||
partial void OnIsRunningChanged(bool value)
|
||||
{
|
||||
OnPropertyChanged(nameof(IsStarting));
|
||||
if (value) _startedAtUtc = _utcNow();
|
||||
}
|
||||
|
||||
partial void OnHasExitedChanged(bool value) => OnPropertyChanged(nameof(IsStarting));
|
||||
partial void OnStartErrorChanged(string? value) => OnPropertyChanged(nameof(IsStarting));
|
||||
|
||||
public InteractiveTerminalViewModel()
|
||||
public InteractiveTerminalViewModel() : this(() => DateTime.UtcNow)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>Test-only seam for controlling "time since launch" without real delays.</summary>
|
||||
internal InteractiveTerminalViewModel(Func<DateTime> utcNow)
|
||||
{
|
||||
_utcNow = utcNow;
|
||||
_session.ProcessExited += OnSessionProcessExited;
|
||||
}
|
||||
|
||||
@@ -76,11 +96,18 @@ public sealed partial class InteractiveTerminalViewModel : ViewModelBase, IDispo
|
||||
}
|
||||
}
|
||||
|
||||
private void OnSessionProcessExited(object? sender, int exitCode)
|
||||
/// <summary>Internal (not private) so tests can drive it directly without a real ConPTY spawn.</summary>
|
||||
internal void OnSessionProcessExited(object? sender, int exitCode)
|
||||
{
|
||||
IsRunning = false;
|
||||
HasExited = true;
|
||||
ExitCode = exitCode;
|
||||
|
||||
// Died at startup: route through the same banner as a launch-time failure instead of
|
||||
// leaving a dead terminal with no Retry affordance (ConPtyPaneViewModel.CanRetry
|
||||
// requires StartError).
|
||||
if (exitCode != 0 && _startedAtUtc is { } startedAt && _utcNow() - startedAt < StartupGraceWindow)
|
||||
StartError = $"Session exited immediately (code {exitCode}).";
|
||||
}
|
||||
|
||||
/// <summary>Reports a failure that happened before <see cref="Start"/> could be called (e.g. the
|
||||
|
||||
@@ -35,6 +35,9 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
|
||||
private readonly Action<string, string, string, DateTime> _workerTaskFinishedHandler;
|
||||
private readonly Action<string> _workerWorktreeUpdatedHandler;
|
||||
private readonly Action<string> _workerTaskUpdatedHandler;
|
||||
private readonly Action<string, string> _workerPlanningMergeStartedHandler;
|
||||
private readonly Action<string> _workerPlanningMergeAbortedHandler;
|
||||
private readonly Action<string> _workerPlanningCompletedHandler;
|
||||
|
||||
[ObservableProperty] private bool _isNotesMode;
|
||||
[ObservableProperty] private bool _isPrepMode;
|
||||
@@ -219,6 +222,7 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
|
||||
[ObservableProperty] private string? _branchLine;
|
||||
[ObservableProperty] private int _turns;
|
||||
[ObservableProperty] private int _tokens;
|
||||
[ObservableProperty] private string? _tokensBreakdown;
|
||||
[ObservableProperty] private int _diffAdditions;
|
||||
[ObservableProperty] private int _diffDeletions;
|
||||
[ObservableProperty] private int _commitsOnBranch;
|
||||
@@ -350,6 +354,8 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
|
||||
ResetAndRetryCommand.NotifyCanExecuteChanged();
|
||||
ContinueCommand.NotifyCanExecuteChanged();
|
||||
SendRoadblockReplyCommand.NotifyCanExecuteChanged();
|
||||
CancelReviewCommand.NotifyCanExecuteChanged();
|
||||
DeleteTaskCommand.NotifyCanExecuteChanged();
|
||||
}
|
||||
};
|
||||
_worker.PropertyChanged += _workerPropertyChangedHandler;
|
||||
@@ -365,6 +371,10 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
|
||||
if (Task?.Id != taskId) return;
|
||||
_ = RefreshWorktreeAsync(taskId);
|
||||
_ = RefreshChildOutcomeAsync(taskId);
|
||||
// The run only gets a session id once it has started, so a task that was already
|
||||
// selected while it ran still holds the null from selection time. Without this the
|
||||
// roadblock reply box and Continue stay dead until the task is re-selected.
|
||||
_ = RefreshLatestRunSessionIdAsync(taskId);
|
||||
};
|
||||
_worker.TaskFinishedEvent += _workerTaskFinishedHandler;
|
||||
|
||||
@@ -387,6 +397,24 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
|
||||
};
|
||||
_worker.TaskUpdatedEvent += _workerTaskUpdatedHandler;
|
||||
|
||||
_workerPlanningMergeStartedHandler = (planningTaskId, _) =>
|
||||
{
|
||||
if (Task?.Id == planningTaskId) IsMergeDraining = true;
|
||||
};
|
||||
_worker.PlanningMergeStartedEvent += _workerPlanningMergeStartedHandler;
|
||||
|
||||
_workerPlanningMergeAbortedHandler = planningTaskId =>
|
||||
{
|
||||
if (Task?.Id == planningTaskId) IsMergeDraining = false;
|
||||
};
|
||||
_worker.PlanningMergeAbortedEvent += _workerPlanningMergeAbortedHandler;
|
||||
|
||||
_workerPlanningCompletedHandler = planningTaskId =>
|
||||
{
|
||||
if (Task?.Id == planningTaskId) IsMergeDraining = false;
|
||||
};
|
||||
_worker.PlanningCompletedEvent += _workerPlanningCompletedHandler;
|
||||
|
||||
ChildOutcomes.CollectionChanged += (_, _) =>
|
||||
{
|
||||
Merge.SyncChildOutcomes(HasChildOutcomes, Subtasks.Count);
|
||||
@@ -396,6 +424,8 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_subscribedTask is not null)
|
||||
_subscribedTask.PropertyChanged -= OnBoundTaskPropertyChanged;
|
||||
Monitor.PropertyChanged -= OnMonitorPropertyChanged;
|
||||
Monitor.Dispose();
|
||||
Loc.LanguageChanged -= _langChangedHandler;
|
||||
@@ -404,6 +434,9 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
|
||||
_worker.TaskFinishedEvent -= _workerTaskFinishedHandler;
|
||||
_worker.WorktreeUpdatedEvent -= _workerWorktreeUpdatedHandler;
|
||||
_worker.TaskUpdatedEvent -= _workerTaskUpdatedHandler;
|
||||
_worker.PlanningMergeStartedEvent -= _workerPlanningMergeStartedHandler;
|
||||
_worker.PlanningMergeAbortedEvent -= _workerPlanningMergeAbortedHandler;
|
||||
_worker.PlanningCompletedEvent -= _workerPlanningCompletedHandler;
|
||||
AgentSettings.Dispose();
|
||||
Prep.Dispose();
|
||||
}
|
||||
@@ -433,6 +466,7 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
|
||||
DequeueCommand.NotifyCanExecuteChanged();
|
||||
ResetAndRetryCommand.NotifyCanExecuteChanged();
|
||||
ContinueCommand.NotifyCanExecuteChanged();
|
||||
CancelReviewCommand.NotifyCanExecuteChanged();
|
||||
// A state change means a new run/review cycle: the diff must be
|
||||
// re-inspected before merge can be approved again.
|
||||
ReviewDiffViewed = false;
|
||||
@@ -542,6 +576,7 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
|
||||
OnPropertyChanged(nameof(TaskIdBadge));
|
||||
Monitor.Reset();
|
||||
RoadblockReplyDraft = string.Empty;
|
||||
IsMergeDraining = false;
|
||||
Subtasks.Clear();
|
||||
ChildOutcomes.Clear();
|
||||
Attachments.Clear();
|
||||
@@ -619,7 +654,15 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
|
||||
// Restore turn/token counts from the last run so a reloaded terminal task
|
||||
// shows its real turns instead of "0/max".
|
||||
Turns = latestRun?.TurnCount ?? 0;
|
||||
Tokens = (latestRun?.TokensIn ?? 0) + (latestRun?.TokensOut ?? 0);
|
||||
// Raw total = what actually counts against the 5h/7d Claude usage limit: cached
|
||||
// context resend (cache-read + cache-write) dwarfs fresh input/output on a
|
||||
// resumed session, so it must be included, not just the uncached input/output.
|
||||
var tokensIn = latestRun?.TokensIn ?? 0;
|
||||
var tokensOut = latestRun?.TokensOut ?? 0;
|
||||
var cacheRead = latestRun?.CacheReadTokens ?? 0;
|
||||
var cacheWrite = latestRun?.CacheWriteTokens ?? 0;
|
||||
Tokens = tokensIn + tokensOut + cacheRead + cacheWrite;
|
||||
TokensBreakdown = $"in {tokensIn} · out {tokensOut} · cache-read {cacheRead} · cache-write {cacheWrite}";
|
||||
Monitor.ApplyOutcome(entity.Result, latestRun?.ErrorMarkdown);
|
||||
|
||||
Monitor.SetTaskId(row.Id);
|
||||
@@ -792,6 +835,18 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
|
||||
// Refreshes the bound row itself (status, planning phase, worktree/branch mirrors, etc.) from
|
||||
// the DB. Kept independent of TasksIslandViewModel's own handler: the row instance backing
|
||||
// Task may have been replaced by a full list reload, so this must not assume it stayed live.
|
||||
private async System.Threading.Tasks.Task RefreshLatestRunSessionIdAsync(string taskId)
|
||||
{
|
||||
try
|
||||
{
|
||||
await using var ctx = await _dbFactory.CreateDbContextAsync();
|
||||
var latestRun = await new TaskRunRepository(ctx).GetLatestByTaskIdAsync(taskId);
|
||||
if (Task?.Id != taskId) return;
|
||||
LatestRunSessionId = latestRun?.SessionId;
|
||||
}
|
||||
catch { /* best-effort */ }
|
||||
}
|
||||
|
||||
private async System.Threading.Tasks.Task RefreshBoundTaskAsync(string taskId)
|
||||
{
|
||||
try
|
||||
@@ -855,14 +910,33 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
|
||||
Merge.SyncWorktree(WorktreePath, WorktreeBaseCommit, WorktreeHeadCommit,
|
||||
WorktreeStateLabel, _listWorkingDir);
|
||||
|
||||
// Tracks whichever row we last subscribed to, so a later Task switch can unsubscribe the old
|
||||
// one cleanly even though the generated OnTaskChanged only hands us the new value.
|
||||
private TaskRowViewModel? _subscribedTask;
|
||||
|
||||
partial void OnTaskChanged(TaskRowViewModel? value)
|
||||
{
|
||||
if (_subscribedTask is not null)
|
||||
_subscribedTask.PropertyChanged -= OnBoundTaskPropertyChanged;
|
||||
_subscribedTask = value;
|
||||
if (value is not null)
|
||||
value.PropertyChanged += OnBoundTaskPropertyChanged;
|
||||
|
||||
ReviewDiffViewed = false;
|
||||
Merge.SyncTaskContext(Task?.Id, Task?.Title, Task?.IsPlanningParent == true);
|
||||
NotifySessionSections();
|
||||
OnPropertyChanged(nameof(CanAcceptDrop));
|
||||
}
|
||||
|
||||
// The bound row's HasInteractiveSession can flip from outside (Mission Control opening/closing
|
||||
// a ConPTY pane) without Task itself changing, so ResetAndRetryCommand needs its own listener
|
||||
// to stay in sync with the gate in CanResetAndRetry.
|
||||
private void OnBoundTaskPropertyChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e)
|
||||
{
|
||||
if (e.PropertyName == nameof(TaskRowViewModel.HasInteractiveSession))
|
||||
ResetAndRetryCommand.NotifyCanExecuteChanged();
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void CloseDetails() => CloseDetail?.Invoke();
|
||||
|
||||
@@ -911,7 +985,7 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
|
||||
await repo.UpdateAsync(entity);
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
[RelayCommand(CanExecute = nameof(CanDeleteTask))]
|
||||
private async System.Threading.Tasks.Task DeleteTaskAsync()
|
||||
{
|
||||
if (Task == null) return;
|
||||
@@ -921,18 +995,27 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
|
||||
var ok = await ConfirmAsync($"Delete \"{row.Title}\"? This cannot be undone.");
|
||||
if (!ok) return;
|
||||
}
|
||||
// Routed through the worker (mirrors the MCP delete_task tool) so a deleted child
|
||||
// correctly advances a WaitingForChildren parent — a direct-repo delete from here used
|
||||
// to bypass TaskStateService.TryAdvanceParentAsync and could wedge the parent forever.
|
||||
bool deleted;
|
||||
string? error;
|
||||
try
|
||||
{
|
||||
await using var ctx = _dbFactory.CreateDbContext();
|
||||
var repo = new TaskRepository(ctx);
|
||||
await repo.DeleteAsync(row.Id);
|
||||
(deleted, error) = await _worker.DeleteTaskAsync(row.Id);
|
||||
}
|
||||
catch (Microsoft.EntityFrameworkCore.DbUpdateException ex) when (
|
||||
ex.Message.Contains("FOREIGN KEY", StringComparison.OrdinalIgnoreCase)
|
||||
|| ex.InnerException?.Message.Contains("FOREIGN KEY", StringComparison.OrdinalIgnoreCase) == true)
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Belt and braces: the connection can drop between the CanExecute check and
|
||||
// this call, so a stray throw here must surface, not vanish silently.
|
||||
if (ShowErrorAsync != null)
|
||||
await ShowErrorAsync(ex.Message);
|
||||
return;
|
||||
}
|
||||
if (!deleted)
|
||||
{
|
||||
if (ShowErrorAsync != null)
|
||||
await ShowErrorAsync("This task has child tasks. Discard the planning session or delete child tasks first.");
|
||||
await ShowErrorAsync(error ?? "Delete failed.");
|
||||
return;
|
||||
}
|
||||
if (DeleteFromList != null)
|
||||
@@ -940,6 +1023,8 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
|
||||
CloseDetail?.Invoke();
|
||||
}
|
||||
|
||||
private bool CanDeleteTask() => Task != null && _worker.IsConnected;
|
||||
|
||||
[RelayCommand]
|
||||
private async System.Threading.Tasks.Task CommitSubtaskEditAsync(SubtaskRowViewModel? row)
|
||||
{
|
||||
@@ -1085,8 +1170,11 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
|
||||
catch { /* offline */ }
|
||||
}
|
||||
|
||||
// Reset & Retry discards the branch/uncommitted work and queues an autonomous run into the
|
||||
// same worktree — must stay off while the user is hand-editing it in an interactive ConPTY
|
||||
// pane, same reasoning as TaskRowViewModel.CanSendToQueue.
|
||||
private bool CanResetAndRetry() =>
|
||||
Task != null && _worker.IsConnected && ShowResetAndRetry;
|
||||
Task != null && _worker.IsConnected && ShowResetAndRetry && !Task.HasInteractiveSession;
|
||||
|
||||
// Set once the user opens the diff/combined-diff for the current review. Reset on
|
||||
// task switch and on every state change (a new run means a new diff to read), so
|
||||
@@ -1179,14 +1267,28 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
|
||||
catch { /* stale review action; broadcast reconciles */ }
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
// True while a unit merge is actively draining this task's subtasks onto the target
|
||||
// branch — set from PlanningMergeStarted, cleared on PlanningMergeAborted/Completed.
|
||||
// Mirrors the worker-side guard in TaskStateService.CancelAsync (the real correctness
|
||||
// fix); this just keeps the button from inviting a click the worker will reject anyway.
|
||||
[ObservableProperty]
|
||||
[NotifyCanExecuteChangedFor(nameof(CancelReviewCommand))]
|
||||
private bool _isMergeDraining;
|
||||
|
||||
[RelayCommand(CanExecute = nameof(CanCancelReview))]
|
||||
private async System.Threading.Tasks.Task CancelReviewAsync()
|
||||
{
|
||||
if (Task is null || !_worker.IsConnected) return;
|
||||
try { await _worker.CancelReviewAsync(Task.Id); }
|
||||
catch { /* stale review action; broadcast reconciles */ }
|
||||
catch (Exception ex)
|
||||
{
|
||||
ErrorReported?.Invoke(ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
private bool CanCancelReview() =>
|
||||
Task != null && _worker.IsConnected && !IsMergeDraining;
|
||||
|
||||
private async System.Threading.Tasks.Task ReloadAttachmentsAsync()
|
||||
{
|
||||
if (Task is null) return;
|
||||
|
||||
@@ -30,6 +30,9 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable
|
||||
public event EventHandler? FocusSearchRequested;
|
||||
public void RequestFocusSearch() => FocusSearchRequested?.Invoke(this, EventArgs.Empty);
|
||||
|
||||
// mirrors TasksIslandViewModel.ErrorReported — surfaces modal-owned failures in the footer strip.
|
||||
public event Action<string>? ErrorReported;
|
||||
|
||||
public IDialogService? Dialogs { get; set; }
|
||||
|
||||
[RelayCommand]
|
||||
@@ -58,6 +61,7 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable
|
||||
{
|
||||
if (Dialogs is null || _services is null) return;
|
||||
var vm = _services.GetRequiredService<RepoImportModalViewModel>();
|
||||
vm.ErrorReported += msg => ErrorReported?.Invoke(msg);
|
||||
await vm.LoadAsync();
|
||||
await Dialogs.ShowRepoImportAsync(vm);
|
||||
await LoadAsync();
|
||||
@@ -121,6 +125,7 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable
|
||||
{
|
||||
var dir = row?.WorkingDir;
|
||||
if (string.IsNullOrWhiteSpace(dir) || !System.IO.Directory.Exists(dir)) return;
|
||||
ForegroundHelper.AllowAny();
|
||||
try
|
||||
{
|
||||
System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo
|
||||
|
||||
@@ -99,11 +99,15 @@ public sealed partial class TaskRowViewModel : ViewModelBase
|
||||
public bool CanRemoveFromQueue => IsQueued || HasQueuedSubtasks;
|
||||
// "Send to queue" is the single queue entry. On a finalized planning parent it queues the
|
||||
// plan (children) via CanQueuePlan; an Active (not-yet-finalized) planning parent is hidden —
|
||||
// it must be finalized first.
|
||||
// it must be finalized first. The worker never sees a UI-hosted ConPTY session (it never
|
||||
// touches task status), so this gate has to live here: queueing a task the user is actively
|
||||
// hand-editing in an interactive pane would spawn an autonomous run racing it in the same
|
||||
// worktree.
|
||||
public bool CanSendToQueue => !IsRunning && !IsQueued && !IsWaitingForReview && !HasQueuedSubtasks
|
||||
&& (!IsChild || ParentFinalized)
|
||||
&& PlanningPhase != PlanningPhase.Active
|
||||
&& !IsManual;
|
||||
&& !IsManual
|
||||
&& !HasInteractiveSession;
|
||||
// Parent-level "send plan to queue" — only once the plan is finalized (children Planned).
|
||||
// Drives the routing inside SendToQueue, not a separate menu entry.
|
||||
public bool CanQueuePlan => !IsChild && HasPlanningChildren
|
||||
@@ -242,6 +246,7 @@ public sealed partial class TaskRowViewModel : ViewModelBase
|
||||
OnPropertyChanged(nameof(StatusLabel));
|
||||
OnPropertyChanged(nameof(ShowStatusChip));
|
||||
OnPropertyChanged(nameof(InteractiveChipTooltip));
|
||||
OnPropertyChanged(nameof(CanSendToQueue));
|
||||
}
|
||||
|
||||
partial void OnHasQueuedSubtasksChanged(bool value)
|
||||
|
||||
@@ -73,12 +73,34 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
|
||||
[ObservableProperty] private bool _showNotesRow;
|
||||
[ObservableProperty] private bool _isMyDayList;
|
||||
[ObservableProperty] private bool _isLetClaudeVisible;
|
||||
[ObservableProperty] private bool _isQuickClaudeVisible;
|
||||
|
||||
public event EventHandler? LetClaudeHandleRequested;
|
||||
|
||||
[RelayCommand]
|
||||
private void LetClaudeHandle() => LetClaudeHandleRequested?.Invoke(this, EventArgs.Empty);
|
||||
|
||||
// Opens a task-less ConPTY session directly in the current list's working dir. The shell owns
|
||||
// Mission Control, so this just raises an event for it to act on (mirrors OpenConPtySessionRequested).
|
||||
public event Action<string>? OpenQuickClaudeSessionRequested;
|
||||
|
||||
[RelayCommand]
|
||||
private void OpenQuickClaudeSession()
|
||||
{
|
||||
var dir = _currentList?.WorkingDir;
|
||||
if (string.IsNullOrWhiteSpace(dir))
|
||||
{
|
||||
ErrorReported?.Invoke(Loc.T("vm.tasksIsland.quickClaudeNoWorkingDir"));
|
||||
return;
|
||||
}
|
||||
if (!System.IO.Directory.Exists(dir))
|
||||
{
|
||||
ErrorReported?.Invoke(Loc.T("vm.tasksIsland.quickClaudeDirMissing", dir));
|
||||
return;
|
||||
}
|
||||
OpenQuickClaudeSessionRequested?.Invoke(dir);
|
||||
}
|
||||
|
||||
internal Task? LoadTask { get; private set; }
|
||||
|
||||
public Func<UnfinishedPlanningModalViewModel, Task>? ShowUnfinishedPlanningModal { get; set; }
|
||||
@@ -221,7 +243,10 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
|
||||
if (e.PropertyName == nameof(ListNavItemViewModel.Name))
|
||||
HeaderTitle = vm.Name;
|
||||
else if (e.PropertyName == nameof(ListNavItemViewModel.WorkingDir))
|
||||
{
|
||||
IsLetClaudeVisible = vm.Kind == ListKind.User && !string.IsNullOrWhiteSpace(vm.WorkingDir);
|
||||
IsQuickClaudeVisible = IsLetClaudeVisible;
|
||||
}
|
||||
}
|
||||
|
||||
public void LoadForList(ListNavItemViewModel? list)
|
||||
@@ -259,13 +284,14 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
|
||||
ShowOpenLabel = false;
|
||||
ShowNotesRow = false;
|
||||
if (listChanged) SelectedTask = null;
|
||||
if (list is null) { IsLetClaudeVisible = false; LoadTask = Task.CompletedTask; return; }
|
||||
if (list is null) { IsLetClaudeVisible = false; IsQuickClaudeVisible = false; LoadTask = Task.CompletedTask; return; }
|
||||
|
||||
HeaderTitle = list.Name;
|
||||
HeaderEyebrow = DateTime.Now.ToString("dddd · MMM dd", CultureInfo.InvariantCulture).ToUpperInvariant();
|
||||
ShowNotesRow = list.Id == "smart:my-day";
|
||||
IsMyDayList = list.Id == "smart:my-day";
|
||||
IsLetClaudeVisible = list.Kind == ListKind.User && !string.IsNullOrWhiteSpace(list.WorkingDir);
|
||||
IsQuickClaudeVisible = IsLetClaudeVisible;
|
||||
|
||||
LoadTask = LoadForListAsync(list, ct, reusable);
|
||||
}
|
||||
@@ -807,26 +833,31 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
|
||||
[RelayCommand]
|
||||
private async Task SendToQueueAsync(TaskRowViewModel? row)
|
||||
{
|
||||
if (row is null || row.IsRunning) return;
|
||||
if (row is null || row.IsRunning || row.HasInteractiveSession || _worker is null) return;
|
||||
// A finalized planning parent queues its plan (children sequentially), not itself.
|
||||
if (row.CanQueuePlan)
|
||||
{
|
||||
// The hub's QueuePlanningSubtasksAsync queues every Idle child unconditionally — it has
|
||||
// no notion of a UI-hosted ConPTY session. Block the whole plan if any child has one open,
|
||||
// otherwise that child's worktree would get an autonomous run racing the user's own edits.
|
||||
var interactiveChildren = Items
|
||||
.Where(r => r.ParentTaskId == row.Id && r.HasInteractiveSession)
|
||||
.Select(r => r.Title)
|
||||
.ToList();
|
||||
if (interactiveChildren.Count > 0)
|
||||
{
|
||||
ErrorReported?.Invoke(Loc.T(
|
||||
"vm.tasksIsland.queuePlanBlockedInteractive", string.Join(", ", interactiveChildren)));
|
||||
return;
|
||||
}
|
||||
await QueuePlanningSubtasksAsync(row);
|
||||
return;
|
||||
}
|
||||
await using var db = await _dbFactory.CreateDbContextAsync();
|
||||
var entity = await db.Tasks.FirstOrDefaultAsync(t => t.Id == row.Id);
|
||||
if (entity is null) return;
|
||||
entity.Status = TaskStatus.Queued;
|
||||
await db.SaveChangesAsync();
|
||||
row.Status = TaskStatus.Queued;
|
||||
if (_worker is not null)
|
||||
{
|
||||
try { await _worker.WakeQueueAsync(); } catch { }
|
||||
}
|
||||
Regroup();
|
||||
UpdateSubtitle();
|
||||
TasksChanged?.Invoke(this, EventArgs.Empty);
|
||||
// Goes through the worker hub (TaskStateService.EnqueueAsync) rather than a raw EF write
|
||||
// so the manual/draft-child guards apply here too; the row refreshes from the resulting
|
||||
// TaskUpdated broadcast.
|
||||
try { await _worker.SetTaskStatusAsync(row.Id, TaskStatus.Queued); }
|
||||
catch (Exception ex) { ErrorReported?.Invoke(Loc.T("vm.tasksIsland.sendToQueueFailed", ex.Message)); }
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
@@ -912,7 +943,7 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
|
||||
{
|
||||
if (row is null || !row.IsWaitingForReview || _worker is null) return;
|
||||
try { await _worker.CancelReviewAsync(row.Id); }
|
||||
catch { /* offline; broadcast reconciles on return */ }
|
||||
catch (Exception ex) { ErrorReported?.Invoke(Loc.T("vm.tasksIsland.cancelReviewFailed", ex.Message)); }
|
||||
}
|
||||
|
||||
public async Task SetScheduledForAsync(TaskRowViewModel row, DateTime? when)
|
||||
|
||||
@@ -5,6 +5,7 @@ using System.Threading.Tasks;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using ClaudeDo.Data;
|
||||
using ClaudeDo.Data.Git;
|
||||
using ClaudeDo.Data.Models;
|
||||
using ClaudeDo.Ui.Localization;
|
||||
using ClaudeDo.Ui.Services;
|
||||
@@ -34,6 +35,7 @@ public sealed partial class IslandsShellViewModel : ViewModelBase, IDisposable
|
||||
private readonly UpdateCheckService _updateCheck = null!;
|
||||
private readonly InstallerLocator _installerLocator = null!;
|
||||
private readonly WorkerLocator _workerLocator = null!;
|
||||
private readonly GitService? _git;
|
||||
private readonly IDbContextFactory<ClaudeDoDbContext>? _dbFactory;
|
||||
private readonly Func<WorktreesOverviewModalViewModel> _worktreesOverviewVmFactory = () => null!;
|
||||
private readonly Func<WeeklyReportModalViewModel> _weeklyReportVmFactory = () => null!;
|
||||
@@ -102,6 +104,18 @@ public sealed partial class IslandsShellViewModel : ViewModelBase, IDisposable
|
||||
[ObservableProperty] private string? _updateBannerLatestVersion;
|
||||
private bool _bannerDismissedThisSession;
|
||||
|
||||
// Persistent (not auto-clearing) banner: the running worker predates the selected list's
|
||||
// merged HEAD, so "verified" claims made against the current process are stale. ClaudeDo-repo
|
||||
// only — see RefreshStaleWorkerCheckAsync.
|
||||
[ObservableProperty] private bool _isStaleWorkerBannerVisible;
|
||||
|
||||
// planningTaskId -> subtaskId, for unit-merge conflicts an MCP session (not the UI) started.
|
||||
// Kept as a dictionary so a later conflict on the same planning task updates in place instead
|
||||
// of piling up duplicate entries.
|
||||
private readonly Dictionary<string, string> _externalMergeConflicts = new();
|
||||
[ObservableProperty] private bool _isExternalMergeBannerVisible;
|
||||
|
||||
|
||||
[ObservableProperty]
|
||||
private double _windowWidth = 1280;
|
||||
|
||||
@@ -178,13 +192,59 @@ public sealed partial class IslandsShellViewModel : ViewModelBase, IDisposable
|
||||
_primeStatusTimer.Start();
|
||||
}
|
||||
|
||||
private void OnPlanningMergeConflict(string planningTaskId, string subtaskId, IReadOnlyList<string> conflictedFiles)
|
||||
public void OnPlanningMergeConflict(
|
||||
string planningTaskId, string subtaskId, IReadOnlyList<string> conflictedFiles, bool externallyDriven)
|
||||
{
|
||||
// Already on UI thread (WorkerClient dispatches via Dispatcher.UIThread.Post).
|
||||
if (externallyDriven)
|
||||
{
|
||||
// An MCP session (review_task/continue_merge) is driving this merge — it owns
|
||||
// resolution. Auto-opening the resolver here raced with the session's own writes
|
||||
// (two parties editing the same shared checkout at once); show a banner instead and
|
||||
// leave the resolver reachable only via a deliberate click.
|
||||
_externalMergeConflicts[planningTaskId] = subtaskId;
|
||||
IsExternalMergeBannerVisible = true;
|
||||
return;
|
||||
}
|
||||
|
||||
// A unit-merge conflict resolves in the same in-app 3-way editor as a single-task merge.
|
||||
_ = OpenPlanningConflictAsync(planningTaskId, subtaskId);
|
||||
}
|
||||
|
||||
public void OnPlanningMergeAborted(string planningTaskId) => ClearExternalMergeConflict(planningTaskId);
|
||||
public void OnPlanningMergeCompleted(string planningTaskId) => ClearExternalMergeConflict(planningTaskId);
|
||||
|
||||
private void ClearExternalMergeConflict(string planningTaskId)
|
||||
{
|
||||
_externalMergeConflicts.Remove(planningTaskId);
|
||||
IsExternalMergeBannerVisible = _externalMergeConflicts.Count > 0;
|
||||
}
|
||||
|
||||
/// <summary>Re-syncs the external-merge banner from the worker on (re)connect — the
|
||||
/// one-shot PlanningMergeConflict broadcast isn't replayed after a UI restart, so this is
|
||||
/// the recovery path. The worker checks MERGE_HEAD before reporting a conflict as active,
|
||||
/// so a session that died mid-merge without cleaning up doesn't leave a stale banner up.</summary>
|
||||
private async Task RefreshExternalMergeConflictsAsync()
|
||||
{
|
||||
if (Worker is null) return;
|
||||
IReadOnlyList<PlanningMergeConflictStateDto> active;
|
||||
try { active = await Worker.GetActiveExternalPlanningMergeConflictsAsync(); }
|
||||
catch { return; }
|
||||
|
||||
_externalMergeConflicts.Clear();
|
||||
foreach (var c in active)
|
||||
_externalMergeConflicts[c.PlanningTaskId] = c.SubtaskId;
|
||||
IsExternalMergeBannerVisible = _externalMergeConflicts.Count > 0;
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private Task OpenExternalMergeConflictAsync()
|
||||
{
|
||||
if (_externalMergeConflicts.Count == 0) return Task.CompletedTask;
|
||||
var (planningTaskId, subtaskId) = _externalMergeConflicts.First();
|
||||
return OpenPlanningConflictAsync(planningTaskId, subtaskId);
|
||||
}
|
||||
|
||||
private async Task OpenPlanningConflictAsync(string planningTaskId, string subtaskId)
|
||||
{
|
||||
if (ConflictResolverFactory is null || Dialogs is null) return;
|
||||
@@ -212,7 +272,8 @@ public sealed partial class IslandsShellViewModel : ViewModelBase, IDisposable
|
||||
Func<MergeModalViewModel> mergeVmFactory,
|
||||
Func<RepoImportModalViewModel> repoImportVmFactory,
|
||||
MissionControlViewModel missionControl,
|
||||
UsagePillViewModel usagePill)
|
||||
UsagePillViewModel usagePill,
|
||||
GitService? git = null)
|
||||
{
|
||||
Lists = lists; Tasks = tasks; Details = details; Worker = worker;
|
||||
MissionControl = missionControl;
|
||||
@@ -232,16 +293,24 @@ public sealed partial class IslandsShellViewModel : ViewModelBase, IDisposable
|
||||
_usageMonitorVmFactory = usageMonitorVmFactory;
|
||||
_mergeVmFactory = mergeVmFactory;
|
||||
_repoImportVmFactory = repoImportVmFactory;
|
||||
_git = git;
|
||||
Lists.SelectionChanged += (_, _) => Tasks.LoadForList(Lists.SelectedList);
|
||||
Lists.SelectionChanged += (_, _) => _ = RefreshStaleWorkerCheckAsync();
|
||||
Tasks.SelectionChanged += (_, _) => Details.Bind(Tasks.SelectedTask);
|
||||
Tasks.NotesRequested += () => Details.ShowNotes();
|
||||
Tasks.PrepRequested += () => Details.ShowPrep();
|
||||
Tasks.ErrorReported += FlashFooterError;
|
||||
Lists.ErrorReported += FlashFooterError;
|
||||
Tasks.OpenConPtySessionRequested += taskId =>
|
||||
{
|
||||
OpenMissionControl();
|
||||
_ = MissionControl.OpenConPtySessionAsync(taskId);
|
||||
};
|
||||
Tasks.OpenQuickClaudeSessionRequested += dir =>
|
||||
{
|
||||
OpenMissionControl();
|
||||
_ = MissionControl.OpenAdHocConPtySessionAsync(dir);
|
||||
};
|
||||
Tasks.OpenPlanningConPtyRequested += (taskId, resume) =>
|
||||
{
|
||||
OpenMissionControl();
|
||||
@@ -280,7 +349,11 @@ public sealed partial class IslandsShellViewModel : ViewModelBase, IDisposable
|
||||
}
|
||||
};
|
||||
Worker.WorkerLogReceivedEvent += OnWorkerLogReceived;
|
||||
Worker.ConnectionRestoredEvent += () => _ = RefreshStaleWorkerCheckAsync();
|
||||
Worker.PlanningMergeConflictEvent += OnPlanningMergeConflict;
|
||||
Worker.PlanningMergeAbortedEvent += OnPlanningMergeAborted;
|
||||
Worker.PlanningCompletedEvent += OnPlanningMergeCompleted;
|
||||
Worker.ConnectionRestoredEvent += () => _ = RefreshExternalMergeConflictsAsync();
|
||||
Worker.PrimeFired += OnPrimeFired;
|
||||
_clearTimer.Elapsed += (_, _) =>
|
||||
{
|
||||
@@ -319,6 +392,8 @@ public sealed partial class IslandsShellViewModel : ViewModelBase, IDisposable
|
||||
_connectTimer.Dispose();
|
||||
_primeStatusTimer.Stop();
|
||||
_primeStatusTimer.Dispose();
|
||||
_staleWorkerCts?.Cancel();
|
||||
_staleWorkerCts?.Dispose();
|
||||
}
|
||||
|
||||
private void RefreshBannerFromStatus()
|
||||
@@ -338,6 +413,57 @@ public sealed partial class IslandsShellViewModel : ViewModelBase, IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
private CancellationTokenSource? _staleWorkerCts;
|
||||
|
||||
// Re-evaluates the stale-worker banner for the currently selected list. Cheap (one hub call +
|
||||
// up to two git subprocesses) and only ever runs for a git-backed list, so it's fine to fire on
|
||||
// every selection change / reconnect rather than caching.
|
||||
private async Task RefreshStaleWorkerCheckAsync()
|
||||
{
|
||||
_staleWorkerCts?.Cancel();
|
||||
var cts = new CancellationTokenSource();
|
||||
_staleWorkerCts = cts;
|
||||
|
||||
var stale = await ComputeIsStaleWorkerAsync(Lists?.SelectedList?.WorkingDir, cts.Token);
|
||||
if (cts.IsCancellationRequested) return;
|
||||
IsStaleWorkerBannerVisible = stale;
|
||||
}
|
||||
|
||||
private async Task<bool> ComputeIsStaleWorkerAsync(string? workingDir, CancellationToken ct)
|
||||
{
|
||||
if (_git is null || Worker is null || string.IsNullOrWhiteSpace(workingDir)) return false;
|
||||
|
||||
try
|
||||
{
|
||||
var buildInfo = await Worker.GetWorkerBuildInfoAsync();
|
||||
var buildSha = buildInfo?.BuildSha;
|
||||
if (string.IsNullOrWhiteSpace(buildSha)) return false;
|
||||
if (!await _git.IsGitRepoAsync(workingDir, ct)) return false;
|
||||
|
||||
var head = await _git.RevParseHeadAsync(workingDir, ct);
|
||||
var isAncestor = string.Equals(buildSha, head, StringComparison.OrdinalIgnoreCase)
|
||||
? (bool?)false // equal — never "stale" on a match, and no need to ask git
|
||||
: await _git.IsAncestorAsync(workingDir, buildSha, head, ct);
|
||||
return ShouldShowStaleWorkerBanner(buildSha, head, isAncestor);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Worker offline, dir no longer a repo, etc. — unknown, so stay quiet.
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Pure decision extracted for testability. isAncestor is the tri-state result of
|
||||
// `git merge-base --is-ancestor buildSha head`: true = worker predates head (stale), false =
|
||||
// equal or diverged (never claim "stale" on a match or an unrelated history), null = unknown
|
||||
// (e.g. buildSha isn't a commit this repo knows about — never treat "unknown" as "stale").
|
||||
internal static bool ShouldShowStaleWorkerBanner(string? buildSha, string? headSha, bool? isAncestor)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(buildSha) || string.IsNullOrWhiteSpace(headSha)) return false;
|
||||
if (string.Equals(buildSha, headSha, StringComparison.OrdinalIgnoreCase)) return false;
|
||||
return isAncestor == true;
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void OpenMissionControl()
|
||||
{
|
||||
@@ -347,7 +473,7 @@ public sealed partial class IslandsShellViewModel : ViewModelBase, IDisposable
|
||||
|
||||
private void SyncInteractiveSessionChips()
|
||||
{
|
||||
if (MissionControl is null) return;
|
||||
if (MissionControl is null || Tasks is null) return;
|
||||
Tasks.SyncInteractiveSessions(
|
||||
MissionControl.ConPtySessions
|
||||
.Select(s => s.TaskId)
|
||||
|
||||
@@ -20,7 +20,11 @@ public sealed partial class ConPtyPaneViewModel : ViewModelBase, IMissionControl
|
||||
|
||||
[ObservableProperty] private string _displayTitle;
|
||||
|
||||
public InteractiveTerminalViewModel Terminal { get; } = new();
|
||||
[ObservableProperty] private InteractiveTerminalViewModel _terminal = new();
|
||||
|
||||
/// <summary>Set by the host (Mission Control) while a submit-for-review round trip is in
|
||||
/// flight, so a rapid double-click can't race two submissions for the same task.</summary>
|
||||
[ObservableProperty] private bool _isSubmitPending;
|
||||
|
||||
/// <summary>Raised when the terminal failed to start — the host surfaces this via the footer error strip.</summary>
|
||||
public event Action<string>? ErrorReported;
|
||||
@@ -76,12 +80,21 @@ public sealed partial class ConPtyPaneViewModel : ViewModelBase, IMissionControl
|
||||
{
|
||||
if (e.PropertyName == nameof(InteractiveTerminalViewModel.StartError) && Terminal.StartError is { Length: > 0 } error)
|
||||
ErrorReported?.Invoke(error);
|
||||
|
||||
if (e.PropertyName is nameof(InteractiveTerminalViewModel.IsStarting)
|
||||
or nameof(InteractiveTerminalViewModel.StartError)
|
||||
or nameof(InteractiveTerminalViewModel.HasExited))
|
||||
{
|
||||
SubmitForReviewCommand.NotifyCanExecuteChanged();
|
||||
RetryCommand.NotifyCanExecuteChanged();
|
||||
}
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void Close() => CloseRequested?.Invoke(this);
|
||||
|
||||
private bool CanSubmitForReview() => IsTaskBased;
|
||||
private bool CanSubmitForReview() =>
|
||||
IsTaskBased && !IsSubmitPending && !Terminal.IsStarting && Terminal.StartError is null && !Terminal.HasExited;
|
||||
|
||||
[RelayCommand(CanExecute = nameof(CanSubmitForReview))]
|
||||
private void SubmitForReview()
|
||||
@@ -89,6 +102,22 @@ public sealed partial class ConPtyPaneViewModel : ViewModelBase, IMissionControl
|
||||
if (TaskId is { } id) SubmitForReviewRequested?.Invoke(id);
|
||||
}
|
||||
|
||||
partial void OnIsSubmitPendingChanged(bool value) => SubmitForReviewCommand.NotifyCanExecuteChanged();
|
||||
|
||||
// A launch failure permanently occupies the TaskId dedupe slot unless the user can retry —
|
||||
// re-opening the same task would otherwise just re-focus a dead tile.
|
||||
private bool CanRetry() => Terminal.HasExited && Terminal.StartError is not null;
|
||||
|
||||
[RelayCommand(CanExecute = nameof(CanRetry))]
|
||||
private void Retry()
|
||||
{
|
||||
Terminal.PropertyChanged -= OnTerminalPropertyChanged;
|
||||
Terminal.Dispose();
|
||||
Terminal = new InteractiveTerminalViewModel();
|
||||
Terminal.PropertyChanged += OnTerminalPropertyChanged;
|
||||
Start();
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Terminal.PropertyChanged -= OnTerminalPropertyChanged;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user