Compare commits
29
Commits
914fa5aa9f
...
85c7e650c9
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
85c7e650c9 | ||
|
|
c412a84fdf | ||
|
|
d8194ad57e | ||
|
|
d9a4627a1f | ||
|
|
2b06ab0ab4 | ||
|
|
bb62740ac8 | ||
|
|
25922a2768 | ||
|
|
3feb08d9d9 | ||
|
|
9ab48d7094 | ||
|
|
0513265c49 | ||
|
|
d28c63d2df | ||
|
|
5f740c05d8 | ||
|
|
1245e75902 | ||
|
|
d91ad2d635 | ||
|
|
17235a6cde | ||
|
|
f33838d028 | ||
|
|
1cfd96c15f | ||
|
|
7c3c061428 | ||
|
|
b4c58087d2 | ||
|
|
4626481359 | ||
|
|
dea2b7db8b | ||
|
|
54cdaf89d5 | ||
|
|
dbaefe92c6 | ||
|
|
62b245aaea | ||
|
|
4e5057d3f6 | ||
|
|
1bf08eca27 | ||
|
|
eb88dc130c | ||
|
|
140ae2fda1 | ||
|
|
865e12c0de |
+15
-7
@@ -17,13 +17,21 @@ Kein Code-Aufwand, nur Durchspielen mit explizit notiertem Pass-Kriterium. Der G
|
||||
- **UI-Sichtprüfung (neu, 2026-06-10, nach Refactoring-Merges):** Detail-Insel komplett durchklicken (Output/Git/Session-Tabs, Merge-Sektion, Agent-Settings-Overrides, Prep-Panel) — `DetailsIslandViewModel` wurde in Sektions-VMs aufgeteilt, Bindings angepasst. Außerdem: DiffModal-Fehler-State „Diff nicht mehr verfügbar" (Commit-Range ohne aufgezeichnete Commits) und der In-App-Konflikt-Resolver (Hub-Methoden umbenannt).
|
||||
- **UI-Sichtprüfung (neu, 2026-06-19, Rider-Style 3-Pane Merge-Editor):** Echten Konflikt auslösen (Single-Task-Approve mit Konflikt **und** Planning-Unit-Merge) und prüfen: drei Panes (Ours read-only | Result editierbar | Theirs read-only), Konfliktblöcke rot / aufgelöst grün in allen Panes, Inline-Accept `›`/`‹` in den Zwischen-Guttern landen die jeweilige Seite im Result, nur Konfliktregionen im Result editierbar (Stable read-only), synchrones vertikales Scrollen, File-Switcher bei mehreren Dateien, `M conflicts · K resolved`-Readout, Continue erst bei allen Konflikten gelöst, Binär-Guard. **Bekannte Kanten:** (1) Konflikt mit leerer Ours-Seite → Result-Region ist null-lang (Gutter via 1-Zeichen-Probe positioniert, Accept funktioniert; nur Hand-Tippen in die leere Region ist fummelig). (2) Gutter-Y nutzt `TranslatePoint` vom Result-`TextView` — bei sehr hohen Fenstern / großen Scrollständen die Ausrichtung gegenprüfen. (3) Blöcke richten sich nur über Stable-Text aus; nach einem Konflikt mit unterschiedlicher Zeilenzahl je Seite driften nachfolgende Blöcke vertikal (aligned/virtual-space Scroll ist bewusst zurückgestellt).
|
||||
- **Worker-Autostart am Gerät:** Logoff/Logon-Autostart, Update-Pfad, Uninstall entfernt die Startup-`.lnk`.
|
||||
- **In-App Interactive Sessions (neu, 2026-06-26):** ersetzt den externen `wt`-„Run interactively"-Launch durch einen In-App-Streaming-Chat (`StreamingClaudeSession`, `claude --input-format stream-json`). Real-CLI-Smoke (kein xUnit, kein Claude in Tests):
|
||||
- Task rechtsklick → „Run interactively" startet **keinen** Terminal mehr; der Stream erscheint im Detail-Output-Tab des (selektierten) Tasks und als Monitor in Mission Control.
|
||||
- Composer: Nachricht tippen + Enter/Send → erscheint sofort als `log-user`-Zeile in **Akzentfarbe** (via `LogKindForegroundConverter`, lokale Bindung schlägt den dim Style), Claude antwortet im selben Prozess.
|
||||
- **Senden während Claude arbeitet = Queue (Default):** die Nachricht wird gepuffert und beim `result` des laufenden Turns abgeschickt (kein Interrupt). Mehrere Queue-Nachrichten FIFO, eine pro Turn. Gequeute Nachrichten erscheinen in einem **Pending-Streifen über der Eingabezeile** (⧗-Liste, via `InteractiveQueueChanged`); eine Nachricht landet erst im Transkript (`log-user`-Zeile via `InteractiveMessageSent`), wenn sie tatsächlich an Claude zugestellt wird. Der seeded Erst-Prompt erscheint als erste User-Zeile. Jede gequeute Zeile hat ein **✕ zum Entfernen** (`RemoveQueuedInteractiveMessage`, by-text first-match; Worker re-broadcastet die Queue).
|
||||
- **Interrupt opt-in:** der kleine ■-Stop-Button neben Send unterbricht den laufenden Turn (`control_request`/`interrupt`, verifiziert mit CLI 2.1.191; Abbruch-`result` = `error_during_execution`, als Turn-Ende behandelt) — danach flusht die ggf. gequeute Nachricht im selben Prozess mit erhaltenem Kontext. Stop-Button ist immer sichtbar solange live (Interrupt im Idle ist ein No-op; Turn-in-flight wird nicht in die UI gebroadcastet).
|
||||
- Session-Ende: Prozess-Exit/Stop → `InteractiveSessionEnded`, Composer verschwindet, Monitor wird „done".
|
||||
- **Sicht-Konsistenz:** Mission-Control-Composer (SessionTerminalView-Bottom-Row mit Send-Button) vs. Detail-Composer (WorkConsole-Shell-Prompt `❯ … [Send]`) sehen unterschiedlich aus — ggf. angleichen.
|
||||
- **In-App Interactive Sessions (2026-06-26, REMOVED 2026-07-23):** der In-App-Streaming-Chat (`StreamingClaudeSession`, Composer/Queue auf `TaskMonitorViewModel`/`SessionTerminalView`) wurde komplett entfernt und durch die **embedded ConPTY**-Sessions ersetzt (echte `claude`-TUI im UI-Prozess, siehe `docs/superpowers/specs/2026-07-23-conpty-interactive-sessions-design.md`). Kein offener Punkt mehr — nur zur Historie.
|
||||
- **Embedded ConPTY Sessions (neu, 2026-07-23):** Command Center hostet echte `claude`-TUI-Kacheln (`Iciclecreek.Avalonia.Terminal` 2.0.3 via `TerminalControl.LaunchProcess()`). Rendering/Input/Tempo vom User verifiziert. **Noch durchzuspielen:**
|
||||
- Task-basiert (Kontextmenü „Open ConPTY session"): frischer Task → Worktree wird on-demand angelegt, `claude` startet mit Task-Prompt (Title+Description als positionaler Prompt) — **verifizieren, dass `claude "<prompt>"` interaktiv wirklich SENDET**, nicht nur vorbefüllt.
|
||||
- Ad-hoc („New session"-Button → Ordnerwahl): freie Session im gewählten Verzeichnis.
|
||||
- Grid↔Tabs-Toggle, Close killt Session + entfernt Kachel, mehrere Sessions parallel, Pane-Resize reflowt.
|
||||
- Resume einer interaktiven Session: nur via claude-eigenes `claude --continue`/`--resume` im Worktree-Dir (ClaudeDo speichert die Session-Id NICHT — ConPTY ist opak).
|
||||
- **Zurückgestellt:** Avalonia-12.1-Upgrade (braucht .NET-9-SDK-Floor wg. Roslyn-4.14-XAML-Generator; CI-Risiko) — bleibt auf 12.0.x.
|
||||
- **Pick up in terminal (neu, 2026-07-01):** neue Aktion, die die Claude-Session einer Task per `claude --resume <id>` in einem **echten** `wt`-Terminal fortsetzt (echte TUI: Permission-Prompts/Fragen inklusive) — bewusst NICHT der In-App-Streaming-Chat. Real-CLI-Smoke (kein Claude in Tests):
|
||||
- Kontextmenü einer Task in **WaitingForReview** oder **Failed** → „Pick up in terminal" sowie der Terminal-Button (Icon `ArrowOut`) im Detail-Header sind sichtbar; bei anderen Status (Idle/Running/Queued/Done) NICHT.
|
||||
- Klick → neues Windows-Terminal im Worktree-Verzeichnis der Task, Claude nimmt die letzte Session mit erhaltenem Kontext wieder auf.
|
||||
- Fehlerfälle surfacen sauber (Footer-Strip aus der Task-Insel bzw. Fehler-Dialog im Detail): laufende/gequeuete Task (verboten), keine persistierte Session-Id, kein aktiver Worktree.
|
||||
- **Gating-Kante:** parked-Idle (reject-park) hat oft noch Session+Worktree, wird aber bewusst NICHT angezeigt (Idle nicht von fresh-Idle unterscheidbar). Falls das nervt → `CanPickUpInTerminal` erweitern.
|
||||
- **Session Skills (neu, 2026-07-03):** per-Ebene (global/list/task, additiv-union) Skills für headless Task-Agenten; Registry klont+pinnt ein GitHub-Repo, Worker seedet aktivierte Skills in `<cwd>/.claude/skills/` vor jedem Run (worktree-`info/exclude`, damit `git add -A` sie nicht committet). Discovery-Mechanismus ist bereits verifiziert (headless `claude -p` lädt cwd-Skills). Spec: `docs/superpowers/specs/2026-07-03-session-skills-design.md`. Offen (kein Code, nur Durchspielen):
|
||||
- **E2E-Smoke (echter Worker, kein Claude in Tests):** In Settings → Skills `https://github.com/DietrichGebert/ponytail` installieren → 6 Skills erscheinen (ponytail, -help, -review, -audit, -debt, -gain), gepinnt auf einen Commit, Dateien unter `~/.todo-app/session-skills/<name>/`. Skill per-Task (Agent-Settings-Flyout) und/oder global aktivieren → eine Task laufen lassen → im Worktree liegt `.claude/skills/<name>/`, der Skill ist dem Agenten verfügbar, und er wird **nicht** mitcommittet (`git status` im Worktree sauber). Gegenprobe: eine **nicht** aktivierte/andere interaktive Session sieht den Skill nicht (kein global-Leak in `~/.claude`).
|
||||
- **UI-Sichtprüfung:** neuer Skills-Tab (Install-Zeile, installierte-Skill-Karten mit Update/Remove), Session-Skills-Checkbox-Liste im General-Tab und im `AgentConfigEditor` (List-Settings-Modal + per-Task-Flyout — Flyout-Höhe prüfen). Leerer Zustand (0 installierte Skills) rendert eine leere Liste ohne Platzhaltertext — ok oder Empty-State ergänzen. Lange Namen/URLs (Trimming).
|
||||
- **Drag-and-drop file attachments on the detail pane:** verify the "Drop to attach" hover overlay, drop round-trip (file appears in the list), "Add file…" picker, remove button, and that files land under `~/.todo-app/attachments/<taskId>/`. Also verify the MCP `AddTaskAttachment`/`ListTaskAttachments`/`RemoveTaskAttachment` tools and that a Running task refuses add/remove. (Manual; can't be unit-tested.)
|
||||
|
||||
## Offene Code-Punkte
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
# Session Skills — Implementation Plan
|
||||
|
||||
Spec: `docs/superpowers/specs/2026-07-03-session-skills-design.md`
|
||||
Approach: subagent-driven TDD (sonnet), build + test + commit per task, stage files by
|
||||
path (never `git add -A`).
|
||||
|
||||
**Pre-flight (do first, before building anything):** manual smoke test — drop a skill
|
||||
into a scratch worktree's `.claude/skills/` and run `claude -p` to confirm cwd skills are
|
||||
discovered in headless mode. The whole feature rests on this. If it fails, stop and
|
||||
redesign around `CLAUDE_CONFIG_DIR`.
|
||||
|
||||
---
|
||||
|
||||
## Task 1 — Data layer: columns + registry table + migration
|
||||
|
||||
- Add nullable `SessionSkills` (string, JSON array) to `TaskEntity`, `ListConfigEntity`,
|
||||
`AppSettingsEntity`; map `session_skills` columns in their `*Configuration.cs`.
|
||||
- New `SessionSkillEntity` (`name` PK, `source_url`, `pinned_ref`, `subpath`,
|
||||
`description`, `added_at`) — one row per skill; a multi-skill repo writes N rows sharing
|
||||
`source_url`/`pinned_ref` — + configuration + `session_skills` table.
|
||||
- New `SessionSkillRepository` (async, CancellationToken): `ListAsync`, `GetAsync(name)`,
|
||||
`UpsertAsync`, `DeleteAsync(name)`, `DeleteBySourceAsync(url)`, `ListBySourceAsync(url)`.
|
||||
- EF migration `AddSessionSkills` (columns + table).
|
||||
- **Tests (Data.Tests):** repository CRUD on real SQLite; JSON column round-trips a
|
||||
name list.
|
||||
|
||||
## Task 2 — Registry service (install / update / remove)
|
||||
|
||||
- `Skills/SessionSkillRegistry` + `Skills/Interfaces/ISessionSkillRegistry`,
|
||||
`IRepoCloner` (clone abstraction so tests inject a local source dir).
|
||||
- `GitRepoCloner` (production) does `git clone` + resolves HEAD SHA.
|
||||
- Install: clone → **detect layout** (`skills/*/SKILL.md` bundle → each subskill; else
|
||||
root `SKILL.md` → single; else reject) → per skill parse YAML frontmatter (`name`,
|
||||
`description`), copy its dir **flat** to `~/.todo-app/session-skills/<name>/`, upsert a
|
||||
row with `subpath`. Reject collision with a skill from a different source; reinstalling
|
||||
the same source refreshes.
|
||||
- Update(sourceUrl) / Remove(sourceUrl) per spec (act on all of a source's skills).
|
||||
- **Tests (Worker.Tests):** install a **multi-skill** fixture (fake cloner, mirrors
|
||||
ponytail's `skills/*/SKILL.md`) → N rows + N flat dirs; install a root-`SKILL.md`
|
||||
fixture → 1 row; neither → rejected; cross-source name collision rejected;
|
||||
remove-by-source deletes all its dirs + rows. **No real network / no real claude CLI.**
|
||||
|
||||
## Task 3 — Resolution: union into ClaudeRunConfig
|
||||
|
||||
- Add `IReadOnlyList<string> SkillNames` to `ClaudeRunConfig` (default empty).
|
||||
- In `TaskRunner.ResolveConfigAsync`: parse each level's `session_skills`, union + dedup,
|
||||
filter to registry-existing names (drop + log missing).
|
||||
- **Tests (Worker.Tests):** union across the three levels; dedup; unknown name dropped.
|
||||
|
||||
## Task 4 — Seeder
|
||||
|
||||
- `Skills/SessionSkillSeeder` + interface. `SeedAsync(cwd, skillNames, isWorktree, ct)`:
|
||||
copy each installed skill dir → `<cwd>/.claude/skills/<name>/`; if worktree, append
|
||||
`/.claude/skills/<name>/` to `git rev-parse --git-path info/exclude` target if absent.
|
||||
- Wire into `TaskRunner` after run-dir resolution, before `ClaudeProcess.RunAsync`
|
||||
(both worktree and sandbox paths).
|
||||
- **Tests (Worker.Tests):** seeds into real temp dir; idempotent re-seed; worktree
|
||||
exclude line written once and not duplicated; seeded path is git-ignored (real git
|
||||
temp repo → `git status` clean for the seeded dir).
|
||||
|
||||
## Task 5 — Hub + DTOs + client
|
||||
|
||||
- `WorkerHub`: `GetSessionSkills`, `InstallSessionSkill(url)`, `UpdateSessionSkill(name)`,
|
||||
`RemoveSessionSkill(name)`.
|
||||
- New `SessionSkillDto`; extend `AppSettingsDto`, `ListConfigDto`, `UpdateListConfigDto`,
|
||||
`UpdateTaskAgentSettingsDto` with skill-name lists; map in the update handlers.
|
||||
- `IWorkerClient` + `WorkerClient` additions.
|
||||
- **Update hand-rolled fakes** in Worker.Tests + Ui.Tests (memory
|
||||
`iworkerclient_fakes_sync`).
|
||||
- **Tests:** hub method round-trip via existing hub test harness where present.
|
||||
|
||||
## Task 6 — UI: registry tab + selectors
|
||||
|
||||
- `SessionSkillsSettingsTabViewModel` + a **Skills** tab in `SettingsModalView.axaml`:
|
||||
installed list, Add (URL), Update, Remove, status line. Mirror
|
||||
`FilesSettingsTabViewModel`.
|
||||
- Global multi-select in General settings tab → `AppSettings.SessionSkills`.
|
||||
- Skills multi-select in shared `AgentConfigEditor` (covers List + Task) with inheritance
|
||||
badge, wired through `AgentConfigEditorViewModel`.
|
||||
- Localization: add EN + DE keys in parity (Localization.Tests enforces).
|
||||
- **Tests (Ui.Tests / Localization.Tests):** VM load/save of selections; locale parity.
|
||||
- **Visual verification is Mika's** — flag the gaps.
|
||||
|
||||
## Task 7 — Wiring, build, end-to-end smoke
|
||||
|
||||
- DI registration (registry, cloner, seeder) in `Program.cs`.
|
||||
- Build all touched projects `-c Release`; run Worker/Data/Ui/Localization test projects.
|
||||
- Manual E2E: install ponytail via the UI, enable per-task, run a task, confirm the skill
|
||||
is available to the agent and **not** committed and **not** in interactive sessions.
|
||||
|
||||
---
|
||||
|
||||
Commit per task with Conventional Commits (`feat(worker|ui|data): …`). Commit the
|
||||
spec + plan docs first.
|
||||
@@ -0,0 +1,88 @@
|
||||
# Plan — ConPTY Interactive Sessions
|
||||
|
||||
Spec: `docs/superpowers/specs/2026-07-23-conpty-interactive-sessions-design.md`
|
||||
Date: 2026-07-23
|
||||
|
||||
Execution: subagent-driven-development, sonnet model, TDD where meaningful,
|
||||
build + test + commit per task. Stage files explicitly by path (never
|
||||
`git add -A`). Terminal rendering is visual — flagged for the user's visual pass.
|
||||
|
||||
## Task 0 — Spike: embed a ConPTY terminal running `claude`
|
||||
|
||||
Not TDD; a throwaway proof. Add a temporary window/view that embeds each
|
||||
candidate control and launches `claude` in a known worktree.
|
||||
|
||||
- Evaluate **SvcSystems.UI.Terminal** and **Iciclecreek.Avalonia.Terminal**.
|
||||
- Acceptance: the real `claude` TUI renders correctly — colors, resize/reflow,
|
||||
and a live permission prompt is usable; input reaches the CLI.
|
||||
- Output: pick one library; note the control API (start with cwd/exe/args/env,
|
||||
process-exited event, dispose/kill). Record the decision in the spec.
|
||||
- Remove the throwaway harness before Task 1 (or keep as a manual dev sample,
|
||||
not wired into the app).
|
||||
|
||||
**Stop for the user's visual verification of the spike before continuing.**
|
||||
|
||||
## Task 1 — Worker: interactive launch-spec endpoint
|
||||
|
||||
- Add a Worker service/hub method that, given a taskId, prepares the worktree
|
||||
(session-skills seeding, agent files, MCP config, env — reuse the autonomous
|
||||
run prep path) and returns a `LaunchSpec { cwd, exe, args, env }`.
|
||||
- Reuse `WindowsTerminalLauncher.BuildResumeCommand` for exe/args.
|
||||
- Guards mirror `ResumeTaskInTerminal` (not Running/Queued, persisted SessionId,
|
||||
worktree Active/Kept). Never-run task → spec without `--resume` (fresh start).
|
||||
- Tests (Worker.Tests, real SQLite/git): guard cases, spec contents for a
|
||||
resumable task, fresh-start case. No real `claude` in tests.
|
||||
|
||||
## Task 2 — Worker: ad-hoc launch-spec
|
||||
|
||||
- Method to build a `LaunchSpec` for a free session in a given directory:
|
||||
MCP config + env set up, no task/session-skills seeding.
|
||||
- Tests: env/MCP presence, arbitrary cwd.
|
||||
|
||||
## Task 3 — UI: terminal host control + view model
|
||||
|
||||
- Wrap the chosen library in an app control/view (e.g. `InteractiveTerminalView`
|
||||
+ `InteractiveTerminalViewModel`) that starts from a `LaunchSpec` and exposes
|
||||
running/exited state.
|
||||
- `IWorkerClient`: add methods to fetch the task and ad-hoc launch specs; wire
|
||||
the SignalR client + hub method.
|
||||
- Update hand-rolled `IWorkerClient`/hub fakes in BOTH test projects.
|
||||
- Tests: view model starts/stops lifecycle with a fake terminal backend;
|
||||
fake worker returns a spec.
|
||||
|
||||
## Task 4 — Command Center: host interactive panes + entry points
|
||||
|
||||
- `MonitorPaneView`: autonomous panes keep the streamed log; interactive panes
|
||||
host the terminal control.
|
||||
- Entry points: "Open interactive session" from a task (task-based) and a
|
||||
"New session" action (ad-hoc, pick directory).
|
||||
- Layout toggle: focus (tabs) ↔ overview (grid); reuse/extend the existing
|
||||
`UniformGrid` column logic for the grid mode.
|
||||
- Tests: view-model level (pane kind selection, layout toggle state). Rendering
|
||||
is a visual-pass item.
|
||||
|
||||
## Task 5 — Remove the streaming interactive stack
|
||||
|
||||
Only after Tasks 1–4 land and the terminal path works.
|
||||
|
||||
- Worker: delete `StreamingClaudeSession`, `InteractiveSessionService`,
|
||||
interactive `WorkerHub` methods + broadcast events, DI registrations.
|
||||
Verify `LiveSessionRegistry` / `IdleSessionReaper` usage first; remove only if
|
||||
unreferenced.
|
||||
- UI: remove composer bits on `TaskMonitorViewModel`, the composer/queued portion
|
||||
of `SessionTerminalView`, `IWorkerClient` interactive methods.
|
||||
- Update fakes and delete now-dead tests. Full build + all test projects green.
|
||||
|
||||
## Task 6 — Docs
|
||||
|
||||
- Update `docs/open.md` with visual-verification items (spike render, terminal
|
||||
resize/focus, grid vs tabs).
|
||||
- Update affected per-project `CLAUDE.md` (Worker interactive removal, UI new
|
||||
terminal host).
|
||||
|
||||
## Verification gates
|
||||
|
||||
- After Task 0: user visual pass on the spike.
|
||||
- After Task 4: user visual pass on Command Center (task + ad-hoc, tabs + grid,
|
||||
permission prompt round-trip).
|
||||
- Never claim the terminal UI works without the user running it.
|
||||
@@ -0,0 +1,169 @@
|
||||
# Session Skills — Design
|
||||
|
||||
**Date:** 2026-07-03
|
||||
**Status:** Approved (design), implementation not started
|
||||
|
||||
## Problem
|
||||
|
||||
Mika wants to give headless task agents a specific Claude skill (e.g.
|
||||
[ponytail](https://github.com/DietrichGebert/ponytail)) **without** installing it
|
||||
globally in `~/.claude/skills/`, where it would leak into every interactive session.
|
||||
Skills should be a first-class, per-level session setting alongside `model`,
|
||||
`max_turns`, and `system_prompt` — configurable **global / per-list / per-task** — and
|
||||
sourced from a GitHub URL.
|
||||
|
||||
## Key facts that shape the design
|
||||
|
||||
- The Claude CLI discovers skills from two places: the **global** `~/.claude/skills/`
|
||||
(every session — undesirable here) and the **working directory's** `.claude/skills/`
|
||||
(plus plugins). ClaudeDo fully controls each spawned session's `WorkingDirectory`
|
||||
(`ClaudeProcess.cs:30`), so a skill dropped into the session cwd is scoped to exactly
|
||||
that headless run.
|
||||
- **Auto-commit uses `git add -A`** (`WorktreeManager.CommitIfChangedAsync` →
|
||||
`_git.AddAllAsync`, `WorktreeManager.cs:142`). Anything seeded into a worktree's
|
||||
`.claude/skills/` would be committed unless explicitly excluded → the seeder must add
|
||||
the seeded paths to the worktree's `info/exclude`.
|
||||
- A skill is not just prompt text: `SKILL.md` may reference scripts that run via Bash.
|
||||
Headless agents run with `--permission-mode auto` (effectively unattended), so a skill
|
||||
pulled from an arbitrary URL is **unattended third-party code execution**. This is why
|
||||
install is a deliberate, pinned, reviewable step — not a live per-run URL fetch.
|
||||
|
||||
## Decisions (locked)
|
||||
|
||||
1. **Install-and-pin, not live fetch.** A dedicated registry screen installs a skill
|
||||
once: clone the repo, pin to the current commit, store locally. Per-level config then
|
||||
references installed skills **by name** (checkboxes), never a URL.
|
||||
2. **Three levels, additive union.** Effective skill set = `global ∪ list ∪ task`.
|
||||
(Unlike `model`/`prompt`, which override — skills add up. Trade-off accepted: an
|
||||
inherited skill can't be switched off for a single task in the MVP.)
|
||||
3. **A repo can contribute multiple skills.** Installer detects the layout:
|
||||
- `skills/*/SKILL.md` (plugin bundle) → import **each** subskill flat. This is
|
||||
ponytail: it ships 6 skills (`ponytail`, `-help`, `-review`, `-audit`, `-debt`,
|
||||
`-gain`) under `skills/<name>/SKILL.md` plus `.claude-plugin/`, hooks, commands, an
|
||||
MCP — none of which we consume; we take only the `skills/<name>/` dirs.
|
||||
- root `SKILL.md` → single skill.
|
||||
- neither → reject.
|
||||
The CLI expects `.claude/skills/<name>/SKILL.md` **flat**, so subskills are flattened
|
||||
on install. Selection is **per individual skill name** (enable just `ponytail` +
|
||||
`ponytail-help` if you want, not all six).
|
||||
4. **Public repos only** (plain `git clone` over HTTPS, no auth) for MVP.
|
||||
|
||||
> **Correction (2026-07-03, from the smoke test):** the original "one repo = one skill
|
||||
> at root" MVP was wrong for the very target repo — ponytail is a multi-skill plugin.
|
||||
> Decision 3 above replaces it.
|
||||
|
||||
## Architecture
|
||||
|
||||
### Storage & registry
|
||||
|
||||
- Each discovered skill is copied **flat** to `~/.todo-app/session-skills/<name>/` (its
|
||||
own self-contained dir with `SKILL.md` at the root of that dir), so the seeder just
|
||||
copies `<name>/` → cwd.
|
||||
- New DB table `session_skills`, one row **per skill** (a multi-skill repo writes N rows
|
||||
sharing `source_url` + `pinned_ref`): `name` (PK), `source_url`, `pinned_ref` (commit
|
||||
SHA), `subpath` (dir within the repo the skill came from, e.g. `skills/ponytail` or
|
||||
`.` for root), `description`, `added_at`. Repo-level ops act on all rows with the same
|
||||
`source_url` (no separate sources table — keep it flat).
|
||||
- New worker service `SessionSkillRegistry` (in a new `Skills/` area under the Worker):
|
||||
- `InstallAsync(url)` — clone to temp → **detect layout** (`skills/*/SKILL.md` bundle
|
||||
vs root `SKILL.md`) → for each discovered skill parse frontmatter (`name`,
|
||||
`description`), resolve HEAD SHA as `pinned_ref`, copy its dir flat into place, upsert
|
||||
a row. Returns the list of installed skill names. Name collision (a skill name from a
|
||||
*different* source) → error surfaced to UI; reinstalling the same source updates.
|
||||
Clone is injected (`IRepoCloner`) so tests use a local source dir — **no real network,
|
||||
no real CLI**.
|
||||
- `UpdateAsync(sourceUrl)` — re-clone, re-detect, refresh that source's skills +
|
||||
`pinned_ref`.
|
||||
- `RemoveAsync(sourceUrl)` — delete all its skill dirs + rows.
|
||||
- `ListAsync()` — registry entries for the UI (grouped by source for display).
|
||||
|
||||
### Resolution
|
||||
|
||||
`TaskRunner.ResolveConfigAsync` (`TaskRunner.cs:488`) already merges
|
||||
task → list → global for the other fields. Add:
|
||||
|
||||
```
|
||||
SkillNames = Union(task.SessionSkills, listConfig?.SessionSkills, global.SessionSkills)
|
||||
```
|
||||
|
||||
deduped, filtered to names that still exist in the registry (a removed skill is silently
|
||||
dropped — logged). Add `IReadOnlyList<string> SkillNames` to `ClaudeRunConfig`
|
||||
(`ClaudeArgsBuilder.cs:5`). **No CLI flag is emitted** — skills are seeded on disk, not
|
||||
passed as args. `ClaudeArgsBuilder.Build` is unchanged for skills.
|
||||
|
||||
Per-level storage: a nullable TEXT column `session_skills` (JSON array of names) on
|
||||
`tasks`, `list_config`, and `app_settings`.
|
||||
|
||||
### Seeding
|
||||
|
||||
New service `SessionSkillSeeder`, called by `TaskRunner` after the working dir is
|
||||
resolved and before `ClaudeProcess.RunAsync`:
|
||||
|
||||
- For each resolved skill name, copy `~/.todo-app/session-skills/<name>/` →
|
||||
`<cwd>/.claude/skills/<name>/` (overwrite → idempotent for resume/re-run).
|
||||
- If the cwd is a git worktree, append `/.claude/skills/<name>/` to the worktree's
|
||||
`info/exclude` (path via `git rev-parse --git-path info/exclude`, so it targets the
|
||||
per-worktree exclude), only if not already present. **Only the seeded subdirs are
|
||||
excluded** — never blanket-exclude `/.claude/`, in case the target project commits its
|
||||
own `.claude/`.
|
||||
- Sandbox runs (not a repo) skip the exclude step.
|
||||
- No separate cleanup: seeded dirs vanish with the worktree/sandbox.
|
||||
|
||||
### allowedTools caveat
|
||||
|
||||
`--allowedTools` is only emitted when set (`ClaudeArgsBuilder.cs:80`); normal task runs
|
||||
leave it null → all tools allowed → the `Skill` tool is available. If a future per-task
|
||||
allowedTools restriction is added, it must include `Skill`. Noted, not handled in MVP.
|
||||
|
||||
## UI
|
||||
|
||||
Mirror the existing agent-file pattern.
|
||||
|
||||
- **Registry screen ("extra mask"):** a new **Skills** tab in the Settings modal
|
||||
(`SettingsModalView.axaml`) with `SessionSkillsSettingsTabViewModel`. **Add** (URL text
|
||||
box → install a repo, which may yield several skills); lists installed skills grouped by
|
||||
source (name, description, source, short ref); **Update** / **Remove** act per source
|
||||
(repo). Status/error line like `FilesSettingsTabViewModel`.
|
||||
- **Global selector:** multi-select (checkbox list) of installed skills in the General
|
||||
settings tab → `AppSettings.SessionSkills`.
|
||||
- **List + Task selectors:** add a skills multi-select to the shared
|
||||
`AgentConfigEditor` control (`AgentConfigEditor.axaml` /
|
||||
`AgentConfigEditorViewModel.cs`), which is already reused by both List settings and the
|
||||
per-task flyout — one addition covers both levels, with the existing inheritance-badge
|
||||
pattern.
|
||||
|
||||
### Hub / client surface
|
||||
|
||||
New `WorkerHub` methods + `IWorkerClient` entries (update hand-rolled fakes in both test
|
||||
projects — see memory `iworkerclient_fakes_sync`):
|
||||
`GetSessionSkills`, `InstallSessionSkill(url)`, `UpdateSessionSkill(sourceUrl)`,
|
||||
`RemoveSessionSkill(sourceUrl)`. Extend `AppSettingsDto`, `ListConfigDto`,
|
||||
`UpdateListConfigDto`, `UpdateTaskAgentSettingsDto` with the selected skill-name lists.
|
||||
New `SessionSkillDto`.
|
||||
|
||||
## Edge cases
|
||||
|
||||
- **Removed skill still referenced** by a level → dropped at resolve time, logged, no
|
||||
failure.
|
||||
- **Name collision on install** → reject with a clear message; offer Update instead.
|
||||
- **Repo without root `SKILL.md`** → reject at install.
|
||||
- **Target project already has `.claude/skills/`** → additive copy; exclude only our
|
||||
subdirs.
|
||||
- **Resume / re-run** reuses the worktree → re-seed overwrites, exclude append is
|
||||
idempotent.
|
||||
|
||||
## Verification (must-check, can't be unit-tested)
|
||||
|
||||
- **Does `claude -p` actually load and invoke a skill placed in cwd `.claude/skills/`?**
|
||||
**CONFIRMED 2026-07-03.** Mika ran, in a stable terminal, a `claude -p` invocation in a
|
||||
scratch cwd holding `.claude/skills/ponytail*/SKILL.md`; the model invoked the
|
||||
`ponytail-help` skill and returned its exact Lite/Full/Ultra table. Headless mode does
|
||||
surface cwd skills → the whole approach holds; no `CLAUDE_CONFIG_DIR` fallback needed.
|
||||
- Seeded skill is **not** committed by the auto-commit step (worktree run).
|
||||
- Skill does not appear in a normal interactive session (no global leak).
|
||||
|
||||
## Out of scope (MVP)
|
||||
|
||||
Private-repo auth; consuming a plugin's *non-skill* parts (hooks, commands, MCP — we take
|
||||
only `skills/<name>/`); auto-update & update notifications; per-task *disabling* of an
|
||||
inherited skill; surfacing skill invocation in the run log.
|
||||
@@ -0,0 +1,171 @@
|
||||
# ConPTY Interactive Sessions — Design
|
||||
|
||||
Date: 2026-07-23
|
||||
Status: Approved (design), implementation not started
|
||||
|
||||
## Problem
|
||||
|
||||
The current in-app interactive session path streams `stream-json` from a
|
||||
`claude` process spawned **in the Worker** and renders it as a chat log with a
|
||||
composer. It does not surface permission requests, AskUser questions, and other
|
||||
TUI-native interactions well — the rendering is a partial reimplementation of
|
||||
what the real Claude Code TUI already does. We want full fidelity for
|
||||
interactive work without rebuilding the TUI.
|
||||
|
||||
## Decision
|
||||
|
||||
**Hybrid execution model:**
|
||||
|
||||
- **Autonomous queue tasks** (`Status=Queued`, picked by the queue): unchanged.
|
||||
Headless `stream-json`, full orchestration (status flow, diff, review, merge).
|
||||
- **Interactive sessions**: an **embedded ConPTY terminal** running the real
|
||||
`claude` CLI, rendered in the **UI process**. Full TUI fidelity (permission
|
||||
prompts, questions, colors, everything the standalone CLI does). Detached from
|
||||
the review/merge/status machinery — these are a manual cockpit.
|
||||
|
||||
This is the third direction for interactive (external `wt` terminal → streaming
|
||||
chat → embedded ConPTY). The streaming interactive stack is **removed**, not run
|
||||
in parallel — accepted as discarded work in exchange for one interactive path
|
||||
and full fidelity.
|
||||
|
||||
## Architecture
|
||||
|
||||
### Process location
|
||||
|
||||
ConPTY terminal controls render in-process and spawn their child (`claude`) as a
|
||||
child of the host process. Therefore interactive sessions move **out of the
|
||||
Worker and into the UI process**. They no longer flow over SignalR. This mirrors
|
||||
the existing `ResumeTaskInTerminal` behavior (launch real `claude`), but embedded
|
||||
instead of via external `wt.exe`.
|
||||
|
||||
### Worktree preparation (task-based sessions)
|
||||
|
||||
Interactive task sessions get the **same worktree preparation as autonomous
|
||||
runs**: session-skills seeding, agent files, MCP config, environment. The Worker
|
||||
performs the prep and returns a launch spec to the UI:
|
||||
|
||||
```
|
||||
LaunchSpec {
|
||||
cwd: string // worktree path
|
||||
exe: string // resolved claude executable / shell
|
||||
args: string[] // e.g. --resume <sessionId>
|
||||
env: Dictionary<string,string>
|
||||
}
|
||||
```
|
||||
|
||||
The command construction reuses `WindowsTerminalLauncher.BuildResumeArgs`/`Resolve`.
|
||||
Guards mirror `ResumeTaskInTerminal` for Running/Queued (rejected). Worktree
|
||||
handling (FINAL):
|
||||
- Existing Active/Kept worktree + persisted SessionId → `--resume <id>`.
|
||||
- No usable worktree but the list has a WorkingDir (git repo) → create a worktree
|
||||
**on demand** via `WorktreeManager.CreateAsync` (the same path autonomous runs
|
||||
use), then a fresh-start spec. This lets never-run tasks be opened interactively.
|
||||
- Fresh (non-resume) session → the task's prompt (title + description) is passed
|
||||
as claude's positional prompt so the session starts on the task.
|
||||
- No worktree and no WorkingDir → clear error.
|
||||
Interactive sessions are detached: ClaudeDo does NOT record their claude session
|
||||
id (ConPTY is opaque, no stream-json), so resuming a specific past interactive
|
||||
conversation is only via claude's own `--continue`/`--resume` in the worktree dir.
|
||||
|
||||
### Free / ad-hoc sessions
|
||||
|
||||
In addition to task-based sessions, the user can open an ad-hoc terminal in a
|
||||
chosen directory (no task). These also get MCP config + env set up so the
|
||||
`claudedo` tools are available, but no per-level session-skills seeding tied to a
|
||||
task.
|
||||
|
||||
### Terminal host control — RESOLVED by spike (2026-07-23)
|
||||
|
||||
**Library: `Iciclecreek.Avalonia.Terminal` 2.0.3** (namespace `Iciclecreek.Terminal`).
|
||||
It needs Avalonia >= 12.0.2; the repo is on 12.0.4 → compatible, no bump.
|
||||
`SvcSystems.UI.Terminal` (latest) needs Avalonia 12.1+ → rejected.
|
||||
|
||||
Visual pass (user, 2026-07-23): the real `claude` TUI renders correctly inside
|
||||
the embedded control — Claude Code opened and was usable.
|
||||
|
||||
**Binding approach — use the library's own `LaunchProcess()` (FINAL).**
|
||||
An initial attempt drove `Porta.Pty` ourselves (own read loop, key tunneling via
|
||||
`GenerateKeyInput`/`GenerateCharInput`, manual resize) to bypass
|
||||
`LaunchProcess()` and inject a custom `PtyOptions.Environment`. That was a
|
||||
mistake: it rendered wrong, lagged, and dropped input. The spike had proven the
|
||||
library's own `TerminalControl.LaunchProcess()` pipeline renders correctly, stays
|
||||
responsive, and handles input/resize/focus. So `PtyTerminalSession` is a thin
|
||||
wrapper (`src/ClaudeDo.Ui/Services/PtyTerminalSession.cs`):
|
||||
|
||||
- Set `control.Process = descriptor.Exe`, `control.Args = descriptor.Args`,
|
||||
`control.StartingDirectory = descriptor.Cwd`, then `await control.LaunchProcess()`.
|
||||
- Relay the control's own `ProcessExited` event and `Kill()`.
|
||||
- `Process=""` stays on the AXAML `TerminalControl` to suppress the library's
|
||||
auto-launch-on-load, so exactly one process starts (our manual launch).
|
||||
|
||||
**Environment:** `LaunchProcess()` gives no per-launch env dict, but
|
||||
`Porta.Pty.SpawnAsync` inherits the *current process* environment. So apply
|
||||
`descriptor.Env` entries via `Environment.SetEnvironmentVariable(key, value)`
|
||||
(process scope) before `LaunchProcess()`. The only var needed is
|
||||
`MCP_TOOL_TIMEOUT`; session-skills/agent-files/MCP-config are on disk / globally
|
||||
registered, independent of env. (The earlier "child gets zero env vars" claim was
|
||||
wrong — Porta.Pty seeds from the process env.)
|
||||
|
||||
**Sizing gotcha (critical):** the control derives Cols/Rows from
|
||||
`arranged-size / character-cell-size`. Without an EXPLICIT monospace font the cell
|
||||
metrics are wrong and the child TUI renders into the wrong area. Set
|
||||
`FontFamily="Cascadia Mono,Consolas,monospace"`, `FontSize`, `BufferSize`, and
|
||||
`HorizontalAlignment/VerticalAlignment=Stretch` on the `TerminalControl` (matching
|
||||
the spike) — this is what made rendering correct in the pane.
|
||||
|
||||
**Lesson:** do not hand-roll pty/input/render around this control — use its
|
||||
`LaunchProcess()` pipeline.
|
||||
|
||||
### Command Center layout
|
||||
|
||||
- `MonitorPaneView` keeps the streamed log for autonomous tasks.
|
||||
- Interactive panes host the terminal control instead of the log+composer.
|
||||
- Layout is **toggleable**: focus mode (tabs, one session large) ↔ overview mode
|
||||
(grid, several sessions at once).
|
||||
|
||||
## Removals
|
||||
|
||||
Worker:
|
||||
- `StreamingClaudeSession`, `InteractiveSessionService`
|
||||
- `WorkerHub` interactive methods: `OpenInteractiveTerminal`,
|
||||
`SendInteractiveMessage`, `RemoveQueuedInteractiveMessage`,
|
||||
`StopInteractiveSession`, `InterruptInteractiveSession`
|
||||
- Broadcast events: `InteractiveSessionStarted/Ended`, `InteractiveQueueChanged`,
|
||||
`InteractiveMessageSent`
|
||||
- `IdleSessionReaper` and `LiveSessionRegistry` **iff** unused elsewhere
|
||||
(verify during implementation — do not delete blindly).
|
||||
|
||||
UI:
|
||||
- Composer on `TaskMonitorViewModel`: `ComposerDraft`, `SubmitComposerCommand`,
|
||||
`InterruptInteractiveCommand`, `StopInteractiveCommand`, `QueuedMessages`,
|
||||
`IsInteractiveLive`.
|
||||
- The composer + queued-messages portion of `SessionTerminalView` (the log
|
||||
portion stays for autonomous panes).
|
||||
- `IWorkerClient` interactive methods.
|
||||
|
||||
## Open items (implementation time)
|
||||
|
||||
- Whether `LiveSessionRegistry` is referenced outside the interactive path.
|
||||
- Session-id availability for a never-run task (no `--resume` → start fresh).
|
||||
|
||||
Resolved: env approach (see Terminal host control — add custom vars on top of the
|
||||
inherited process env via `PtyOptions.Environment`); library + binding seam.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- No screen-scraping of terminal output back into task status/diff/review.
|
||||
- No change to the autonomous queue execution path.
|
||||
|
||||
## Status (2026-07-23)
|
||||
|
||||
Implemented on main (not pushed) and visually verified by the user (rendering,
|
||||
input, responsiveness correct after switching to `LaunchProcess()` + setting a
|
||||
monospace font). Done: worker launch-spec (task + ad-hoc, on-demand worktree,
|
||||
prompt seeding), UI terminal host, Command Center hosting (grid↔tabs), streaming
|
||||
stack removed. Permission mode: left to claude's default (not forced), per user.
|
||||
|
||||
Deferred: **Avalonia 12.1 upgrade** — blocked, not adopted. 12.1's XAML source
|
||||
generator needs Roslyn 4.14 (.NET 9.0.3xx SDK); the repo pins .NET 8 in
|
||||
`global.json`, and bumping the SDK floor risks the Gitea Actions release build.
|
||||
Staying on Avalonia 12.0.x (Iciclecreek 2.0.3 works there). Revisit only with a
|
||||
deliberate SDK-floor decision.
|
||||
@@ -51,6 +51,7 @@ public class ClaudeDoDbContext : DbContext
|
||||
public DbSet<PrimeScheduleEntity> PrimeSchedules => Set<PrimeScheduleEntity>();
|
||||
public DbSet<DailyNoteEntity> DailyNotes => Set<DailyNoteEntity>();
|
||||
public DbSet<WeekReportEntity> WeekReports => Set<WeekReportEntity>();
|
||||
public DbSet<SessionSkillEntity> SessionSkills => Set<SessionSkillEntity>();
|
||||
|
||||
private static readonly ValueConverter<DateTime, DateTime> UtcConverter =
|
||||
new(v => v, v => DateTime.SpecifyKind(v, DateTimeKind.Utc));
|
||||
|
||||
@@ -44,6 +44,8 @@ public class AppSettingsEntityConfiguration : IEntityTypeConfiguration<AppSettin
|
||||
builder.Property(s => s.DailyPrepMaxTasks)
|
||||
.HasColumnName("daily_prep_max_tasks").IsRequired().HasDefaultValue(5);
|
||||
|
||||
builder.Property(s => s.SessionSkills).HasColumnName("session_skills");
|
||||
|
||||
builder.HasData(new AppSettingsEntity { Id = AppSettingsEntity.SingletonId });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,5 +16,6 @@ public class ListConfigEntityConfiguration : IEntityTypeConfiguration<ListConfig
|
||||
builder.Property(c => c.SystemPrompt).HasColumnName("system_prompt");
|
||||
builder.Property(c => c.AgentPath).HasColumnName("agent_path");
|
||||
builder.Property(c => c.MaxTurns).HasColumnName("max_turns");
|
||||
builder.Property(c => c.SessionSkills).HasColumnName("session_skills");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
using ClaudeDo.Data.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
namespace ClaudeDo.Data.Configuration;
|
||||
|
||||
public class SessionSkillEntityConfiguration : IEntityTypeConfiguration<SessionSkillEntity>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<SessionSkillEntity> builder)
|
||||
{
|
||||
builder.ToTable("session_skills");
|
||||
|
||||
builder.HasKey(s => s.Name);
|
||||
builder.Property(s => s.Name).HasColumnName("name");
|
||||
builder.Property(s => s.SourceUrl).HasColumnName("source_url").IsRequired();
|
||||
builder.Property(s => s.PinnedRef).HasColumnName("pinned_ref").IsRequired();
|
||||
builder.Property(s => s.Subpath).HasColumnName("subpath").IsRequired();
|
||||
builder.Property(s => s.Description).HasColumnName("description").IsRequired();
|
||||
builder.Property(s => s.AddedAt).HasColumnName("added_at").IsRequired();
|
||||
}
|
||||
}
|
||||
@@ -91,6 +91,7 @@ public class TaskEntityConfiguration : IEntityTypeConfiguration<TaskEntity>
|
||||
builder.Property(t => t.IsMyDay).HasColumnName("is_my_day").HasDefaultValue(false);
|
||||
builder.Property(t => t.Notes).HasColumnName("notes");
|
||||
builder.Property(t => t.SortOrder).HasColumnName("sort_order").IsRequired().HasDefaultValue(0);
|
||||
builder.Property(t => t.SessionSkills).HasColumnName("session_skills");
|
||||
|
||||
builder.Property(t => t.ParentTaskId).HasColumnName("parent_task_id");
|
||||
builder.Property(t => t.PlanningSessionId).HasColumnName("planning_session_id");
|
||||
|
||||
@@ -0,0 +1,786 @@
|
||||
// <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("20260703072917_AddSessionSkills")]
|
||||
partial class AddSessionSkills
|
||||
{
|
||||
/// <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>("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>("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,
|
||||
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.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<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<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>("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>("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,75 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace ClaudeDo.Data.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddSessionSkills : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "session_skills",
|
||||
table: "tasks",
|
||||
type: "TEXT",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "session_skills",
|
||||
table: "list_config",
|
||||
type: "TEXT",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "session_skills",
|
||||
table: "app_settings",
|
||||
type: "TEXT",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "session_skills",
|
||||
columns: table => new
|
||||
{
|
||||
name = table.Column<string>(type: "TEXT", nullable: false),
|
||||
source_url = table.Column<string>(type: "TEXT", nullable: false),
|
||||
pinned_ref = table.Column<string>(type: "TEXT", nullable: false),
|
||||
subpath = table.Column<string>(type: "TEXT", nullable: false),
|
||||
description = table.Column<string>(type: "TEXT", nullable: false),
|
||||
added_at = table.Column<DateTimeOffset>(type: "TEXT", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_session_skills", x => x.name);
|
||||
});
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "app_settings",
|
||||
keyColumn: "id",
|
||||
keyValue: 1,
|
||||
column: "session_skills",
|
||||
value: null);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "session_skills");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "session_skills",
|
||||
table: "tasks");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "session_skills",
|
||||
table: "list_config");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "session_skills",
|
||||
table: "app_settings");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -74,6 +74,10 @@ namespace ClaudeDo.Data.Migrations
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("report_excluded_paths");
|
||||
|
||||
b.Property<string>("SessionSkills")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("session_skills");
|
||||
|
||||
b.Property<int>("StandupWeekday")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
@@ -168,6 +172,10 @@ namespace ClaudeDo.Data.Migrations
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("model");
|
||||
|
||||
b.Property<string>("SessionSkills")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("session_skills");
|
||||
|
||||
b.Property<string>("SystemPrompt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("system_prompt");
|
||||
@@ -256,6 +264,41 @@ namespace ClaudeDo.Data.Migrations
|
||||
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")
|
||||
@@ -437,6 +480,10 @@ namespace ClaudeDo.Data.Migrations
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("scheduled_for");
|
||||
|
||||
b.Property<string>("SessionSkills")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("session_skills");
|
||||
|
||||
b.Property<int>("SortOrder")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
|
||||
@@ -27,4 +27,7 @@ public sealed class AppSettingsEntity
|
||||
|
||||
// Max number of open tasks the daily prep ("Prime Claude") may place in MyDay.
|
||||
public int DailyPrepMaxTasks { get; set; } = 5;
|
||||
|
||||
// JSON array of session skill names applied by default to new tasks.
|
||||
public string? SessionSkills { get; set; }
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ public sealed class ListConfigEntity
|
||||
public string? SystemPrompt { get; set; }
|
||||
public string? AgentPath { get; set; }
|
||||
public int? MaxTurns { get; set; }
|
||||
public string? SessionSkills { get; set; }
|
||||
|
||||
// Navigation property
|
||||
public ListEntity List { get; set; } = null!;
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
namespace ClaudeDo.Data.Models;
|
||||
|
||||
public sealed class SessionSkillEntity
|
||||
{
|
||||
public required string Name { get; init; }
|
||||
public required string SourceUrl { get; set; }
|
||||
public required string PinnedRef { get; set; }
|
||||
public required string Subpath { get; set; }
|
||||
public required string Description { get; set; }
|
||||
public required DateTimeOffset AddedAt { get; set; }
|
||||
}
|
||||
@@ -45,6 +45,7 @@ public sealed class TaskEntity
|
||||
public bool IsMyDay { get; set; }
|
||||
public string? Notes { get; set; }
|
||||
public int SortOrder { get; set; }
|
||||
public string? SessionSkills { get; set; }
|
||||
|
||||
public string? ParentTaskId { get; set; }
|
||||
public string? PlanningSessionId { get; set; }
|
||||
|
||||
@@ -64,6 +64,7 @@ public sealed class AppSettingsRepository
|
||||
? null : updated.ReportExcludedPaths;
|
||||
row.StandupWeekday = updated.StandupWeekday;
|
||||
row.DailyPrepMaxTasks = updated.DailyPrepMaxTasks < 1 ? 1 : updated.DailyPrepMaxTasks;
|
||||
row.SessionSkills = string.IsNullOrWhiteSpace(updated.SessionSkills) ? null : updated.SessionSkills;
|
||||
|
||||
await _context.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
@@ -77,6 +77,7 @@ public sealed class ListRepository
|
||||
existing.SystemPrompt = config.SystemPrompt;
|
||||
existing.AgentPath = config.AgentPath;
|
||||
existing.MaxTurns = config.MaxTurns;
|
||||
existing.SessionSkills = config.SessionSkills;
|
||||
}
|
||||
await _context.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
using ClaudeDo.Data.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ClaudeDo.Data.Repositories;
|
||||
|
||||
public sealed class SessionSkillRepository
|
||||
{
|
||||
private readonly ClaudeDoDbContext _context;
|
||||
|
||||
public SessionSkillRepository(ClaudeDoDbContext context) => _context = context;
|
||||
|
||||
public async Task<IReadOnlyList<SessionSkillEntity>> ListAsync(CancellationToken ct = default)
|
||||
{
|
||||
return await _context.SessionSkills.AsNoTracking()
|
||||
.OrderBy(s => s.Name)
|
||||
.ToListAsync(ct);
|
||||
}
|
||||
|
||||
public async Task<SessionSkillEntity?> GetAsync(string name, CancellationToken ct = default) =>
|
||||
await _context.SessionSkills.AsNoTracking().FirstOrDefaultAsync(s => s.Name == name, ct);
|
||||
|
||||
public async Task UpsertAsync(SessionSkillEntity entity, CancellationToken ct = default)
|
||||
{
|
||||
var existing = await _context.SessionSkills.FirstOrDefaultAsync(s => s.Name == entity.Name, ct);
|
||||
if (existing is null)
|
||||
{
|
||||
_context.SessionSkills.Add(entity);
|
||||
}
|
||||
else
|
||||
{
|
||||
existing.SourceUrl = entity.SourceUrl;
|
||||
existing.PinnedRef = entity.PinnedRef;
|
||||
existing.Subpath = entity.Subpath;
|
||||
existing.Description = entity.Description;
|
||||
existing.AddedAt = entity.AddedAt;
|
||||
}
|
||||
await _context.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
public async Task DeleteAsync(string name, CancellationToken ct = default)
|
||||
{
|
||||
await _context.SessionSkills
|
||||
.Where(s => s.Name == name)
|
||||
.ExecuteDeleteAsync(ct);
|
||||
}
|
||||
|
||||
public async Task DeleteBySourceAsync(string sourceUrl, CancellationToken ct = default)
|
||||
{
|
||||
await _context.SessionSkills
|
||||
.Where(s => s.SourceUrl == sourceUrl)
|
||||
.ExecuteDeleteAsync(ct);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<SessionSkillEntity>> ListBySourceAsync(string sourceUrl, CancellationToken ct = default)
|
||||
{
|
||||
return await _context.SessionSkills.AsNoTracking()
|
||||
.Where(s => s.SourceUrl == sourceUrl)
|
||||
.OrderBy(s => s.Name)
|
||||
.ToListAsync(ct);
|
||||
}
|
||||
}
|
||||
@@ -189,6 +189,7 @@ public sealed class TaskRepository
|
||||
string? systemPrompt,
|
||||
string? agentPath,
|
||||
int? maxTurns = null,
|
||||
string? sessionSkills = null,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
await _context.Tasks
|
||||
@@ -197,7 +198,8 @@ public sealed class TaskRepository
|
||||
.SetProperty(t => t.Model, model)
|
||||
.SetProperty(t => t.SystemPrompt, systemPrompt)
|
||||
.SetProperty(t => t.AgentPath, agentPath)
|
||||
.SetProperty(t => t.MaxTurns, maxTurns), ct);
|
||||
.SetProperty(t => t.MaxTurns, maxTurns)
|
||||
.SetProperty(t => t.SessionSkills, sessionSkills), ct);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
"tabWorktrees": "Worktrees",
|
||||
"tabFiles": "Dateien",
|
||||
"tabPrime": "Prime Claude",
|
||||
"tabSkills": "Skills",
|
||||
"general": {
|
||||
"defaultInstructions": "Standard-Anweisungen",
|
||||
"defaultInstructionsPlaceholder": "Basis-Anweisungen, die auf jede Aufgabe angewendet werden",
|
||||
@@ -25,7 +26,9 @@
|
||||
"weekdayWednesday": "Mittwoch",
|
||||
"weekdayThursday": "Donnerstag",
|
||||
"weekdayFriday": "Freitag",
|
||||
"weekdaySaturday": "Samstag"
|
||||
"weekdaySaturday": "Samstag",
|
||||
"sessionSkills": "Session-Skills",
|
||||
"sessionSkillsHint": "Gilt für jede Aufgabe. Kombiniert sich mit Listen- und Aufgaben-Auswahl."
|
||||
},
|
||||
"worktrees": {
|
||||
"strategy": "Strategie",
|
||||
@@ -95,7 +98,17 @@
|
||||
"systemPrompt": "System-Prompt (angehängt)",
|
||||
"promptPrepended": "Wird automatisch vorangestellt:",
|
||||
"agentFile": "Agent-Datei",
|
||||
"browse": "Durchsuchen..."
|
||||
"browse": "Durchsuchen...",
|
||||
"sessionSkills": "Session-Skills",
|
||||
"sessionSkillsHint": "Kombiniert sich additiv mit globaler (und Listen-)Auswahl."
|
||||
},
|
||||
"skills": {
|
||||
"installSection": "INSTALLIEREN",
|
||||
"installUrlPlaceholder": "Git-Repo-URL (owner/repo oder vollständige URL)",
|
||||
"installButton": "Installieren",
|
||||
"installedSection": "INSTALLIERT",
|
||||
"updateButton": "Aktualisieren",
|
||||
"removeButton": "Entfernen"
|
||||
}
|
||||
},
|
||||
"tasks": {
|
||||
@@ -115,7 +128,8 @@
|
||||
"ctxMarkAs": "Markieren als",
|
||||
"ctxMarkDone": "Erledigt",
|
||||
"ctxMarkCancelled": "Abgebrochen",
|
||||
"ctxRunInteractively": "Interaktiv ausführen",
|
||||
"ctxOpenConPtySession": "ConPTY-Sitzung öffnen",
|
||||
"ctxPickUpInTerminal": "Im Terminal fortsetzen",
|
||||
"ctxOpenPlanningSession": "Planungssitzung öffnen",
|
||||
"ctxResumePlanningSession": "Planungssitzung fortsetzen",
|
||||
"ctxFinalizePlanningSession": "Plan finalisieren",
|
||||
@@ -162,6 +176,7 @@
|
||||
"addReposTip": "Repos als Listen hinzufügen"
|
||||
},
|
||||
"details": {
|
||||
"pickUpInTerminalTip": "Diese Sitzung im Terminal fortsetzen",
|
||||
"deleteTaskTip": "Aufgabe löschen",
|
||||
"killSessionTip": "Laufende Sitzung beenden",
|
||||
"closeTip": "Schließen",
|
||||
@@ -231,15 +246,7 @@
|
||||
"chipDone": "FERTIG",
|
||||
"chipFailed": "FEHLGESCHLAGEN",
|
||||
"reviewContinueTip": "Dieses Feedback senden und die Aufgabe erneut ausführen",
|
||||
"reviewResetTip": "Alle Änderungen verwerfen und die Aufgabe auf Leerlauf zurücksetzen",
|
||||
"composer": {
|
||||
"placeholder": "Nachricht an die Sitzung…",
|
||||
"send": "Senden",
|
||||
"stop": "Sitzung beenden",
|
||||
"interrupt": "Aktuellen Zug unterbrechen",
|
||||
"queued": "Wartet — wird nach dem aktuellen Zug gesendet",
|
||||
"unqueue": "Aus Warteschlange entfernen"
|
||||
}
|
||||
"reviewResetTip": "Alle Änderungen verwerfen und die Aufgabe auf Leerlauf zurücksetzen"
|
||||
},
|
||||
"missionControl": {
|
||||
"openInApp": "In App öffnen",
|
||||
@@ -247,11 +254,16 @@
|
||||
"detach": "Abdocken",
|
||||
"redock": "Andocken",
|
||||
"windowTitle": "Mission Control",
|
||||
"newSession": "Neue Sitzung",
|
||||
"clearFinished": "Erledigte entfernen",
|
||||
"empty": "Keine laufenden Aufgaben",
|
||||
"settings": "Einstellungen",
|
||||
"queue": "Warteschlange",
|
||||
"blocked": "Blockiert",
|
||||
"focusMode": "Fokus",
|
||||
"overviewMode": "Übersicht",
|
||||
"closeSession": "Sitzung schließen",
|
||||
"conptyLaunchFailed": "ConPTY-Sitzung konnte nicht geöffnet werden: {0}",
|
||||
"question": {
|
||||
"title": "Claude fragt nach",
|
||||
"placeholder": "Antwort eingeben…",
|
||||
@@ -492,7 +504,7 @@
|
||||
"taskStatus": { "idle": "Leerlauf", "queued": "In Warteschlange", "running": "Läuft", "waitingForReview": "Wartet auf Prüfung", "waitingForChildren": "Wartet auf Verbesserungen", "done": "Fertig", "failed": "Fehlgeschlagen", "cancelled": "Abgebrochen", "parked": "Geparkt" },
|
||||
"planningBadge": { "active": "PLANUNG", "finalized": "GEPLANT" },
|
||||
"taskRow": { "createdPrefix": "Erstellt {0}", "stepsText": "{0}/{1} Schritte" },
|
||||
"tasksIsland": { "completedHeader": "ABGESCHLOSSEN", "completedHeaderCount": "ABGESCHLOSSEN · {0}", "runInteractiveFailed": "Interaktiv ausführen fehlgeschlagen: {0}", "planningOpenFailed": "Planungssitzung konnte nicht geöffnet werden: {0}" },
|
||||
"tasksIsland": { "completedHeader": "ABGESCHLOSSEN", "completedHeaderCount": "ABGESCHLOSSEN · {0}", "planningOpenFailed": "Planungssitzung konnte nicht geöffnet werden: {0}", "pickUpInTerminalFailed": "Im Terminal fortsetzen fehlgeschlagen: {0}", "approveFailed": "Genehmigen & Mergen fehlgeschlagen: {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)." },
|
||||
"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}" },
|
||||
@@ -501,6 +513,7 @@
|
||||
"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}" },
|
||||
"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}" },
|
||||
"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." },
|
||||
"worktreesOverview": { "titleAll": "Worktrees", "titleList": "Worktrees — {0}", "listFallback": "Liste", "cleanupFailed": "Aufräumen fehlgeschlagen.", "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.", "batchProgress": "Merge {0}/{1}…", "batchDone": "{0} gemergt, {1} zu lösen." },
|
||||
"listSettings": { "untitled": "Unbenannt" },
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
"tabWorktrees": "Worktrees",
|
||||
"tabFiles": "Files",
|
||||
"tabPrime": "Prime Claude",
|
||||
"tabSkills": "Skills",
|
||||
"general": {
|
||||
"defaultInstructions": "Default instructions",
|
||||
"defaultInstructionsPlaceholder": "Baseline instructions applied to every task",
|
||||
@@ -25,7 +26,9 @@
|
||||
"weekdayWednesday": "Wednesday",
|
||||
"weekdayThursday": "Thursday",
|
||||
"weekdayFriday": "Friday",
|
||||
"weekdaySaturday": "Saturday"
|
||||
"weekdaySaturday": "Saturday",
|
||||
"sessionSkills": "Session skills",
|
||||
"sessionSkillsHint": "Applied to every task. Combines with list- and task-level selections."
|
||||
},
|
||||
"worktrees": {
|
||||
"strategy": "Strategy",
|
||||
@@ -95,7 +98,17 @@
|
||||
"systemPrompt": "System prompt (appended)",
|
||||
"promptPrepended": "Prepended automatically:",
|
||||
"agentFile": "Agent file",
|
||||
"browse": "Browse..."
|
||||
"browse": "Browse...",
|
||||
"sessionSkills": "Session skills",
|
||||
"sessionSkillsHint": "Combines additively with global (and list) selections."
|
||||
},
|
||||
"skills": {
|
||||
"installSection": "INSTALL",
|
||||
"installUrlPlaceholder": "Git repo URL (owner/repo or full URL)",
|
||||
"installButton": "Install",
|
||||
"installedSection": "INSTALLED",
|
||||
"updateButton": "Update",
|
||||
"removeButton": "Remove"
|
||||
}
|
||||
},
|
||||
"tasks": {
|
||||
@@ -115,7 +128,8 @@
|
||||
"ctxMarkAs": "Mark as",
|
||||
"ctxMarkDone": "Done",
|
||||
"ctxMarkCancelled": "Cancelled",
|
||||
"ctxRunInteractively": "Run interactively",
|
||||
"ctxOpenConPtySession": "Open ConPTY session",
|
||||
"ctxPickUpInTerminal": "Pick up in terminal",
|
||||
"ctxOpenPlanningSession": "Open planning Session",
|
||||
"ctxResumePlanningSession": "Resume planning Session",
|
||||
"ctxFinalizePlanningSession": "Finalize plan",
|
||||
@@ -162,6 +176,7 @@
|
||||
"addReposTip": "Add repos as lists"
|
||||
},
|
||||
"details": {
|
||||
"pickUpInTerminalTip": "Pick up this session in a terminal",
|
||||
"deleteTaskTip": "Delete task",
|
||||
"killSessionTip": "Kill the running session",
|
||||
"closeTip": "Close",
|
||||
@@ -231,15 +246,7 @@
|
||||
"chipDone": "DONE",
|
||||
"chipFailed": "FAILED",
|
||||
"reviewContinueTip": "Send this feedback and re-run the task",
|
||||
"reviewResetTip": "Discard all changes and reset the task to Idle",
|
||||
"composer": {
|
||||
"placeholder": "Message the session…",
|
||||
"send": "Send",
|
||||
"stop": "Stop session",
|
||||
"interrupt": "Interrupt current turn",
|
||||
"queued": "Queued — sends after the current turn",
|
||||
"unqueue": "Remove from queue"
|
||||
}
|
||||
"reviewResetTip": "Discard all changes and reset the task to Idle"
|
||||
},
|
||||
"missionControl": {
|
||||
"openInApp": "Open in app",
|
||||
@@ -247,11 +254,16 @@
|
||||
"detach": "Detach",
|
||||
"redock": "Re-dock",
|
||||
"windowTitle": "Mission Control",
|
||||
"newSession": "New session",
|
||||
"clearFinished": "Clear finished",
|
||||
"empty": "No running tasks",
|
||||
"settings": "Settings",
|
||||
"queue": "Queue",
|
||||
"blocked": "Blocked",
|
||||
"focusMode": "Focus",
|
||||
"overviewMode": "Overview",
|
||||
"closeSession": "Close session",
|
||||
"conptyLaunchFailed": "Couldn't open ConPTY session: {0}",
|
||||
"question": {
|
||||
"title": "Claude is asking",
|
||||
"placeholder": "Type your answer…",
|
||||
@@ -492,7 +504,7 @@
|
||||
"taskStatus": { "idle": "Idle", "queued": "Queued", "running": "Running", "waitingForReview": "Waiting for Review", "waitingForChildren": "Waiting for Improvements", "done": "Done", "failed": "Failed", "cancelled": "Cancelled", "parked": "Parked" },
|
||||
"planningBadge": { "active": "PLANNING", "finalized": "PLANNED" },
|
||||
"taskRow": { "createdPrefix": "Created {0}", "stepsText": "{0}/{1} steps" },
|
||||
"tasksIsland": { "completedHeader": "COMPLETED", "completedHeaderCount": "COMPLETED · {0}", "runInteractiveFailed": "Run interactively failed: {0}", "planningOpenFailed": "Couldn't open planning session: {0}" },
|
||||
"tasksIsland": { "completedHeader": "COMPLETED", "completedHeaderCount": "COMPLETED · {0}", "planningOpenFailed": "Couldn't open planning session: {0}", "pickUpInTerminalFailed": "Pick up in terminal failed: {0}", "approveFailed": "Approve & merge failed: {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)." },
|
||||
"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}" },
|
||||
@@ -501,6 +513,7 @@
|
||||
"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}" },
|
||||
"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}" },
|
||||
"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)." },
|
||||
"worktreesOverview": { "titleAll": "Worktrees", "titleList": "Worktrees — {0}", "listFallback": "list", "cleanupFailed": "Cleanup failed.", "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.", "batchProgress": "Merging {0}/{1}…", "batchDone": "Merged {0}, {1} need resolution." },
|
||||
"listSettings": { "untitled": "Untitled" },
|
||||
|
||||
@@ -35,7 +35,7 @@ Design/ — Tokens.axaml (design tokens; merged before styles) + IslandStyle
|
||||
|
||||
- **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`.
|
||||
- **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`); `RunInteractivelyAsync`, `RefineTask`; MyDay extras (`IsMyDayList`, `ClearDayCommand`, `ShowPrepLogCommand`) and the pinned Notes pseudo-row (`ShowNotesRow`, `OpenNotesCommand`). Raises `NotesRequested`/`PrepRequested` events consumed by the shell.
|
||||
- **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), `PickUpInTerminalAsync`; 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 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` and call `ShowDiffViewer`), **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`; list row: kind Smart/Virtual/User, count, icon/dot keys, drop hints).
|
||||
- **NotesEditorViewModel** — day navigator + bullet CRUD for daily notes via `INotesApi`.
|
||||
@@ -45,7 +45,7 @@ Design/ — Tokens.axaml (design tokens; merged before styles) + IslandStyle
|
||||
|
||||
## 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, interactive terminal, refine), 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`). Events mirror `HubBroadcaster` (task/worktree/list/run updates, prep events, planning-merge events, refine events, worker log). Lifecycle (`StartAsync`/`StopAsync`) and a few admin methods live only on the concrete `WorkerClient`.
|
||||
- **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), pick-up-in-terminal + 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`). Events mirror `HubBroadcaster` (task/worktree/list/run updates, prep events, planning-merge events, refine events, worker log). 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).
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="8.0.1" />
|
||||
<PackageReference Include="Microsoft.Win32.Registry" Version="5.0.0" />
|
||||
<PackageReference Include="Duende.IdentityModel.OidcClient" Version="7.1.0" />
|
||||
<PackageReference Include="Iciclecreek.Avalonia.Terminal" Version="2.0.3" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -25,11 +25,6 @@ public interface IWorkerClient : INotifyPropertyChanged
|
||||
/// <summary>A pending question was answered, timed out, or the run ended: (taskId, questionId).</summary>
|
||||
event Action<string, string>? TaskQuestionResolvedEvent;
|
||||
|
||||
event Action<string>? InteractiveSessionStartedEvent;
|
||||
event Action<string>? InteractiveSessionEndedEvent;
|
||||
event Action<string, IReadOnlyList<string>>? InteractiveQueueChangedEvent;
|
||||
event Action<string, string>? InteractiveMessageSentEvent;
|
||||
|
||||
event Action? PrepStartedEvent;
|
||||
event Action<string>? PrepLineEvent;
|
||||
event Action<bool>? PrepFinishedEvent;
|
||||
@@ -51,10 +46,6 @@ public interface IWorkerClient : INotifyPropertyChanged
|
||||
Task ContinueTaskAsync(string taskId, string followUpPrompt);
|
||||
/// <summary>Answer a question a running task raised via AskUser.</summary>
|
||||
Task AnswerTaskQuestionAsync(string taskId, string questionId, string answer);
|
||||
Task SendInteractiveMessageAsync(string taskId, string text);
|
||||
Task RemoveQueuedInteractiveMessageAsync(string taskId, string text);
|
||||
Task StopInteractiveSessionAsync(string taskId);
|
||||
Task InterruptInteractiveSessionAsync(string taskId);
|
||||
/// <summary>The question a running task is currently blocked on, if any (for re-attach).</summary>
|
||||
Task<PendingQuestionDto?> GetPendingQuestionAsync(string taskId);
|
||||
Task ResetTaskAsync(string taskId);
|
||||
@@ -64,6 +55,10 @@ public interface IWorkerClient : INotifyPropertyChanged
|
||||
Task<SeedResultDto?> RestoreDefaultAgentsAsync();
|
||||
Task<ListConfigDto?> GetListConfigAsync(string listId);
|
||||
Task UpdateTaskAgentSettingsAsync(UpdateTaskAgentSettingsDto dto);
|
||||
Task<List<SessionSkillDto>> GetSessionSkillsAsync();
|
||||
Task<List<string>> InstallSessionSkillAsync(string url);
|
||||
Task UpdateSessionSkillAsync(string sourceUrl);
|
||||
Task RemoveSessionSkillAsync(string sourceUrl);
|
||||
Task SetTaskStatusAsync(string taskId, TaskStatus status);
|
||||
Task<MergeResultDto?> ApproveReviewAsync(string taskId, string targetBranch);
|
||||
Task<MergePreviewDto?> PreviewMergeAsync(string taskId, string targetBranch);
|
||||
@@ -79,7 +74,14 @@ public interface IWorkerClient : INotifyPropertyChanged
|
||||
Task<MergeResultDto> ContinueConflictMergeAsync(string taskId);
|
||||
Task AbortConflictMergeAsync(string taskId);
|
||||
Task StartPlanningSessionAsync(string taskId, CancellationToken ct = default);
|
||||
Task OpenInteractiveTerminalAsync(string taskId, CancellationToken ct = default);
|
||||
// Picks up a task's Claude session in a real terminal window (--resume).
|
||||
Task ResumeTaskInTerminalAsync(string taskId, CancellationToken ct = default);
|
||||
/// <summary>Launch spec for an embedded ConPTY terminal to open an interactive session
|
||||
/// in a task's worktree (same worktree prep as an autonomous run).</summary>
|
||||
Task<LaunchSpec> GetInteractiveLaunchSpecAsync(string taskId, CancellationToken ct = default);
|
||||
/// <summary>Launch spec for an ad-hoc interactive session in an arbitrary directory --
|
||||
/// no task, no worktree.</summary>
|
||||
Task<LaunchSpec> GetAdHocLaunchSpecAsync(string directory, CancellationToken ct = default);
|
||||
Task ResumePlanningSessionAsync(string taskId, CancellationToken ct = default);
|
||||
Task<DiscardPlanningOutcome> DiscardPlanningSessionAsync(string taskId, bool dequeueQueuedChildren = false, CancellationToken ct = default);
|
||||
Task FinalizePlanningSessionAsync(string taskId, bool queueAgentTasks = true, CancellationToken ct = default);
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Iciclecreek.Terminal;
|
||||
|
||||
namespace ClaudeDo.Ui.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Thin wrapper around <see cref="TerminalControl.LaunchProcess()"/> — the library owns the
|
||||
/// Porta.Pty spawn, keyboard input, rendering, resize, and focus end to end (see
|
||||
/// <c>spikes/ConPtyTerminal/MainWindow.axaml.cs</c>, which proved this renders correctly and
|
||||
/// stays responsive). We only:
|
||||
/// 1) apply <see cref="TerminalLaunchDescriptor.Env"/> onto the current process environment
|
||||
/// before launching — Porta.Pty inherits the calling process's environment and there is no
|
||||
/// per-launch env seam on <see cref="TerminalControl"/>/<see cref="TerminalControl.LaunchProcess()"/> —
|
||||
/// 2) relay the control's own <see cref="TerminalControl.ProcessExited"/> event and
|
||||
/// <see cref="TerminalControl.Kill()"/> method.
|
||||
/// </summary>
|
||||
public sealed class PtyTerminalSession : IDisposable
|
||||
{
|
||||
private TerminalControl? _control;
|
||||
private bool _disposed;
|
||||
|
||||
public bool IsRunning { get; private set; }
|
||||
public int? ExitCode { get; private set; }
|
||||
|
||||
/// <summary>Raised when the child process exits, on the UI thread, with its exit code.</summary>
|
||||
public event EventHandler<int>? ProcessExited;
|
||||
|
||||
/// <summary>
|
||||
/// Applies <paramref name="descriptor"/>'s env vars to the current process, then drives
|
||||
/// <paramref name="control"/> to launch it via <see cref="TerminalControl.LaunchProcess()"/>.
|
||||
/// </summary>
|
||||
public async Task StartAsync(TerminalLaunchDescriptor descriptor, TerminalControl control, CancellationToken ct = default)
|
||||
{
|
||||
if (_control is not null) throw new InvalidOperationException("Session already started.");
|
||||
_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();
|
||||
IsRunning = true;
|
||||
}
|
||||
|
||||
private void OnControlProcessExited(object? sender, ProcessExitedEventArgs e)
|
||||
{
|
||||
IsRunning = false;
|
||||
ExitCode = e.ExitCode;
|
||||
ProcessExited?.Invoke(this, e.ExitCode);
|
||||
}
|
||||
|
||||
public void Kill()
|
||||
{
|
||||
try { _control?.Kill(); }
|
||||
catch (Exception) { /* already exited */ }
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed) return;
|
||||
_disposed = true;
|
||||
|
||||
if (_control is not null)
|
||||
_control.ProcessExited -= OnControlProcessExited;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace ClaudeDo.Ui.Services;
|
||||
|
||||
/// <summary>Plain launch spec for an embedded ConPTY terminal session — no worker/SignalR coupling.</summary>
|
||||
public sealed record TerminalLaunchDescriptor(
|
||||
string Cwd,
|
||||
string Exe,
|
||||
IReadOnlyList<string> Args,
|
||||
IReadOnlyDictionary<string, string> Env);
|
||||
@@ -49,10 +49,6 @@ 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>? InteractiveSessionStartedEvent;
|
||||
public event Action<string>? InteractiveSessionEndedEvent;
|
||||
public event Action<string, IReadOnlyList<string>>? InteractiveQueueChangedEvent;
|
||||
public event Action<string, string>? InteractiveMessageSentEvent;
|
||||
public event Action? ConnectionRestoredEvent;
|
||||
public event Action<string>? WorktreeUpdatedEvent;
|
||||
public event Action<string>? ListUpdatedEvent;
|
||||
@@ -152,26 +148,6 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
|
||||
Dispatcher.UIThread.Post(() => TaskQuestionResolvedEvent?.Invoke(taskId, questionId));
|
||||
});
|
||||
|
||||
_hub.On<string>("InteractiveSessionStarted", taskId =>
|
||||
{
|
||||
Dispatcher.UIThread.Post(() => InteractiveSessionStartedEvent?.Invoke(taskId));
|
||||
});
|
||||
|
||||
_hub.On<string>("InteractiveSessionEnded", taskId =>
|
||||
{
|
||||
Dispatcher.UIThread.Post(() => InteractiveSessionEndedEvent?.Invoke(taskId));
|
||||
});
|
||||
|
||||
_hub.On<string, IReadOnlyList<string>>("InteractiveQueueChanged", (taskId, pending) =>
|
||||
{
|
||||
Dispatcher.UIThread.Post(() => InteractiveQueueChangedEvent?.Invoke(taskId, pending));
|
||||
});
|
||||
|
||||
_hub.On<string, string>("InteractiveMessageSent", (taskId, text) =>
|
||||
{
|
||||
Dispatcher.UIThread.Post(() => InteractiveMessageSentEvent?.Invoke(taskId, text));
|
||||
});
|
||||
|
||||
_hub.On<string>("WorktreeUpdated", taskId =>
|
||||
{
|
||||
Dispatcher.UIThread.Post(() => WorktreeUpdatedEvent?.Invoke(taskId));
|
||||
@@ -303,30 +279,6 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
|
||||
catch { /* offline or already resolved — the UI clears optimistically */ }
|
||||
}
|
||||
|
||||
public async Task SendInteractiveMessageAsync(string taskId, string text)
|
||||
{
|
||||
try { await _hub.InvokeAsync("SendInteractiveMessage", taskId, text); }
|
||||
catch { /* offline or session already ended */ }
|
||||
}
|
||||
|
||||
public async Task RemoveQueuedInteractiveMessageAsync(string taskId, string text)
|
||||
{
|
||||
try { await _hub.InvokeAsync("RemoveQueuedInteractiveMessage", taskId, text); }
|
||||
catch { /* offline or session already ended */ }
|
||||
}
|
||||
|
||||
public async Task StopInteractiveSessionAsync(string taskId)
|
||||
{
|
||||
try { await _hub.InvokeAsync("StopInteractiveSession", taskId); }
|
||||
catch { /* offline */ }
|
||||
}
|
||||
|
||||
public async Task InterruptInteractiveSessionAsync(string taskId)
|
||||
{
|
||||
try { await _hub.InvokeAsync("InterruptInteractiveSession", taskId); }
|
||||
catch { /* offline */ }
|
||||
}
|
||||
|
||||
public Task<PendingQuestionDto?> GetPendingQuestionAsync(string taskId)
|
||||
=> TryInvokeAsync<PendingQuestionDto>("GetPendingQuestion", taskId);
|
||||
|
||||
@@ -481,6 +433,18 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
|
||||
await _hub.InvokeAsync("UpdateTaskAgentSettings", dto);
|
||||
}
|
||||
|
||||
public async Task<List<SessionSkillDto>> GetSessionSkillsAsync()
|
||||
=> await TryInvokeAsync<List<SessionSkillDto>>("GetSessionSkills") ?? [];
|
||||
|
||||
public Task<List<string>> InstallSessionSkillAsync(string url)
|
||||
=> _hub.InvokeAsync<List<string>>("InstallSessionSkill", url);
|
||||
|
||||
public Task UpdateSessionSkillAsync(string sourceUrl)
|
||||
=> _hub.InvokeAsync("UpdateSessionSkill", sourceUrl);
|
||||
|
||||
public Task RemoveSessionSkillAsync(string sourceUrl)
|
||||
=> _hub.InvokeAsync("RemoveSessionSkill", sourceUrl);
|
||||
|
||||
public async Task SetTaskStatusAsync(string taskId, ClaudeDo.Data.Models.TaskStatus status)
|
||||
{
|
||||
await _hub.InvokeAsync("SetTaskStatus", taskId, status.ToString());
|
||||
@@ -546,8 +510,14 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
|
||||
public async Task<PlanningSessionResumeInfo> ResumePlanningSessionAsync(string taskId, CancellationToken ct = default)
|
||||
=> await _hub.InvokeAsync<PlanningSessionResumeInfo>("ResumePlanningSessionAsync", taskId, ct);
|
||||
|
||||
public async Task OpenInteractiveTerminalAsync(string taskId, CancellationToken ct = default)
|
||||
=> await _hub.InvokeAsync("OpenInteractiveTerminalAsync", taskId, ct);
|
||||
public async Task ResumeTaskInTerminalAsync(string taskId, CancellationToken ct = default)
|
||||
=> await _hub.InvokeAsync("ResumeTaskInTerminal", taskId, ct);
|
||||
|
||||
public async Task<LaunchSpec> GetInteractiveLaunchSpecAsync(string taskId, CancellationToken ct = default)
|
||||
=> await _hub.InvokeAsync<LaunchSpec>("GetInteractiveLaunchSpec", taskId, ct);
|
||||
|
||||
public async Task<LaunchSpec> GetAdHocLaunchSpecAsync(string directory, CancellationToken ct = default)
|
||||
=> await _hub.InvokeAsync<LaunchSpec>("GetAdHocLaunchSpec", directory, ct);
|
||||
|
||||
public async Task<DiscardPlanningOutcome> DiscardPlanningSessionAsync(string taskId, bool dequeueQueuedChildren = false, CancellationToken ct = default)
|
||||
=> await _hub.InvokeAsync<DiscardPlanningOutcome>("DiscardPlanningSessionAsync", taskId, dequeueQueuedChildren, ct);
|
||||
@@ -624,7 +594,15 @@ public sealed record AppSettingsDto(
|
||||
int WorktreeAutoCleanupDays,
|
||||
string? ReportExcludedPaths,
|
||||
int StandupWeekday,
|
||||
int DailyPrepMaxTasks);
|
||||
int DailyPrepMaxTasks,
|
||||
List<string>? SessionSkills = null);
|
||||
|
||||
public sealed record SessionSkillDto(
|
||||
string Name,
|
||||
string Description,
|
||||
string SourceUrl,
|
||||
string PinnedRef,
|
||||
DateTimeOffset AddedAt);
|
||||
|
||||
public sealed record WorktreeCleanupDto(int Removed);
|
||||
public sealed record WorktreeResetDto(int Removed, int TasksAffected, bool Blocked, int RunningTasks);
|
||||
@@ -635,9 +613,9 @@ public record MergeConflictDocumentsDto(string TaskId, IReadOnlyList<ConflictDoc
|
||||
public record ConflictDocumentDto(string Path, bool IsBinary, IReadOnlyList<MergeSegmentDto> Segments);
|
||||
public record MergeSegmentDto(bool IsConflict, string Text, string Ours, string? Base, string Theirs);
|
||||
public sealed record UpdateListDto(string Id, string Name, string? WorkingDir, string DefaultCommitType);
|
||||
public sealed record UpdateListConfigDto(string ListId, string? Model, string? SystemPrompt, string? AgentPath, int? MaxTurns = null);
|
||||
public sealed record UpdateTaskAgentSettingsDto(string TaskId, string? Model, string? SystemPrompt, string? AgentPath, int? MaxTurns = null);
|
||||
public sealed record ListConfigDto(string? Model, string? SystemPrompt, string? AgentPath, int? MaxTurns = null);
|
||||
public sealed record UpdateListConfigDto(string ListId, string? Model, string? SystemPrompt, string? AgentPath, int? MaxTurns = null, List<string>? SessionSkills = null);
|
||||
public sealed record UpdateTaskAgentSettingsDto(string TaskId, string? Model, string? SystemPrompt, string? AgentPath, int? MaxTurns = null, List<string>? SessionSkills = null);
|
||||
public sealed record ListConfigDto(string? Model, string? SystemPrompt, string? AgentPath, int? MaxTurns = null, List<string>? SessionSkills = null);
|
||||
public sealed record SeedResultDto(int Copied, int Skipped);
|
||||
|
||||
public sealed record WorktreeOverviewDto(
|
||||
@@ -654,6 +632,12 @@ public sealed record WorktreeOverviewDto(
|
||||
DateTime CreatedAt,
|
||||
bool PathExistsOnDisk);
|
||||
|
||||
public sealed record LaunchSpec(
|
||||
string Cwd,
|
||||
string Exe,
|
||||
IReadOnlyList<string> Args,
|
||||
IReadOnlyDictionary<string, string> Env);
|
||||
|
||||
public sealed record ForceRemoveResultDto(bool Removed, string? Reason);
|
||||
public sealed record PendingQuestionDto(string TaskId, string QuestionId, string Question);
|
||||
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Collections.Specialized;
|
||||
using System.ComponentModel;
|
||||
using System.Text.Json;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using ClaudeDo.Data.Models;
|
||||
@@ -57,6 +60,7 @@ public sealed partial class AgentConfigEditorViewModel : ViewModelBase, IDisposa
|
||||
|
||||
public ObservableCollection<string> ModelOptions { get; } = new(ModelRegistry.Aliases);
|
||||
public ObservableCollection<AgentInfo> Agents { get; } = new();
|
||||
public ObservableCollection<SelectableSkillViewModel> SessionSkills { get; } = new();
|
||||
|
||||
public AgentConfigEditorViewModel(IWorkerClient worker, AgentConfigScope scope)
|
||||
{
|
||||
@@ -67,9 +71,31 @@ public sealed partial class AgentConfigEditorViewModel : ViewModelBase, IDisposa
|
||||
// short-lived modal recreated with the current language on each open.
|
||||
if (scope == AgentConfigScope.Task)
|
||||
Loc.LanguageChanged += _langChangedHandler;
|
||||
|
||||
SessionSkills.CollectionChanged += OnSessionSkillsCollectionChanged;
|
||||
}
|
||||
|
||||
public void Dispose() => Loc.LanguageChanged -= _langChangedHandler;
|
||||
private void OnSessionSkillsCollectionChanged(object? sender, NotifyCollectionChangedEventArgs e)
|
||||
{
|
||||
if (e.NewItems is not null)
|
||||
foreach (SelectableSkillViewModel item in e.NewItems)
|
||||
item.PropertyChanged += OnSkillItemPropertyChanged;
|
||||
if (e.OldItems is not null)
|
||||
foreach (SelectableSkillViewModel item in e.OldItems)
|
||||
item.PropertyChanged -= OnSkillItemPropertyChanged;
|
||||
}
|
||||
|
||||
private void OnSkillItemPropertyChanged(object? sender, PropertyChangedEventArgs e)
|
||||
{
|
||||
if (e.PropertyName == nameof(SelectableSkillViewModel.IsSelected)) QueueSave();
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Loc.LanguageChanged -= _langChangedHandler;
|
||||
SessionSkills.CollectionChanged -= OnSessionSkillsCollectionChanged;
|
||||
foreach (var s in SessionSkills) s.PropertyChanged -= OnSkillItemPropertyChanged;
|
||||
}
|
||||
|
||||
partial void OnModelChanged(string? value) { RecomputeModelBadge(); QueueSave(); }
|
||||
|
||||
@@ -154,11 +180,18 @@ public sealed partial class AgentConfigEditorViewModel : ViewModelBase, IDisposa
|
||||
var sp = string.IsNullOrWhiteSpace(SystemPrompt) ? null : SystemPrompt;
|
||||
var ap = SelectedAgent is null || string.IsNullOrWhiteSpace(SelectedAgent.Path) ? null : SelectedAgent.Path;
|
||||
var turns = MaxTurns is decimal d ? (int?)d : null;
|
||||
var skills = SelectedSessionSkillNames();
|
||||
|
||||
if (_scope == AgentConfigScope.Task)
|
||||
await _worker.UpdateTaskAgentSettingsAsync(new UpdateTaskAgentSettingsDto(TargetId, model, sp, ap, turns));
|
||||
await _worker.UpdateTaskAgentSettingsAsync(new UpdateTaskAgentSettingsDto(TargetId, model, sp, ap, turns, skills));
|
||||
else
|
||||
await _worker.UpdateListConfigAsync(new UpdateListConfigDto(TargetId, model, sp, ap, turns));
|
||||
await _worker.UpdateListConfigAsync(new UpdateListConfigDto(TargetId, model, sp, ap, turns, skills));
|
||||
}
|
||||
|
||||
private List<string>? SelectedSessionSkillNames()
|
||||
{
|
||||
var names = SessionSkills.Where(s => s.IsSelected).Select(s => s.Name).ToList();
|
||||
return names.Count == 0 ? null : names;
|
||||
}
|
||||
|
||||
public async System.Threading.Tasks.Task LoadForListAsync(string listId, CancellationToken ct = default)
|
||||
@@ -172,6 +205,7 @@ public sealed partial class AgentConfigEditorViewModel : ViewModelBase, IDisposa
|
||||
|
||||
var cfg = await _worker.GetListConfigAsync(listId);
|
||||
ApplyConfig(cfg?.Model, cfg?.MaxTurns, cfg?.SystemPrompt, cfg?.AgentPath);
|
||||
await ReloadSessionSkillsAsync(cfg?.SessionSkills);
|
||||
|
||||
_listModel = null; _listMaxTurns = null; _listAgentName = null;
|
||||
EffectiveSystemPromptHint = "";
|
||||
@@ -189,6 +223,7 @@ public sealed partial class AgentConfigEditorViewModel : ViewModelBase, IDisposa
|
||||
TargetId = entity.Id;
|
||||
await ReloadAgentsAsync("(inherited)");
|
||||
ApplyConfig(entity.Model, entity.MaxTurns, entity.SystemPrompt, entity.AgentPath);
|
||||
await ReloadSessionSkillsAsync(ParseSessionSkills(entity.SessionSkills));
|
||||
|
||||
var listCfg = await _worker.GetListConfigAsync(entity.ListId);
|
||||
await LoadGlobalDefaultsAsync();
|
||||
@@ -214,12 +249,30 @@ public sealed partial class AgentConfigEditorViewModel : ViewModelBase, IDisposa
|
||||
MaxTurns = null;
|
||||
SystemPrompt = "";
|
||||
SelectedAgent = null;
|
||||
foreach (var s in SessionSkills) s.IsSelected = false;
|
||||
}
|
||||
finally { _suppressSave = false; }
|
||||
EffectiveSystemPromptHint = "";
|
||||
TargetId = null;
|
||||
}
|
||||
|
||||
private static List<string>? ParseSessionSkills(string? json)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(json)) return null;
|
||||
try { return JsonSerializer.Deserialize<List<string>>(json); }
|
||||
catch (JsonException) { return null; }
|
||||
}
|
||||
|
||||
private async System.Threading.Tasks.Task ReloadSessionSkillsAsync(IReadOnlyCollection<string>? selected)
|
||||
{
|
||||
var installed = await _worker.GetSessionSkillsAsync();
|
||||
var selectedSet = selected is null ? new HashSet<string>() : new HashSet<string>(selected);
|
||||
foreach (var s in SessionSkills) s.PropertyChanged -= OnSkillItemPropertyChanged;
|
||||
SessionSkills.Clear();
|
||||
foreach (var s in installed)
|
||||
SessionSkills.Add(new SelectableSkillViewModel(s.Name, s.Description, selectedSet.Contains(s.Name)));
|
||||
}
|
||||
|
||||
private async System.Threading.Tasks.Task ReloadAgentsAsync(string placeholderName)
|
||||
{
|
||||
Agents.Clear();
|
||||
@@ -255,5 +308,6 @@ public sealed partial class AgentConfigEditorViewModel : ViewModelBase, IDisposa
|
||||
MaxTurns = null;
|
||||
SystemPrompt = "";
|
||||
SelectedAgent = Agents.Count > 0 ? Agents[0] : null;
|
||||
foreach (var s in SessionSkills) s.IsSelected = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
|
||||
namespace ClaudeDo.Ui.ViewModels.Agent;
|
||||
|
||||
/// <summary>
|
||||
/// One installed session skill shown as a checkbox row. Reused by the global (General tab),
|
||||
/// list, and task selectors — selection is additive-union across all three levels, so this
|
||||
/// is deliberately just a name + checked flag with no inheritance/override state.
|
||||
/// </summary>
|
||||
public sealed partial class SelectableSkillViewModel : ViewModelBase
|
||||
{
|
||||
public string Name { get; }
|
||||
public string Description { get; }
|
||||
|
||||
[ObservableProperty] private bool _isSelected;
|
||||
|
||||
public SelectableSkillViewModel(string name, string description = "", bool isSelected = false)
|
||||
{
|
||||
Name = name;
|
||||
Description = description;
|
||||
_isSelected = isSelected;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using ClaudeDo.Ui.Services;
|
||||
using Iciclecreek.Terminal;
|
||||
|
||||
namespace ClaudeDo.Ui.ViewModels;
|
||||
|
||||
/// <summary>
|
||||
/// Hosts a <see cref="PtyTerminalSession"/> for an embedded ConPTY terminal. The view attaches
|
||||
/// its <see cref="TerminalControl"/> once loaded via <see cref="AttachControl"/>; <see cref="Start"/>
|
||||
/// can be called before or after attach — whichever happens second triggers the launch.
|
||||
/// </summary>
|
||||
public sealed partial class InteractiveTerminalViewModel : ViewModelBase, IDisposable
|
||||
{
|
||||
private readonly PtyTerminalSession _session = new();
|
||||
private TerminalControl? _control;
|
||||
private TerminalLaunchDescriptor? _pendingDescriptor;
|
||||
|
||||
[ObservableProperty] private bool _isRunning;
|
||||
[ObservableProperty] private bool _hasExited;
|
||||
[ObservableProperty] private int? _exitCode;
|
||||
[ObservableProperty] private string? _startError;
|
||||
|
||||
public InteractiveTerminalViewModel()
|
||||
{
|
||||
_session.ProcessExited += OnSessionProcessExited;
|
||||
}
|
||||
|
||||
/// <summary>Called by the view once its <see cref="TerminalControl"/> has loaded (template applied).</summary>
|
||||
public void AttachControl(TerminalControl control)
|
||||
{
|
||||
_control = control;
|
||||
if (_pendingDescriptor is { } descriptor)
|
||||
{
|
||||
_pendingDescriptor = null;
|
||||
_ = StartCoreAsync(descriptor);
|
||||
}
|
||||
}
|
||||
|
||||
public void Start(TerminalLaunchDescriptor descriptor)
|
||||
{
|
||||
if (_control is null)
|
||||
{
|
||||
_pendingDescriptor = descriptor;
|
||||
return;
|
||||
}
|
||||
_ = StartCoreAsync(descriptor);
|
||||
}
|
||||
|
||||
private async Task StartCoreAsync(TerminalLaunchDescriptor descriptor)
|
||||
{
|
||||
if (_control is null) return;
|
||||
try
|
||||
{
|
||||
StartError = null;
|
||||
await _session.StartAsync(descriptor, _control);
|
||||
IsRunning = true;
|
||||
HasExited = false;
|
||||
ExitCode = null;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
IsRunning = false;
|
||||
HasExited = true;
|
||||
StartError = ex.Message;
|
||||
}
|
||||
}
|
||||
|
||||
private void OnSessionProcessExited(object? sender, int exitCode)
|
||||
{
|
||||
IsRunning = false;
|
||||
HasExited = true;
|
||||
ExitCode = exitCode;
|
||||
}
|
||||
|
||||
public void Kill() => _session.Kill();
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_session.ProcessExited -= OnSessionProcessExited;
|
||||
_session.Dispose();
|
||||
}
|
||||
}
|
||||
@@ -751,6 +751,7 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
|
||||
Merge.SyncTaskContext(Task?.Id, Task?.Title, Task?.IsPlanningParent == true);
|
||||
NotifySessionSections();
|
||||
OnPropertyChanged(nameof(CanAcceptDrop));
|
||||
OnPropertyChanged(nameof(CanPickUpInTerminal));
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
@@ -892,6 +893,24 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
|
||||
catch { /* offline */ }
|
||||
}
|
||||
|
||||
// Pick up in a terminal only where a session + worktree reliably still exist (parked
|
||||
// for review or failed mid-run); the worker reports a clear error otherwise.
|
||||
public bool CanPickUpInTerminal => Task is not null
|
||||
&& Task.Status is ClaudeDo.Data.Models.TaskStatus.WaitingForReview
|
||||
or ClaudeDo.Data.Models.TaskStatus.Failed;
|
||||
|
||||
[RelayCommand]
|
||||
private async System.Threading.Tasks.Task PickUpInTerminalAsync()
|
||||
{
|
||||
if (Task is null) return;
|
||||
ClaudeDo.Ui.Services.ForegroundHelper.AllowAny();
|
||||
try { await _worker.ResumeTaskInTerminalAsync(Task.Id); }
|
||||
catch (System.Exception ex)
|
||||
{
|
||||
if (ShowErrorAsync != null) await ShowErrorAsync(ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
[RelayCommand(CanExecute = nameof(CanEnqueue))]
|
||||
private async System.Threading.Tasks.Task EnqueueAsync()
|
||||
{
|
||||
|
||||
@@ -7,11 +7,12 @@ using ClaudeDo.Data.Repositories;
|
||||
using ClaudeDo.Ui.Helpers;
|
||||
using ClaudeDo.Ui.Localization;
|
||||
using ClaudeDo.Ui.Services;
|
||||
using ClaudeDo.Ui.ViewModels.MissionControl;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ClaudeDo.Ui.ViewModels.Islands;
|
||||
|
||||
public sealed partial class TaskMonitorViewModel : ViewModelBase, IDisposable
|
||||
public sealed partial class TaskMonitorViewModel : ViewModelBase, IMissionControlPane, IDisposable
|
||||
{
|
||||
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
|
||||
private readonly IWorkerClient _worker;
|
||||
@@ -70,9 +71,6 @@ public sealed partial class TaskMonitorViewModel : ViewModelBase, IDisposable
|
||||
|
||||
private const string RoadblockMarker = "Roadblocks reported during the run:";
|
||||
|
||||
public ObservableCollection<QueuedMessageViewModel> QueuedMessages { get; } = new();
|
||||
public bool HasQueuedMessages => QueuedMessages.Count > 0;
|
||||
|
||||
// Captured handler delegates for disposal
|
||||
private readonly Action<string, string> _onTaskMessage;
|
||||
private readonly Action<string, string, DateTime> _onTaskStarted;
|
||||
@@ -80,19 +78,6 @@ public sealed partial class TaskMonitorViewModel : ViewModelBase, IDisposable
|
||||
private readonly Action<string> _onTaskUpdated;
|
||||
private readonly Action<string, string, string> _onTaskQuestionAsked;
|
||||
private readonly Action<string, string> _onTaskQuestionResolved;
|
||||
private readonly Action<string> _onInteractiveStarted;
|
||||
private readonly Action<string> _onInteractiveEnded;
|
||||
private readonly Action<string, IReadOnlyList<string>> _onInteractiveQueueChanged;
|
||||
private readonly Action<string, string> _onInteractiveMessageSent;
|
||||
|
||||
// Interactive composer — active while the worker is in an interactive session.
|
||||
[ObservableProperty]
|
||||
[NotifyCanExecuteChangedFor(nameof(SubmitComposerCommand))]
|
||||
private bool _isInteractiveLive;
|
||||
|
||||
[ObservableProperty]
|
||||
[NotifyCanExecuteChangedFor(nameof(SubmitComposerCommand))]
|
||||
private string _composerDraft = string.Empty;
|
||||
|
||||
// A question the running task raised via AskUser and is blocking on, plus the answer
|
||||
// the user is typing. Ephemeral (in-memory + live events) — the task is still Running.
|
||||
@@ -160,46 +145,6 @@ public sealed partial class TaskMonitorViewModel : ViewModelBase, IDisposable
|
||||
ClearPendingQuestion();
|
||||
};
|
||||
_worker.TaskQuestionResolvedEvent += _onTaskQuestionResolved;
|
||||
|
||||
_onInteractiveStarted = taskId =>
|
||||
{
|
||||
if (taskId == _subscribedTaskId) { IsInteractiveLive = true; AgentState = "running"; }
|
||||
};
|
||||
_worker.InteractiveSessionStartedEvent += _onInteractiveStarted;
|
||||
|
||||
_onInteractiveEnded = taskId =>
|
||||
{
|
||||
if (taskId != _subscribedTaskId) return;
|
||||
IsInteractiveLive = false;
|
||||
AgentState = "done";
|
||||
QueuedMessages.Clear();
|
||||
OnPropertyChanged(nameof(HasQueuedMessages));
|
||||
};
|
||||
_worker.InteractiveSessionEndedEvent += _onInteractiveEnded;
|
||||
|
||||
_onInteractiveQueueChanged = (taskId, pending) =>
|
||||
{
|
||||
if (taskId != _subscribedTaskId) return;
|
||||
QueuedMessages.Clear();
|
||||
foreach (var m in pending)
|
||||
{
|
||||
var text = m;
|
||||
QueuedMessages.Add(new QueuedMessageViewModel
|
||||
{
|
||||
Text = text,
|
||||
RemoveCommand = new CommunityToolkit.Mvvm.Input.RelayCommand(() => _ = RemoveQueuedAsync(text)),
|
||||
});
|
||||
}
|
||||
OnPropertyChanged(nameof(HasQueuedMessages));
|
||||
};
|
||||
_worker.InteractiveQueueChangedEvent += _onInteractiveQueueChanged;
|
||||
|
||||
_onInteractiveMessageSent = (taskId, text) =>
|
||||
{
|
||||
if (taskId == _subscribedTaskId)
|
||||
Log.Add(new LogLineViewModel { Kind = LogKind.User, Text = text });
|
||||
};
|
||||
_worker.InteractiveMessageSentEvent += _onInteractiveMessageSent;
|
||||
}
|
||||
|
||||
// Surface a pending question (used by live event + re-attach hydration).
|
||||
@@ -209,45 +154,6 @@ public sealed partial class TaskMonitorViewModel : ViewModelBase, IDisposable
|
||||
PendingQuestion = question;
|
||||
}
|
||||
|
||||
// Used by Mission Control when it creates the monitor after the started event already fired.
|
||||
public void SetInteractiveLive(bool live)
|
||||
{
|
||||
IsInteractiveLive = live;
|
||||
if (live) AgentState = "running";
|
||||
}
|
||||
|
||||
[RelayCommand(CanExecute = nameof(CanSubmitComposer))]
|
||||
private async System.Threading.Tasks.Task SubmitComposer()
|
||||
{
|
||||
if (string.IsNullOrEmpty(_subscribedTaskId)) return;
|
||||
var text = ComposerDraft;
|
||||
if (string.IsNullOrWhiteSpace(text)) return;
|
||||
ComposerDraft = string.Empty;
|
||||
await _worker.SendInteractiveMessageAsync(_subscribedTaskId, text);
|
||||
}
|
||||
|
||||
private bool CanSubmitComposer() => IsInteractiveLive && !string.IsNullOrWhiteSpace(ComposerDraft);
|
||||
|
||||
[RelayCommand]
|
||||
private async System.Threading.Tasks.Task StopInteractive()
|
||||
{
|
||||
if (!string.IsNullOrEmpty(_subscribedTaskId) && IsInteractiveLive)
|
||||
await _worker.StopInteractiveSessionAsync(_subscribedTaskId);
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async System.Threading.Tasks.Task InterruptInteractive()
|
||||
{
|
||||
if (!string.IsNullOrEmpty(_subscribedTaskId) && IsInteractiveLive)
|
||||
await _worker.InterruptInteractiveSessionAsync(_subscribedTaskId);
|
||||
}
|
||||
|
||||
private async System.Threading.Tasks.Task RemoveQueuedAsync(string text)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(_subscribedTaskId))
|
||||
await _worker.RemoveQueuedInteractiveMessageAsync(_subscribedTaskId, text);
|
||||
}
|
||||
|
||||
private void ClearPendingQuestion()
|
||||
{
|
||||
PendingQuestionId = null;
|
||||
@@ -296,10 +202,6 @@ public sealed partial class TaskMonitorViewModel : ViewModelBase, IDisposable
|
||||
SessionOutcome = null;
|
||||
Roadblocks = null;
|
||||
ClearPendingQuestion();
|
||||
IsInteractiveLive = false;
|
||||
ComposerDraft = string.Empty;
|
||||
QueuedMessages.Clear();
|
||||
OnPropertyChanged(nameof(HasQueuedMessages));
|
||||
}
|
||||
|
||||
[ObservableProperty]
|
||||
@@ -523,15 +425,5 @@ public sealed partial class TaskMonitorViewModel : ViewModelBase, IDisposable
|
||||
_worker.TaskUpdatedEvent -= _onTaskUpdated;
|
||||
_worker.TaskQuestionAskedEvent -= _onTaskQuestionAsked;
|
||||
_worker.TaskQuestionResolvedEvent -= _onTaskQuestionResolved;
|
||||
_worker.InteractiveSessionStartedEvent -= _onInteractiveStarted;
|
||||
_worker.InteractiveSessionEndedEvent -= _onInteractiveEnded;
|
||||
_worker.InteractiveQueueChangedEvent -= _onInteractiveQueueChanged;
|
||||
_worker.InteractiveMessageSentEvent -= _onInteractiveMessageSent;
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class QueuedMessageViewModel
|
||||
{
|
||||
public required string Text { get; init; }
|
||||
public required System.Windows.Input.ICommand RemoveCommand { get; init; }
|
||||
}
|
||||
|
||||
@@ -63,6 +63,11 @@ public sealed partial class TaskRowViewModel : ViewModelBase
|
||||
&& !IsChild;
|
||||
public bool CanResumeOrDiscardPlanning => PlanningPhase == PlanningPhase.Active;
|
||||
|
||||
// Pick up in a terminal only where a session + worktree reliably still exist: a task
|
||||
// parked for review, or one that failed mid-run. The worker validates and reports a
|
||||
// clear error if there's no resumable session/worktree.
|
||||
public bool CanPickUpInTerminal => Status is TaskStatus.WaitingForReview or TaskStatus.Failed;
|
||||
|
||||
public string? PlanningBadge => PlanningPhase switch
|
||||
{
|
||||
PlanningPhase.Active => Loc.T("vm.planningBadge.active"),
|
||||
@@ -141,6 +146,7 @@ public sealed partial class TaskRowViewModel : ViewModelBase
|
||||
OnPropertyChanged(nameof(StatusLabel));
|
||||
OnPropertyChanged(nameof(IsRunning));
|
||||
OnPropertyChanged(nameof(IsWaitingForReview));
|
||||
OnPropertyChanged(nameof(CanPickUpInTerminal));
|
||||
OnPropertyChanged(nameof(IsParked));
|
||||
OnPropertyChanged(nameof(IsQueued));
|
||||
OnPropertyChanged(nameof(IsWaiting));
|
||||
|
||||
@@ -732,7 +732,7 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
|
||||
{
|
||||
if (row is null || !row.IsWaitingForReview || _worker is null) return;
|
||||
try { await _worker.ApproveReviewAsync(row.Id, ""); }
|
||||
catch { /* offline; broadcast reconciles on return */ }
|
||||
catch (Exception ex) { ErrorReported?.Invoke(Loc.T("vm.tasksIsland.approveFailed", ex.Message)); }
|
||||
}
|
||||
|
||||
public async Task RejectReviewToQueueAsync(TaskRowViewModel row, string feedback)
|
||||
@@ -810,13 +810,24 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
|
||||
catch (Exception ex) { ErrorReported?.Invoke(Loc.T("vm.tasksIsland.planningOpenFailed", ex.Message)); }
|
||||
}
|
||||
|
||||
// Opens the task in an embedded ConPTY terminal pane in the Command Center. The shell owns
|
||||
// the Mission Control view model, so this just raises an event for it to act on.
|
||||
public event Action<string>? OpenConPtySessionRequested;
|
||||
|
||||
[RelayCommand]
|
||||
private async Task RunInteractivelyAsync(TaskRowViewModel? row)
|
||||
private void OpenConPtySession(TaskRowViewModel? row)
|
||||
{
|
||||
if (row is null) return;
|
||||
OpenConPtySessionRequested?.Invoke(row.Id);
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task PickUpInTerminalAsync(TaskRowViewModel? row)
|
||||
{
|
||||
if (row is null || _worker is null) return;
|
||||
ForegroundHelper.AllowAny();
|
||||
try { await _worker.OpenInteractiveTerminalAsync(row.Id); }
|
||||
catch (Exception ex) { ErrorReported?.Invoke(Loc.T("vm.tasksIsland.runInteractiveFailed", ex.Message)); }
|
||||
try { await _worker.ResumeTaskInTerminalAsync(row.Id); }
|
||||
catch (Exception ex) { ErrorReported?.Invoke(Loc.T("vm.tasksIsland.pickUpInTerminalFailed", ex.Message)); }
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
|
||||
@@ -215,6 +215,7 @@ public sealed partial class IslandsShellViewModel : ViewModelBase, IDisposable
|
||||
MissionControl.OpenInApp = id => _ = RevealTaskAsync(id);
|
||||
MissionControl.ShowDetached = (monitor, reDock) => Dialogs?.ShowDetachedMonitor(monitor, reDock);
|
||||
MissionControl.OpenSettingsRequested = () => Lists.OpenSettingsCommand.Execute(null);
|
||||
MissionControl.ErrorReported += FlashFooterError;
|
||||
_updateCheck = updateCheck;
|
||||
_installerLocator = installerLocator;
|
||||
_workerLocator = workerLocator;
|
||||
@@ -228,6 +229,11 @@ public sealed partial class IslandsShellViewModel : ViewModelBase, IDisposable
|
||||
Tasks.NotesRequested += () => Details.ShowNotes();
|
||||
Tasks.PrepRequested += () => Details.ShowPrep();
|
||||
Tasks.ErrorReported += FlashFooterError;
|
||||
Tasks.OpenConPtySessionRequested += taskId =>
|
||||
{
|
||||
OpenMissionControl();
|
||||
_ = MissionControl.OpenConPtySessionAsync(taskId);
|
||||
};
|
||||
Tasks.TasksChanged += (_, _) => _ = Lists.RefreshCountsAsync();
|
||||
Tasks.OpenListSettingsRequested += (_, _) =>
|
||||
{
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
using System;
|
||||
using System.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using ClaudeDo.Ui.Services;
|
||||
|
||||
namespace ClaudeDo.Ui.ViewModels.MissionControl;
|
||||
|
||||
/// <summary>
|
||||
/// Command Center pane hosting an embedded ConPTY terminal for either one task's interactive
|
||||
/// Claude session, or an ad-hoc/free session in a user-chosen directory (no task, <see cref="TaskId"/>
|
||||
/// is null — ad-hoc panes are never deduped, unlike task-based ones). Distinct from the streamed-log
|
||||
/// <see cref="ClaudeDo.Ui.ViewModels.Islands.TaskMonitorViewModel"/> pane; the two coexist until
|
||||
/// the streaming interactive stack is removed.
|
||||
/// </summary>
|
||||
public sealed partial class ConPtyPaneViewModel : ViewModelBase, IMissionControlPane, IDisposable
|
||||
{
|
||||
public string? TaskId { get; }
|
||||
|
||||
[ObservableProperty] private string _displayTitle;
|
||||
|
||||
public InteractiveTerminalViewModel Terminal { get; } = new();
|
||||
|
||||
/// <summary>Raised when the terminal failed to start — the host surfaces this via the footer error strip.</summary>
|
||||
public event Action<string>? ErrorReported;
|
||||
|
||||
/// <summary>Set by the host (Mission Control) to remove this pane from its collection.</summary>
|
||||
public Action<ConPtyPaneViewModel>? CloseRequested { get; set; }
|
||||
|
||||
/// <summary>Task-based pane — dedup'd by <see cref="TaskId"/>. Pass null for an ad-hoc pane
|
||||
/// (no task, never deduped); prefer <see cref="CreateAdHoc"/> at ad-hoc call sites.</summary>
|
||||
public ConPtyPaneViewModel(string? taskId, string displayTitle, TerminalLaunchDescriptor descriptor)
|
||||
{
|
||||
TaskId = taskId;
|
||||
_displayTitle = displayTitle;
|
||||
Terminal.PropertyChanged += OnTerminalPropertyChanged;
|
||||
Terminal.Start(descriptor);
|
||||
}
|
||||
|
||||
/// <summary>Ad-hoc pane — no task, no dedup.</summary>
|
||||
public static ConPtyPaneViewModel CreateAdHoc(string displayTitle, TerminalLaunchDescriptor descriptor)
|
||||
=> new(null, displayTitle, descriptor);
|
||||
|
||||
private void OnTerminalPropertyChanged(object? sender, PropertyChangedEventArgs e)
|
||||
{
|
||||
if (e.PropertyName == nameof(InteractiveTerminalViewModel.StartError) && Terminal.StartError is { Length: > 0 } error)
|
||||
ErrorReported?.Invoke(error);
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void Close() => CloseRequested?.Invoke(this);
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Terminal.PropertyChanged -= OnTerminalPropertyChanged;
|
||||
Terminal.Kill();
|
||||
Terminal.Dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
namespace ClaudeDo.Ui.ViewModels.MissionControl;
|
||||
|
||||
/// <summary>
|
||||
/// Common contract for anything hosted as a pane in the Command Center — implemented by both
|
||||
/// the streamed-log <see cref="ClaudeDo.Ui.ViewModels.Islands.TaskMonitorViewModel"/> and the
|
||||
/// embedded ConPTY <see cref="ConPtyPaneViewModel"/> — so the layout toggle (grid/tabs) can
|
||||
/// bind one heterogeneous pane collection.
|
||||
/// </summary>
|
||||
public interface IMissionControlPane
|
||||
{
|
||||
string DisplayTitle { get; }
|
||||
}
|
||||
@@ -1,12 +1,15 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Collections.Specialized;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using ClaudeDo.Data;
|
||||
using ClaudeDo.Data.Repositories;
|
||||
using ClaudeDo.Ui.Localization;
|
||||
using ClaudeDo.Ui.Services;
|
||||
using ClaudeDo.Ui.ViewModels.Islands;
|
||||
using ClaudeDo.Ui.ViewModels.MissionControl;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ClaudeDo.Ui.ViewModels;
|
||||
@@ -19,12 +22,31 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable
|
||||
private readonly Action<string, string, string, DateTime> _onTaskFinished;
|
||||
private readonly Action<string> _onTaskUpdated;
|
||||
private readonly Action _onConnectionRestored;
|
||||
private readonly Action<string> _onInteractiveStarted;
|
||||
|
||||
public ObservableCollection<TaskMonitorViewModel> Monitors { get; } = new();
|
||||
|
||||
// Embedded ConPTY sessions (task-based only) — a manual cockpit detached from the
|
||||
// review/merge/status machinery. Mirrored into Panes alongside the streamed-log Monitors.
|
||||
public ObservableCollection<ConPtyPaneViewModel> ConPtySessions { get; } = new();
|
||||
|
||||
// Unified view of Monitors ++ ConPtySessions (in that order) so the layout toggle can
|
||||
// present one heterogeneous collection as either a grid or tabs.
|
||||
public ObservableCollection<IMissionControlPane> Panes { get; } = new();
|
||||
|
||||
[ObservableProperty] private int _columnCount = 1;
|
||||
|
||||
[ObservableProperty]
|
||||
[NotifyPropertyChangedFor(nameof(LayoutToggleLabel))]
|
||||
private bool _isFocusMode;
|
||||
|
||||
[ObservableProperty] private IMissionControlPane? _focusedPane;
|
||||
|
||||
public string LayoutToggleLabel => Loc.T(IsFocusMode ? "missionControl.overviewMode" : "missionControl.focusMode");
|
||||
|
||||
/// <summary>Surfaces a Command Center failure (e.g. a ConPTY launch spec fetch) — the shell
|
||||
/// wires this into the footer error strip, same as the island view models' ErrorReported.</summary>
|
||||
public event Action<string>? ErrorReported;
|
||||
|
||||
private Action<string>? _openInApp;
|
||||
public Action<string>? OpenInApp
|
||||
{
|
||||
@@ -44,6 +66,7 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable
|
||||
public Action? OpenSettingsRequested { get; set; }
|
||||
|
||||
public bool HasMonitors => Monitors.Count > 0;
|
||||
public bool HasPanes => Panes.Count > 0;
|
||||
|
||||
// Read-only view of the worker queue (tasks waiting to run), shown as a side strip.
|
||||
public ObservableCollection<QueuedTaskViewModel> Queued { get; } = new();
|
||||
@@ -55,6 +78,8 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable
|
||||
_worker = worker;
|
||||
|
||||
Monitors.CollectionChanged += OnMonitorsChanged;
|
||||
ConPtySessions.CollectionChanged += OnConPtySessionsChanged;
|
||||
Panes.CollectionChanged += OnPanesChanged;
|
||||
|
||||
_onTaskStarted = (slot, taskId, startedAt) => { EnsureMonitor(taskId); _ = RefreshQueueAsync(); };
|
||||
_worker.TaskStartedEvent += _onTaskStarted;
|
||||
@@ -68,14 +93,6 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable
|
||||
_onConnectionRestored = () => { SeedActive(); _ = RefreshQueueAsync(); };
|
||||
_worker.ConnectionRestoredEvent += _onConnectionRestored;
|
||||
|
||||
_onInteractiveStarted = taskId =>
|
||||
{
|
||||
EnsureMonitor(taskId);
|
||||
var m = Monitors.FirstOrDefault(x => x.SubscribedTaskId == taskId);
|
||||
m?.SetInteractiveLive(true);
|
||||
};
|
||||
_worker.InteractiveSessionStartedEvent += _onInteractiveStarted;
|
||||
|
||||
SeedActive();
|
||||
_ = RefreshQueueAsync();
|
||||
}
|
||||
@@ -192,6 +209,9 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable
|
||||
[RelayCommand]
|
||||
private void OpenSettings() => OpenSettingsRequested?.Invoke();
|
||||
|
||||
[RelayCommand]
|
||||
private void ToggleLayout() => IsFocusMode = !IsFocusMode;
|
||||
|
||||
public void MoveMonitor(TaskMonitorViewModel dragged, TaskMonitorViewModel target)
|
||||
{
|
||||
if (ReferenceEquals(dragged, target)) return;
|
||||
@@ -201,15 +221,129 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable
|
||||
Monitors.Move(from, to);
|
||||
}
|
||||
|
||||
// Fetches the launch spec for a task's worktree and hosts an embedded ConPTY session as a
|
||||
// Command Center pane (task-based only).
|
||||
public async System.Threading.Tasks.Task OpenConPtySessionAsync(string taskId)
|
||||
{
|
||||
if (string.IsNullOrEmpty(taskId)) return;
|
||||
if (ConPtySessions.FirstOrDefault(s => s.TaskId == taskId) is { } existing)
|
||||
{
|
||||
FocusedPane = existing;
|
||||
return;
|
||||
}
|
||||
|
||||
var title = taskId;
|
||||
try
|
||||
{
|
||||
await using var ctx = await _dbFactory.CreateDbContextAsync();
|
||||
var entity = await ctx.Tasks.AsNoTracking().FirstOrDefaultAsync(t => t.Id == taskId);
|
||||
if (entity?.Title is { Length: > 0 } t) title = t;
|
||||
}
|
||||
catch { /* best-effort title lookup */ }
|
||||
|
||||
try
|
||||
{
|
||||
var spec = await _worker.GetInteractiveLaunchSpecAsync(taskId);
|
||||
var descriptor = new TerminalLaunchDescriptor(spec.Cwd, spec.Exe, spec.Args, spec.Env);
|
||||
var pane = new ConPtyPaneViewModel(taskId, title, descriptor);
|
||||
pane.ErrorReported += OnConPtyPaneError;
|
||||
pane.CloseRequested += CloseConPtySession;
|
||||
ConPtySessions.Add(pane);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
ErrorReported?.Invoke(Loc.T("missionControl.conptyLaunchFailed", ex.Message));
|
||||
}
|
||||
}
|
||||
|
||||
// Ad-hoc (task-less) ConPTY session in a user-chosen directory. Never deduped — every call
|
||||
// opens a fresh pane, unlike the task-based OpenConPtySessionAsync above.
|
||||
public async System.Threading.Tasks.Task OpenAdHocConPtySessionAsync(string directory)
|
||||
{
|
||||
if (string.IsNullOrEmpty(directory)) return;
|
||||
|
||||
var title = Path.GetFileName(directory.TrimEnd('\\', '/'));
|
||||
if (string.IsNullOrEmpty(title)) title = directory;
|
||||
|
||||
try
|
||||
{
|
||||
var spec = await _worker.GetAdHocLaunchSpecAsync(directory);
|
||||
var descriptor = new TerminalLaunchDescriptor(spec.Cwd, spec.Exe, spec.Args, spec.Env);
|
||||
var pane = ConPtyPaneViewModel.CreateAdHoc(title, descriptor);
|
||||
pane.ErrorReported += OnConPtyPaneError;
|
||||
pane.CloseRequested += CloseConPtySession;
|
||||
ConPtySessions.Add(pane);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
ErrorReported?.Invoke(Loc.T("missionControl.conptyLaunchFailed", ex.Message));
|
||||
}
|
||||
}
|
||||
|
||||
private void OnConPtyPaneError(string message) => ErrorReported?.Invoke(message);
|
||||
|
||||
private void CloseConPtySession(ConPtyPaneViewModel pane)
|
||||
{
|
||||
if (!ConPtySessions.Contains(pane)) return;
|
||||
pane.ErrorReported -= OnConPtyPaneError;
|
||||
pane.CloseRequested -= CloseConPtySession;
|
||||
ConPtySessions.Remove(pane);
|
||||
pane.Dispose();
|
||||
}
|
||||
|
||||
// Mirrors Monitors' add/remove/move into the front (Monitors-prefix) section of Panes.
|
||||
private void OnMonitorsChanged(object? sender, NotifyCollectionChangedEventArgs e)
|
||||
{
|
||||
ColumnCount = Monitors.Count switch
|
||||
switch (e.Action)
|
||||
{
|
||||
case NotifyCollectionChangedAction.Add:
|
||||
Panes.Insert(e.NewStartingIndex, (TaskMonitorViewModel)e.NewItems![0]!);
|
||||
break;
|
||||
case NotifyCollectionChangedAction.Remove:
|
||||
Panes.RemoveAt(e.OldStartingIndex);
|
||||
break;
|
||||
case NotifyCollectionChangedAction.Move:
|
||||
Panes.Move(e.OldStartingIndex, e.NewStartingIndex);
|
||||
break;
|
||||
default: // Reset (Dispose's Monitors.Clear())
|
||||
foreach (var p in Panes.OfType<TaskMonitorViewModel>().ToList())
|
||||
Panes.Remove(p);
|
||||
break;
|
||||
}
|
||||
OnPropertyChanged(nameof(HasMonitors));
|
||||
}
|
||||
|
||||
// Mirrors ConPtySessions' add/remove into the tail (ConPtySessions-suffix) section of Panes.
|
||||
private void OnConPtySessionsChanged(object? sender, NotifyCollectionChangedEventArgs e)
|
||||
{
|
||||
switch (e.Action)
|
||||
{
|
||||
case NotifyCollectionChangedAction.Add:
|
||||
Panes.Insert(Monitors.Count + e.NewStartingIndex, (ConPtyPaneViewModel)e.NewItems![0]!);
|
||||
break;
|
||||
case NotifyCollectionChangedAction.Remove:
|
||||
Panes.RemoveAt(Monitors.Count + e.OldStartingIndex);
|
||||
break;
|
||||
default: // Reset
|
||||
foreach (var p in Panes.OfType<ConPtyPaneViewModel>().ToList())
|
||||
Panes.Remove(p);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private void OnPanesChanged(object? sender, NotifyCollectionChangedEventArgs e)
|
||||
{
|
||||
ColumnCount = Panes.Count switch
|
||||
{
|
||||
<= 1 => 1,
|
||||
<= 4 => 2,
|
||||
_ => 3,
|
||||
};
|
||||
OnPropertyChanged(nameof(HasMonitors));
|
||||
OnPropertyChanged(nameof(HasPanes));
|
||||
if (e.Action == NotifyCollectionChangedAction.Add && e.NewItems?[0] is IMissionControlPane added)
|
||||
FocusedPane = added;
|
||||
else if (e.Action == NotifyCollectionChangedAction.Remove && ReferenceEquals(FocusedPane, e.OldItems?[0]))
|
||||
FocusedPane = Panes.LastOrDefault();
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
@@ -218,10 +352,19 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable
|
||||
_worker.TaskFinishedEvent -= _onTaskFinished;
|
||||
_worker.TaskUpdatedEvent -= _onTaskUpdated;
|
||||
_worker.ConnectionRestoredEvent -= _onConnectionRestored;
|
||||
_worker.InteractiveSessionStartedEvent -= _onInteractiveStarted;
|
||||
Monitors.CollectionChanged -= OnMonitorsChanged;
|
||||
ConPtySessions.CollectionChanged -= OnConPtySessionsChanged;
|
||||
Panes.CollectionChanged -= OnPanesChanged;
|
||||
foreach (var m in Monitors) m.Dispose();
|
||||
Monitors.Clear();
|
||||
foreach (var c in ConPtySessions.ToList())
|
||||
{
|
||||
c.ErrorReported -= OnConPtyPaneError;
|
||||
c.CloseRequested -= CloseConPtySession;
|
||||
c.Dispose();
|
||||
}
|
||||
ConPtySessions.Clear();
|
||||
Panes.Clear();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using ClaudeDo.Data.Models;
|
||||
using ClaudeDo.Localization;
|
||||
using ClaudeDo.Ui.Services;
|
||||
using ClaudeDo.Ui.ViewModels.Agent;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
|
||||
namespace ClaudeDo.Ui.ViewModels.Modals.Settings;
|
||||
@@ -22,6 +25,8 @@ public sealed partial class GeneralSettingsTabViewModel : ViewModelBase
|
||||
public IReadOnlyList<string> Models { get; } = ModelRegistry.Aliases;
|
||||
public IReadOnlyList<string> PermissionModes { get; } = PermissionModeRegistry.Modes;
|
||||
|
||||
public ObservableCollection<SelectableSkillViewModel> SessionSkills { get; } = new();
|
||||
|
||||
public GeneralSettingsTabViewModel() { }
|
||||
|
||||
public GeneralSettingsTabViewModel(ILocalizer localizer, Action<string> persist)
|
||||
@@ -51,4 +56,20 @@ public sealed partial class GeneralSettingsTabViewModel : ViewModelBase
|
||||
return "Max parallel executions must be between 1 and 20.";
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>Loads the installed-skills checkbox list, reflecting the given selection.</summary>
|
||||
public async Task LoadSessionSkillsAsync(IWorkerClient worker, IReadOnlyCollection<string>? selected)
|
||||
{
|
||||
var installed = await worker.GetSessionSkillsAsync();
|
||||
var selectedSet = selected is null ? new HashSet<string>() : new HashSet<string>(selected);
|
||||
SessionSkills.Clear();
|
||||
foreach (var s in installed)
|
||||
SessionSkills.Add(new SelectableSkillViewModel(s.Name, s.Description, selectedSet.Contains(s.Name)));
|
||||
}
|
||||
|
||||
public List<string>? SelectedSessionSkillNames()
|
||||
{
|
||||
var names = SessionSkills.Where(s => s.IsSelected).Select(s => s.Name).ToList();
|
||||
return names.Count == 0 ? null : names;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using ClaudeDo.Ui.Localization;
|
||||
using ClaudeDo.Ui.Services;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
|
||||
namespace ClaudeDo.Ui.ViewModels.Modals.Settings;
|
||||
|
||||
public sealed partial class SessionSkillsSettingsTabViewModel : ViewModelBase
|
||||
{
|
||||
private readonly IWorkerClient _worker;
|
||||
|
||||
[ObservableProperty] private string _installUrl = "";
|
||||
[ObservableProperty] private string _statusMessage = "";
|
||||
[ObservableProperty] private bool _isBusy;
|
||||
|
||||
public ObservableCollection<SessionSkillDto> Skills { get; } = new();
|
||||
|
||||
public SessionSkillsSettingsTabViewModel(IWorkerClient worker) => _worker = worker;
|
||||
|
||||
public async Task LoadAsync()
|
||||
{
|
||||
IsBusy = true;
|
||||
try
|
||||
{
|
||||
var skills = await _worker.GetSessionSkillsAsync();
|
||||
Skills.Clear();
|
||||
foreach (var s in skills) Skills.Add(s);
|
||||
}
|
||||
finally { IsBusy = false; }
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task InstallAsync()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(InstallUrl)) return;
|
||||
IsBusy = true; StatusMessage = "";
|
||||
try
|
||||
{
|
||||
var installed = await _worker.InstallSessionSkillAsync(InstallUrl.Trim());
|
||||
StatusMessage = Loc.T("vm.sessionSkillsTab.installed", string.Join(", ", installed));
|
||||
InstallUrl = "";
|
||||
await LoadAsync();
|
||||
}
|
||||
catch (Exception ex) { StatusMessage = Loc.T("vm.sessionSkillsTab.installFailed", ex.Message); }
|
||||
finally { IsBusy = false; }
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task UpdateAsync(string? sourceUrl)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(sourceUrl)) return;
|
||||
IsBusy = true; StatusMessage = "";
|
||||
try
|
||||
{
|
||||
await _worker.UpdateSessionSkillAsync(sourceUrl);
|
||||
StatusMessage = Loc.T("vm.sessionSkillsTab.updated");
|
||||
await LoadAsync();
|
||||
}
|
||||
catch (Exception ex) { StatusMessage = Loc.T("vm.sessionSkillsTab.updateFailed", ex.Message); }
|
||||
finally { IsBusy = false; }
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task RemoveAsync(string? sourceUrl)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(sourceUrl)) return;
|
||||
IsBusy = true; StatusMessage = "";
|
||||
try
|
||||
{
|
||||
await _worker.RemoveSessionSkillAsync(sourceUrl);
|
||||
StatusMessage = Loc.T("vm.sessionSkillsTab.removed");
|
||||
await LoadAsync();
|
||||
}
|
||||
catch (Exception ex) { StatusMessage = Loc.T("vm.sessionSkillsTab.removeFailed", ex.Message); }
|
||||
finally { IsBusy = false; }
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,7 @@ public sealed partial class SettingsModalViewModel : ViewModelBase
|
||||
public FilesSettingsTabViewModel Files { get; }
|
||||
public PrimeClaudeTabViewModel Prime { get; }
|
||||
public OnlineInboxSettingsViewModel OnlineInbox { get; }
|
||||
public SessionSkillsSettingsTabViewModel SessionSkills { get; }
|
||||
|
||||
[ObservableProperty] private string _validationError = "";
|
||||
[ObservableProperty] private bool _isBusy;
|
||||
@@ -39,6 +40,7 @@ public sealed partial class SettingsModalViewModel : ViewModelBase
|
||||
Files = new FilesSettingsTabViewModel(worker);
|
||||
Prime = prime;
|
||||
OnlineInbox = new OnlineInboxSettingsViewModel(worker, onlineLoginService);
|
||||
SessionSkills = new SessionSkillsSettingsTabViewModel(worker);
|
||||
}
|
||||
|
||||
public async Task LoadAsync()
|
||||
@@ -69,6 +71,8 @@ public sealed partial class SettingsModalViewModel : ViewModelBase
|
||||
|
||||
await Prime.LoadAsync();
|
||||
await OnlineInbox.LoadAsync();
|
||||
await SessionSkills.LoadAsync();
|
||||
await General.LoadSessionSkillsAsync(_worker, dto?.SessionSkills);
|
||||
}
|
||||
finally { IsBusy = false; }
|
||||
}
|
||||
@@ -97,7 +101,8 @@ public sealed partial class SettingsModalViewModel : ViewModelBase
|
||||
General.ReportExcludedPaths
|
||||
.Split('\n').Select(l => l.Trim().TrimEnd('\r')).Where(l => l.Length > 0).ToList()),
|
||||
General.StandupWeekday,
|
||||
Prime.DailyPrepMaxTasks);
|
||||
Prime.DailyPrepMaxTasks,
|
||||
General.SelectedSessionSkillNames());
|
||||
await _worker.UpdateAppSettingsAsync(dto);
|
||||
await Prime.SaveAsync();
|
||||
await OnlineInbox.SaveAsync();
|
||||
|
||||
@@ -81,5 +81,20 @@
|
||||
IsVisible="{Binding SelectedAgent.Path, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
|
||||
</StackPanel>
|
||||
|
||||
<!-- Session skills (additive union with list/global; no inheritance badge) -->
|
||||
<StackPanel Spacing="4">
|
||||
<TextBlock Classes="field-label" Text="{loc:Tr settings.agentEditor.sessionSkills}"/>
|
||||
<TextBlock Classes="meta" Opacity="0.6" TextWrapping="Wrap"
|
||||
Text="{loc:Tr settings.agentEditor.sessionSkillsHint}"/>
|
||||
<ItemsControl ItemsSource="{Binding SessionSkills}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:SelectableSkillViewModel">
|
||||
<CheckBox IsChecked="{Binding IsSelected, Mode=TwoWay}" Content="{Binding Name}"
|
||||
ToolTip.Tip="{Binding Description}"/>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</StackPanel>
|
||||
|
||||
</StackPanel>
|
||||
</UserControl>
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:vm="using:ClaudeDo.Ui.ViewModels"
|
||||
xmlns:term="using:Iciclecreek.Terminal"
|
||||
x:Class="ClaudeDo.Ui.Views.InteractiveTerminalView"
|
||||
x:DataType="vm:InteractiveTerminalViewModel"
|
||||
x:Name="Root">
|
||||
<!--
|
||||
Process="" suppresses TerminalView.OnLoaded's built-in auto-launch (it otherwise spawns its
|
||||
own cmd.exe/bash the moment this control loads). PtyTerminalSession sets Process/Args/
|
||||
StartingDirectory to our real target and calls TerminalControl.LaunchProcess() exactly once,
|
||||
once the view model's descriptor is known (see AttachControl/Start).
|
||||
-->
|
||||
<!--
|
||||
Font + BufferSize match the working spike. The control derives Cols/Rows from
|
||||
(arranged size / character cell size), so an explicit monospace font is what keeps that
|
||||
calculation correct; without it the terminal miscomputes its size and the child TUI renders
|
||||
into the wrong area. Stretch so the pane's full size reaches the control's Arrange pass.
|
||||
-->
|
||||
<term:TerminalControl x:Name="TerminalHost" Process=""
|
||||
FontFamily="Cascadia Mono,Consolas,monospace"
|
||||
FontSize="14"
|
||||
BufferSize="2000"
|
||||
HorizontalAlignment="Stretch"
|
||||
VerticalAlignment="Stretch" />
|
||||
</UserControl>
|
||||
@@ -0,0 +1,28 @@
|
||||
using System;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Interactivity;
|
||||
using ClaudeDo.Ui.ViewModels;
|
||||
|
||||
namespace ClaudeDo.Ui.Views;
|
||||
|
||||
public partial class InteractiveTerminalView : UserControl
|
||||
{
|
||||
public InteractiveTerminalView()
|
||||
{
|
||||
InitializeComponent();
|
||||
TerminalHost.Loaded += OnTerminalHostLoaded;
|
||||
DataContextChanged += OnDataContextChanged;
|
||||
}
|
||||
|
||||
private void OnDataContextChanged(object? sender, EventArgs e)
|
||||
{
|
||||
if (DataContext is InteractiveTerminalViewModel vm && TerminalHost.IsLoaded)
|
||||
vm.AttachControl(TerminalHost);
|
||||
}
|
||||
|
||||
private void OnTerminalHostLoaded(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (DataContext is InteractiveTerminalViewModel vm)
|
||||
vm.AttachControl(TerminalHost);
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,7 @@
|
||||
x:Class="ClaudeDo.Ui.Views.Islands.Detail.TaskHeaderBar"
|
||||
x:DataType="vm:DetailsIslandViewModel">
|
||||
|
||||
<Grid ColumnDefinitions="*,Auto,Auto">
|
||||
<Grid ColumnDefinitions="*,Auto,Auto,Auto">
|
||||
|
||||
<!-- Column 0: id badge + editable title -->
|
||||
<StackPanel Grid.Column="0" Spacing="0">
|
||||
@@ -27,8 +27,18 @@
|
||||
Padding="0"/>
|
||||
</StackPanel>
|
||||
|
||||
<!-- Column 1: trash button (not running) -->
|
||||
<!-- Column 1: pick up in terminal (review / failed) -->
|
||||
<Button Grid.Column="1" Classes="icon-btn"
|
||||
Command="{Binding PickUpInTerminalCommand}"
|
||||
ToolTip.Tip="{loc:Tr details.pickUpInTerminalTip}"
|
||||
IsVisible="{Binding CanPickUpInTerminal}"
|
||||
VerticalAlignment="Top"
|
||||
Margin="6,0,0,0">
|
||||
<PathIcon Data="{StaticResource Icon.ArrowOut}" Width="14" Height="14"/>
|
||||
</Button>
|
||||
|
||||
<!-- Column 2: trash button (not running) -->
|
||||
<Button Grid.Column="2" Classes="icon-btn"
|
||||
Command="{Binding DeleteTaskCommand}"
|
||||
ToolTip.Tip="{loc:Tr details.deleteTaskTip}"
|
||||
IsVisible="{Binding !IsRunning}"
|
||||
@@ -38,8 +48,8 @@
|
||||
Foreground="{DynamicResource BloodBrush}"/>
|
||||
</Button>
|
||||
|
||||
<!-- Column 1: skull button (running) -->
|
||||
<Button Grid.Column="1" Classes="icon-btn"
|
||||
<!-- Column 2: skull button (running) -->
|
||||
<Button Grid.Column="2" Classes="icon-btn"
|
||||
Command="{Binding StopCommand}"
|
||||
ToolTip.Tip="{loc:Tr details.killSessionTip}"
|
||||
IsVisible="{Binding IsRunning}"
|
||||
@@ -49,8 +59,8 @@
|
||||
Foreground="{DynamicResource BloodBrush}"/>
|
||||
</Button>
|
||||
|
||||
<!-- Column 2: gear button with agent settings flyout -->
|
||||
<Button Grid.Column="2" Classes="icon-btn"
|
||||
<!-- Column 3: gear button with agent settings flyout -->
|
||||
<Button Grid.Column="3" Classes="icon-btn"
|
||||
ToolTip.Tip="{loc:Tr details.agentSettingsTip}"
|
||||
IsEnabled="{Binding AgentSettings.IsEnabled}"
|
||||
VerticalAlignment="Top"
|
||||
|
||||
@@ -263,83 +263,6 @@
|
||||
Command="{Binding RejectReviewCommand}" />
|
||||
</Grid>
|
||||
|
||||
<!-- Interactive composer + queued strip — chat with a live in-app session -->
|
||||
<StackPanel DockPanel.Dock="Bottom" Orientation="Vertical">
|
||||
<!-- Queued messages strip -->
|
||||
<Border IsVisible="{Binding Monitor.HasQueuedMessages}"
|
||||
BorderBrush="{DynamicResource LineBrush}"
|
||||
BorderThickness="0,1,0,0"
|
||||
Padding="12,4">
|
||||
<StackPanel Spacing="2">
|
||||
<TextBlock Classes="meta"
|
||||
Text="{loc:Tr session.composer.queued}"
|
||||
Foreground="{DynamicResource TextMuteBrush}" />
|
||||
<ItemsControl ItemsSource="{Binding Monitor.QueuedMessages}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:QueuedMessageViewModel">
|
||||
<Grid ColumnDefinitions="Auto,*,Auto" Margin="0,1">
|
||||
<TextBlock Grid.Column="0"
|
||||
Text="⧗"
|
||||
Foreground="{DynamicResource TextMuteBrush}"
|
||||
FontFamily="{StaticResource MonoFont}"
|
||||
FontSize="{StaticResource FontSizeMono}"
|
||||
VerticalAlignment="Center"
|
||||
Margin="0,0,6,0" />
|
||||
<TextBlock Grid.Column="1"
|
||||
Text="{Binding Text}"
|
||||
Foreground="{DynamicResource TextDimBrush}"
|
||||
FontFamily="{StaticResource MonoFont}"
|
||||
FontSize="{StaticResource FontSizeMono}"
|
||||
TextTrimming="CharacterEllipsis"
|
||||
VerticalAlignment="Center" />
|
||||
<Button Grid.Column="2"
|
||||
Classes="title-ctrl"
|
||||
Command="{Binding RemoveCommand}"
|
||||
ToolTip.Tip="{loc:Tr session.composer.unqueue}"
|
||||
Margin="4,0,0,0">
|
||||
<PathIcon Data="{StaticResource Icon.WinClose}" Width="8" Height="8"/>
|
||||
</Button>
|
||||
</Grid>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
<!-- Composer input row -->
|
||||
<Grid IsVisible="{Binding Monitor.IsInteractiveLive}"
|
||||
ColumnDefinitions="Auto,*,Auto,Auto"
|
||||
Margin="12,2,12,8">
|
||||
<TextBlock Grid.Column="0" Text="❯"
|
||||
FontFamily="{StaticResource MonoFont}"
|
||||
FontSize="{StaticResource FontSizeMono}"
|
||||
Foreground="{DynamicResource AccentBrush}"
|
||||
VerticalAlignment="Center" Margin="0,0,8,0" />
|
||||
<TextBox Grid.Column="1"
|
||||
Classes="review-prompt"
|
||||
Text="{Binding Monitor.ComposerDraft, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}"
|
||||
AcceptsReturn="False"
|
||||
TextWrapping="Wrap"
|
||||
MaxHeight="160"
|
||||
PlaceholderText="{loc:Tr session.composer.placeholder}"
|
||||
VerticalContentAlignment="Center"
|
||||
FontFamily="{StaticResource MonoFont}"
|
||||
FontSize="{StaticResource FontSizeMono}">
|
||||
<TextBox.KeyBindings>
|
||||
<KeyBinding Gesture="Enter" Command="{Binding Monitor.SubmitComposerCommand}" />
|
||||
</TextBox.KeyBindings>
|
||||
</TextBox>
|
||||
<Button Grid.Column="2" Classes="prompt-action"
|
||||
VerticalAlignment="Center" Margin="12,0,0,0"
|
||||
Command="{Binding Monitor.InterruptInteractiveCommand}"
|
||||
ToolTip.Tip="{loc:Tr session.composer.interrupt}">
|
||||
<PathIcon Data="{StaticResource Icon.Stop}" Width="10" Height="10"/>
|
||||
</Button>
|
||||
<Button Grid.Column="3" Classes="prompt-action accent" Content="[Send]"
|
||||
VerticalAlignment="Center" Margin="4,0,0,0"
|
||||
Command="{Binding Monitor.SubmitComposerCommand}" />
|
||||
</Grid>
|
||||
</StackPanel>
|
||||
|
||||
<ScrollViewer Name="LogScroll"
|
||||
VerticalScrollBarVisibility="Visible"
|
||||
AllowAutoHide="False"
|
||||
|
||||
@@ -50,79 +50,6 @@
|
||||
</Border>
|
||||
</Grid>
|
||||
|
||||
<!-- ── Queued strip + Composer bar ── -->
|
||||
<StackPanel DockPanel.Dock="Bottom" Orientation="Vertical">
|
||||
<!-- Queued messages strip -->
|
||||
<Border IsVisible="{Binding #Root.HasQueuedMessages}"
|
||||
Background="{DynamicResource Surface2Brush}"
|
||||
BorderBrush="{DynamicResource LineBrush}"
|
||||
BorderThickness="0,0,0,1"
|
||||
Padding="8,4">
|
||||
<StackPanel Spacing="2">
|
||||
<TextBlock Classes="meta"
|
||||
Text="{loc:Tr session.composer.queued}"
|
||||
Foreground="{DynamicResource TextMuteBrush}" />
|
||||
<ItemsControl ItemsSource="{Binding #Root.QueuedMessages}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:QueuedMessageViewModel">
|
||||
<Grid ColumnDefinitions="Auto,*,Auto" Margin="0,1">
|
||||
<TextBlock Grid.Column="0"
|
||||
Text="⧗"
|
||||
Foreground="{DynamicResource TextMuteBrush}"
|
||||
FontFamily="{StaticResource MonoFont}"
|
||||
FontSize="{StaticResource FontSizeMono}"
|
||||
VerticalAlignment="Center"
|
||||
Margin="0,0,6,0" />
|
||||
<TextBlock Grid.Column="1"
|
||||
Text="{Binding Text}"
|
||||
Foreground="{DynamicResource TextDimBrush}"
|
||||
FontFamily="{StaticResource MonoFont}"
|
||||
FontSize="{StaticResource FontSizeMono}"
|
||||
TextTrimming="CharacterEllipsis"
|
||||
VerticalAlignment="Center" />
|
||||
<Button Grid.Column="2"
|
||||
Classes="title-ctrl"
|
||||
Command="{Binding RemoveCommand}"
|
||||
ToolTip.Tip="{loc:Tr session.composer.unqueue}"
|
||||
Margin="4,0,0,0">
|
||||
<PathIcon Data="{StaticResource Icon.WinClose}" Width="8" Height="8"/>
|
||||
</Button>
|
||||
</Grid>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
<!-- Composer input row -->
|
||||
<Border IsVisible="{Binding #Root.IsComposerVisible}"
|
||||
Background="{DynamicResource Surface2Brush}"
|
||||
BorderBrush="{DynamicResource LineBrush}"
|
||||
BorderThickness="0,1,0,0"
|
||||
Padding="6,5">
|
||||
<Grid ColumnDefinitions="*,Auto,Auto">
|
||||
<TextBox Grid.Column="0"
|
||||
Text="{Binding #Root.ComposerText, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}"
|
||||
PlaceholderText="{Binding #Root.ComposerPlaceholder}"
|
||||
AcceptsReturn="False">
|
||||
<TextBox.KeyBindings>
|
||||
<KeyBinding Gesture="Enter" Command="{Binding #Root.SubmitCommand}"/>
|
||||
</TextBox.KeyBindings>
|
||||
</TextBox>
|
||||
<Button Grid.Column="1"
|
||||
Margin="6,0,0,0"
|
||||
Classes="title-ctrl"
|
||||
Command="{Binding #Root.InterruptCommand}"
|
||||
ToolTip.Tip="{loc:Tr session.composer.interrupt}">
|
||||
<PathIcon Data="{StaticResource Icon.Stop}" Width="10" Height="10"/>
|
||||
</Button>
|
||||
<Button Grid.Column="2"
|
||||
Margin="6,0,0,0"
|
||||
Content="{loc:Tr session.composer.send}"
|
||||
Command="{Binding #Root.SubmitCommand}"/>
|
||||
</Grid>
|
||||
</Border>
|
||||
</StackPanel>
|
||||
|
||||
<!-- ── Log output ── -->
|
||||
<ScrollViewer Name="LogScroll"
|
||||
VerticalScrollBarVisibility="Visible"
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
using System.Collections;
|
||||
using System.Collections.Specialized;
|
||||
using System.Windows.Input;
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Data;
|
||||
|
||||
namespace ClaudeDo.Ui.Views.Islands;
|
||||
|
||||
@@ -19,33 +17,12 @@ public partial class SessionTerminalView : UserControl
|
||||
AvaloniaProperty.Register<SessionTerminalView, bool>(nameof(IsDone));
|
||||
public static readonly StyledProperty<bool> IsFailedProperty =
|
||||
AvaloniaProperty.Register<SessionTerminalView, bool>(nameof(IsFailed));
|
||||
public static readonly StyledProperty<bool> IsComposerVisibleProperty =
|
||||
AvaloniaProperty.Register<SessionTerminalView, bool>(nameof(IsComposerVisible), defaultValue: false);
|
||||
public static readonly StyledProperty<string?> ComposerTextProperty =
|
||||
AvaloniaProperty.Register<SessionTerminalView, string?>(nameof(ComposerText), defaultBindingMode: BindingMode.TwoWay);
|
||||
public static readonly StyledProperty<ICommand?> SubmitCommandProperty =
|
||||
AvaloniaProperty.Register<SessionTerminalView, ICommand?>(nameof(SubmitCommand));
|
||||
public static readonly StyledProperty<ICommand?> InterruptCommandProperty =
|
||||
AvaloniaProperty.Register<SessionTerminalView, ICommand?>(nameof(InterruptCommand));
|
||||
public static readonly StyledProperty<string?> ComposerPlaceholderProperty =
|
||||
AvaloniaProperty.Register<SessionTerminalView, string?>(nameof(ComposerPlaceholder));
|
||||
public static readonly StyledProperty<System.Collections.IEnumerable?> QueuedMessagesProperty =
|
||||
AvaloniaProperty.Register<SessionTerminalView, System.Collections.IEnumerable?>(nameof(QueuedMessages));
|
||||
public static readonly StyledProperty<bool> HasQueuedMessagesProperty =
|
||||
AvaloniaProperty.Register<SessionTerminalView, bool>(nameof(HasQueuedMessages), defaultValue: false);
|
||||
|
||||
public IEnumerable? Entries { get => GetValue(EntriesProperty); set => SetValue(EntriesProperty, value); }
|
||||
public string? Label { get => GetValue(LabelProperty); set => SetValue(LabelProperty, value); }
|
||||
public bool IsRunning { get => GetValue(IsRunningProperty); set => SetValue(IsRunningProperty, value); }
|
||||
public bool IsDone { get => GetValue(IsDoneProperty); set => SetValue(IsDoneProperty, value); }
|
||||
public bool IsFailed { get => GetValue(IsFailedProperty); set => SetValue(IsFailedProperty, value); }
|
||||
public bool IsComposerVisible { get => GetValue(IsComposerVisibleProperty); set => SetValue(IsComposerVisibleProperty, value); }
|
||||
public string? ComposerText { get => GetValue(ComposerTextProperty); set => SetValue(ComposerTextProperty, value); }
|
||||
public ICommand? SubmitCommand { get => GetValue(SubmitCommandProperty); set => SetValue(SubmitCommandProperty, value); }
|
||||
public ICommand? InterruptCommand { get => GetValue(InterruptCommandProperty); set => SetValue(InterruptCommandProperty, value); }
|
||||
public string? ComposerPlaceholder { get => GetValue(ComposerPlaceholderProperty); set => SetValue(ComposerPlaceholderProperty, value); }
|
||||
public System.Collections.IEnumerable? QueuedMessages { get => GetValue(QueuedMessagesProperty); set => SetValue(QueuedMessagesProperty, value); }
|
||||
public bool HasQueuedMessages { get => GetValue(HasQueuedMessagesProperty); set => SetValue(HasQueuedMessagesProperty, value); }
|
||||
|
||||
private INotifyCollectionChanged? _subscribedCollection;
|
||||
|
||||
|
||||
@@ -49,8 +49,11 @@
|
||||
<MenuItem Header="{loc:Tr tasks.ctxMarkCancelled}" Tag="Cancelled" Click="OnSetStatusClick"/>
|
||||
</MenuItem>
|
||||
<Separator/>
|
||||
<MenuItem Header="{loc:Tr tasks.ctxRunInteractively}"
|
||||
Click="OnRunInteractivelyClick"/>
|
||||
<MenuItem Header="{loc:Tr tasks.ctxOpenConPtySession}"
|
||||
Click="OnOpenConPtySessionClick"/>
|
||||
<MenuItem Header="{loc:Tr tasks.ctxPickUpInTerminal}"
|
||||
Click="OnPickUpInTerminalClick"
|
||||
IsVisible="{Binding CanPickUpInTerminal}"/>
|
||||
<MenuItem Header="{loc:Tr tasks.ctxOpenPlanningSession}"
|
||||
Click="OnOpenPlanningSessionClick"
|
||||
IsVisible="{Binding CanOpenPlanningSession}"/>
|
||||
|
||||
@@ -61,10 +61,16 @@ public partial class TaskRowView : UserControl
|
||||
await vm.OpenPlanningSessionCommand.ExecuteAsync(row);
|
||||
}
|
||||
|
||||
private async void OnRunInteractivelyClick(object? sender, RoutedEventArgs e)
|
||||
private void OnOpenConPtySessionClick(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (DataContext is TaskRowViewModel row && FindTasksVm() is { } vm)
|
||||
await vm.RunInteractivelyCommand.ExecuteAsync(row);
|
||||
vm.OpenConPtySessionCommand.Execute(row);
|
||||
}
|
||||
|
||||
private async void OnPickUpInTerminalClick(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (DataContext is TaskRowViewModel row && FindTasksVm() is { } vm)
|
||||
await vm.PickUpInTerminalCommand.ExecuteAsync(row);
|
||||
}
|
||||
|
||||
private async void OnResumePlanningSessionClick(object? sender, RoutedEventArgs e)
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:vm="using:ClaudeDo.Ui.ViewModels.MissionControl"
|
||||
xmlns:views="using:ClaudeDo.Ui.Views"
|
||||
xmlns:loc="using:ClaudeDo.Ui.Localization"
|
||||
xmlns:conv="using:Avalonia.Data.Converters"
|
||||
x:DataType="vm:ConPtyPaneViewModel"
|
||||
x:Class="ClaudeDo.Ui.Views.MissionControl.ConPtyPaneView">
|
||||
<Border Classes="monitor-pane" BorderThickness="1" CornerRadius="10" ClipToBounds="True">
|
||||
<DockPanel LastChildFill="True">
|
||||
|
||||
<!-- Header: title + close -->
|
||||
<Border DockPanel.Dock="Top"
|
||||
Background="{DynamicResource Surface2Brush}"
|
||||
BorderBrush="{DynamicResource LineBrush}"
|
||||
BorderThickness="0,0,0,1" Padding="8,3">
|
||||
<Grid ColumnDefinitions="*,Auto">
|
||||
<TextBlock Grid.Column="0" Classes="meta" Text="{Binding DisplayTitle}"
|
||||
TextTrimming="CharacterEllipsis"
|
||||
ToolTip.Tip="{Binding DisplayTitle}"
|
||||
Foreground="{DynamicResource TextDimBrush}"
|
||||
VerticalAlignment="Center" Margin="4,0,0,0" />
|
||||
<Button Grid.Column="1" Classes="title-ctrl"
|
||||
Command="{Binding CloseCommand}"
|
||||
ToolTip.Tip="{loc:Tr missionControl.closeSession}">
|
||||
<PathIcon Data="{StaticResource Icon.WinClose}" Width="12" Height="12"/>
|
||||
</Button>
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<!-- Start-failure banner -->
|
||||
<Border DockPanel.Dock="Top"
|
||||
IsVisible="{Binding Terminal.StartError, Converter={x:Static conv:ObjectConverters.IsNotNull}}"
|
||||
Background="{DynamicResource ErrorTintBrush}"
|
||||
BorderBrush="{DynamicResource BloodBrush}"
|
||||
BorderThickness="0,0,0,1" Padding="12,6">
|
||||
<TextBlock Classes="meta" Text="{Binding Terminal.StartError}"
|
||||
Foreground="{DynamicResource BloodBrush}"
|
||||
TextWrapping="Wrap" />
|
||||
</Border>
|
||||
|
||||
<!-- Embedded ConPTY terminal -->
|
||||
<views:InteractiveTerminalView DataContext="{Binding Terminal}" />
|
||||
|
||||
</DockPanel>
|
||||
</Border>
|
||||
</UserControl>
|
||||
@@ -0,0 +1,8 @@
|
||||
using Avalonia.Controls;
|
||||
|
||||
namespace ClaudeDo.Ui.Views.MissionControl;
|
||||
|
||||
public partial class ConPtyPaneView : UserControl
|
||||
{
|
||||
public ConPtyPaneView() => InitializeComponent();
|
||||
}
|
||||
@@ -2,10 +2,21 @@
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:vm="using:ClaudeDo.Ui.ViewModels"
|
||||
xmlns:vmi="using:ClaudeDo.Ui.ViewModels.Islands"
|
||||
xmlns:vmm="using:ClaudeDo.Ui.ViewModels.MissionControl"
|
||||
xmlns:mc="using:ClaudeDo.Ui.Views.MissionControl"
|
||||
xmlns:loc="using:ClaudeDo.Ui.Localization"
|
||||
x:DataType="vm:MissionControlViewModel"
|
||||
x:Class="ClaudeDo.Ui.Views.MissionControl.MissionControlView">
|
||||
<UserControl.DataTemplates>
|
||||
<!-- Polymorphic pane templates: both the grid ItemsControl and the focus-mode TabControl
|
||||
resolve per-item content through these (no explicit ItemTemplate on either). -->
|
||||
<DataTemplate DataType="vmi:TaskMonitorViewModel">
|
||||
<mc:MonitorPaneView Margin="6" />
|
||||
</DataTemplate>
|
||||
<DataTemplate DataType="vmm:ConPtyPaneViewModel">
|
||||
<mc:ConPtyPaneView Margin="6" />
|
||||
</DataTemplate>
|
||||
</UserControl.DataTemplates>
|
||||
<DockPanel LastChildFill="True" Background="{DynamicResource VoidBrush}"
|
||||
DragDrop.AllowDrop="True">
|
||||
|
||||
@@ -21,6 +32,18 @@
|
||||
LetterSpacing="1.4" VerticalAlignment="Center" />
|
||||
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="8"
|
||||
VerticalAlignment="Center">
|
||||
<Button Classes="icon-btn"
|
||||
Click="OnNewSessionClicked"
|
||||
ToolTip.Tip="{loc:Tr missionControl.newSession}">
|
||||
<PathIcon Data="{StaticResource Icon.Plus}" Width="15" Height="15"
|
||||
Foreground="{DynamicResource TextMuteBrush}"/>
|
||||
</Button>
|
||||
<Button Classes="icon-btn"
|
||||
Command="{Binding ToggleLayoutCommand}"
|
||||
ToolTip.Tip="{Binding LayoutToggleLabel}">
|
||||
<PathIcon Data="{StaticResource Icon.Grid}" Width="15" Height="15"
|
||||
Foreground="{DynamicResource TextMuteBrush}"/>
|
||||
</Button>
|
||||
<Button Classes="icon-btn"
|
||||
Command="{Binding OpenSettingsCommand}"
|
||||
ToolTip.Tip="{loc:Tr missionControl.settings}">
|
||||
@@ -72,22 +95,29 @@
|
||||
</DockPanel>
|
||||
</Border>
|
||||
|
||||
<!-- Grid / empty state -->
|
||||
<!-- Grid / tabs / empty state -->
|
||||
<Panel Margin="6">
|
||||
<ItemsControl ItemsSource="{Binding Monitors}" IsVisible="{Binding HasMonitors}">
|
||||
<!-- Overview mode: the existing UniformGrid, one tile per pane -->
|
||||
<ItemsControl ItemsSource="{Binding Panes}" IsVisible="{Binding !IsFocusMode}">
|
||||
<ItemsControl.ItemsPanel>
|
||||
<ItemsPanelTemplate>
|
||||
<UniformGrid x:CompileBindings="False" Columns="{Binding ColumnCount}" />
|
||||
</ItemsPanelTemplate>
|
||||
</ItemsControl.ItemsPanel>
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="vmi:TaskMonitorViewModel">
|
||||
<mc:MonitorPaneView Margin="6" />
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
|
||||
<TextBlock IsVisible="{Binding !HasMonitors}"
|
||||
<!-- Focus mode: one pane large, switched via tabs -->
|
||||
<TabControl ItemsSource="{Binding Panes}"
|
||||
SelectedItem="{Binding FocusedPane}"
|
||||
IsVisible="{Binding IsFocusMode}">
|
||||
<TabControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="vmm:IMissionControlPane">
|
||||
<TextBlock Text="{Binding DisplayTitle}" TextTrimming="CharacterEllipsis" MaxWidth="160" />
|
||||
</DataTemplate>
|
||||
</TabControl.ItemTemplate>
|
||||
</TabControl>
|
||||
|
||||
<TextBlock IsVisible="{Binding !HasPanes}"
|
||||
Text="{loc:Tr missionControl.empty}"
|
||||
Foreground="{DynamicResource TextMuteBrush}"
|
||||
HorizontalAlignment="Center" VerticalAlignment="Center" />
|
||||
|
||||
@@ -2,6 +2,7 @@ using System.Linq;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Input;
|
||||
using Avalonia.Interactivity;
|
||||
using Avalonia.Platform.Storage;
|
||||
using Avalonia.VisualTree;
|
||||
using ClaudeDo.Ui.ViewModels;
|
||||
using ClaudeDo.Ui.ViewModels.Islands;
|
||||
@@ -21,6 +22,23 @@ public partial class MissionControlView : UserControl
|
||||
AddHandler(DragDrop.DropEvent, OnPaneDrop);
|
||||
}
|
||||
|
||||
// Ad-hoc ConPTY session: the view owns the folder picker, the VM only takes the chosen path.
|
||||
private async void OnNewSessionClicked(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (DataContext is not MissionControlViewModel vm) return;
|
||||
var topLevel = TopLevel.GetTopLevel(this);
|
||||
if (topLevel is null) return;
|
||||
|
||||
var folders = await topLevel.StorageProvider.OpenFolderPickerAsync(new FolderPickerOpenOptions
|
||||
{
|
||||
Title = "Choose a directory",
|
||||
AllowMultiple = false,
|
||||
});
|
||||
if (folders.Count == 0) return;
|
||||
|
||||
await vm.OpenAdHocConPtySessionAsync(folders[0].Path.LocalPath);
|
||||
}
|
||||
|
||||
private void OnPaneDragOver(object? sender, DragEventArgs e)
|
||||
{
|
||||
var dt = e.DataTransfer;
|
||||
|
||||
@@ -107,14 +107,7 @@
|
||||
Label="{Binding DisplayTitle}"
|
||||
IsRunning="{Binding IsRunning}"
|
||||
IsDone="{Binding IsDone}"
|
||||
IsFailed="{Binding IsFailed}"
|
||||
IsComposerVisible="{Binding IsInteractiveLive}"
|
||||
ComposerText="{Binding ComposerDraft, Mode=TwoWay}"
|
||||
SubmitCommand="{Binding SubmitComposerCommand}"
|
||||
InterruptCommand="{Binding InterruptInteractiveCommand}"
|
||||
ComposerPlaceholder="{loc:Tr session.composer.placeholder}"
|
||||
QueuedMessages="{Binding QueuedMessages}"
|
||||
HasQueuedMessages="{Binding HasQueuedMessages}" />
|
||||
IsFailed="{Binding IsFailed}" />
|
||||
|
||||
</DockPanel>
|
||||
</Border>
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:vm="using:ClaudeDo.Ui.ViewModels.Modals"
|
||||
xmlns:settings="using:ClaudeDo.Ui.ViewModels.Modals.Settings"
|
||||
xmlns:agent="using:ClaudeDo.Ui.ViewModels.Agent"
|
||||
xmlns:services="using:ClaudeDo.Ui.Services"
|
||||
xmlns:ctl="using:ClaudeDo.Ui.Views.Controls"
|
||||
xmlns:loc="using:ClaudeDo.Ui.Localization"
|
||||
xmlns:locm="using:ClaudeDo.Localization"
|
||||
@@ -108,6 +110,19 @@
|
||||
<ComboBoxItem Content="{loc:Tr settings.general.weekdaySaturday}"/>
|
||||
</ComboBox>
|
||||
</StackPanel>
|
||||
<StackPanel Spacing="4">
|
||||
<TextBlock Classes="field-label" Text="{loc:Tr settings.general.sessionSkills}"/>
|
||||
<TextBlock Classes="meta" Opacity="0.6" TextWrapping="Wrap"
|
||||
Text="{loc:Tr settings.general.sessionSkillsHint}"/>
|
||||
<ItemsControl ItemsSource="{Binding General.SessionSkills}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="agent:SelectableSkillViewModel">
|
||||
<CheckBox IsChecked="{Binding IsSelected, Mode=TwoWay}" Content="{Binding Name}"
|
||||
ToolTip.Tip="{Binding Description}"/>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
</TabItem>
|
||||
@@ -354,6 +369,58 @@
|
||||
</ScrollViewer>
|
||||
</TabItem>
|
||||
|
||||
<TabItem Header="{loc:Tr settings.tabSkills}">
|
||||
<ScrollViewer>
|
||||
<StackPanel Spacing="12" Margin="0,8,0,0">
|
||||
<StackPanel Spacing="6">
|
||||
<TextBlock Classes="section-label" Text="{loc:Tr settings.skills.installSection}"/>
|
||||
<Grid ColumnDefinitions="*,Auto" ColumnSpacing="8">
|
||||
<TextBox Grid.Column="0" Text="{Binding SessionSkills.InstallUrl, Mode=TwoWay}"
|
||||
PlaceholderText="{loc:Tr settings.skills.installUrlPlaceholder}"/>
|
||||
<Button Grid.Column="1" Classes="btn" Content="{loc:Tr settings.skills.installButton}"
|
||||
Command="{Binding SessionSkills.InstallCommand}"
|
||||
IsEnabled="{Binding !SessionSkills.IsBusy}"/>
|
||||
</Grid>
|
||||
</StackPanel>
|
||||
|
||||
<Border BorderBrush="{DynamicResource LineBrush}" BorderThickness="0,1,0,0" Margin="0,2,0,0"/>
|
||||
|
||||
<StackPanel Spacing="6">
|
||||
<TextBlock Classes="section-label" Text="{loc:Tr settings.skills.installedSection}"/>
|
||||
<ItemsControl ItemsSource="{Binding SessionSkills.Skills}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="services:SessionSkillDto">
|
||||
<Border BorderBrush="{DynamicResource LineBrush}" BorderThickness="1"
|
||||
CornerRadius="6" Padding="10,8" Margin="0,0,0,8"
|
||||
Background="{DynamicResource DeepBrush}">
|
||||
<StackPanel Spacing="4">
|
||||
<Grid ColumnDefinitions="*,Auto,Auto">
|
||||
<TextBlock Grid.Column="0" Classes="title" Text="{Binding Name}"/>
|
||||
<Button Grid.Column="1" Classes="btn" Content="{loc:Tr settings.skills.updateButton}"
|
||||
Margin="0,0,6,0"
|
||||
Command="{Binding $parent[ItemsControl].((vm:SettingsModalViewModel)DataContext).SessionSkills.UpdateCommand}"
|
||||
CommandParameter="{Binding SourceUrl}"/>
|
||||
<Button Grid.Column="2" Classes="btn danger" Content="{loc:Tr settings.skills.removeButton}"
|
||||
Command="{Binding $parent[ItemsControl].((vm:SettingsModalViewModel)DataContext).SessionSkills.RemoveCommand}"
|
||||
CommandParameter="{Binding SourceUrl}"/>
|
||||
</Grid>
|
||||
<TextBlock Classes="meta" Text="{Binding Description}" TextWrapping="Wrap"
|
||||
IsVisible="{Binding Description, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
|
||||
<TextBlock Classes="meta" Opacity="0.6" Text="{Binding PinnedRef}"/>
|
||||
<TextBlock Classes="path-mono" Text="{Binding SourceUrl}" TextTrimming="PrefixCharacterEllipsis"/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</StackPanel>
|
||||
|
||||
<TextBlock Classes="meta" Text="{Binding SessionSkills.StatusMessage}"
|
||||
IsVisible="{Binding SessionSkills.StatusMessage, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
</TabItem>
|
||||
|
||||
</TabControl>
|
||||
</DockPanel>
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ Worker/
|
||||
Worktrees/ — WorktreeMaintenanceService
|
||||
Agents/ — AgentFileService, DefaultAgentSeeder
|
||||
Runner/ — TaskRunner + Claude CLI integration; TaskRunMcpService/TaskRunMcpContext/TaskRunTokenRegistry (in-task MCP wired during execution)
|
||||
Planning/ — PlanningSessionManager, PlanningChainCoordinator, PlanningMcpService, PlanningMergeOrchestrator, PlanningAggregator, PlanningSessionContext/PlanningTokenAuth/PlanningMcpContextAccessor, WindowsTerminalLauncher (ITerminalLauncher) — wt launcher for planning + interactive sessions
|
||||
Planning/ — PlanningSessionManager, PlanningChainCoordinator, PlanningMcpService, PlanningMergeOrchestrator, PlanningAggregator, PlanningSessionContext/PlanningTokenAuth/PlanningMcpContextAccessor, WindowsTerminalLauncher (ITerminalLauncher) — wt launcher for planning sessions + pick-up-in-terminal
|
||||
Refine/ — RefineRunner + RefinePrompt (hub `RefineTask`; broadcasts RefineStarted/RefineFinished)
|
||||
External/ — ExternalMcpService + sibling tool classes
|
||||
Config/ — WorkerConfig
|
||||
@@ -154,7 +154,8 @@ Each CLI invocation is recorded in the `task_runs` table via `TaskRunRepository`
|
||||
- Execution: `Ping`, `GetActive`, `RunNow`, `CancelTask`, `WakeQueue`, `ContinueTask`, `ResetTask`, `SetTaskStatus`, `RefineTask`
|
||||
- Review/merge: `ApproveReview(taskId, targetBranch) -> MergeResultDto` (childless task: merges its worktree then Done, conflict stays WaitingForReview; task with children: drives `PlanningMergeOrchestrator` to merge the whole unit), `ContinuePlanningMerge` / `AbortPlanningMerge` (resolve a unit-merge conflict), `PreviewMerge(taskId, targetBranch) -> MergePreviewDto` (non-destructive mergeability check), `RejectReviewToQueue`, `RejectReviewToIdle`, `CancelReview`, `MergeTask`, `GetMergeTargets`
|
||||
- Single-task conflict resolver (Layer C): `StartConflictMerge`, `GetMergeConflictDocuments` (segments), `WriteConflictResolution`, `ContinueConflictMerge`, `AbortConflictMerge` (service-level `TaskMergeService.ContinueMergeAsync`/`AbortMergeAsync` keep their names)
|
||||
- Planning sessions: `StartPlanningSession`, `ResumePlanningSession`, `DiscardPlanningSession`, `FinalizePlanningSession`, `QueuePlanningSubtasks`, `GetPendingDraftCount`, `OpenInteractiveTerminal`, `GetPlanningAggregate` (per-subtask diffs), `BuildPlanningIntegrationBranch` (combined diff)
|
||||
- Planning sessions: `StartPlanningSession`, `ResumePlanningSession`, `DiscardPlanningSession`, `FinalizePlanningSession`, `QueuePlanningSubtasks`, `GetPendingDraftCount`, `GetPlanningAggregate` (per-subtask diffs), `BuildPlanningIntegrationBranch` (combined diff)
|
||||
- Interactive sessions (embedded ConPTY, UI process): `ResumeTaskInTerminal` (pick-up-in-terminal), `GetInteractiveLaunchSpec`, `GetAdHocLaunchSpec`
|
||||
- Worktrees: `CleanupFinishedWorktrees`, `ResetAllWorktrees`, `GetWorktreesOverview`, `SetWorktreeState`, `ForceRemoveWorktree`
|
||||
- Agents/settings/lists: `GetAgents`, `RefreshAgents`, `RestoreDefaultAgents`, `GetAppSettings`, `UpdateAppSettings`, `UpdateList`, `UpdateListConfig`, `GetListConfig`, `UpdateTaskAgentSettings`
|
||||
- Reports/notes/prep: `GetWeekReport`, `GenerateWeekReport`, `GetDailyNotes`, `AddDailyNote`, `UpdateDailyNote`, `DeleteDailyNote`, `RunDailyPrepNow`, `ClearMyDay`, `GetLastPrepLog`, `ListPrimeSchedules`, `UpsertPrimeSchedule`, `DeletePrimeSchedule`
|
||||
|
||||
@@ -41,10 +41,6 @@ public sealed class WorkerConfig
|
||||
[JsonPropertyName("external_mcp_api_key")]
|
||||
public string? ExternalMcpApiKey { get; set; }
|
||||
|
||||
/// <summary>Interactive/streaming sessions idle longer than this are stopped by IdleSessionReaper. 0 disables reaping.</summary>
|
||||
[JsonPropertyName("interactive_idle_timeout_minutes")]
|
||||
public int InteractiveIdleTimeoutMinutes { get; set; } = 30;
|
||||
|
||||
[JsonPropertyName("online_inbox")]
|
||||
public OnlineInboxConfig OnlineInbox { get; set; } = new();
|
||||
|
||||
|
||||
+1
-1
@@ -58,7 +58,7 @@ public sealed class ConfigMcpTools
|
||||
_ = await _tasks.GetByIdAsync(taskId, cancellationToken)
|
||||
?? throw new InvalidOperationException($"Task {taskId} not found.");
|
||||
|
||||
await _tasks.UpdateAgentSettingsAsync(taskId, model.NullIfBlank(), systemPrompt.NullIfBlank(), agentPath.NullIfBlank(), maxTurns, cancellationToken);
|
||||
await _tasks.UpdateAgentSettingsAsync(taskId, model.NullIfBlank(), systemPrompt.NullIfBlank(), agentPath.NullIfBlank(), maxTurns, ct: cancellationToken);
|
||||
await _broadcaster.TaskUpdated(taskId);
|
||||
}
|
||||
|
||||
|
||||
@@ -77,16 +77,4 @@ public sealed class HubBroadcaster : IPrimeBroadcaster, IRefineBroadcaster
|
||||
Task IRefineBroadcaster.RefineStartedAsync(string taskId) => RefineStarted(taskId);
|
||||
Task IRefineBroadcaster.RefineFinishedAsync(string taskId, bool success, string? error) =>
|
||||
RefineFinished(taskId, success, error);
|
||||
|
||||
public Task InteractiveSessionStarted(string taskId) =>
|
||||
_hub.Clients.All.SendAsync("InteractiveSessionStarted", taskId);
|
||||
|
||||
public Task InteractiveSessionEnded(string taskId) =>
|
||||
_hub.Clients.All.SendAsync("InteractiveSessionEnded", taskId);
|
||||
|
||||
public Task InteractiveQueueChanged(string taskId, IReadOnlyList<string> pending) =>
|
||||
_hub.Clients.All.SendAsync("InteractiveQueueChanged", taskId, pending);
|
||||
|
||||
public Task InteractiveMessageSent(string taskId, string text) =>
|
||||
_hub.Clients.All.SendAsync("InteractiveMessageSent", taskId, text);
|
||||
}
|
||||
|
||||
@@ -15,8 +15,10 @@ using ClaudeDo.Worker.Queue;
|
||||
using ClaudeDo.Worker.Refine;
|
||||
using ClaudeDo.Worker.Report;
|
||||
using ClaudeDo.Worker.Report.Interfaces;
|
||||
using ClaudeDo.Worker.Skills;
|
||||
using ClaudeDo.Worker.State;
|
||||
using ClaudeDo.Worker.Worktrees;
|
||||
using System.Text.Json;
|
||||
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
@@ -37,7 +39,15 @@ public record AppSettingsDto(
|
||||
int WorktreeAutoCleanupDays,
|
||||
string? ReportExcludedPaths,
|
||||
int StandupWeekday,
|
||||
int DailyPrepMaxTasks);
|
||||
int DailyPrepMaxTasks,
|
||||
List<string>? SessionSkills = null);
|
||||
|
||||
public record SessionSkillDto(
|
||||
string Name,
|
||||
string Description,
|
||||
string SourceUrl,
|
||||
string PinnedRef,
|
||||
DateTimeOffset AddedAt);
|
||||
|
||||
public record WorktreeCleanupDto(int Removed);
|
||||
public record WorktreeResetDto(int Removed, int TasksAffected, bool Blocked, int RunningTasks);
|
||||
@@ -65,9 +75,9 @@ public record MergeConflictDocumentsDto(string TaskId, IReadOnlyList<ConflictDoc
|
||||
public record ConflictDocumentDto(string Path, bool IsBinary, IReadOnlyList<MergeSegmentDto> Segments);
|
||||
public record MergeSegmentDto(bool IsConflict, string Text, string Ours, string? Base, string Theirs);
|
||||
public record UpdateListDto(string Id, string Name, string? WorkingDir, string DefaultCommitType);
|
||||
public record UpdateListConfigDto(string ListId, string? Model, string? SystemPrompt, string? AgentPath, int? MaxTurns = null);
|
||||
public record UpdateTaskAgentSettingsDto(string TaskId, string? Model, string? SystemPrompt, string? AgentPath, int? MaxTurns = null);
|
||||
public record ListConfigDto(string? Model, string? SystemPrompt, string? AgentPath, int? MaxTurns = null);
|
||||
public record UpdateListConfigDto(string ListId, string? Model, string? SystemPrompt, string? AgentPath, int? MaxTurns = null, List<string>? SessionSkills = null);
|
||||
public record UpdateTaskAgentSettingsDto(string TaskId, string? Model, string? SystemPrompt, string? AgentPath, int? MaxTurns = null, List<string>? SessionSkills = null);
|
||||
public record ListConfigDto(string? Model, string? SystemPrompt, string? AgentPath, int? MaxTurns = null, List<string>? SessionSkills = null);
|
||||
public record SeedResultDto(int Copied, int Skipped);
|
||||
|
||||
public record OnlineInboxStateDto(
|
||||
@@ -117,8 +127,9 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
|
||||
private readonly OnlineInboxConfig _onlineInboxConfig;
|
||||
private readonly OnlineTokenStore _onlineTokenStore;
|
||||
private readonly Runner.PendingQuestionRegistry _pendingQuestions;
|
||||
private readonly InteractiveSessionService _interactive;
|
||||
private readonly LogRingBuffer? _logBuffer;
|
||||
private readonly ISessionSkillRegistry _skillRegistry;
|
||||
private readonly IInteractiveLaunchSpecService? _interactiveLaunchSpec;
|
||||
|
||||
public WorkerHub(
|
||||
QueueService queue,
|
||||
@@ -144,8 +155,9 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
|
||||
OnlineInboxConfig onlineInboxConfig,
|
||||
OnlineTokenStore onlineTokenStore,
|
||||
Runner.PendingQuestionRegistry pendingQuestions,
|
||||
InteractiveSessionService interactive,
|
||||
LogRingBuffer? logBuffer = null)
|
||||
ISessionSkillRegistry skillRegistry,
|
||||
LogRingBuffer? logBuffer = null,
|
||||
IInteractiveLaunchSpecService? interactiveLaunchSpec = null)
|
||||
{
|
||||
_queue = queue;
|
||||
_waker = waker;
|
||||
@@ -170,8 +182,22 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
|
||||
_onlineInboxConfig = onlineInboxConfig;
|
||||
_onlineTokenStore = onlineTokenStore;
|
||||
_pendingQuestions = pendingQuestions;
|
||||
_interactive = interactive;
|
||||
_skillRegistry = skillRegistry;
|
||||
_logBuffer = logBuffer;
|
||||
_interactiveLaunchSpec = interactiveLaunchSpec;
|
||||
}
|
||||
|
||||
// Persistence boundary for the session_skills JSON-array columns (task/list/global).
|
||||
// A null/empty selection persists as null so "inherit / none" stays clean, and the
|
||||
// shape matches TaskRunner.UnionSkillNames's expected JSON string array.
|
||||
private static string? SkillsToJson(List<string>? names) =>
|
||||
names is null or { Count: 0 } ? null : JsonSerializer.Serialize(names);
|
||||
|
||||
private static List<string>? SkillsFromJson(string? json)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(json)) return null;
|
||||
try { return JsonSerializer.Deserialize<List<string>>(json); }
|
||||
catch (JsonException) { return null; }
|
||||
}
|
||||
|
||||
/// <summary>Deliver the user's answer to a question a running task raised via AskUser.
|
||||
@@ -288,7 +314,8 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
|
||||
row.WorktreeAutoCleanupDays,
|
||||
row.ReportExcludedPaths,
|
||||
row.StandupWeekday,
|
||||
row.DailyPrepMaxTasks);
|
||||
row.DailyPrepMaxTasks,
|
||||
SkillsFromJson(row.SessionSkills));
|
||||
}
|
||||
|
||||
public async Task UpdateAppSettings(AppSettingsDto dto)
|
||||
@@ -310,9 +337,28 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
|
||||
ReportExcludedPaths = dto.ReportExcludedPaths,
|
||||
StandupWeekday = dto.StandupWeekday is >= 0 and <= 6 ? dto.StandupWeekday : (int)DayOfWeek.Wednesday,
|
||||
DailyPrepMaxTasks = dto.DailyPrepMaxTasks,
|
||||
SessionSkills = SkillsToJson(dto.SessionSkills),
|
||||
});
|
||||
}
|
||||
|
||||
public async Task<List<SessionSkillDto>> GetSessionSkills()
|
||||
{
|
||||
var rows = await _skillRegistry.ListAsync(Context.ConnectionAborted);
|
||||
return rows.Select(r => new SessionSkillDto(r.Name, r.Description, r.SourceUrl, r.PinnedRef, r.AddedAt)).ToList();
|
||||
}
|
||||
|
||||
public Task<List<string>> InstallSessionSkill(string url) => HubGuard(async () =>
|
||||
{
|
||||
var installed = await _skillRegistry.InstallAsync(url, Context.ConnectionAborted);
|
||||
return installed.ToList();
|
||||
});
|
||||
|
||||
public Task UpdateSessionSkill(string sourceUrl) => HubGuard(
|
||||
() => _skillRegistry.UpdateAsync(sourceUrl, Context.ConnectionAborted));
|
||||
|
||||
public Task RemoveSessionSkill(string sourceUrl) => HubGuard(
|
||||
() => _skillRegistry.RemoveAsync(sourceUrl, Context.ConnectionAborted));
|
||||
|
||||
public async Task<WorktreeCleanupDto> CleanupFinishedWorktrees(string? listId = null)
|
||||
{
|
||||
var result = await _wtMaintenance.CleanupFinishedAsync(listId, Context.ConnectionAborted);
|
||||
@@ -456,8 +502,9 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
|
||||
var model = dto.Model.NullIfBlank();
|
||||
var systemPrompt = dto.SystemPrompt.NullIfBlank();
|
||||
var agentPath = dto.AgentPath.NullIfBlank();
|
||||
var sessionSkills = SkillsToJson(dto.SessionSkills);
|
||||
|
||||
if (model is null && systemPrompt is null && agentPath is null && dto.MaxTurns is null)
|
||||
if (model is null && systemPrompt is null && agentPath is null && dto.MaxTurns is null && sessionSkills is null)
|
||||
{
|
||||
await repo.DeleteConfigAsync(dto.ListId);
|
||||
}
|
||||
@@ -470,6 +517,7 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
|
||||
SystemPrompt = systemPrompt,
|
||||
AgentPath = agentPath,
|
||||
MaxTurns = dto.MaxTurns,
|
||||
SessionSkills = sessionSkills,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -482,7 +530,7 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
|
||||
var repo = new ListRepository(ctx);
|
||||
var config = await repo.GetConfigAsync(listId);
|
||||
if (config is null) return null;
|
||||
return new ListConfigDto(config.Model, config.SystemPrompt, config.AgentPath, config.MaxTurns);
|
||||
return new ListConfigDto(config.Model, config.SystemPrompt, config.AgentPath, config.MaxTurns, SkillsFromJson(config.SessionSkills));
|
||||
}
|
||||
|
||||
public async Task SetTaskStatus(string taskId, string status)
|
||||
@@ -543,7 +591,8 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
|
||||
dto.Model.NullIfBlank(),
|
||||
dto.SystemPrompt.NullIfBlank(),
|
||||
dto.AgentPath.NullIfBlank(),
|
||||
dto.MaxTurns);
|
||||
dto.MaxTurns,
|
||||
SkillsToJson(dto.SessionSkills));
|
||||
|
||||
await _broadcaster.TaskUpdated(dto.TaskId);
|
||||
}
|
||||
@@ -572,20 +621,57 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
|
||||
return ctx;
|
||||
}
|
||||
|
||||
public Task OpenInteractiveTerminalAsync(string taskId) =>
|
||||
_interactive.StartAsync(taskId, Context.ConnectionAborted);
|
||||
// Picks up a task's Claude session in a real terminal window (--resume) so the user can
|
||||
// drive it by hand. Only for tasks the worker isn't actively running, with a persisted
|
||||
// session id and a live worktree.
|
||||
public Task ResumeTaskInTerminal(string taskId) => HubGuard(async () =>
|
||||
{
|
||||
await using var ctx = await _dbFactory.CreateDbContextAsync();
|
||||
|
||||
public Task SendInteractiveMessage(string taskId, string text) =>
|
||||
_interactive.SendAsync(taskId, text, Context.ConnectionAborted);
|
||||
var task = await new TaskRepository(ctx).GetByIdAsync(taskId, Context.ConnectionAborted)
|
||||
?? throw new KeyNotFoundException();
|
||||
if (task.Status is TaskStatus.Running or TaskStatus.Queued)
|
||||
throw new InvalidOperationException("Can't pick up a running or queued task — interrupt it first.");
|
||||
|
||||
public Task StopInteractiveSession(string taskId) =>
|
||||
_interactive.StopAsync(taskId, Context.ConnectionAborted);
|
||||
var run = await new TaskRunRepository(ctx).GetLatestByTaskIdAsync(taskId, Context.ConnectionAborted);
|
||||
if (run?.SessionId is not { Length: > 0 } sessionId)
|
||||
throw new InvalidOperationException("This task has no resumable Claude session yet.");
|
||||
|
||||
public Task InterruptInteractiveSession(string taskId) =>
|
||||
_interactive.InterruptAsync(taskId, Context.ConnectionAborted);
|
||||
var worktree = await new WorktreeRepository(ctx).GetByTaskIdAsync(taskId, Context.ConnectionAborted);
|
||||
if (worktree is null || worktree.State is not (WorktreeState.Active or WorktreeState.Kept))
|
||||
throw new InvalidOperationException("This task has no active worktree to resume in.");
|
||||
if (!Directory.Exists(worktree.Path))
|
||||
throw new InvalidOperationException("The task's worktree directory no longer exists.");
|
||||
|
||||
public Task RemoveQueuedInteractiveMessage(string taskId, string text) =>
|
||||
_interactive.RemoveQueuedAsync(taskId, text, Context.ConnectionAborted);
|
||||
try
|
||||
{
|
||||
await _launcher.LaunchResumeAsync(worktree.Path, sessionId, Context.ConnectionAborted);
|
||||
}
|
||||
catch (TerminalLaunchException ex)
|
||||
{
|
||||
throw new InvalidOperationException(ex.Message);
|
||||
}
|
||||
});
|
||||
|
||||
// Builds the launch spec an embedded ConPTY terminal (UI process) needs to open an
|
||||
// interactive Claude session in a task's worktree -- same worktree prep as an
|
||||
// autonomous run (session-skills seeding, run env vars), --resume if the task has a
|
||||
// persisted session or a fresh-start spec otherwise. Guards mirror ResumeTaskInTerminal.
|
||||
public Task<LaunchSpec> GetInteractiveLaunchSpec(string taskId) => HubGuard(() =>
|
||||
{
|
||||
if (_interactiveLaunchSpec is null)
|
||||
throw new InvalidOperationException("Interactive launch spec service is not configured.");
|
||||
return _interactiveLaunchSpec.BuildForTaskAsync(taskId, Context.ConnectionAborted);
|
||||
});
|
||||
|
||||
// Builds the launch spec for an ad-hoc interactive session in an arbitrary directory --
|
||||
// no task, no worktree, no session-skills seeding.
|
||||
public Task<LaunchSpec> GetAdHocLaunchSpec(string directory) => HubGuard(() =>
|
||||
{
|
||||
if (_interactiveLaunchSpec is null)
|
||||
throw new InvalidOperationException("Interactive launch spec service is not configured.");
|
||||
return _interactiveLaunchSpec.BuildForDirectoryAsync(directory, Context.ConnectionAborted);
|
||||
});
|
||||
|
||||
public async Task<DiscardPlanningOutcome> DiscardPlanningSessionAsync(string taskId, bool dequeueQueuedChildren = false)
|
||||
{
|
||||
|
||||
@@ -1,172 +0,0 @@
|
||||
using System.Text;
|
||||
using ClaudeDo.Data;
|
||||
using ClaudeDo.Data.Models;
|
||||
using ClaudeDo.Data.Repositories;
|
||||
using ClaudeDo.Worker.Config;
|
||||
using ClaudeDo.Worker.Hub;
|
||||
using ClaudeDo.Worker.Runner;
|
||||
using ClaudeDo.Worker.Runner.Interfaces;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ClaudeDo.Worker.Planning;
|
||||
|
||||
public sealed class InteractiveSessionService
|
||||
{
|
||||
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
|
||||
private readonly WorkerConfig _cfg;
|
||||
private readonly HubBroadcaster _broadcaster;
|
||||
private readonly LiveSessionRegistry _registry;
|
||||
private readonly ILoggerFactory _loggerFactory;
|
||||
|
||||
// Optional factory for tests. Signature: (onLine) -> (session, waitForExitTask).
|
||||
// The waitForExitTask completes when the underlying process has exited.
|
||||
private readonly Func<string, IReadOnlyList<string>, Func<string, Task>, (ILiveSession session, Task exitTask)>? _sessionFactory;
|
||||
|
||||
public InteractiveSessionService(
|
||||
IDbContextFactory<ClaudeDoDbContext> dbFactory,
|
||||
WorkerConfig cfg,
|
||||
HubBroadcaster broadcaster,
|
||||
LiveSessionRegistry registry,
|
||||
ILoggerFactory loggerFactory,
|
||||
Func<string, IReadOnlyList<string>, Func<string, Task>, (ILiveSession session, Task exitTask)>? sessionFactory = null)
|
||||
{
|
||||
_dbFactory = dbFactory;
|
||||
_cfg = cfg;
|
||||
_broadcaster = broadcaster;
|
||||
_registry = registry;
|
||||
_loggerFactory = loggerFactory;
|
||||
_sessionFactory = sessionFactory;
|
||||
}
|
||||
|
||||
public async Task StartAsync(string taskId, CancellationToken ct)
|
||||
{
|
||||
if (_registry.TryGet(taskId, out _))
|
||||
throw new InvalidOperationException("An interactive session is already running for this task.");
|
||||
|
||||
await using var ctx = _dbFactory.CreateDbContext();
|
||||
var tasks = new TaskRepository(ctx);
|
||||
var lists = new ListRepository(ctx);
|
||||
|
||||
var task = await tasks.GetByIdAsync(taskId, ct)
|
||||
?? throw new InvalidOperationException($"Task {taskId} not found.");
|
||||
var list = await lists.GetByIdAsync(task.ListId, ct)
|
||||
?? throw new InvalidOperationException($"List {task.ListId} not found.");
|
||||
|
||||
var workingDir = list.WorkingDir;
|
||||
if (string.IsNullOrWhiteSpace(workingDir) || !Directory.Exists(workingDir))
|
||||
throw new InvalidOperationException(
|
||||
$"List '{list.Name}' has no valid working directory configured.");
|
||||
|
||||
var seededPrompt = BuildInteractivePrompt(task);
|
||||
|
||||
var args = new[]
|
||||
{
|
||||
"-p",
|
||||
"--input-format", "stream-json",
|
||||
"--output-format", "stream-json",
|
||||
"--verbose",
|
||||
"--replay-user-messages",
|
||||
"--model", ModelRegistry.PlanningAlias,
|
||||
"--permission-mode", "auto",
|
||||
};
|
||||
|
||||
Func<string, Task> onLine = line =>
|
||||
{
|
||||
_registry.Touch(taskId);
|
||||
return _broadcaster.TaskMessage(taskId, "[stdout] " + line);
|
||||
};
|
||||
|
||||
ILiveSession session;
|
||||
Task exitTask;
|
||||
|
||||
if (_sessionFactory is not null)
|
||||
{
|
||||
// Factory is responsible for providing a ready-to-use session and its exit signal.
|
||||
(session, exitTask) = _sessionFactory(workingDir, args, onLine);
|
||||
}
|
||||
else
|
||||
{
|
||||
var transport = new ProcessClaudeStreamTransport(
|
||||
_cfg,
|
||||
_loggerFactory.CreateLogger<ProcessClaudeStreamTransport>());
|
||||
var streamingSession = new StreamingClaudeSession(
|
||||
transport,
|
||||
onLine,
|
||||
_loggerFactory.CreateLogger<StreamingClaudeSession>(),
|
||||
onQueueChanged: pending => _ = _broadcaster.InteractiveQueueChanged(taskId, pending),
|
||||
onUserMessageSent: text => _ = _broadcaster.InteractiveMessageSent(taskId, text));
|
||||
await streamingSession.StartAsync(args, workingDir, seededPrompt, ct);
|
||||
session = streamingSession;
|
||||
exitTask = transport.WaitForExitAsync();
|
||||
}
|
||||
_registry.Register(taskId, session);
|
||||
await _broadcaster.InteractiveSessionStarted(taskId);
|
||||
|
||||
var logger = _loggerFactory.CreateLogger<InteractiveSessionService>();
|
||||
_ = WatchExitAsync(taskId, exitTask, logger);
|
||||
}
|
||||
|
||||
private async Task WatchExitAsync(string taskId, Task exitTask, ILogger logger)
|
||||
{
|
||||
try
|
||||
{
|
||||
await exitTask;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogWarning(ex, "Interactive session exit watcher caught an exception for task {task_id}", taskId);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_registry.Unregister(taskId);
|
||||
try { await _broadcaster.InteractiveSessionEnded(taskId); }
|
||||
catch (Exception ex) { logger.LogWarning(ex, "InteractiveSessionEnded broadcast failed for task {task_id}", taskId); }
|
||||
}
|
||||
}
|
||||
|
||||
public async Task SendAsync(string taskId, string text, CancellationToken ct)
|
||||
{
|
||||
if (!_registry.TryGet(taskId, out var session))
|
||||
throw new InvalidOperationException("No interactive session is running for this task.");
|
||||
_registry.Touch(taskId);
|
||||
await session.SendUserMessageAsync(text, ct);
|
||||
}
|
||||
|
||||
public async Task RemoveQueuedAsync(string taskId, string text, CancellationToken ct)
|
||||
{
|
||||
if (_registry.TryGet(taskId, out var session))
|
||||
{
|
||||
_registry.Touch(taskId);
|
||||
await session.RemoveQueuedAsync(text, ct);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task InterruptAsync(string taskId, CancellationToken ct)
|
||||
{
|
||||
if (_registry.TryGet(taskId, out var session))
|
||||
{
|
||||
_registry.Touch(taskId);
|
||||
await session.InterruptAsync(ct);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task StopAsync(string taskId, CancellationToken ct)
|
||||
{
|
||||
// StopAsync removes from registry and kills the session.
|
||||
// The exit watcher will fire InteractiveSessionEnded once the process exits,
|
||||
// so we don't broadcast here — the watcher is the single authoritative source.
|
||||
await _registry.StopAsync(taskId);
|
||||
}
|
||||
|
||||
private static string BuildInteractivePrompt(TaskEntity task)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine($"# Task: {task.Title}");
|
||||
if (!string.IsNullOrWhiteSpace(task.Description))
|
||||
{
|
||||
sb.AppendLine();
|
||||
sb.AppendLine(task.Description);
|
||||
}
|
||||
return sb.ToString();
|
||||
}
|
||||
}
|
||||
@@ -2,11 +2,15 @@ namespace ClaudeDo.Worker.Planning;
|
||||
|
||||
// Launches the Claude CLI in a visible terminal for human-driven planning sessions.
|
||||
// Not used for headless task execution (that path is ClaudeProcess, prompt over stdin)
|
||||
// nor for interactive sessions (those use InteractiveSessionService + StreamingClaudeSession).
|
||||
// nor for embedded ConPTY interactive sessions (those use IInteractiveLaunchSpecService).
|
||||
public interface ITerminalLauncher
|
||||
{
|
||||
Task LaunchPlanningStartAsync(PlanningSessionStartContext ctx, CancellationToken cancellationToken);
|
||||
Task LaunchPlanningResumeAsync(PlanningSessionResumeContext ctx, CancellationToken cancellationToken);
|
||||
|
||||
// Resumes an arbitrary task's Claude session (--resume <id>) in a visible terminal so
|
||||
// the user can pick up the conversation by hand — the "pick up in terminal" action.
|
||||
Task LaunchResumeAsync(string workingDir, string claudeSessionId, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
public sealed class TerminalLaunchException : Exception
|
||||
|
||||
@@ -2,15 +2,21 @@
|
||||
// Thinking budget: env var MAX_THINKING_TOKENS=20000 (no CLI flag exists)
|
||||
// Allowed-tools: --allowedTools (camelCase), comma-separated tokens
|
||||
// System prompt: --append-system-prompt-file <path> (file form)
|
||||
// Extra read roots: --add-dir <dir...> (variadic)
|
||||
// Session ID: no pre-assign flag; resume with --resume <id>
|
||||
// Launch model: wt.exe -> powershell -> claude.exe (UseShellExecute=false).
|
||||
// wt.exe treats ';' as a tab/command delimiter in EVERY argument, regardless of
|
||||
// quoting, and there is no escape that survives (microsoft/terminal#13264). So the
|
||||
// free-text prompt must never appear on the wt command line. We hand it to PowerShell
|
||||
// out-of-band via an environment variable and reference it as $env:VAR — PowerShell
|
||||
// binds a variable's value as a single argument without re-tokenizing it, so the prompt
|
||||
// is robust to ';', '&', quotes, and newlines. All other (controlled) tokens are
|
||||
// single-quoted. No cmd shim: cmd re-parses %VAR% and would re-introduce the problem.
|
||||
// quoting (microsoft/terminal#13264), so nothing containing ';' may appear on the wt
|
||||
// command line. Every token we place there is a controlled constant or a filesystem
|
||||
// path under ~/.todo-app/sessions/<taskId> — none can contain ';' — and each is
|
||||
// single-quoted for PowerShell.
|
||||
//
|
||||
// The free-text task brief is NEVER passed as an argument. An interactive `claude`
|
||||
// session auto-submits its positional prompt, and a newline in that prompt ends the
|
||||
// first line — a multi-line brief was silently truncated at the first newline. So we
|
||||
// hand claude a single-line kickoff that points it at the brief FILE (written by
|
||||
// PlanningSessionManager) and expose that file's directory via --add-dir so the Read
|
||||
// tool can open it. The full multi-line brief reaches claude intact via the file.
|
||||
|
||||
using System.Diagnostics;
|
||||
using System.Text;
|
||||
@@ -27,10 +33,6 @@ public sealed class WindowsTerminalLauncher : ITerminalLauncher
|
||||
private const string AllowedTools = "mcp__claudedo__*,Read,Grep,Glob,WebFetch,WebSearch,Skill";
|
||||
private const string Model = ModelRegistry.PlanningAlias;
|
||||
|
||||
// Carries the free-text initial prompt to PowerShell out-of-band (never on the
|
||||
// command line) so wt.exe cannot split it on ';'.
|
||||
private const string PromptEnvVar = "CLAUDEDO_LAUNCH_PROMPT";
|
||||
|
||||
private readonly string _wtPath;
|
||||
private readonly string _claudePath;
|
||||
|
||||
@@ -53,22 +55,12 @@ public sealed class WindowsTerminalLauncher : ITerminalLauncher
|
||||
var resolvedWt = ResolveWtOrThrow();
|
||||
var resolvedClaude = ResolveClaudeOrThrow();
|
||||
|
||||
// Arg order: --allowedTools is variadic (space-separated). The positional prompt
|
||||
// must follow a single-value flag, or it will be swallowed —
|
||||
// --append-system-prompt-file serves as that buffer.
|
||||
var command = BuildPwshCommand(resolvedClaude, new[]
|
||||
{
|
||||
"--model", Model,
|
||||
"--permission-mode", "plan",
|
||||
"--allowedTools", AllowedTools,
|
||||
"--append-system-prompt-file", ctx.Files.SystemPromptPath,
|
||||
}, appendPrompt: true);
|
||||
var command = BuildPlanningStartCommand(resolvedClaude, ctx);
|
||||
|
||||
StartInWindowsTerminal(resolvedWt, ctx.WorkingDir, command, env =>
|
||||
{
|
||||
env["MAX_THINKING_TOKENS"] = "20000";
|
||||
env["CLAUDEDO_PLANNING_TOKEN"] = ctx.Token;
|
||||
env[PromptEnvVar] = File.ReadAllText(ctx.Files.InitialPromptPath);
|
||||
});
|
||||
|
||||
return Task.CompletedTask;
|
||||
@@ -86,7 +78,7 @@ public sealed class WindowsTerminalLauncher : ITerminalLauncher
|
||||
{
|
||||
"--permission-mode", "plan",
|
||||
"--resume", ctx.ClaudeSessionId,
|
||||
}, appendPrompt: false);
|
||||
});
|
||||
|
||||
StartInWindowsTerminal(resolvedWt, ctx.WorkingDir, command, env =>
|
||||
{
|
||||
@@ -96,26 +88,67 @@ public sealed class WindowsTerminalLauncher : ITerminalLauncher
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task LaunchResumeAsync(string workingDir, string claudeSessionId, CancellationToken cancellationToken)
|
||||
{
|
||||
if (!Directory.Exists(workingDir))
|
||||
throw new TerminalLaunchException($"Working directory does not exist: {workingDir}");
|
||||
|
||||
var resolvedWt = ResolveWtOrThrow();
|
||||
var resolvedClaude = ResolveClaudeOrThrow();
|
||||
|
||||
var command = BuildResumeCommand(resolvedClaude, claudeSessionId);
|
||||
|
||||
StartInWindowsTerminal(resolvedWt, workingDir, command, static _ => { });
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
// Resumes a session by id in default (interactive) permission mode: the user drives
|
||||
// tool approvals in the terminal, unlike planning which pins --permission-mode plan.
|
||||
internal static string BuildResumeCommand(string claudePath, string claudeSessionId) =>
|
||||
BuildPwshCommand(claudePath, BuildResumeArgs(claudeSessionId));
|
||||
|
||||
// The raw claude CLI args for a --resume launch, shared with InteractiveLaunchSpecService
|
||||
// (which needs the bare Exe/Args pair for a ConPTY host, not a wrapped pwsh command line).
|
||||
internal static IReadOnlyList<string> BuildResumeArgs(string claudeSessionId) =>
|
||||
new[] { "--resume", claudeSessionId };
|
||||
|
||||
// Builds the PowerShell command that launches an interactive planning session.
|
||||
// Arg order matters: variadic flags (--allowedTools, --add-dir) come first; the
|
||||
// single-line kickoff prompt is positional, so it must follow a single-value flag
|
||||
// (--append-system-prompt-file) or a variadic flag would swallow it.
|
||||
internal static string BuildPlanningStartCommand(string claudePath, PlanningSessionStartContext ctx)
|
||||
{
|
||||
var kickoff =
|
||||
$"Read the file {ctx.Files.InitialPromptPath} first. It contains the task you must plan. " +
|
||||
"After reading it, begin the planning session as your instructions describe.";
|
||||
|
||||
return BuildPwshCommand(claudePath, new[]
|
||||
{
|
||||
"--model", Model,
|
||||
"--permission-mode", "plan",
|
||||
"--allowedTools", AllowedTools,
|
||||
"--add-dir", ctx.Files.SessionDirectory,
|
||||
"--append-system-prompt-file", ctx.Files.SystemPromptPath,
|
||||
kickoff,
|
||||
});
|
||||
}
|
||||
|
||||
private string ResolveWtOrThrow() =>
|
||||
Resolve(_wtPath) ?? throw new TerminalLaunchException($"Windows Terminal not found: {_wtPath}");
|
||||
|
||||
private string ResolveClaudeOrThrow() =>
|
||||
Resolve(_claudePath) ?? throw new TerminalLaunchException($"claude executable not found: {_claudePath}");
|
||||
|
||||
// Builds the PowerShell command that invokes claude with the given (controlled)
|
||||
// arguments, optionally appending the free-text prompt from $env:CLAUDEDO_LAUNCH_PROMPT.
|
||||
// The prompt is referenced as a variable so PowerShell binds its value as ONE argument
|
||||
// (never re-tokenized). The `-replace '"','\"'` works around Windows PowerShell 5.1's
|
||||
// native-argument quirk, which otherwise strips embedded double-quotes before the child
|
||||
// sees them; all other characters (';', '&', spaces, backslashes, newlines) pass through.
|
||||
private static string BuildPwshCommand(string claudePath, IReadOnlyList<string> args, bool appendPrompt)
|
||||
// Builds the PowerShell command that invokes claude with the given tokens. Each token
|
||||
// is single-quoted, so ';', '&', spaces, quotes, and backslashes pass through to the
|
||||
// child process untouched. No cmd shim: cmd would re-parse the arguments.
|
||||
private static string BuildPwshCommand(string claudePath, IReadOnlyList<string> args)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
sb.Append("& ").Append(PwshQuote(claudePath));
|
||||
foreach (var a in args)
|
||||
sb.Append(' ').Append(PwshQuote(a));
|
||||
if (appendPrompt)
|
||||
sb.Append(" ($env:").Append(PromptEnvVar).Append(" -replace '\"','\\\"')");
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
@@ -146,7 +179,9 @@ public sealed class WindowsTerminalLauncher : ITerminalLauncher
|
||||
?? throw new TerminalLaunchException("Failed to start Windows Terminal process.");
|
||||
}
|
||||
|
||||
private static string? Resolve(string pathOrName)
|
||||
// Not private: reused by InteractiveLaunchSpecService to resolve the claude executable
|
||||
// for a ConPTY-hosted launch spec the same way a wt-launched terminal resolves it.
|
||||
internal static string? Resolve(string pathOrName)
|
||||
{
|
||||
if (File.Exists(pathOrName))
|
||||
return pathOrName;
|
||||
|
||||
@@ -18,6 +18,7 @@ using ClaudeDo.Worker.Prime;
|
||||
using ClaudeDo.Worker.Refine;
|
||||
using ClaudeDo.Worker.Report;
|
||||
using ClaudeDo.Worker.Report.Interfaces;
|
||||
using ClaudeDo.Worker.Skills;
|
||||
using ClaudeDo.Worker.Worktrees;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Serilog;
|
||||
@@ -73,6 +74,10 @@ builder.Services.AddSingleton<WorktreeManager>();
|
||||
builder.Services.AddSingleton<ClaudeArgsBuilder>();
|
||||
builder.Services.AddSingleton<TaskRunTokenRegistry>();
|
||||
builder.Services.AddSingleton<PendingQuestionRegistry>();
|
||||
builder.Services.AddSingleton<IRepoCloner, GitRepoCloner>();
|
||||
builder.Services.AddSingleton<ISessionSkillRegistry, SessionSkillRegistry>();
|
||||
builder.Services.AddSingleton<ISessionSkillSeeder, SessionSkillSeeder>();
|
||||
builder.Services.AddSingleton<IInteractiveLaunchSpecService, InteractiveLaunchSpecService>();
|
||||
builder.Services.AddSingleton<TaskRunner>();
|
||||
builder.Services.AddSingleton<WorktreeMaintenanceService>();
|
||||
builder.Services.AddSingleton<TaskResetService>();
|
||||
@@ -80,9 +85,6 @@ builder.Services.AddSingleton<TaskMergeService>();
|
||||
builder.Services.AddSingleton<PlanningAggregator>();
|
||||
builder.Services.AddSingleton<PlanningMergeOrchestrator>();
|
||||
builder.Services.AddSingleton<PlanningChainCoordinator>();
|
||||
builder.Services.AddSingleton<LiveSessionRegistry>();
|
||||
builder.Services.AddSingleton<InteractiveSessionService>();
|
||||
builder.Services.AddHostedService<IdleSessionReaper>();
|
||||
|
||||
// Queue dispatch primitives. QueueWaker holds the wake semaphore; the queue picker
|
||||
// performs atomic Queued→Running claim. Both injected into the state service so it
|
||||
|
||||
@@ -10,8 +10,12 @@ public sealed record ClaudeRunConfig(
|
||||
int? MaxTurns = null,
|
||||
string? PermissionMode = null,
|
||||
string? McpConfigPath = null,
|
||||
string? AllowedTools = null
|
||||
);
|
||||
string? AllowedTools = null,
|
||||
IReadOnlyList<string>? SkillNames = null
|
||||
)
|
||||
{
|
||||
public IReadOnlyList<string> SkillNames { get; init; } = SkillNames ?? Array.Empty<string>();
|
||||
}
|
||||
|
||||
public sealed class ClaudeArgsBuilder
|
||||
{
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
using ClaudeDo.Worker.Config;
|
||||
|
||||
namespace ClaudeDo.Worker.Runner;
|
||||
|
||||
// Stops interactive/streaming sessions that have gone idle. Interactive `claude` processes wait
|
||||
// on stdin and never exit on their own, and there is no client-disconnect teardown — so an
|
||||
// abandoned chat (UI closed, navigated away, crashed) keeps its claude.exe (+ conhost) alive for
|
||||
// the worker's entire lifetime. Under a long-running autostart worker these pile up (observed:
|
||||
// ~170 child processes). This sweep reaps the idle ones.
|
||||
public sealed class IdleSessionReaper : BackgroundService
|
||||
{
|
||||
private static readonly TimeSpan SweepInterval = TimeSpan.FromMinutes(5);
|
||||
|
||||
private readonly LiveSessionRegistry _registry;
|
||||
private readonly WorkerConfig _cfg;
|
||||
private readonly ILogger<IdleSessionReaper> _logger;
|
||||
|
||||
public IdleSessionReaper(LiveSessionRegistry registry, WorkerConfig cfg, ILogger<IdleSessionReaper> logger)
|
||||
{
|
||||
_registry = registry;
|
||||
_cfg = cfg;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
var idleTimeout = TimeSpan.FromMinutes(_cfg.InteractiveIdleTimeoutMinutes);
|
||||
if (idleTimeout <= TimeSpan.Zero)
|
||||
return; // reaper disabled
|
||||
|
||||
using var timer = new PeriodicTimer(SweepInterval);
|
||||
while (await timer.WaitForNextTickAsync(stoppingToken))
|
||||
{
|
||||
try
|
||||
{
|
||||
var reaped = await _registry.ReapIdleAsync(DateTime.UtcNow, idleTimeout);
|
||||
if (reaped.Count > 0)
|
||||
_logger.LogInformation(
|
||||
"Reaped {session_count} idle interactive session(s) after {idle_minutes} min: {task_ids}",
|
||||
reaped.Count, _cfg.InteractiveIdleTimeoutMinutes, string.Join(", ", reaped));
|
||||
}
|
||||
catch (OperationCanceledException) { throw; }
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Idle session reap sweep failed");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
using ClaudeDo.Data;
|
||||
using ClaudeDo.Data.Models;
|
||||
using ClaudeDo.Data.Repositories;
|
||||
using ClaudeDo.Worker.Config;
|
||||
using ClaudeDo.Worker.Planning;
|
||||
using ClaudeDo.Worker.Skills;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
||||
|
||||
namespace ClaudeDo.Worker.Runner;
|
||||
|
||||
// Builds the launch spec an embedded ConPTY terminal needs to open an interactive Claude
|
||||
// session in an existing task's worktree -- the SAME worktree prep as an autonomous run:
|
||||
// session-skills seeded onto disk (reuses ISessionSkillSeeder + TaskRunner.UnionSkillNames,
|
||||
// exactly like TaskRunner.RunAsync/ContinueAsync) and the same run environment variables
|
||||
// (reuses ClaudeProcess's MCP_TOOL_TIMEOUT). Exe/Args reuse WindowsTerminalLauncher's
|
||||
// --resume argument construction. Guards mirror WorkerHub.ResumeTaskInTerminal, except a
|
||||
// never-run task (no persisted SessionId) is not an error here -- it's a fresh-start spec.
|
||||
//
|
||||
// The run-scoped "claudedo_run" MCP server (AskUser/SuggestImprovement) that TaskRunner
|
||||
// wires per headless run is intentionally NOT reused: it exists so an unattended run can
|
||||
// ask the user a question, which is moot when the user is already driving the session by
|
||||
// hand. The always-on `mcp__claudedo__*` tools remain available via the globally-registered
|
||||
// MCP server (installer's RegisterMcpStep), exactly as they already are for a plain
|
||||
// `--resume` pickup in a Windows Terminal window.
|
||||
public sealed class InteractiveLaunchSpecService : IInteractiveLaunchSpecService
|
||||
{
|
||||
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
|
||||
private readonly ISessionSkillSeeder _skillSeeder;
|
||||
private readonly ISessionSkillRegistry _skillRegistry;
|
||||
private readonly WorktreeManager _wtManager;
|
||||
private readonly string _claudePath;
|
||||
|
||||
public InteractiveLaunchSpecService(
|
||||
IDbContextFactory<ClaudeDoDbContext> dbFactory,
|
||||
ISessionSkillSeeder skillSeeder,
|
||||
ISessionSkillRegistry skillRegistry,
|
||||
WorktreeManager wtManager,
|
||||
WorkerConfig cfg)
|
||||
{
|
||||
_dbFactory = dbFactory;
|
||||
_skillSeeder = skillSeeder;
|
||||
_skillRegistry = skillRegistry;
|
||||
_wtManager = wtManager;
|
||||
_claudePath = cfg.ClaudeBin;
|
||||
}
|
||||
|
||||
public async Task<LaunchSpec> BuildForTaskAsync(string taskId, CancellationToken ct)
|
||||
{
|
||||
await using var ctx = await _dbFactory.CreateDbContextAsync(ct);
|
||||
|
||||
var task = await new TaskRepository(ctx).GetByIdAsync(taskId, ct)
|
||||
?? throw new KeyNotFoundException();
|
||||
if (task.Status is TaskStatus.Running or TaskStatus.Queued)
|
||||
throw new InvalidOperationException("Can't open an interactive session for a running or queued task -- interrupt it first.");
|
||||
|
||||
var worktree = await new WorktreeRepository(ctx).GetByTaskIdAsync(taskId, ct);
|
||||
var isFreshWorktree = false;
|
||||
|
||||
if (worktree is null || worktree.State is not (WorktreeState.Active or WorktreeState.Kept))
|
||||
{
|
||||
// No usable worktree yet -- if the task's list points at a git repo, create one
|
||||
// on demand via the SAME mechanism an autonomous run uses (WorktreeManager.CreateAsync:
|
||||
// branch naming, base commit resolution, worktree-root strategy, DB registration).
|
||||
var list = await new ListRepository(ctx).GetByIdAsync(task.ListId, ct);
|
||||
if (list?.WorkingDir is null)
|
||||
throw new InvalidOperationException("This task has no working directory configured -- can't create a worktree.");
|
||||
|
||||
await _wtManager.CreateAsync(task, list, ct);
|
||||
worktree = await new WorktreeRepository(ctx).GetByTaskIdAsync(taskId, ct)
|
||||
?? throw new InvalidOperationException("Worktree creation did not persist a worktree row.");
|
||||
isFreshWorktree = true;
|
||||
}
|
||||
else if (!Directory.Exists(worktree.Path))
|
||||
{
|
||||
throw new InvalidOperationException("The task's worktree directory no longer exists.");
|
||||
}
|
||||
|
||||
var listConfig = await new ListRepository(ctx).GetConfigAsync(task.ListId, ct);
|
||||
var globalSettings = await new AppSettingsRepository(ctx).GetAsync(ct);
|
||||
// A brand-new worktree has no prior session to resume, regardless of any session
|
||||
// history the task accumulated before its previous worktree went away.
|
||||
var run = isFreshWorktree ? null : await new TaskRunRepository(ctx).GetLatestByTaskIdAsync(taskId, ct);
|
||||
|
||||
var requestedSkills = TaskRunner.UnionSkillNames(task.SessionSkills, listConfig?.SessionSkills, globalSettings.SessionSkills);
|
||||
var skillNames = await FilterToInstalledSkillsAsync(requestedSkills, ct);
|
||||
await _skillSeeder.SeedAsync(worktree.Path, skillNames, isWorktree: true, ct);
|
||||
|
||||
var resolvedClaude = WindowsTerminalLauncher.Resolve(_claudePath)
|
||||
?? throw new InvalidOperationException($"claude executable not found: {_claudePath}");
|
||||
|
||||
// Resume an existing session as-is; for a fresh session, seed the interactive TUI with
|
||||
// the task's prompt (title + description) as claude's positional prompt so it starts on
|
||||
// the task -- the user then supervises/answers rather than retyping it.
|
||||
var args = run?.SessionId is { Length: > 0 } sessionId
|
||||
? WindowsTerminalLauncher.BuildResumeArgs(sessionId)
|
||||
: BuildFreshPromptArgs(task);
|
||||
|
||||
// Same run environment variable ClaudeProcess sets for every headless run: the
|
||||
// AskUser MCP tool call caps at 60s unless raised, and lifting it is harmless for
|
||||
// every other tool.
|
||||
var env = new Dictionary<string, string>
|
||||
{
|
||||
["MCP_TOOL_TIMEOUT"] = "200000",
|
||||
};
|
||||
|
||||
return new LaunchSpec(worktree.Path, resolvedClaude, args, env);
|
||||
}
|
||||
|
||||
public Task<LaunchSpec> BuildForDirectoryAsync(string directory, CancellationToken ct)
|
||||
{
|
||||
if (!Directory.Exists(directory))
|
||||
throw new InvalidOperationException($"Directory does not exist: {directory}");
|
||||
|
||||
var resolvedClaude = WindowsTerminalLauncher.Resolve(_claudePath)
|
||||
?? throw new InvalidOperationException($"claude executable not found: {_claudePath}");
|
||||
|
||||
var env = new Dictionary<string, string>
|
||||
{
|
||||
["MCP_TOOL_TIMEOUT"] = "200000",
|
||||
};
|
||||
|
||||
return Task.FromResult(new LaunchSpec(directory, resolvedClaude, Array.Empty<string>(), env));
|
||||
}
|
||||
|
||||
// The positional prompt claude opens the interactive session on. Empty (no positional arg)
|
||||
// if the task has neither a title nor a description.
|
||||
private static IReadOnlyList<string> BuildFreshPromptArgs(TaskEntity task)
|
||||
{
|
||||
var title = task.Title?.Trim();
|
||||
var description = task.Description?.Trim();
|
||||
var prompt = (string.IsNullOrEmpty(title), string.IsNullOrEmpty(description)) switch
|
||||
{
|
||||
(false, false) => $"{title}\n\n{description}",
|
||||
(false, true) => title!,
|
||||
(true, false) => description!,
|
||||
_ => string.Empty,
|
||||
};
|
||||
return string.IsNullOrEmpty(prompt) ? Array.Empty<string>() : new[] { prompt };
|
||||
}
|
||||
|
||||
private async Task<IReadOnlyList<string>> FilterToInstalledSkillsAsync(IReadOnlyList<string> requested, CancellationToken ct)
|
||||
{
|
||||
if (requested.Count == 0) return requested;
|
||||
|
||||
var installed = (await _skillRegistry.ListAsync(ct))
|
||||
.Select(s => s.Name)
|
||||
.ToHashSet(StringComparer.Ordinal);
|
||||
return requested.Where(installed.Contains).ToList();
|
||||
}
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
namespace ClaudeDo.Worker.Runner.Interfaces;
|
||||
|
||||
public interface IClaudeStreamTransport : IAsyncDisposable
|
||||
{
|
||||
Task StartAsync(IReadOnlyList<string> args, string workingDirectory, CancellationToken ct);
|
||||
Task WriteLineAsync(string jsonLine, CancellationToken ct);
|
||||
event Func<string, Task>? LineReceived;
|
||||
event Func<string, Task>? StderrReceived;
|
||||
void Kill();
|
||||
Task WaitForExitAsync();
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
namespace ClaudeDo.Worker.Runner;
|
||||
|
||||
public interface IInteractiveLaunchSpecService
|
||||
{
|
||||
/// <summary>Builds a LaunchSpec for opening an interactive session in a task's worktree.
|
||||
/// Throws KeyNotFoundException if the task doesn't exist, InvalidOperationException
|
||||
/// if it's Running/Queued. If the task has no usable worktree yet, one is created on
|
||||
/// demand (same mechanism as an autonomous run) provided the task's list has a working
|
||||
/// directory pointing at a git repo -- otherwise throws InvalidOperationException. A task
|
||||
/// that has never run, or whose worktree was just created fresh, gets a fresh-start spec
|
||||
/// (no --resume); an existing worktree with a persisted SessionId gets --resume.</summary>
|
||||
Task<LaunchSpec> BuildForTaskAsync(string taskId, CancellationToken ct);
|
||||
|
||||
/// <summary>Builds a LaunchSpec for an ad-hoc interactive session in an arbitrary directory --
|
||||
/// no task, no worktree, no session-skills seeding. Throws InvalidOperationException if the
|
||||
/// directory doesn't exist.</summary>
|
||||
Task<LaunchSpec> BuildForDirectoryAsync(string directory, CancellationToken ct);
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
namespace ClaudeDo.Worker.Runner.Interfaces;
|
||||
|
||||
public interface ILiveSession : IAsyncDisposable
|
||||
{
|
||||
bool IsTurnInFlight { get; }
|
||||
Task SendUserMessageAsync(string text, CancellationToken ct);
|
||||
Task RemoveQueuedAsync(string text, CancellationToken ct);
|
||||
Task InterruptAsync(CancellationToken ct);
|
||||
Task StopAsync();
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
namespace ClaudeDo.Worker.Runner;
|
||||
|
||||
// What an embedded ConPTY terminal (UI process) needs to start a real `claude` process for
|
||||
// a task's worktree, with the same setup as an autonomous run (session-skills seeded onto
|
||||
// disk, the same run environment variables) plus the --resume-vs-fresh-start choice.
|
||||
public sealed record LaunchSpec(
|
||||
string Cwd,
|
||||
string Exe,
|
||||
IReadOnlyList<string> Args,
|
||||
IReadOnlyDictionary<string, string> Env);
|
||||
@@ -1,87 +0,0 @@
|
||||
using System.Collections.Concurrent;
|
||||
using ClaudeDo.Worker.Runner.Interfaces;
|
||||
|
||||
namespace ClaudeDo.Worker.Runner;
|
||||
|
||||
// Singleton in-memory registry of active live streaming sessions.
|
||||
// A session's lifetime matches its associated task run; dead entries are removed by the runner.
|
||||
//
|
||||
// Interactive (stream-json) sessions never exit on their own — they wait on stdin — and there is
|
||||
// no client-disconnect teardown, so an abandoned chat would otherwise keep its claude.exe alive
|
||||
// for the worker's whole lifetime. IdleSessionReaper periodically stops sessions that have seen
|
||||
// no activity past a timeout (see ReapIdleAsync); Touch() records that activity.
|
||||
public sealed class LiveSessionRegistry
|
||||
{
|
||||
private sealed class Entry
|
||||
{
|
||||
public required ILiveSession Session { get; init; }
|
||||
public long LastActivityTicksUtc;
|
||||
}
|
||||
|
||||
private readonly ConcurrentDictionary<string, Entry> _sessions = new();
|
||||
|
||||
public void Register(string taskId, ILiveSession session)
|
||||
{
|
||||
if (_sessions.TryRemove(taskId, out var existing))
|
||||
{
|
||||
// Best-effort stop of the replaced session; don't await to avoid deadlock risk.
|
||||
_ = existing.Session.StopAsync().ContinueWith(t =>
|
||||
{
|
||||
if (t.IsFaulted) { /* swallow — old session is already orphaned */ }
|
||||
}, TaskScheduler.Default);
|
||||
}
|
||||
_sessions[taskId] = new Entry { Session = session, LastActivityTicksUtc = DateTime.UtcNow.Ticks };
|
||||
}
|
||||
|
||||
// Marks a session as active so the idle reaper leaves it alone. Called on every user
|
||||
// message and every output line. No-op if the session is not (yet) registered.
|
||||
public void Touch(string taskId)
|
||||
{
|
||||
if (_sessions.TryGetValue(taskId, out var entry))
|
||||
Interlocked.Exchange(ref entry.LastActivityTicksUtc, DateTime.UtcNow.Ticks);
|
||||
}
|
||||
|
||||
public bool TryGet(string taskId, out ILiveSession session)
|
||||
{
|
||||
if (_sessions.TryGetValue(taskId, out var entry))
|
||||
{
|
||||
session = entry.Session;
|
||||
return true;
|
||||
}
|
||||
session = null!;
|
||||
return false;
|
||||
}
|
||||
|
||||
public void Unregister(string taskId) => _sessions.TryRemove(taskId, out _);
|
||||
|
||||
public async Task StopAsync(string taskId)
|
||||
{
|
||||
if (_sessions.TryRemove(taskId, out var entry))
|
||||
await entry.Session.StopAsync();
|
||||
}
|
||||
|
||||
// Stops and removes every session whose last activity is older than (nowUtc - idleTimeout),
|
||||
// skipping any session with a turn in flight (an agent that's actively working, even if quiet).
|
||||
// Returns the reaped task ids.
|
||||
public async Task<IReadOnlyList<string>> ReapIdleAsync(DateTime nowUtc, TimeSpan idleTimeout)
|
||||
{
|
||||
var cutoffTicks = (nowUtc - idleTimeout).Ticks;
|
||||
List<string>? reaped = null;
|
||||
|
||||
foreach (var kvp in _sessions)
|
||||
{
|
||||
var entry = kvp.Value;
|
||||
if (entry.Session.IsTurnInFlight) continue;
|
||||
if (Interlocked.Read(ref entry.LastActivityTicksUtc) > cutoffTicks) continue;
|
||||
|
||||
if (_sessions.TryRemove(kvp.Key, out var removed))
|
||||
{
|
||||
try { await removed.Session.StopAsync(); }
|
||||
catch { /* already dead — leave it removed */ }
|
||||
(reaped ??= new()).Add(kvp.Key);
|
||||
}
|
||||
}
|
||||
|
||||
return reaped ?? (IReadOnlyList<string>)Array.Empty<string>();
|
||||
}
|
||||
}
|
||||
@@ -1,111 +0,0 @@
|
||||
using System.Diagnostics;
|
||||
using System.Text;
|
||||
using ClaudeDo.Worker.Config;
|
||||
using ClaudeDo.Worker.Runner.Interfaces;
|
||||
|
||||
namespace ClaudeDo.Worker.Runner;
|
||||
|
||||
public sealed class ProcessClaudeStreamTransport : IClaudeStreamTransport
|
||||
{
|
||||
private readonly WorkerConfig _cfg;
|
||||
private readonly ILogger<ProcessClaudeStreamTransport> _logger;
|
||||
|
||||
private Process? _process;
|
||||
private Task? _stdoutTask;
|
||||
private Task? _stderrTask;
|
||||
|
||||
public event Func<string, Task>? LineReceived;
|
||||
public event Func<string, Task>? StderrReceived;
|
||||
|
||||
public ProcessClaudeStreamTransport(WorkerConfig cfg, ILogger<ProcessClaudeStreamTransport> logger)
|
||||
{
|
||||
_cfg = cfg;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public Task StartAsync(IReadOnlyList<string> args, string workingDirectory, CancellationToken ct)
|
||||
{
|
||||
var psi = new ProcessStartInfo
|
||||
{
|
||||
FileName = _cfg.ClaudeBin,
|
||||
WorkingDirectory = workingDirectory,
|
||||
RedirectStandardInput = true,
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true,
|
||||
StandardOutputEncoding = Encoding.UTF8,
|
||||
StandardErrorEncoding = Encoding.UTF8,
|
||||
};
|
||||
|
||||
foreach (var arg in args)
|
||||
psi.ArgumentList.Add(arg);
|
||||
|
||||
psi.Environment["MCP_TOOL_TIMEOUT"] = "200000";
|
||||
|
||||
_process = new Process { StartInfo = psi };
|
||||
_process.Start();
|
||||
ProcessJobObject.Assign(_process, _logger);
|
||||
|
||||
// Keep stdin open — turns are driven by WriteLineAsync calls.
|
||||
_process.StandardInput.AutoFlush = false;
|
||||
|
||||
_stdoutTask = Task.Run(async () =>
|
||||
{
|
||||
while (await _process.StandardOutput.ReadLineAsync() is { } line)
|
||||
{
|
||||
if (string.IsNullOrEmpty(line)) continue;
|
||||
var handler = LineReceived;
|
||||
if (handler is not null)
|
||||
{
|
||||
try { await handler(line); }
|
||||
catch (Exception ex) { _logger.LogWarning(ex, "LineReceived handler threw"); }
|
||||
}
|
||||
}
|
||||
}, CancellationToken.None);
|
||||
|
||||
_stderrTask = Task.Run(async () =>
|
||||
{
|
||||
while (await _process.StandardError.ReadLineAsync() is { } line)
|
||||
{
|
||||
if (string.IsNullOrEmpty(line)) continue;
|
||||
var handler = StderrReceived;
|
||||
if (handler is not null)
|
||||
{
|
||||
try { await handler(line); }
|
||||
catch (Exception ex) { _logger.LogWarning(ex, "StderrReceived handler threw"); }
|
||||
}
|
||||
}
|
||||
}, CancellationToken.None);
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public async Task WriteLineAsync(string jsonLine, CancellationToken ct)
|
||||
{
|
||||
if (_process is null) throw new InvalidOperationException("Transport not started.");
|
||||
await _process.StandardInput.WriteAsync((jsonLine + "\n").AsMemory(), ct);
|
||||
await _process.StandardInput.FlushAsync(ct);
|
||||
}
|
||||
|
||||
public void Kill()
|
||||
{
|
||||
try { _process?.Kill(entireProcessTree: true); }
|
||||
catch { /* already exited */ }
|
||||
}
|
||||
|
||||
public async Task WaitForExitAsync()
|
||||
{
|
||||
if (_process is not null)
|
||||
await _process.WaitForExitAsync(CancellationToken.None);
|
||||
if (_stdoutTask is not null) await _stdoutTask;
|
||||
if (_stderrTask is not null) await _stderrTask;
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
Kill();
|
||||
await WaitForExitAsync();
|
||||
_process?.Dispose();
|
||||
}
|
||||
}
|
||||
@@ -1,207 +0,0 @@
|
||||
using System.Text.Json;
|
||||
using ClaudeDo.Worker.Runner.Interfaces;
|
||||
|
||||
namespace ClaudeDo.Worker.Runner;
|
||||
|
||||
public sealed class StreamingClaudeSession : ILiveSession
|
||||
{
|
||||
private readonly IClaudeStreamTransport _transport;
|
||||
private readonly Func<string, Task> _onLine;
|
||||
private readonly ILogger<StreamingClaudeSession> _logger;
|
||||
private readonly Action<IReadOnlyList<string>>? _onQueueChanged;
|
||||
private readonly Action<string>? _onUserMessageSent;
|
||||
|
||||
private readonly SemaphoreSlim _sendLock = new(1, 1);
|
||||
private volatile bool _isTurnInFlight;
|
||||
private readonly Queue<string> _pending = new();
|
||||
|
||||
public bool IsTurnInFlight => _isTurnInFlight;
|
||||
|
||||
public StreamingClaudeSession(
|
||||
IClaudeStreamTransport transport,
|
||||
Func<string, Task> onLine,
|
||||
ILogger<StreamingClaudeSession> logger,
|
||||
Action<IReadOnlyList<string>>? onQueueChanged = null,
|
||||
Action<string>? onUserMessageSent = null)
|
||||
{
|
||||
_transport = transport;
|
||||
_onLine = onLine;
|
||||
_logger = logger;
|
||||
_onQueueChanged = onQueueChanged;
|
||||
_onUserMessageSent = onUserMessageSent;
|
||||
}
|
||||
|
||||
private IReadOnlyList<string> SnapshotPending() => _pending.ToArray();
|
||||
|
||||
public async Task StartAsync(
|
||||
IReadOnlyList<string> args,
|
||||
string workingDirectory,
|
||||
string firstPrompt,
|
||||
CancellationToken ct)
|
||||
{
|
||||
_transport.LineReceived += HandleLineAsync;
|
||||
await _transport.StartAsync(args, workingDirectory, ct);
|
||||
await SendTurnAsync(firstPrompt, ct);
|
||||
_onUserMessageSent?.Invoke(firstPrompt);
|
||||
}
|
||||
|
||||
private async Task HandleLineAsync(string line)
|
||||
{
|
||||
try { await _onLine(line); }
|
||||
catch (Exception ex) { _logger.LogWarning(ex, "onLine callback threw"); }
|
||||
|
||||
bool isResult;
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(line);
|
||||
isResult = doc.RootElement.TryGetProperty("type", out var typeProp)
|
||||
&& typeProp.GetString() == "result";
|
||||
}
|
||||
catch { isResult = false; }
|
||||
|
||||
if (!isResult) return;
|
||||
|
||||
// Turn ended — flush one queued message if available.
|
||||
string? flushedText = null;
|
||||
IReadOnlyList<string>? remainingSnapshot = null;
|
||||
|
||||
await _sendLock.WaitAsync();
|
||||
try
|
||||
{
|
||||
_isTurnInFlight = false;
|
||||
if (_pending.Count > 0)
|
||||
{
|
||||
flushedText = _pending.Dequeue();
|
||||
remainingSnapshot = SnapshotPending();
|
||||
await SendTurnAsync(flushedText, CancellationToken.None);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
_sendLock.Release();
|
||||
}
|
||||
|
||||
if (flushedText is not null)
|
||||
{
|
||||
_onQueueChanged?.Invoke(remainingSnapshot!);
|
||||
_onUserMessageSent?.Invoke(flushedText);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task SendUserMessageAsync(string text, CancellationToken ct)
|
||||
{
|
||||
bool enqueued = false;
|
||||
IReadOnlyList<string>? snapshot = null;
|
||||
|
||||
await _sendLock.WaitAsync(ct);
|
||||
try
|
||||
{
|
||||
if (_isTurnInFlight || _pending.Count > 0)
|
||||
{
|
||||
_pending.Enqueue(text);
|
||||
snapshot = SnapshotPending();
|
||||
enqueued = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
await SendTurnAsync(text, ct);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
_sendLock.Release();
|
||||
}
|
||||
|
||||
if (enqueued)
|
||||
_onQueueChanged?.Invoke(snapshot!);
|
||||
else
|
||||
_onUserMessageSent?.Invoke(text);
|
||||
}
|
||||
|
||||
public async Task RemoveQueuedAsync(string text, CancellationToken ct)
|
||||
{
|
||||
IReadOnlyList<string>? snapshot = null;
|
||||
|
||||
await _sendLock.WaitAsync(ct);
|
||||
try
|
||||
{
|
||||
if (_pending.Count == 0) return;
|
||||
|
||||
var list = _pending.ToList();
|
||||
var idx = list.IndexOf(text);
|
||||
if (idx < 0) return;
|
||||
|
||||
list.RemoveAt(idx);
|
||||
_pending.Clear();
|
||||
foreach (var item in list)
|
||||
_pending.Enqueue(item);
|
||||
|
||||
snapshot = SnapshotPending();
|
||||
}
|
||||
finally
|
||||
{
|
||||
_sendLock.Release();
|
||||
}
|
||||
|
||||
if (snapshot is not null)
|
||||
_onQueueChanged?.Invoke(snapshot);
|
||||
}
|
||||
|
||||
public async Task InterruptAsync(CancellationToken ct)
|
||||
{
|
||||
await _sendLock.WaitAsync(ct);
|
||||
try
|
||||
{
|
||||
if (!_isTurnInFlight) return;
|
||||
|
||||
var requestId = Guid.NewGuid().ToString();
|
||||
var payload = JsonSerializer.Serialize(new
|
||||
{
|
||||
type = "control_request",
|
||||
request_id = requestId,
|
||||
request = new { subtype = "interrupt" }
|
||||
});
|
||||
|
||||
try { await _transport.WriteLineAsync(payload, ct); }
|
||||
catch (Exception ex) { _logger.LogWarning(ex, "Failed to write interrupt control_request; degrading gracefully."); }
|
||||
}
|
||||
finally
|
||||
{
|
||||
_sendLock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task SendTurnAsync(string text, CancellationToken ct)
|
||||
{
|
||||
_isTurnInFlight = true;
|
||||
|
||||
var payload = JsonSerializer.Serialize(new
|
||||
{
|
||||
type = "user",
|
||||
message = new
|
||||
{
|
||||
role = "user",
|
||||
content = new[]
|
||||
{
|
||||
new { type = "text", text }
|
||||
}
|
||||
},
|
||||
parent_tool_use_id = (string?)null
|
||||
});
|
||||
|
||||
await _transport.WriteLineAsync(payload, ct);
|
||||
}
|
||||
|
||||
public async Task StopAsync()
|
||||
{
|
||||
_transport.Kill();
|
||||
await _transport.WaitForExitAsync();
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
await StopAsync();
|
||||
await _transport.DisposeAsync();
|
||||
_sendLock.Dispose();
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ using ClaudeDo.Data.Models;
|
||||
using ClaudeDo.Data.Repositories;
|
||||
using ClaudeDo.Worker.Config;
|
||||
using ClaudeDo.Worker.Hub;
|
||||
using ClaudeDo.Worker.Skills;
|
||||
using ClaudeDo.Worker.State;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
||||
@@ -22,6 +23,7 @@ public sealed class TaskRunner
|
||||
private readonly ITaskStateService _state;
|
||||
private readonly TaskRunTokenRegistry _tokens;
|
||||
private readonly AttachmentStore _attachments;
|
||||
private readonly ISessionSkillSeeder _skillSeeder;
|
||||
|
||||
public TaskRunner(
|
||||
IClaudeProcess claude,
|
||||
@@ -33,7 +35,8 @@ public sealed class TaskRunner
|
||||
ILogger<TaskRunner> logger,
|
||||
ITaskStateService state,
|
||||
TaskRunTokenRegistry tokens,
|
||||
AttachmentStore attachments)
|
||||
AttachmentStore attachments,
|
||||
ISessionSkillSeeder skillSeeder)
|
||||
{
|
||||
_claude = claude;
|
||||
_dbFactory = dbFactory;
|
||||
@@ -45,6 +48,7 @@ public sealed class TaskRunner
|
||||
_state = state;
|
||||
_tokens = tokens;
|
||||
_attachments = attachments;
|
||||
_skillSeeder = skillSeeder;
|
||||
}
|
||||
|
||||
public async Task RunAsync(TaskEntity task, string slot, CancellationToken ct, bool alreadyClaimed = false)
|
||||
@@ -122,6 +126,8 @@ public sealed class TaskRunner
|
||||
}
|
||||
await _broadcaster.TaskStarted(slot, task.Id, now);
|
||||
|
||||
await _skillSeeder.SeedAsync(runDir, resolvedConfig.SkillNames, wtCtx is not null, ct);
|
||||
|
||||
// Build prompt: title + description + only the OPEN sub-tasks (resolved ones are dropped).
|
||||
var prompt = TaskPromptComposer.Compose(
|
||||
task.Title, task.Description,
|
||||
@@ -242,6 +248,8 @@ public sealed class TaskRunner
|
||||
}
|
||||
await _broadcaster.TaskStarted(slot, taskId, now);
|
||||
|
||||
await _skillSeeder.SeedAsync(runDir, resolvedConfig.SkillNames, wtCtx is not null, ct);
|
||||
|
||||
try
|
||||
{
|
||||
var nextRunNumber = lastRun.RunNumber + 1;
|
||||
@@ -505,13 +513,70 @@ public sealed class TaskRunner
|
||||
var instructions = MergeInstructions(
|
||||
systemFile, improvementPrompt, global.DefaultClaudeInstructions, listConfig?.SystemPrompt, task.SystemPrompt);
|
||||
|
||||
var requestedSkills = UnionSkillNames(task.SessionSkills, listConfig?.SessionSkills, global.SessionSkills);
|
||||
var skillNames = await FilterToInstalledSkillsAsync(task.Id, requestedSkills, ct);
|
||||
|
||||
return new ClaudeRunConfig(
|
||||
Model: task.Model ?? listConfig?.Model ?? global.DefaultModel,
|
||||
SystemPrompt: string.IsNullOrWhiteSpace(instructions) ? null : instructions,
|
||||
AgentPath: task.AgentPath ?? listConfig?.AgentPath,
|
||||
ResumeSessionId: resumeSessionId,
|
||||
MaxTurns: ResolveMaxTurns(task.MaxTurns, listConfig?.MaxTurns, global.DefaultMaxTurns),
|
||||
PermissionMode: global.DefaultPermissionMode);
|
||||
PermissionMode: global.DefaultPermissionMode,
|
||||
SkillNames: skillNames);
|
||||
}
|
||||
|
||||
private async Task<IReadOnlyList<string>> FilterToInstalledSkillsAsync(
|
||||
string taskId, IReadOnlyList<string> requestedSkills, CancellationToken ct)
|
||||
{
|
||||
if (requestedSkills.Count == 0) return requestedSkills;
|
||||
|
||||
List<SessionSkillEntity> installed;
|
||||
using (var ctx = _dbFactory.CreateDbContext())
|
||||
{
|
||||
var skillRepo = new SessionSkillRepository(ctx);
|
||||
installed = (await skillRepo.ListAsync(ct)).ToList();
|
||||
}
|
||||
|
||||
var installedNames = installed.Select(s => s.Name).ToHashSet(StringComparer.Ordinal);
|
||||
var resolved = requestedSkills.Where(installedNames.Contains).ToList();
|
||||
var dropped = requestedSkills.Where(n => !installedNames.Contains(n)).ToList();
|
||||
if (dropped.Count > 0)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Task {TaskId}: dropped unknown session skill(s) not found in registry: {SkillNames}",
|
||||
taskId, string.Join(", ", dropped));
|
||||
}
|
||||
|
||||
return resolved;
|
||||
}
|
||||
|
||||
internal static IReadOnlyList<string> UnionSkillNames(params string?[] jsonArrays)
|
||||
{
|
||||
var names = new List<string>();
|
||||
var seen = new HashSet<string>(StringComparer.Ordinal);
|
||||
foreach (var json in jsonArrays)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(json)) continue;
|
||||
|
||||
string[]? parsed;
|
||||
try
|
||||
{
|
||||
parsed = JsonSerializer.Deserialize<string[]>(json);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if (parsed is null) continue;
|
||||
|
||||
foreach (var name in parsed)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(name) && seen.Add(name))
|
||||
names.Add(name);
|
||||
}
|
||||
}
|
||||
return names;
|
||||
}
|
||||
|
||||
internal static int? ResolveMaxTurns(int? taskTurns, int? listTurns, int globalDefault)
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
using System.Diagnostics;
|
||||
using System.Text;
|
||||
using ClaudeDo.Data.Git;
|
||||
|
||||
namespace ClaudeDo.Worker.Skills;
|
||||
|
||||
public sealed class GitRepoCloner : IRepoCloner
|
||||
{
|
||||
private readonly GitService _git;
|
||||
|
||||
public GitRepoCloner(GitService git) => _git = git;
|
||||
|
||||
public async Task<ClonedRepo> CloneAsync(string url, string destDir, CancellationToken ct)
|
||||
{
|
||||
var parentDir = Path.GetDirectoryName(destDir);
|
||||
if (!string.IsNullOrEmpty(parentDir))
|
||||
Directory.CreateDirectory(parentDir);
|
||||
|
||||
var (exitCode, stderr) = await RunGitAsync(["clone", "--depth", "1", url, destDir], ct);
|
||||
if (exitCode != 0)
|
||||
throw new InvalidOperationException($"git clone '{url}' failed (exit {exitCode}): {stderr}");
|
||||
|
||||
var pinnedRef = await _git.RevParseHeadAsync(destDir, ct);
|
||||
return new ClonedRepo(destDir, pinnedRef);
|
||||
}
|
||||
|
||||
private static async Task<(int ExitCode, string Stderr)> RunGitAsync(IEnumerable<string> args, CancellationToken ct)
|
||||
{
|
||||
var psi = new ProcessStartInfo
|
||||
{
|
||||
FileName = "git",
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true,
|
||||
StandardOutputEncoding = Encoding.UTF8,
|
||||
StandardErrorEncoding = Encoding.UTF8,
|
||||
};
|
||||
foreach (var a in args) psi.ArgumentList.Add(a);
|
||||
|
||||
using var proc = new Process { StartInfo = psi };
|
||||
proc.Start();
|
||||
|
||||
await using var ctr = ct.Register(() =>
|
||||
{
|
||||
try { proc.Kill(entireProcessTree: true); }
|
||||
catch { /* already exited */ }
|
||||
});
|
||||
|
||||
var stdoutTask = proc.StandardOutput.ReadToEndAsync();
|
||||
var stderrTask = proc.StandardError.ReadToEndAsync();
|
||||
|
||||
await proc.WaitForExitAsync(CancellationToken.None);
|
||||
await stdoutTask;
|
||||
var stderr = await stderrTask;
|
||||
|
||||
ct.ThrowIfCancellationRequested();
|
||||
|
||||
return (proc.ExitCode, stderr.TrimEnd());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace ClaudeDo.Worker.Skills;
|
||||
|
||||
public sealed record ClonedRepo(string LocalPath, string PinnedRef);
|
||||
|
||||
public interface IRepoCloner
|
||||
{
|
||||
Task<ClonedRepo> CloneAsync(string url, string destDir, CancellationToken ct);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
using ClaudeDo.Data.Models;
|
||||
|
||||
namespace ClaudeDo.Worker.Skills;
|
||||
|
||||
public interface ISessionSkillRegistry
|
||||
{
|
||||
Task<IReadOnlyList<string>> InstallAsync(string url, CancellationToken ct);
|
||||
Task UpdateAsync(string sourceUrl, CancellationToken ct);
|
||||
Task RemoveAsync(string sourceUrl, CancellationToken ct);
|
||||
Task<IReadOnlyList<SessionSkillEntity>> ListAsync(CancellationToken ct);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace ClaudeDo.Worker.Skills;
|
||||
|
||||
public interface ISessionSkillSeeder
|
||||
{
|
||||
Task SeedAsync(string workingDir, IReadOnlyList<string> skillNames, bool isWorktree, CancellationToken ct);
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
using ClaudeDo.Data;
|
||||
using ClaudeDo.Data.Models;
|
||||
using ClaudeDo.Data.Repositories;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ClaudeDo.Worker.Skills;
|
||||
|
||||
public sealed class SessionSkillRegistry : ISessionSkillRegistry
|
||||
{
|
||||
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
|
||||
private readonly IRepoCloner _cloner;
|
||||
private readonly string _skillsRoot;
|
||||
|
||||
public SessionSkillRegistry(
|
||||
IDbContextFactory<ClaudeDoDbContext> dbFactory,
|
||||
IRepoCloner cloner,
|
||||
string? skillsRoot = null)
|
||||
{
|
||||
_dbFactory = dbFactory;
|
||||
_cloner = cloner;
|
||||
_skillsRoot = skillsRoot ?? Path.Combine(Paths.AppDataRoot(), "session-skills");
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<string>> InstallAsync(string url, CancellationToken ct)
|
||||
{
|
||||
var tempDir = Path.Combine(Path.GetTempPath(), $"claudedo_skill_clone_{Guid.NewGuid():N}");
|
||||
try
|
||||
{
|
||||
var cloned = await _cloner.CloneAsync(url, tempDir, ct);
|
||||
var discovered = DiscoverSkills(cloned.LocalPath);
|
||||
|
||||
await using var ctx = await _dbFactory.CreateDbContextAsync(ct);
|
||||
var repo = new SessionSkillRepository(ctx);
|
||||
|
||||
foreach (var skill in discovered)
|
||||
{
|
||||
var existing = await repo.GetAsync(skill.Name, ct);
|
||||
if (existing is not null && existing.SourceUrl != url)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Skill '{skill.Name}' is already installed from a different source ('{existing.SourceUrl}'). " +
|
||||
"Remove it before installing from a new source.");
|
||||
}
|
||||
}
|
||||
|
||||
var installedNames = new List<string>();
|
||||
foreach (var skill in discovered)
|
||||
{
|
||||
InstallSkillFiles(skill, url, cloned.PinnedRef, out var entity);
|
||||
await repo.UpsertAsync(entity, ct);
|
||||
installedNames.Add(skill.Name);
|
||||
}
|
||||
|
||||
return installedNames;
|
||||
}
|
||||
finally
|
||||
{
|
||||
TryDeleteDirectory(tempDir);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task UpdateAsync(string sourceUrl, CancellationToken ct)
|
||||
{
|
||||
var tempDir = Path.Combine(Path.GetTempPath(), $"claudedo_skill_clone_{Guid.NewGuid():N}");
|
||||
try
|
||||
{
|
||||
var cloned = await _cloner.CloneAsync(sourceUrl, tempDir, ct);
|
||||
var discovered = DiscoverSkills(cloned.LocalPath);
|
||||
var discoveredNames = discovered.Select(s => s.Name).ToHashSet();
|
||||
|
||||
await using var ctx = await _dbFactory.CreateDbContextAsync(ct);
|
||||
var repo = new SessionSkillRepository(ctx);
|
||||
|
||||
var existingRows = await repo.ListBySourceAsync(sourceUrl, ct);
|
||||
foreach (var stale in existingRows.Where(r => !discoveredNames.Contains(r.Name)))
|
||||
{
|
||||
TryDeleteDirectory(Path.Combine(_skillsRoot, stale.Name));
|
||||
await repo.DeleteAsync(stale.Name, ct);
|
||||
}
|
||||
|
||||
foreach (var skill in discovered)
|
||||
{
|
||||
InstallSkillFiles(skill, sourceUrl, cloned.PinnedRef, out var entity);
|
||||
await repo.UpsertAsync(entity, ct);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
TryDeleteDirectory(tempDir);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task RemoveAsync(string sourceUrl, CancellationToken ct)
|
||||
{
|
||||
await using var ctx = await _dbFactory.CreateDbContextAsync(ct);
|
||||
var repo = new SessionSkillRepository(ctx);
|
||||
|
||||
var rows = await repo.ListBySourceAsync(sourceUrl, ct);
|
||||
foreach (var row in rows)
|
||||
TryDeleteDirectory(Path.Combine(_skillsRoot, row.Name));
|
||||
|
||||
await repo.DeleteBySourceAsync(sourceUrl, ct);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<SessionSkillEntity>> ListAsync(CancellationToken ct)
|
||||
{
|
||||
await using var ctx = await _dbFactory.CreateDbContextAsync(ct);
|
||||
return await new SessionSkillRepository(ctx).ListAsync(ct);
|
||||
}
|
||||
|
||||
private sealed record DiscoveredSkill(string Name, string Description, string SourceDir, string Subpath);
|
||||
|
||||
private static List<DiscoveredSkill> DiscoverSkills(string cloneRoot)
|
||||
{
|
||||
var skillsDir = Path.Combine(cloneRoot, "skills");
|
||||
var discovered = new List<DiscoveredSkill>();
|
||||
|
||||
if (Directory.Exists(skillsDir))
|
||||
{
|
||||
foreach (var dir in Directory.EnumerateDirectories(skillsDir).OrderBy(d => d, StringComparer.Ordinal))
|
||||
{
|
||||
var skillMd = Path.Combine(dir, "SKILL.md");
|
||||
if (!File.Exists(skillMd)) continue;
|
||||
|
||||
var frontmatter = SkillFrontmatter.Parse(skillMd);
|
||||
var dirName = Path.GetFileName(dir);
|
||||
discovered.Add(new DiscoveredSkill(frontmatter.Name, frontmatter.Description, dir, $"skills/{dirName}"));
|
||||
}
|
||||
|
||||
if (discovered.Count > 0)
|
||||
return discovered;
|
||||
}
|
||||
|
||||
var rootSkillMd = Path.Combine(cloneRoot, "SKILL.md");
|
||||
if (File.Exists(rootSkillMd))
|
||||
{
|
||||
var frontmatter = SkillFrontmatter.Parse(rootSkillMd);
|
||||
discovered.Add(new DiscoveredSkill(frontmatter.Name, frontmatter.Description, cloneRoot, "."));
|
||||
return discovered;
|
||||
}
|
||||
|
||||
throw new InvalidOperationException(
|
||||
"No skills found: expected either a top-level 'skills/<name>/SKILL.md' layout or a root 'SKILL.md'.");
|
||||
}
|
||||
|
||||
private void InstallSkillFiles(DiscoveredSkill skill, string sourceUrl, string pinnedRef, out SessionSkillEntity entity)
|
||||
{
|
||||
var destDir = Path.Combine(_skillsRoot, skill.Name);
|
||||
if (Directory.Exists(destDir))
|
||||
Directory.Delete(destDir, recursive: true);
|
||||
Directory.CreateDirectory(destDir);
|
||||
|
||||
CopyFlat(skill.SourceDir, destDir);
|
||||
|
||||
entity = new SessionSkillEntity
|
||||
{
|
||||
Name = skill.Name,
|
||||
SourceUrl = sourceUrl,
|
||||
PinnedRef = pinnedRef,
|
||||
Subpath = skill.Subpath,
|
||||
Description = skill.Description,
|
||||
AddedAt = DateTimeOffset.UtcNow,
|
||||
};
|
||||
}
|
||||
|
||||
private static void CopyFlat(string sourceDir, string destDir)
|
||||
{
|
||||
foreach (var file in Directory.EnumerateFiles(sourceDir, "*", SearchOption.AllDirectories))
|
||||
{
|
||||
var relative = Path.GetRelativePath(sourceDir, file);
|
||||
if (relative.Split(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar)
|
||||
.Contains(".git", StringComparer.Ordinal))
|
||||
continue;
|
||||
|
||||
var destPath = Path.Combine(destDir, relative);
|
||||
var destParent = Path.GetDirectoryName(destPath);
|
||||
if (!string.IsNullOrEmpty(destParent))
|
||||
Directory.CreateDirectory(destParent);
|
||||
File.Copy(file, destPath, overwrite: true);
|
||||
}
|
||||
}
|
||||
|
||||
private static void TryDeleteDirectory(string dir)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (Directory.Exists(dir))
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
catch { /* best effort */ }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
using System.Diagnostics;
|
||||
using System.Text;
|
||||
using ClaudeDo.Data;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace ClaudeDo.Worker.Skills;
|
||||
|
||||
public sealed class SessionSkillSeeder : ISessionSkillSeeder
|
||||
{
|
||||
private readonly string _skillsRoot;
|
||||
private readonly ILogger<SessionSkillSeeder> _logger;
|
||||
|
||||
public SessionSkillSeeder(ILogger<SessionSkillSeeder> logger, string? skillsRoot = null)
|
||||
{
|
||||
_logger = logger;
|
||||
_skillsRoot = skillsRoot ?? Path.Combine(Paths.AppDataRoot(), "session-skills");
|
||||
}
|
||||
|
||||
public async Task SeedAsync(string workingDir, IReadOnlyList<string> skillNames, bool isWorktree, CancellationToken ct)
|
||||
{
|
||||
if (skillNames.Count == 0) return;
|
||||
|
||||
var skillsDestRoot = Path.Combine(workingDir, ".claude", "skills");
|
||||
|
||||
foreach (var name in skillNames)
|
||||
{
|
||||
var sourceDir = Path.Combine(_skillsRoot, name);
|
||||
if (!Directory.Exists(sourceDir))
|
||||
{
|
||||
_logger.LogWarning("Session skill '{SkillName}' not found on disk at {SourceDir}; skipping", name, sourceDir);
|
||||
continue;
|
||||
}
|
||||
|
||||
var destDir = Path.Combine(skillsDestRoot, name);
|
||||
CopyDirectory(sourceDir, destDir);
|
||||
|
||||
if (isWorktree)
|
||||
await AppendExcludeLineAsync(workingDir, $"/.claude/skills/{name}/", ct);
|
||||
}
|
||||
}
|
||||
|
||||
private static void CopyDirectory(string sourceDir, string destDir)
|
||||
{
|
||||
Directory.CreateDirectory(destDir);
|
||||
foreach (var file in Directory.EnumerateFiles(sourceDir, "*", SearchOption.AllDirectories))
|
||||
{
|
||||
var relative = Path.GetRelativePath(sourceDir, file);
|
||||
var destPath = Path.Combine(destDir, relative);
|
||||
var destParent = Path.GetDirectoryName(destPath);
|
||||
if (!string.IsNullOrEmpty(destParent))
|
||||
Directory.CreateDirectory(destParent);
|
||||
File.Copy(file, destPath, overwrite: true);
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task AppendExcludeLineAsync(string workingDir, string excludeLine, CancellationToken ct)
|
||||
{
|
||||
var relativeExcludePath = await RunGitCaptureAsync(workingDir, ["rev-parse", "--git-path", "info/exclude"], ct);
|
||||
var excludeFile = Path.IsPathRooted(relativeExcludePath)
|
||||
? relativeExcludePath
|
||||
: Path.GetFullPath(Path.Combine(workingDir, relativeExcludePath));
|
||||
|
||||
var excludeDir = Path.GetDirectoryName(excludeFile);
|
||||
if (!string.IsNullOrEmpty(excludeDir))
|
||||
Directory.CreateDirectory(excludeDir);
|
||||
|
||||
if (File.Exists(excludeFile))
|
||||
{
|
||||
var existingLines = await File.ReadAllLinesAsync(excludeFile, ct);
|
||||
if (existingLines.Any(l => l.Trim() == excludeLine))
|
||||
return;
|
||||
}
|
||||
|
||||
await File.AppendAllTextAsync(excludeFile, excludeLine + Environment.NewLine, ct);
|
||||
}
|
||||
|
||||
private static async Task<string> RunGitCaptureAsync(string workingDir, IEnumerable<string> args, CancellationToken ct)
|
||||
{
|
||||
var psi = new ProcessStartInfo
|
||||
{
|
||||
FileName = "git",
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true,
|
||||
StandardOutputEncoding = Encoding.UTF8,
|
||||
StandardErrorEncoding = Encoding.UTF8,
|
||||
};
|
||||
psi.ArgumentList.Add("-C");
|
||||
psi.ArgumentList.Add(workingDir);
|
||||
foreach (var a in args) psi.ArgumentList.Add(a);
|
||||
|
||||
using var proc = new Process { StartInfo = psi };
|
||||
proc.Start();
|
||||
|
||||
await using var ctr = ct.Register(() =>
|
||||
{
|
||||
try { proc.Kill(entireProcessTree: true); }
|
||||
catch { /* already exited */ }
|
||||
});
|
||||
|
||||
var stdoutTask = proc.StandardOutput.ReadToEndAsync();
|
||||
var stderrTask = proc.StandardError.ReadToEndAsync();
|
||||
|
||||
await proc.WaitForExitAsync(CancellationToken.None);
|
||||
var stdout = await stdoutTask;
|
||||
var stderr = await stderrTask;
|
||||
|
||||
ct.ThrowIfCancellationRequested();
|
||||
|
||||
if (proc.ExitCode != 0)
|
||||
throw new InvalidOperationException($"git rev-parse --git-path failed (exit {proc.ExitCode}): {stderr.TrimEnd()}");
|
||||
|
||||
return stdout.TrimEnd('\r', '\n');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
namespace ClaudeDo.Worker.Skills;
|
||||
|
||||
internal static class SkillFrontmatter
|
||||
{
|
||||
public sealed record ParsedFrontmatter(string Name, string Description);
|
||||
|
||||
/// <summary>
|
||||
/// Parses the YAML frontmatter block (between the first two "---" lines) of a SKILL.md
|
||||
/// file, extracting "name" (required) and "description" (optional). The description may
|
||||
/// be a folded scalar ("description: >"), whose indented continuation lines are joined
|
||||
/// with spaces into a single string.
|
||||
/// </summary>
|
||||
public static ParsedFrontmatter Parse(string skillMdPath)
|
||||
{
|
||||
var lines = File.ReadAllLines(skillMdPath);
|
||||
if (lines.Length == 0 || lines[0].Trim() != "---")
|
||||
throw new InvalidOperationException($"'{skillMdPath}' has no YAML frontmatter block.");
|
||||
|
||||
string? name = null;
|
||||
var description = "";
|
||||
|
||||
var i = 1;
|
||||
for (; i < lines.Length; i++)
|
||||
{
|
||||
var line = lines[i];
|
||||
if (line.Trim() == "---") break;
|
||||
|
||||
if (line.StartsWith("name:", StringComparison.Ordinal))
|
||||
{
|
||||
name = line["name:".Length..].Trim().Trim('"', '\'');
|
||||
}
|
||||
else if (line.StartsWith("description:", StringComparison.Ordinal))
|
||||
{
|
||||
var rest = line["description:".Length..].Trim();
|
||||
if (rest == ">" || rest == ">-" || rest == "|" || rest == "|-")
|
||||
{
|
||||
var folded = new List<string>();
|
||||
var j = i + 1;
|
||||
for (; j < lines.Length; j++)
|
||||
{
|
||||
var contLine = lines[j];
|
||||
if (contLine.Trim() == "---") break;
|
||||
if (contLine.Length == 0) continue;
|
||||
if (!char.IsWhiteSpace(contLine[0])) break;
|
||||
folded.Add(contLine.Trim());
|
||||
}
|
||||
description = string.Join(" ", folded);
|
||||
i = j - 1;
|
||||
}
|
||||
else
|
||||
{
|
||||
description = rest.Trim('"', '\'');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
throw new InvalidOperationException($"'{skillMdPath}' frontmatter is missing required 'name'.");
|
||||
|
||||
return new ParsedFrontmatter(name, description);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
using ClaudeDo.Data;
|
||||
using ClaudeDo.Data.Models;
|
||||
using ClaudeDo.Data.Repositories;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ClaudeDo.Data.Tests;
|
||||
|
||||
public sealed class SessionSkillRepositoryTests : IDisposable
|
||||
{
|
||||
private readonly string _dbPath;
|
||||
private readonly ClaudeDoDbContext _ctx;
|
||||
private readonly SessionSkillRepository _repo;
|
||||
|
||||
public SessionSkillRepositoryTests()
|
||||
{
|
||||
_dbPath = Path.Combine(Path.GetTempPath(), $"claudedo_skills_{Guid.NewGuid():N}.db");
|
||||
var options = new DbContextOptionsBuilder<ClaudeDoDbContext>()
|
||||
.UseSqlite($"Data Source={_dbPath}")
|
||||
.Options;
|
||||
_ctx = new ClaudeDoDbContext(options);
|
||||
_ctx.Database.EnsureCreated();
|
||||
_repo = new SessionSkillRepository(_ctx);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_ctx.Dispose();
|
||||
foreach (var suffix in new[] { "", "-wal", "-shm" })
|
||||
try { File.Delete(_dbPath + suffix); } catch { }
|
||||
}
|
||||
|
||||
private static SessionSkillEntity MakeSkill(string name, string sourceUrl = "https://example.com/repo") => new()
|
||||
{
|
||||
Name = name,
|
||||
SourceUrl = sourceUrl,
|
||||
PinnedRef = "main",
|
||||
Subpath = "skills/" + name,
|
||||
Description = "desc for " + name,
|
||||
AddedAt = DateTimeOffset.UtcNow,
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public async Task UpsertAsync_then_GetAsync_roundtrips()
|
||||
{
|
||||
var skill = MakeSkill("brainstorming");
|
||||
|
||||
await _repo.UpsertAsync(skill);
|
||||
var found = await _repo.GetAsync("brainstorming");
|
||||
|
||||
Assert.NotNull(found);
|
||||
Assert.Equal(skill.SourceUrl, found!.SourceUrl);
|
||||
Assert.Equal(skill.PinnedRef, found.PinnedRef);
|
||||
Assert.Equal(skill.Subpath, found.Subpath);
|
||||
Assert.Equal(skill.Description, found.Description);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpsertAsync_updates_existing_row()
|
||||
{
|
||||
await _repo.UpsertAsync(MakeSkill("brainstorming", "https://example.com/old"));
|
||||
await _repo.UpsertAsync(MakeSkill("brainstorming", "https://example.com/new"));
|
||||
|
||||
var found = await _repo.GetAsync("brainstorming");
|
||||
|
||||
Assert.NotNull(found);
|
||||
Assert.Equal("https://example.com/new", found!.SourceUrl);
|
||||
|
||||
var all = await _repo.ListAsync();
|
||||
Assert.Single(all);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetAsync_returns_null_when_missing()
|
||||
{
|
||||
var found = await _repo.GetAsync("nope");
|
||||
Assert.Null(found);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ListAsync_returns_all_ordered_by_name()
|
||||
{
|
||||
await _repo.UpsertAsync(MakeSkill("zeta"));
|
||||
await _repo.UpsertAsync(MakeSkill("alpha"));
|
||||
|
||||
var all = await _repo.ListAsync();
|
||||
|
||||
Assert.Equal(2, all.Count);
|
||||
Assert.Equal("alpha", all[0].Name);
|
||||
Assert.Equal("zeta", all[1].Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteAsync_removes_only_matching_row()
|
||||
{
|
||||
await _repo.UpsertAsync(MakeSkill("keep"));
|
||||
await _repo.UpsertAsync(MakeSkill("remove"));
|
||||
|
||||
await _repo.DeleteAsync("remove");
|
||||
|
||||
var all = await _repo.ListAsync();
|
||||
Assert.Single(all);
|
||||
Assert.Equal("keep", all[0].Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteBySourceAsync_removes_multiple_rows_same_source()
|
||||
{
|
||||
const string source = "https://example.com/shared-repo";
|
||||
await _repo.UpsertAsync(MakeSkill("skill-a", source));
|
||||
await _repo.UpsertAsync(MakeSkill("skill-b", source));
|
||||
await _repo.UpsertAsync(MakeSkill("skill-c", "https://example.com/other"));
|
||||
|
||||
await _repo.DeleteBySourceAsync(source);
|
||||
|
||||
var all = await _repo.ListAsync();
|
||||
Assert.Single(all);
|
||||
Assert.Equal("skill-c", all[0].Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ListBySourceAsync_returns_only_matching_rows()
|
||||
{
|
||||
const string source = "https://example.com/shared-repo";
|
||||
await _repo.UpsertAsync(MakeSkill("skill-a", source));
|
||||
await _repo.UpsertAsync(MakeSkill("skill-b", source));
|
||||
await _repo.UpsertAsync(MakeSkill("skill-c", "https://example.com/other"));
|
||||
|
||||
var result = await _repo.ListBySourceAsync(source);
|
||||
|
||||
Assert.Equal(2, result.Count);
|
||||
Assert.All(result, s => Assert.Equal(source, s.SourceUrl));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
using System.Text.Json;
|
||||
using ClaudeDo.Data;
|
||||
using ClaudeDo.Data.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
||||
|
||||
namespace ClaudeDo.Data.Tests;
|
||||
|
||||
public sealed class SessionSkillsColumnRoundtripTests : IDisposable
|
||||
{
|
||||
private readonly string _dbPath;
|
||||
private readonly ClaudeDoDbContext _ctx;
|
||||
|
||||
public SessionSkillsColumnRoundtripTests()
|
||||
{
|
||||
_dbPath = Path.Combine(Path.GetTempPath(), $"claudedo_skillscol_{Guid.NewGuid():N}.db");
|
||||
var options = new DbContextOptionsBuilder<ClaudeDoDbContext>()
|
||||
.UseSqlite($"Data Source={_dbPath}")
|
||||
.Options;
|
||||
_ctx = new ClaudeDoDbContext(options);
|
||||
_ctx.Database.EnsureCreated();
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_ctx.Dispose();
|
||||
foreach (var suffix in new[] { "", "-wal", "-shm" })
|
||||
try { File.Delete(_dbPath + suffix); } catch { }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Task_SessionSkills_json_array_roundtrips()
|
||||
{
|
||||
var names = new List<string> { "a", "b" };
|
||||
var list = new ListEntity { Id = "l1", Name = "Test", CreatedAt = DateTime.UtcNow };
|
||||
var task = new TaskEntity
|
||||
{
|
||||
Id = "t1",
|
||||
ListId = "l1",
|
||||
Title = "T",
|
||||
Status = TaskStatus.Idle,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
SessionSkills = JsonSerializer.Serialize(names),
|
||||
};
|
||||
_ctx.Lists.Add(list);
|
||||
_ctx.Tasks.Add(task);
|
||||
await _ctx.SaveChangesAsync();
|
||||
|
||||
await using var freshCtx = new ClaudeDoDbContext(
|
||||
new DbContextOptionsBuilder<ClaudeDoDbContext>().UseSqlite($"Data Source={_dbPath}").Options);
|
||||
var reloaded = await freshCtx.Tasks.AsNoTracking().FirstAsync(t => t.Id == "t1");
|
||||
|
||||
var roundtripped = JsonSerializer.Deserialize<List<string>>(reloaded.SessionSkills!);
|
||||
Assert.Equal(names, roundtripped);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ListConfig_SessionSkills_json_array_roundtrips()
|
||||
{
|
||||
var names = new List<string> { "a", "b" };
|
||||
var list = new ListEntity { Id = "l1", Name = "Test", CreatedAt = DateTime.UtcNow };
|
||||
var config = new ListConfigEntity { ListId = "l1", SessionSkills = JsonSerializer.Serialize(names) };
|
||||
_ctx.Lists.Add(list);
|
||||
_ctx.ListConfigs.Add(config);
|
||||
await _ctx.SaveChangesAsync();
|
||||
|
||||
await using var freshCtx = new ClaudeDoDbContext(
|
||||
new DbContextOptionsBuilder<ClaudeDoDbContext>().UseSqlite($"Data Source={_dbPath}").Options);
|
||||
var reloaded = await freshCtx.ListConfigs.AsNoTracking().FirstAsync(c => c.ListId == "l1");
|
||||
|
||||
var roundtripped = JsonSerializer.Deserialize<List<string>>(reloaded.SessionSkills!);
|
||||
Assert.Equal(names, roundtripped);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AppSettings_SessionSkills_json_array_roundtrips()
|
||||
{
|
||||
var names = new List<string> { "a", "b" };
|
||||
var settings = await _ctx.AppSettings.FirstAsync(s => s.Id == AppSettingsEntity.SingletonId);
|
||||
settings.SessionSkills = JsonSerializer.Serialize(names);
|
||||
await _ctx.SaveChangesAsync();
|
||||
|
||||
await using var freshCtx = new ClaudeDoDbContext(
|
||||
new DbContextOptionsBuilder<ClaudeDoDbContext>().UseSqlite($"Data Source={_dbPath}").Options);
|
||||
var reloaded = await freshCtx.AppSettings.AsNoTracking().FirstAsync(s => s.Id == AppSettingsEntity.SingletonId);
|
||||
|
||||
var roundtripped = JsonSerializer.Deserialize<List<string>>(reloaded.SessionSkills!);
|
||||
Assert.Equal(names, roundtripped);
|
||||
}
|
||||
}
|
||||
@@ -25,10 +25,6 @@ public abstract class StubWorkerClient : IWorkerClient
|
||||
public event Action<WorkerLogEntry>? WorkerLogReceivedEvent;
|
||||
public event Action<string, string, string>? TaskQuestionAskedEvent;
|
||||
public event Action<string, string>? TaskQuestionResolvedEvent;
|
||||
public event Action<string>? InteractiveSessionStartedEvent;
|
||||
public event Action<string>? InteractiveSessionEndedEvent;
|
||||
public event Action<string, IReadOnlyList<string>>? InteractiveQueueChangedEvent;
|
||||
public event Action<string, string>? InteractiveMessageSentEvent;
|
||||
public event Action? PrepStartedEvent;
|
||||
public event Action<string>? PrepLineEvent;
|
||||
public event Action<bool>? PrepFinishedEvent;
|
||||
@@ -59,11 +55,6 @@ public abstract class StubWorkerClient : IWorkerClient
|
||||
public void RaisePrepLine(string line) => PrepLineEvent?.Invoke(line);
|
||||
public void RaisePrepFinished(bool ok) => PrepFinishedEvent?.Invoke(ok);
|
||||
|
||||
public void RaiseInteractiveStarted(string taskId) => InteractiveSessionStartedEvent?.Invoke(taskId);
|
||||
public void RaiseInteractiveEnded(string taskId) => InteractiveSessionEndedEvent?.Invoke(taskId);
|
||||
public void RaiseInteractiveQueueChanged(string taskId, IReadOnlyList<string> pending) => InteractiveQueueChangedEvent?.Invoke(taskId, pending);
|
||||
public void RaiseInteractiveMessageSent(string taskId, string text) => InteractiveMessageSentEvent?.Invoke(taskId, text);
|
||||
|
||||
public virtual bool IsConnected => false;
|
||||
public virtual bool IsReconnecting => false;
|
||||
public virtual string? LastApproveTarget => null;
|
||||
@@ -86,6 +77,10 @@ public abstract class StubWorkerClient : IWorkerClient
|
||||
public virtual Task<SeedResultDto?> RestoreDefaultAgentsAsync() => Task.FromResult<SeedResultDto?>(null);
|
||||
public virtual Task<ListConfigDto?> GetListConfigAsync(string listId) => Task.FromResult<ListConfigDto?>(null);
|
||||
public virtual Task UpdateTaskAgentSettingsAsync(UpdateTaskAgentSettingsDto dto) => Task.CompletedTask;
|
||||
public virtual Task<List<SessionSkillDto>> GetSessionSkillsAsync() => Task.FromResult(new List<SessionSkillDto>());
|
||||
public virtual Task<List<string>> InstallSessionSkillAsync(string url) => Task.FromResult(new List<string>());
|
||||
public virtual Task UpdateSessionSkillAsync(string sourceUrl) => Task.CompletedTask;
|
||||
public virtual Task RemoveSessionSkillAsync(string sourceUrl) => Task.CompletedTask;
|
||||
public virtual Task SetTaskStatusAsync(string taskId, TaskStatus status) => Task.CompletedTask;
|
||||
public virtual Task<MergeResultDto?> ApproveReviewAsync(string taskId, string targetBranch) => Task.FromResult<MergeResultDto?>(null);
|
||||
public virtual Task<MergePreviewDto?> PreviewMergeAsync(string taskId, string targetBranch) => Task.FromResult<MergePreviewDto?>(null);
|
||||
@@ -99,7 +94,11 @@ public abstract class StubWorkerClient : IWorkerClient
|
||||
public virtual Task<MergeResultDto> ContinueConflictMergeAsync(string taskId) => Task.FromResult(new MergeResultDto("merged", System.Array.Empty<string>(), null));
|
||||
public virtual Task AbortConflictMergeAsync(string taskId) => Task.CompletedTask;
|
||||
public virtual Task StartPlanningSessionAsync(string taskId, CancellationToken ct = default) => Task.CompletedTask;
|
||||
public virtual Task OpenInteractiveTerminalAsync(string taskId, CancellationToken ct = default) => Task.CompletedTask;
|
||||
public virtual Task ResumeTaskInTerminalAsync(string taskId, CancellationToken ct = default) => Task.CompletedTask;
|
||||
public virtual Task<LaunchSpec> GetInteractiveLaunchSpecAsync(string taskId, CancellationToken ct = default)
|
||||
=> Task.FromResult(new LaunchSpec(".", "claude", Array.Empty<string>(), new Dictionary<string, string>()));
|
||||
public virtual Task<LaunchSpec> GetAdHocLaunchSpecAsync(string directory, CancellationToken ct = default)
|
||||
=> Task.FromResult(new LaunchSpec(directory, "claude", Array.Empty<string>(), new Dictionary<string, string>()));
|
||||
public virtual Task ResumePlanningSessionAsync(string taskId, CancellationToken ct = default) => Task.CompletedTask;
|
||||
public virtual Task<DiscardPlanningOutcome> DiscardPlanningSessionAsync(string taskId, bool dequeueQueuedChildren = false, CancellationToken ct = default)
|
||||
=> Task.FromResult(new DiscardPlanningOutcome(DiscardPlanningResult.Discarded, 0, 0));
|
||||
@@ -142,30 +141,6 @@ public abstract class StubWorkerClient : IWorkerClient
|
||||
public virtual Task SetOnlineInboxConfigAsync(OnlineInboxConfigInputDto input) => Task.CompletedTask;
|
||||
public virtual Task SetOnlineInboxAuthAsync(string refreshToken) => Task.CompletedTask;
|
||||
public virtual Task ClearOnlineInboxAuthAsync() => Task.CompletedTask;
|
||||
public List<(string TaskId, string Text)> SentInteractive { get; } = new();
|
||||
public virtual Task SendInteractiveMessageAsync(string taskId, string text)
|
||||
{
|
||||
SentInteractive.Add((taskId, text));
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
public List<(string TaskId, string Text)> RemovedQueued { get; } = new();
|
||||
public virtual Task RemoveQueuedInteractiveMessageAsync(string taskId, string text)
|
||||
{
|
||||
RemovedQueued.Add((taskId, text));
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
public List<string> StoppedInteractive { get; } = new();
|
||||
public virtual Task StopInteractiveSessionAsync(string taskId)
|
||||
{
|
||||
StoppedInteractive.Add(taskId);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
public List<string> InterruptedInteractive { get; } = new();
|
||||
public virtual Task InterruptInteractiveSessionAsync(string taskId)
|
||||
{
|
||||
InterruptedInteractive.Add(taskId);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
protected void RaisePropertyChanged(string name) => PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));
|
||||
}
|
||||
|
||||
@@ -14,12 +14,14 @@ public class AgentConfigEditorViewModelTests
|
||||
public AppSettingsDto? App;
|
||||
public ListConfigDto? ListCfg;
|
||||
public List<AgentInfo> AgentList = new();
|
||||
public List<SessionSkillDto> InstalledSkills = new();
|
||||
public UpdateListConfigDto? SavedListConfig;
|
||||
public UpdateTaskAgentSettingsDto? SavedTaskSettings;
|
||||
|
||||
public override Task<AppSettingsDto?> GetAppSettingsAsync() => Task.FromResult(App);
|
||||
public override Task<ListConfigDto?> GetListConfigAsync(string listId) => Task.FromResult(ListCfg);
|
||||
public override Task<List<AgentInfo>> GetAgentsAsync() => Task.FromResult(AgentList);
|
||||
public override Task<List<SessionSkillDto>> GetSessionSkillsAsync() => Task.FromResult(InstalledSkills);
|
||||
public override Task UpdateListConfigAsync(UpdateListConfigDto dto) { SavedListConfig = dto; return Task.CompletedTask; }
|
||||
public override Task UpdateTaskAgentSettingsAsync(UpdateTaskAgentSettingsDto dto) { SavedTaskSettings = dto; return Task.CompletedTask; }
|
||||
}
|
||||
@@ -186,4 +188,102 @@ public class AgentConfigEditorViewModelTests
|
||||
await vm.SaveAsync();
|
||||
Assert.Null(w.SavedTaskSettings);
|
||||
}
|
||||
|
||||
// ── Session skills ──────────────────────────────────────────────────────
|
||||
|
||||
private static SessionSkillDto Skill(string name) => new(name, "desc-" + name, "url-" + name, "ref", DateTimeOffset.UtcNow);
|
||||
|
||||
[Fact]
|
||||
public async Task List_load_reflects_selection_from_list_config()
|
||||
{
|
||||
var w = new FakeWorker
|
||||
{
|
||||
App = AppWith("haiku", 50),
|
||||
ListCfg = new ListConfigDto(null, null, null, null, new List<string> { "skill-b" }),
|
||||
InstalledSkills = new() { Skill("skill-a"), Skill("skill-b") },
|
||||
};
|
||||
var vm = new AgentConfigEditorViewModel(w, AgentConfigScope.List);
|
||||
|
||||
await vm.LoadForListAsync("l1");
|
||||
|
||||
Assert.Equal(2, vm.SessionSkills.Count);
|
||||
Assert.False(vm.SessionSkills.Single(s => s.Name == "skill-a").IsSelected);
|
||||
Assert.True(vm.SessionSkills.Single(s => s.Name == "skill-b").IsSelected);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task List_save_includes_selected_skill_names()
|
||||
{
|
||||
var w = new FakeWorker
|
||||
{
|
||||
App = AppWith("haiku", 50),
|
||||
ListCfg = new ListConfigDto(null, null, null, null, null),
|
||||
InstalledSkills = new() { Skill("skill-a"), Skill("skill-b") },
|
||||
};
|
||||
var vm = new AgentConfigEditorViewModel(w, AgentConfigScope.List);
|
||||
await vm.LoadForListAsync("l1");
|
||||
|
||||
vm.SessionSkills.Single(s => s.Name == "skill-a").IsSelected = true;
|
||||
await vm.SaveAsync();
|
||||
|
||||
Assert.NotNull(w.SavedListConfig);
|
||||
Assert.Equal(new List<string> { "skill-a" }, w.SavedListConfig!.SessionSkills);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Task_load_parses_session_skills_json_and_reflects_selection()
|
||||
{
|
||||
var w = new FakeWorker
|
||||
{
|
||||
App = AppWith("haiku", 50),
|
||||
ListCfg = new ListConfigDto(null, null, null, null),
|
||||
InstalledSkills = new() { Skill("skill-a"), Skill("skill-b") },
|
||||
};
|
||||
var vm = new AgentConfigEditorViewModel(w, AgentConfigScope.Task);
|
||||
var entity = TaskWith(null, null, "", null);
|
||||
entity.SessionSkills = "[\"skill-a\"]";
|
||||
|
||||
await vm.LoadForTaskAsync(entity);
|
||||
|
||||
Assert.True(vm.SessionSkills.Single(s => s.Name == "skill-a").IsSelected);
|
||||
Assert.False(vm.SessionSkills.Single(s => s.Name == "skill-b").IsSelected);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Task_toggling_a_skill_auto_saves_selection()
|
||||
{
|
||||
var w = new FakeWorker
|
||||
{
|
||||
App = AppWith("haiku", 50),
|
||||
ListCfg = new ListConfigDto(null, null, null, null),
|
||||
InstalledSkills = new() { Skill("skill-a") },
|
||||
};
|
||||
var vm = new AgentConfigEditorViewModel(w, AgentConfigScope.Task);
|
||||
await vm.LoadForTaskAsync(TaskWith(null, null, "", null));
|
||||
|
||||
vm.SessionSkills.Single(s => s.Name == "skill-a").IsSelected = true;
|
||||
await Task.Delay(500);
|
||||
|
||||
Assert.NotNull(w.SavedTaskSettings);
|
||||
Assert.Equal(new List<string> { "skill-a" }, w.SavedTaskSettings!.SessionSkills);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Clear_deselects_all_skills()
|
||||
{
|
||||
var w = new FakeWorker
|
||||
{
|
||||
App = AppWith("haiku", 50),
|
||||
ListCfg = new ListConfigDto(null, null, null, null),
|
||||
InstalledSkills = new() { Skill("skill-a") },
|
||||
};
|
||||
var vm = new AgentConfigEditorViewModel(w, AgentConfigScope.Task);
|
||||
var entity = TaskWith(null, null, "", null);
|
||||
entity.SessionSkills = "[\"skill-a\"]";
|
||||
await vm.LoadForTaskAsync(entity);
|
||||
|
||||
vm.Clear();
|
||||
|
||||
Assert.All(vm.SessionSkills, s => Assert.False(s.IsSelected));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
using ClaudeDo.Ui.Services;
|
||||
using ClaudeDo.Ui.ViewModels.Modals.Settings;
|
||||
using Xunit;
|
||||
|
||||
namespace ClaudeDo.Ui.Tests.ViewModels;
|
||||
|
||||
public class GeneralSettingsTabViewModelTests
|
||||
{
|
||||
private sealed class FakeWorker : StubWorkerClient
|
||||
{
|
||||
public List<SessionSkillDto> Installed = new();
|
||||
public override Task<List<SessionSkillDto>> GetSessionSkillsAsync() => Task.FromResult(Installed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LoadSessionSkillsAsync_reflects_current_selection()
|
||||
{
|
||||
var w = new FakeWorker
|
||||
{
|
||||
Installed = new() { new SessionSkillDto("a", "", "url-a", "ref", DateTimeOffset.UtcNow),
|
||||
new SessionSkillDto("b", "", "url-b", "ref", DateTimeOffset.UtcNow) },
|
||||
};
|
||||
var vm = new GeneralSettingsTabViewModel();
|
||||
|
||||
await vm.LoadSessionSkillsAsync(w, new List<string> { "b" });
|
||||
|
||||
Assert.Equal(2, vm.SessionSkills.Count);
|
||||
Assert.False(vm.SessionSkills.Single(s => s.Name == "a").IsSelected);
|
||||
Assert.True(vm.SessionSkills.Single(s => s.Name == "b").IsSelected);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SelectedSessionSkillNames_round_trips_through_toggle()
|
||||
{
|
||||
var w = new FakeWorker
|
||||
{
|
||||
Installed = new() { new SessionSkillDto("a", "", "url-a", "ref", DateTimeOffset.UtcNow) },
|
||||
};
|
||||
var vm = new GeneralSettingsTabViewModel();
|
||||
await vm.LoadSessionSkillsAsync(w, null);
|
||||
|
||||
Assert.Null(vm.SelectedSessionSkillNames());
|
||||
|
||||
vm.SessionSkills.Single().IsSelected = true;
|
||||
|
||||
Assert.Equal(new List<string> { "a" }, vm.SelectedSessionSkillNames());
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ using ClaudeDo.Data.Models;
|
||||
using ClaudeDo.Ui.Services;
|
||||
using ClaudeDo.Ui.ViewModels;
|
||||
using ClaudeDo.Ui.ViewModels.Islands;
|
||||
using ClaudeDo.Ui.ViewModels.MissionControl;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Xunit;
|
||||
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
||||
@@ -262,4 +263,159 @@ public class MissionControlViewModelTests : IDisposable
|
||||
var entity = await verify.Tasks.FirstAsync(t => t.Id == "idleTask");
|
||||
Assert.Equal(TaskStatus.Queued, entity.Status);
|
||||
}
|
||||
|
||||
private sealed class ThrowingLaunchSpecWorker : StubWorkerClient
|
||||
{
|
||||
public override Task<LaunchSpec> GetInteractiveLaunchSpecAsync(string taskId, CancellationToken ct = default)
|
||||
=> throw new InvalidOperationException("no worktree");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task OpenConPtySessionAsync_AddsPane_ToConPtySessionsAndPanes()
|
||||
{
|
||||
var worker = new FakeWorker();
|
||||
using var vm = BuildVm(worker);
|
||||
|
||||
await vm.OpenConPtySessionAsync("t1");
|
||||
|
||||
Assert.Single(vm.ConPtySessions);
|
||||
Assert.Equal("t1", vm.ConPtySessions[0].TaskId);
|
||||
Assert.Single(vm.Panes);
|
||||
Assert.Same(vm.ConPtySessions[0], vm.Panes[0]);
|
||||
Assert.True(vm.HasPanes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task OpenConPtySessionAsync_SameTaskTwice_DoesNotDuplicate()
|
||||
{
|
||||
var worker = new FakeWorker();
|
||||
using var vm = BuildVm(worker);
|
||||
|
||||
await vm.OpenConPtySessionAsync("t1");
|
||||
await vm.OpenConPtySessionAsync("t1");
|
||||
|
||||
Assert.Single(vm.ConPtySessions);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task OpenConPtySessionAsync_WorkerThrows_RaisesErrorReported_NoPaneAdded()
|
||||
{
|
||||
var worker = new ThrowingLaunchSpecWorker();
|
||||
using var vm = BuildVm(worker);
|
||||
string? error = null;
|
||||
vm.ErrorReported += msg => error = msg;
|
||||
|
||||
await vm.OpenConPtySessionAsync("t1");
|
||||
|
||||
Assert.Empty(vm.ConPtySessions);
|
||||
Assert.Empty(vm.Panes);
|
||||
Assert.NotNull(error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Panes_CombinesMonitorsThenConPtySessions_InOrder()
|
||||
{
|
||||
var worker = new FakeWorker();
|
||||
using var vm = BuildVm(worker);
|
||||
|
||||
worker.RaiseTaskStarted("slot-1", "t1", DateTime.UtcNow);
|
||||
await vm.OpenConPtySessionAsync("t2");
|
||||
|
||||
Assert.Equal(2, vm.Panes.Count);
|
||||
Assert.IsType<TaskMonitorViewModel>(vm.Panes[0]);
|
||||
Assert.IsType<ConPtyPaneViewModel>(vm.Panes[1]);
|
||||
Assert.Equal(2, vm.ColumnCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CloseConPtySession_RemovesFromConPtySessionsAndPanes()
|
||||
{
|
||||
var worker = new FakeWorker();
|
||||
using var vm = BuildVm(worker);
|
||||
await vm.OpenConPtySessionAsync("t1");
|
||||
var pane = vm.ConPtySessions[0];
|
||||
|
||||
pane.CloseCommand.Execute(null);
|
||||
|
||||
Assert.Empty(vm.ConPtySessions);
|
||||
Assert.Empty(vm.Panes);
|
||||
}
|
||||
|
||||
private sealed class ThrowingAdHocLaunchSpecWorker : StubWorkerClient
|
||||
{
|
||||
public override Task<LaunchSpec> GetAdHocLaunchSpecAsync(string directory, CancellationToken ct = default)
|
||||
=> throw new InvalidOperationException("bad directory");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task OpenAdHocConPtySessionAsync_AddsPane_TitleFromDirectoryLeaf()
|
||||
{
|
||||
var worker = new FakeWorker();
|
||||
using var vm = BuildVm(worker);
|
||||
|
||||
await vm.OpenAdHocConPtySessionAsync(Path.Combine("C:", "Some", "Path", "MyProject"));
|
||||
|
||||
Assert.Single(vm.ConPtySessions);
|
||||
Assert.Null(vm.ConPtySessions[0].TaskId);
|
||||
Assert.Equal("MyProject", vm.ConPtySessions[0].DisplayTitle);
|
||||
Assert.Single(vm.Panes);
|
||||
Assert.Same(vm.ConPtySessions[0], vm.Panes[0]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task OpenAdHocConPtySessionAsync_TwoOpens_NeverDeduped_YieldsTwoPanes()
|
||||
{
|
||||
var worker = new FakeWorker();
|
||||
using var vm = BuildVm(worker);
|
||||
|
||||
var dir = Path.Combine("C:", "Some", "Path");
|
||||
await vm.OpenAdHocConPtySessionAsync(dir);
|
||||
await vm.OpenAdHocConPtySessionAsync(dir);
|
||||
|
||||
Assert.Equal(2, vm.ConPtySessions.Count);
|
||||
Assert.Equal(2, vm.Panes.Count);
|
||||
Assert.All(vm.ConPtySessions, s => Assert.Null(s.TaskId));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task OpenAdHocConPtySessionAsync_WorkerThrows_RaisesErrorReported_NoPaneAdded()
|
||||
{
|
||||
var worker = new ThrowingAdHocLaunchSpecWorker();
|
||||
using var vm = BuildVm(worker);
|
||||
string? error = null;
|
||||
vm.ErrorReported += msg => error = msg;
|
||||
|
||||
await vm.OpenAdHocConPtySessionAsync(Path.Combine("C:", "Some", "Path"));
|
||||
|
||||
Assert.Empty(vm.ConPtySessions);
|
||||
Assert.Empty(vm.Panes);
|
||||
Assert.NotNull(error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CloseConPtySession_RemovesAdHocPane_FromConPtySessionsAndPanes()
|
||||
{
|
||||
var worker = new FakeWorker();
|
||||
using var vm = BuildVm(worker);
|
||||
await vm.OpenAdHocConPtySessionAsync(Path.Combine("C:", "Some", "Path"));
|
||||
var pane = vm.ConPtySessions[0];
|
||||
|
||||
pane.CloseCommand.Execute(null);
|
||||
|
||||
Assert.Empty(vm.ConPtySessions);
|
||||
Assert.Empty(vm.Panes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToggleLayoutCommand_FlipsIsFocusMode()
|
||||
{
|
||||
var worker = new FakeWorker();
|
||||
using var vm = BuildVm(worker);
|
||||
|
||||
Assert.False(vm.IsFocusMode);
|
||||
vm.ToggleLayoutCommand.Execute(null);
|
||||
Assert.True(vm.IsFocusMode);
|
||||
vm.ToggleLayoutCommand.Execute(null);
|
||||
Assert.False(vm.IsFocusMode);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
using System.IO;
|
||||
using ClaudeDo.Localization;
|
||||
using ClaudeDo.Ui.Localization;
|
||||
using ClaudeDo.Ui.Services;
|
||||
using ClaudeDo.Ui.ViewModels.Modals.Settings;
|
||||
using Xunit;
|
||||
|
||||
namespace ClaudeDo.Ui.Tests.ViewModels;
|
||||
|
||||
public class SessionSkillsSettingsTabViewModelTests
|
||||
{
|
||||
public SessionSkillsSettingsTabViewModelTests()
|
||||
{
|
||||
var dir = AppContext.BaseDirectory;
|
||||
while (dir is not null && !Directory.Exists(Path.Combine(dir, "src", "ClaudeDo.Localization", "locales")))
|
||||
dir = Path.GetDirectoryName(dir);
|
||||
Loc.Current = new Localizer(
|
||||
LocaleStore.Load(Path.Combine(dir!, "src", "ClaudeDo.Localization", "locales")), "en");
|
||||
}
|
||||
|
||||
private sealed class FakeWorker : StubWorkerClient
|
||||
{
|
||||
public List<SessionSkillDto> Installed = new();
|
||||
public string? InstallUrlReceived;
|
||||
public string? UpdatedSourceUrl;
|
||||
public string? RemovedSourceUrl;
|
||||
public Exception? InstallError;
|
||||
|
||||
public override Task<List<SessionSkillDto>> GetSessionSkillsAsync() => Task.FromResult(Installed);
|
||||
|
||||
public override Task<List<string>> InstallSessionSkillAsync(string url)
|
||||
{
|
||||
if (InstallError is not null) throw InstallError;
|
||||
InstallUrlReceived = url;
|
||||
Installed = Installed.Append(new SessionSkillDto("new-skill", "desc", url, "abc123", DateTimeOffset.UtcNow)).ToList();
|
||||
return Task.FromResult(new List<string> { "new-skill" });
|
||||
}
|
||||
|
||||
public override Task UpdateSessionSkillAsync(string sourceUrl) { UpdatedSourceUrl = sourceUrl; return Task.CompletedTask; }
|
||||
public override Task RemoveSessionSkillAsync(string sourceUrl)
|
||||
{
|
||||
RemovedSourceUrl = sourceUrl;
|
||||
Installed = Installed.Where(s => s.SourceUrl != sourceUrl).ToList();
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LoadAsync_populates_skills_from_worker()
|
||||
{
|
||||
var w = new FakeWorker { Installed = new() { new SessionSkillDto("a", "desc-a", "url-a", "ref-a", DateTimeOffset.UtcNow) } };
|
||||
var vm = new SessionSkillsSettingsTabViewModel(w);
|
||||
|
||||
await vm.LoadAsync();
|
||||
|
||||
Assert.Single(vm.Skills);
|
||||
Assert.Equal("a", vm.Skills[0].Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InstallAsync_installs_then_refreshes_and_sets_status()
|
||||
{
|
||||
var w = new FakeWorker();
|
||||
var vm = new SessionSkillsSettingsTabViewModel(w) { InstallUrl = "https://example.com/repo.git" };
|
||||
|
||||
await vm.InstallCommand.ExecuteAsync(null);
|
||||
|
||||
Assert.Equal("https://example.com/repo.git", w.InstallUrlReceived);
|
||||
Assert.Single(vm.Skills);
|
||||
Assert.Equal("new-skill", vm.Skills[0].Name);
|
||||
Assert.Contains("new-skill", vm.StatusMessage);
|
||||
Assert.Equal("", vm.InstallUrl);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InstallAsync_surfaces_exception_message_on_failure()
|
||||
{
|
||||
// The worker actually throws a HubException on a name collision; any Exception with a
|
||||
// readable Message exercises the same catch-and-surface path in the view model.
|
||||
var w = new FakeWorker { InstallError = new InvalidOperationException("Skill 'foo' already installed from a different source.") };
|
||||
var vm = new SessionSkillsSettingsTabViewModel(w) { InstallUrl = "https://example.com/dup.git" };
|
||||
|
||||
await vm.InstallCommand.ExecuteAsync(null);
|
||||
|
||||
Assert.Contains("already installed", vm.StatusMessage);
|
||||
Assert.Empty(vm.Skills);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InstallAsync_does_nothing_for_blank_url()
|
||||
{
|
||||
var w = new FakeWorker();
|
||||
var vm = new SessionSkillsSettingsTabViewModel(w) { InstallUrl = " " };
|
||||
|
||||
await vm.InstallCommand.ExecuteAsync(null);
|
||||
|
||||
Assert.Null(w.InstallUrlReceived);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RemoveAsync_removes_then_refreshes()
|
||||
{
|
||||
var w = new FakeWorker { Installed = new() { new SessionSkillDto("a", "", "url-a", "ref-a", DateTimeOffset.UtcNow) } };
|
||||
var vm = new SessionSkillsSettingsTabViewModel(w);
|
||||
await vm.LoadAsync();
|
||||
|
||||
await vm.RemoveCommand.ExecuteAsync("url-a");
|
||||
|
||||
Assert.Equal("url-a", w.RemovedSourceUrl);
|
||||
Assert.Empty(vm.Skills);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateAsync_calls_worker_then_refreshes()
|
||||
{
|
||||
var w = new FakeWorker { Installed = new() { new SessionSkillDto("a", "", "url-a", "ref-a", DateTimeOffset.UtcNow) } };
|
||||
var vm = new SessionSkillsSettingsTabViewModel(w);
|
||||
await vm.LoadAsync();
|
||||
|
||||
await vm.UpdateCommand.ExecuteAsync("url-a");
|
||||
|
||||
Assert.Equal("url-a", w.UpdatedSourceUrl);
|
||||
Assert.NotEmpty(vm.StatusMessage);
|
||||
}
|
||||
}
|
||||
@@ -189,224 +189,4 @@ public class TaskMonitorViewModelTests : IDisposable
|
||||
|
||||
Assert.False(vm.HasPendingQuestion);
|
||||
}
|
||||
|
||||
// ── Interactive composer ──────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void InteractiveStarted_ForSubscribedTask_SetsIsInteractiveLive()
|
||||
{
|
||||
var worker = new FakeWorker();
|
||||
using var vm = Build(worker);
|
||||
vm.SetTaskId("t1");
|
||||
|
||||
worker.RaiseInteractiveStarted("t1");
|
||||
|
||||
Assert.True(vm.IsInteractiveLive);
|
||||
Assert.Equal("running", vm.AgentState);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InteractiveStarted_ForOtherTask_IsIgnored()
|
||||
{
|
||||
var worker = new FakeWorker();
|
||||
using var vm = Build(worker);
|
||||
vm.SetTaskId("t1");
|
||||
|
||||
worker.RaiseInteractiveStarted("other");
|
||||
|
||||
Assert.False(vm.IsInteractiveLive);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InteractiveEnded_ForSubscribedTask_ClearsIsInteractiveLive()
|
||||
{
|
||||
var worker = new FakeWorker();
|
||||
using var vm = Build(worker);
|
||||
vm.SetTaskId("t1");
|
||||
worker.RaiseInteractiveStarted("t1");
|
||||
|
||||
worker.RaiseInteractiveEnded("t1");
|
||||
|
||||
Assert.False(vm.IsInteractiveLive);
|
||||
Assert.Equal("done", vm.AgentState);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InteractiveEnded_ForOtherTask_IsIgnored()
|
||||
{
|
||||
var worker = new FakeWorker();
|
||||
using var vm = Build(worker);
|
||||
vm.SetTaskId("t1");
|
||||
worker.RaiseInteractiveStarted("t1");
|
||||
|
||||
worker.RaiseInteractiveEnded("other");
|
||||
|
||||
Assert.True(vm.IsInteractiveLive); // unchanged
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SubmitComposerCommand_CanExecute_FalseWhenNotLive()
|
||||
{
|
||||
var worker = new FakeWorker();
|
||||
using var vm = Build(worker);
|
||||
vm.SetTaskId("t1");
|
||||
vm.ComposerDraft = "hello";
|
||||
|
||||
Assert.False(vm.SubmitComposerCommand.CanExecute(null));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SubmitComposerCommand_CanExecute_FalseWhenLiveButDraftWhitespace()
|
||||
{
|
||||
var worker = new FakeWorker();
|
||||
using var vm = Build(worker);
|
||||
vm.SetTaskId("t1");
|
||||
worker.RaiseInteractiveStarted("t1");
|
||||
vm.ComposerDraft = " ";
|
||||
|
||||
Assert.False(vm.SubmitComposerCommand.CanExecute(null));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SubmitComposerCommand_CanExecute_TrueWhenLiveAndDraftSet()
|
||||
{
|
||||
var worker = new FakeWorker();
|
||||
using var vm = Build(worker);
|
||||
vm.SetTaskId("t1");
|
||||
worker.RaiseInteractiveStarted("t1");
|
||||
vm.ComposerDraft = "hello";
|
||||
|
||||
Assert.True(vm.SubmitComposerCommand.CanExecute(null));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SubmitComposer_CallsClient_ClearsDraft_DoesNotAddLogLine()
|
||||
{
|
||||
var worker = new FakeWorker();
|
||||
using var vm = Build(worker);
|
||||
vm.SetTaskId("t1");
|
||||
worker.RaiseInteractiveStarted("t1");
|
||||
vm.ComposerDraft = "do the thing";
|
||||
|
||||
await vm.SubmitComposerCommand.ExecuteAsync(null);
|
||||
|
||||
Assert.Single(worker.SentInteractive);
|
||||
Assert.Equal(("t1", "do the thing"), worker.SentInteractive[0]);
|
||||
Assert.Equal(string.Empty, vm.ComposerDraft);
|
||||
// Log must NOT be updated by submit itself; it updates on InteractiveMessageSent
|
||||
Assert.Empty(vm.Log);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InteractiveMessageSent_ForSubscribedTask_AddsUserLogLine()
|
||||
{
|
||||
var worker = new FakeWorker();
|
||||
using var vm = Build(worker);
|
||||
vm.SetTaskId("t1");
|
||||
|
||||
worker.RaiseInteractiveMessageSent("t1", "hello from event");
|
||||
|
||||
Assert.Single(vm.Log);
|
||||
Assert.Equal(LogKind.User, vm.Log[0].Kind);
|
||||
Assert.Equal("hello from event", vm.Log[0].Text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InteractiveMessageSent_ForOtherTask_IsIgnored()
|
||||
{
|
||||
var worker = new FakeWorker();
|
||||
using var vm = Build(worker);
|
||||
vm.SetTaskId("t1");
|
||||
|
||||
worker.RaiseInteractiveMessageSent("other", "not mine");
|
||||
|
||||
Assert.Empty(vm.Log);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InteractiveQueueChanged_ForSubscribedTask_PopulatesQueue()
|
||||
{
|
||||
var worker = new FakeWorker();
|
||||
using var vm = Build(worker);
|
||||
vm.SetTaskId("t1");
|
||||
|
||||
worker.RaiseInteractiveQueueChanged("t1", new[] { "msg1", "msg2" });
|
||||
|
||||
Assert.Equal(2, vm.QueuedMessages.Count);
|
||||
Assert.Equal("msg1", vm.QueuedMessages[0].Text);
|
||||
Assert.Equal("msg2", vm.QueuedMessages[1].Text);
|
||||
Assert.True(vm.HasQueuedMessages);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InteractiveQueueChanged_EmptyList_ClearsQueue()
|
||||
{
|
||||
var worker = new FakeWorker();
|
||||
using var vm = Build(worker);
|
||||
vm.SetTaskId("t1");
|
||||
worker.RaiseInteractiveQueueChanged("t1", new[] { "msg1" });
|
||||
|
||||
worker.RaiseInteractiveQueueChanged("t1", Array.Empty<string>());
|
||||
|
||||
Assert.Empty(vm.QueuedMessages);
|
||||
Assert.False(vm.HasQueuedMessages);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InteractiveQueueChanged_ForOtherTask_IsIgnored()
|
||||
{
|
||||
var worker = new FakeWorker();
|
||||
using var vm = Build(worker);
|
||||
vm.SetTaskId("t1");
|
||||
|
||||
worker.RaiseInteractiveQueueChanged("other", new[] { "msg1" });
|
||||
|
||||
Assert.Empty(vm.QueuedMessages);
|
||||
Assert.False(vm.HasQueuedMessages);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InteractiveEnded_ClearsQueuedMessages()
|
||||
{
|
||||
var worker = new FakeWorker();
|
||||
using var vm = Build(worker);
|
||||
vm.SetTaskId("t1");
|
||||
worker.RaiseInteractiveStarted("t1");
|
||||
worker.RaiseInteractiveQueueChanged("t1", new[] { "pending msg" });
|
||||
|
||||
worker.RaiseInteractiveEnded("t1");
|
||||
|
||||
Assert.Empty(vm.QueuedMessages);
|
||||
Assert.False(vm.HasQueuedMessages);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task QueuedMessageViewModel_RemoveCommand_RecordsRemoveCall()
|
||||
{
|
||||
var worker = new FakeWorker();
|
||||
using var vm = Build(worker);
|
||||
vm.SetTaskId("t1");
|
||||
worker.RaiseInteractiveQueueChanged("t1", new[] { "a", "b" });
|
||||
|
||||
vm.QueuedMessages[0].RemoveCommand.Execute(null);
|
||||
// RemoveQueuedAsync is fire-and-forget; yield to let the async continuation run
|
||||
await System.Threading.Tasks.Task.Yield();
|
||||
|
||||
Assert.Single(worker.RemovedQueued);
|
||||
Assert.Equal(("t1", "a"), worker.RemovedQueued[0]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InterruptInteractiveCommand_WhenLive_RecordsOneCall()
|
||||
{
|
||||
var worker = new FakeWorker();
|
||||
using var vm = Build(worker);
|
||||
vm.SetTaskId("t1");
|
||||
worker.RaiseInteractiveStarted("t1");
|
||||
|
||||
await vm.InterruptInteractiveCommand.ExecuteAsync(null);
|
||||
|
||||
Assert.Single(worker.InterruptedInteractive);
|
||||
Assert.Equal("t1", worker.InterruptedInteractive[0]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
using ClaudeDo.Data;
|
||||
using ClaudeDo.Ui.ViewModels.Islands;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ClaudeDo.Ui.Tests.ViewModels;
|
||||
|
||||
public class TasksIslandOpenConPtySessionTests : IDisposable
|
||||
{
|
||||
private readonly string _dbPath;
|
||||
|
||||
public TasksIslandOpenConPtySessionTests()
|
||||
{
|
||||
_dbPath = Path.Combine(Path.GetTempPath(), $"claudedo_conpty_cmd_{Guid.NewGuid():N}.db");
|
||||
using var ctx = NewContext();
|
||||
ctx.Database.EnsureCreated();
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
try { File.Delete(_dbPath); } catch { }
|
||||
try { File.Delete(_dbPath + "-wal"); } catch { }
|
||||
try { File.Delete(_dbPath + "-shm"); } catch { }
|
||||
}
|
||||
|
||||
private ClaudeDoDbContext NewContext()
|
||||
{
|
||||
var opts = new DbContextOptionsBuilder<ClaudeDoDbContext>()
|
||||
.UseSqlite($"Data Source={_dbPath}")
|
||||
.Options;
|
||||
return new ClaudeDoDbContext(opts);
|
||||
}
|
||||
|
||||
private sealed class TestDbFactory : IDbContextFactory<ClaudeDoDbContext>
|
||||
{
|
||||
private readonly Func<ClaudeDoDbContext> _create;
|
||||
public TestDbFactory(Func<ClaudeDoDbContext> create) => _create = create;
|
||||
public ClaudeDoDbContext CreateDbContext() => _create();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OpenConPtySessionCommand_RaisesOpenConPtySessionRequested_WithTaskId()
|
||||
{
|
||||
var vm = new TasksIslandViewModel(new TestDbFactory(NewContext), worker: null);
|
||||
string? requestedId = null;
|
||||
vm.OpenConPtySessionRequested += id => requestedId = id;
|
||||
|
||||
vm.OpenConPtySessionCommand.Execute(new TaskRowViewModel { Id = "t1" });
|
||||
|
||||
Assert.Equal("t1", requestedId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OpenConPtySessionCommand_NullRow_DoesNotRaiseEvent()
|
||||
{
|
||||
var vm = new TasksIslandViewModel(new TestDbFactory(NewContext), worker: null);
|
||||
var raised = false;
|
||||
vm.OpenConPtySessionRequested += _ => raised = true;
|
||||
|
||||
vm.OpenConPtySessionCommand.Execute(null);
|
||||
|
||||
Assert.False(raised);
|
||||
}
|
||||
}
|
||||
@@ -69,7 +69,7 @@ public sealed class AddSubtaskToolTests : IDisposable
|
||||
var argsBuilder = new ClaudeArgsBuilder();
|
||||
var state = TaskStateServiceBuilder.Build(dbFactory).State;
|
||||
var runner = new TaskRunner(fake, dbFactory, broadcaster, wtManager, argsBuilder, cfg,
|
||||
NullLogger<TaskRunner>.Instance, state, new TaskRunTokenRegistry(), new AttachmentStore());
|
||||
NullLogger<TaskRunner>.Instance, state, new TaskRunTokenRegistry(), new AttachmentStore(), new FakeSessionSkillSeeder());
|
||||
var waker = new ClaudeDo.Worker.Queue.QueueWaker();
|
||||
var picker = new ClaudeDo.Worker.Queue.QueuePicker(dbFactory);
|
||||
var overrideSlot = new OverrideSlotService(dbFactory, runner, NullLogger<OverrideSlotService>.Instance);
|
||||
|
||||
@@ -87,7 +87,7 @@ public sealed class BatchMcpToolsTests : IDisposable
|
||||
var wtManager = new WorktreeManager(new GitService(), dbFactory, cfg, NullLogger<WorktreeManager>.Instance);
|
||||
var state = TaskStateServiceBuilder.Build(dbFactory).State;
|
||||
var runner = new TaskRunner(new FakeClaudeProcess(), dbFactory, broadcaster, wtManager, new ClaudeArgsBuilder(), cfg,
|
||||
NullLogger<TaskRunner>.Instance, state, new TaskRunTokenRegistry(), new AttachmentStore());
|
||||
NullLogger<TaskRunner>.Instance, state, new TaskRunTokenRegistry(), new AttachmentStore(), new FakeSessionSkillSeeder());
|
||||
var overrideSlot = new OverrideSlotService(dbFactory, runner, NullLogger<OverrideSlotService>.Instance);
|
||||
return new QueueService(dbFactory, runner, cfg, NullLogger<QueueService>.Instance,
|
||||
new QueueWaker(), new QueuePicker(dbFactory), overrideSlot, state);
|
||||
|
||||
@@ -149,7 +149,7 @@ public sealed class ExternalMcpServiceTests : IDisposable
|
||||
var argsBuilder = new ClaudeArgsBuilder();
|
||||
var state = TaskStateServiceBuilder.Build(dbFactory).State;
|
||||
var runner = new TaskRunner(fake, dbFactory, broadcaster, wtManager, argsBuilder, cfg,
|
||||
NullLogger<TaskRunner>.Instance, state, new TaskRunTokenRegistry(), new AttachmentStore());
|
||||
NullLogger<TaskRunner>.Instance, state, new TaskRunTokenRegistry(), new AttachmentStore(), new FakeSessionSkillSeeder());
|
||||
var waker = new ClaudeDo.Worker.Queue.QueueWaker();
|
||||
var picker = new ClaudeDo.Worker.Queue.QueuePicker(dbFactory);
|
||||
var overrideSlot = new OverrideSlotService(dbFactory, runner, NullLogger<OverrideSlotService>.Instance);
|
||||
@@ -624,7 +624,7 @@ public sealed class ExternalMcpServiceTests : IDisposable
|
||||
{
|
||||
var listId = await SeedListAsync();
|
||||
var task = await SeedTaskAsync(listId);
|
||||
await _tasks.UpdateAgentSettingsAsync(task.Id, "claude-sonnet-4-6", "be concise", null, 10, CancellationToken.None);
|
||||
await _tasks.UpdateAgentSettingsAsync(task.Id, "claude-sonnet-4-6", "be concise", null, 10, ct: CancellationToken.None);
|
||||
var sut = BuildConfigSut();
|
||||
|
||||
var result = await sut.GetTaskConfig(task.Id, CancellationToken.None);
|
||||
|
||||
@@ -54,7 +54,7 @@ public sealed class PlanningHubTests : IDisposable
|
||||
private WorkerHub CreateHub()
|
||||
{
|
||||
var hub = new WorkerHub(
|
||||
null!, null!, null!, null!, null!, null!, null!, null!, null!,
|
||||
null!, null!, null!, null!, null!, _db.CreateFactory(), null!, null!, null!,
|
||||
_planning, _launcher, null!, null!, null!, null!, null!, null!, null!, null!,
|
||||
null!, new ClaudeDo.Worker.Online.OnlineInboxConfig(), new ClaudeDo.Worker.Online.OnlineTokenStore(),
|
||||
new ClaudeDo.Worker.Runner.PendingQuestionRegistry(), null!);
|
||||
@@ -85,6 +85,88 @@ public sealed class PlanningHubTests : IDisposable
|
||||
return (listId, task.Id);
|
||||
}
|
||||
|
||||
private async Task SeedRunAsync(string taskId, string? sessionId)
|
||||
{
|
||||
await new TaskRunRepository(_ctx).AddAsync(new TaskRunEntity
|
||||
{
|
||||
Id = Guid.NewGuid().ToString(),
|
||||
TaskId = taskId,
|
||||
RunNumber = 1,
|
||||
IsRetry = false,
|
||||
Prompt = "p",
|
||||
SessionId = sessionId,
|
||||
});
|
||||
}
|
||||
|
||||
private async Task<string> SeedWorktreeAsync(string taskId, WorktreeState state)
|
||||
{
|
||||
var path = Path.Combine(_rootDir, $"wt_{Guid.NewGuid():N}");
|
||||
Directory.CreateDirectory(path);
|
||||
await new WorktreeRepository(_ctx).AddAsync(new WorktreeEntity
|
||||
{
|
||||
TaskId = taskId,
|
||||
Path = path,
|
||||
BranchName = "claudedo/x",
|
||||
BaseCommit = "base",
|
||||
State = state,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
});
|
||||
return path;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResumeTaskInTerminal_LaunchesWithWorktreePathAndSessionId()
|
||||
{
|
||||
var (_, taskId) = await SeedAsync();
|
||||
await SeedRunAsync(taskId, "sess-99");
|
||||
var wtPath = await SeedWorktreeAsync(taskId, WorktreeState.Active);
|
||||
var hub = CreateHub();
|
||||
|
||||
await hub.ResumeTaskInTerminal(taskId);
|
||||
|
||||
Assert.Equal(1, _launcher.LaunchTerminalResumeCalls);
|
||||
Assert.Equal(wtPath, _launcher.LastResumeWorkingDir);
|
||||
Assert.Equal("sess-99", _launcher.LastResumeSessionId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResumeTaskInTerminal_NoSession_Throws()
|
||||
{
|
||||
var (_, taskId) = await SeedAsync();
|
||||
await SeedRunAsync(taskId, sessionId: null);
|
||||
await SeedWorktreeAsync(taskId, WorktreeState.Active);
|
||||
var hub = CreateHub();
|
||||
|
||||
await Assert.ThrowsAsync<HubException>(() => hub.ResumeTaskInTerminal(taskId));
|
||||
Assert.Equal(0, _launcher.LaunchTerminalResumeCalls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResumeTaskInTerminal_RunningTask_Throws()
|
||||
{
|
||||
var (_, taskId) = await SeedAsync();
|
||||
await SeedRunAsync(taskId, "sess-1");
|
||||
await SeedWorktreeAsync(taskId, WorktreeState.Active);
|
||||
var task = await _tasks.GetByIdAsync(taskId);
|
||||
task!.Status = TaskStatus.Running;
|
||||
await _tasks.UpdateAsync(task);
|
||||
var hub = CreateHub();
|
||||
|
||||
await Assert.ThrowsAsync<HubException>(() => hub.ResumeTaskInTerminal(taskId));
|
||||
Assert.Equal(0, _launcher.LaunchTerminalResumeCalls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResumeTaskInTerminal_NoWorktree_Throws()
|
||||
{
|
||||
var (_, taskId) = await SeedAsync();
|
||||
await SeedRunAsync(taskId, "sess-1");
|
||||
var hub = CreateHub();
|
||||
|
||||
await Assert.ThrowsAsync<HubException>(() => hub.ResumeTaskInTerminal(taskId));
|
||||
Assert.Equal(0, _launcher.LaunchTerminalResumeCalls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StartPlanningSessionAsync_ChangesStatusToPlanning_AndInvokesLauncher()
|
||||
{
|
||||
@@ -192,6 +274,19 @@ internal sealed class FakeTerminalLauncher : ITerminalLauncher
|
||||
LaunchResumeCalls++;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public int LaunchTerminalResumeCalls { get; private set; }
|
||||
public string? LastResumeWorkingDir { get; private set; }
|
||||
public string? LastResumeSessionId { get; private set; }
|
||||
|
||||
public Task LaunchResumeAsync(string workingDir, string claudeSessionId, CancellationToken cancellationToken)
|
||||
{
|
||||
if (ShouldThrow) throw new TerminalLaunchException("fake launch failure");
|
||||
LaunchTerminalResumeCalls++;
|
||||
LastResumeWorkingDir = workingDir;
|
||||
LastResumeSessionId = claudeSessionId;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class RecordingClientProxy : IClientProxy
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
using ClaudeDo.Data.Models;
|
||||
using ClaudeDo.Data.Repositories;
|
||||
using ClaudeDo.Worker.Hub;
|
||||
using ClaudeDo.Worker.Skills;
|
||||
using ClaudeDo.Worker.Tests.Infrastructure;
|
||||
using Xunit;
|
||||
|
||||
namespace ClaudeDo.Worker.Tests.Hub;
|
||||
|
||||
public sealed class SessionSkillsHubTests : IDisposable
|
||||
{
|
||||
private readonly DbFixture _db = new();
|
||||
|
||||
public void Dispose() => _db.Dispose();
|
||||
|
||||
private sealed class FakeSessionSkillRegistry : ISessionSkillRegistry
|
||||
{
|
||||
public List<SessionSkillEntity> Skills { get; } = new();
|
||||
public string? InstallUrl { get; private set; }
|
||||
public string? UpdateSourceUrl { get; private set; }
|
||||
public string? RemoveSourceUrl { get; private set; }
|
||||
public Exception? ThrowOnInstall { get; set; }
|
||||
|
||||
public Task<IReadOnlyList<string>> InstallAsync(string url, CancellationToken ct)
|
||||
{
|
||||
InstallUrl = url;
|
||||
if (ThrowOnInstall is not null) throw ThrowOnInstall;
|
||||
return Task.FromResult<IReadOnlyList<string>>(new List<string> { "ponytail", "ponytail-help" });
|
||||
}
|
||||
|
||||
public Task UpdateAsync(string sourceUrl, CancellationToken ct)
|
||||
{
|
||||
UpdateSourceUrl = sourceUrl;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task RemoveAsync(string sourceUrl, CancellationToken ct)
|
||||
{
|
||||
RemoveSourceUrl = sourceUrl;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<SessionSkillEntity>> ListAsync(CancellationToken ct)
|
||||
=> Task.FromResult<IReadOnlyList<SessionSkillEntity>>(Skills);
|
||||
}
|
||||
|
||||
private (WorkerHub hub, FakeSessionSkillRegistry registry) CreateHub()
|
||||
{
|
||||
var registry = new FakeSessionSkillRegistry();
|
||||
var broadcaster = new HubBroadcaster(new CapturingHubContext());
|
||||
var hub = new WorkerHub(
|
||||
null!, null!, null!, null!, broadcaster, _db.CreateFactory(),
|
||||
null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, null!,
|
||||
null!, new ClaudeDo.Worker.Online.OnlineInboxConfig(), new ClaudeDo.Worker.Online.OnlineTokenStore(),
|
||||
new ClaudeDo.Worker.Runner.PendingQuestionRegistry(), registry);
|
||||
hub.Clients = new FakeHubCallerClients(new RecordingClientProxy());
|
||||
hub.Context = new FakeHubCallerContext();
|
||||
return (hub, registry);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetSessionSkills_maps_registry_rows_to_dtos()
|
||||
{
|
||||
var (hub, registry) = CreateHub();
|
||||
registry.Skills.Add(new SessionSkillEntity
|
||||
{
|
||||
Name = "ponytail",
|
||||
SourceUrl = "https://example.com/skills.git",
|
||||
PinnedRef = "abc123",
|
||||
Subpath = "skills/ponytail",
|
||||
Description = "A skill",
|
||||
AddedAt = DateTimeOffset.UtcNow,
|
||||
});
|
||||
|
||||
var result = await hub.GetSessionSkills();
|
||||
|
||||
var dto = Assert.Single(result);
|
||||
Assert.Equal("ponytail", dto.Name);
|
||||
Assert.Equal("A skill", dto.Description);
|
||||
Assert.Equal("https://example.com/skills.git", dto.SourceUrl);
|
||||
Assert.Equal("abc123", dto.PinnedRef);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InstallSessionSkill_returns_installed_names_and_forwards_url()
|
||||
{
|
||||
var (hub, registry) = CreateHub();
|
||||
|
||||
var installed = await hub.InstallSessionSkill("https://example.com/skills.git");
|
||||
|
||||
Assert.Equal(new List<string> { "ponytail", "ponytail-help" }, installed);
|
||||
Assert.Equal("https://example.com/skills.git", registry.InstallUrl);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InstallSessionSkill_wraps_InvalidOperationException_as_HubException()
|
||||
{
|
||||
var (hub, registry) = CreateHub();
|
||||
registry.ThrowOnInstall = new InvalidOperationException("boom");
|
||||
|
||||
var ex = await Assert.ThrowsAsync<Microsoft.AspNetCore.SignalR.HubException>(
|
||||
() => hub.InstallSessionSkill("https://example.com/skills.git"));
|
||||
Assert.Equal("boom", ex.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateSessionSkill_forwards_source_url()
|
||||
{
|
||||
var (hub, registry) = CreateHub();
|
||||
|
||||
await hub.UpdateSessionSkill("https://example.com/skills.git");
|
||||
|
||||
Assert.Equal("https://example.com/skills.git", registry.UpdateSourceUrl);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RemoveSessionSkill_forwards_source_url()
|
||||
{
|
||||
var (hub, registry) = CreateHub();
|
||||
|
||||
await hub.RemoveSessionSkill("https://example.com/skills.git");
|
||||
|
||||
Assert.Equal("https://example.com/skills.git", registry.RemoveSourceUrl);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateAppSettings_then_GetAppSettings_RoundTrips_SessionSkills()
|
||||
{
|
||||
var (hub, _) = CreateHub();
|
||||
var current = await hub.GetAppSettings();
|
||||
|
||||
await hub.UpdateAppSettings(current with { SessionSkills = new List<string> { "ponytail", "ponytail-help" } });
|
||||
var reloaded = await hub.GetAppSettings();
|
||||
|
||||
Assert.Equal(new List<string> { "ponytail", "ponytail-help" }, reloaded.SessionSkills);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateAppSettings_EmptySessionSkills_PersistsAsNull()
|
||||
{
|
||||
var (hub, _) = CreateHub();
|
||||
var current = await hub.GetAppSettings();
|
||||
|
||||
await hub.UpdateAppSettings(current with { SessionSkills = new List<string> { "ponytail" } });
|
||||
await hub.UpdateAppSettings(current with { SessionSkills = new List<string>() });
|
||||
var reloaded = await hub.GetAppSettings();
|
||||
|
||||
Assert.Null(reloaded.SessionSkills);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateListConfig_then_GetListConfig_RoundTrips_SessionSkills()
|
||||
{
|
||||
var (hub, _) = CreateHub();
|
||||
var listId = Guid.NewGuid().ToString();
|
||||
using (var ctx = _db.CreateContext())
|
||||
{
|
||||
await new ListRepository(ctx).AddAsync(new ListEntity { Id = listId, Name = "L", CreatedAt = DateTime.UtcNow });
|
||||
}
|
||||
|
||||
await hub.UpdateListConfig(new UpdateListConfigDto(listId, null, null, null, null, new List<string> { "ponytail" }));
|
||||
var config = await hub.GetListConfig(listId);
|
||||
|
||||
Assert.NotNull(config);
|
||||
Assert.Equal(new List<string> { "ponytail" }, config!.SessionSkills);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateTaskAgentSettings_Persists_SessionSkills()
|
||||
{
|
||||
var (hub, _) = CreateHub();
|
||||
var listId = Guid.NewGuid().ToString();
|
||||
var taskId = Guid.NewGuid().ToString();
|
||||
using (var ctx = _db.CreateContext())
|
||||
{
|
||||
await new ListRepository(ctx).AddAsync(new ListEntity { Id = listId, Name = "L", CreatedAt = DateTime.UtcNow });
|
||||
await new TaskRepository(ctx).AddAsync(new TaskEntity
|
||||
{
|
||||
Id = taskId, ListId = listId, Title = "T", CreatedAt = DateTime.UtcNow,
|
||||
});
|
||||
}
|
||||
|
||||
await hub.UpdateTaskAgentSettings(new UpdateTaskAgentSettingsDto(
|
||||
taskId, null, null, null, null, new List<string> { "ponytail", "ponytail-help" }));
|
||||
|
||||
using var readCtx = _db.CreateContext();
|
||||
var entity = await new TaskRepository(readCtx).GetByIdAsync(taskId);
|
||||
Assert.NotNull(entity);
|
||||
Assert.Equal("[\"ponytail\",\"ponytail-help\"]", entity!.SessionSkills);
|
||||
}
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
using ClaudeDo.Worker.Runner.Interfaces;
|
||||
|
||||
namespace ClaudeDo.Worker.Tests.Infrastructure;
|
||||
|
||||
public sealed class FakeClaudeStreamTransport : IClaudeStreamTransport
|
||||
{
|
||||
public List<string> Written { get; } = [];
|
||||
public bool Killed { get; private set; }
|
||||
public bool Started { get; private set; }
|
||||
|
||||
public event Func<string, Task>? LineReceived;
|
||||
public event Func<string, Task>? StderrReceived;
|
||||
|
||||
public Task StartAsync(IReadOnlyList<string> args, string workingDirectory, CancellationToken ct)
|
||||
{
|
||||
Started = true;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task WriteLineAsync(string jsonLine, CancellationToken ct)
|
||||
{
|
||||
Written.Add(jsonLine);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public void Kill() => Killed = true;
|
||||
|
||||
public Task WaitForExitAsync() => Task.CompletedTask;
|
||||
|
||||
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
|
||||
|
||||
// Test helper: push a simulated stdout line to all LineReceived subscribers.
|
||||
public async Task PushLineAsync(string line)
|
||||
{
|
||||
var handler = LineReceived;
|
||||
if (handler is not null)
|
||||
await handler(line);
|
||||
}
|
||||
|
||||
// Test helper: push a simulated stderr line.
|
||||
public async Task PushStderrAsync(string line)
|
||||
{
|
||||
var handler = StderrReceived;
|
||||
if (handler is not null)
|
||||
await handler(line);
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user