Compare commits

...
29 Commits
Author SHA1 Message Date
mika kuns 85c7e650c9 docs(interactive): update ConPTY spec + open items to final state
Changelog / changelog (push) Successful in 2s
Release / release (push) Successful in 41s
Correct the spec's binding approach (library LaunchProcess, not the abandoned
custom-pty bypass), add the monospace-font sizing gotcha, on-demand worktree +
prompt seeding, and the deferred Avalonia 12.1 upgrade. Add a ConPTY manual-
verification entry to open.md.
2026-07-23 16:47:16 +02:00
mika kuns c412a84fdf refactor(interactive): remove streaming interactive stack (superseded by ConPTY)
The embedded ConPTY terminal replaced the in-app streaming interactive session, so
delete the dead stack: StreamingClaudeSession, InteractiveSessionService,
ProcessClaudeStreamTransport, IClaudeStreamTransport, ILiveSession, LiveSessionRegistry,
IdleSessionReaper (+ WorkerConfig.InteractiveIdleTimeoutMinutes), the WorkerHub
interactive methods + HubBroadcaster events, IWorkerClient interactive members, the
TaskMonitorViewModel composer + SessionTerminalView composer markup, and the old
'Run interactively' entry. AskUser/PendingQuestionRegistry, the autonomous path,
planning, ResumeTaskInTerminal, and all ConPTY code are kept. Localization pruned.
2026-07-23 16:47:16 +02:00
mika kuns d8194ad57e feat(interactive): seed fresh task session with the task prompt
A fresh (non-resume) task-based ConPTY session now opens claude on the task's
prompt (title + description) as the positional argument, so the session starts on
the task instead of an empty prompt. Resume sessions and ad-hoc sessions are
unchanged.
2026-07-23 16:47:16 +02:00
mika kuns d9a4627a1f fix(interactive): set monospace font + stretch on ConPTY terminal
The control derives Cols/Rows from arranged-size / character-cell-size; without an
explicit monospace font (as the working spike set) the cell metrics are off and the
terminal miscomputes its size, so the child TUI renders into the wrong area. Match
the spike's font/BufferSize and stretch to fill the pane.
2026-07-23 16:47:15 +02:00
mika kuns 2b06ab0ab4 fix(interactive): use library LaunchProcess instead of custom pty bypass
The custom Porta.Pty bypass (own read loop, key tunneling, resize sync) rendered
wrong, lagged, and dropped input. The spike proved TerminalControl.LaunchProcess()
renders correctly and stays responsive, so hand pty/input/render/resize/focus back
to the library. PtyTerminalSession shrinks to a thin wrapper: apply descriptor.Env
process-wide (Porta.Pty inherits the process env; no per-launch env seam), set
Process/Args/StartingDirectory, LaunchProcess(). Process="" still suppresses the
control's auto-launch so exactly one process starts.
2026-07-23 16:47:15 +02:00
mika kuns bb62740ac8 fix(interactive): correct ConPTY terminal size + reduce lag
Sizing: the pty was spawned at the stale 80x24 default because terminal.Cols/Rows
were read before any layout pass; and a resize during the spawn await was missed.
Force UpdateLayout() before reading the size, subscribe Resized before spawn,
resync once after, and add a LayoutUpdated-driven resync (deduped) as a safety net.
Lag: the read loop awaited a UI-thread dispatch per chunk, serializing pipe reads
behind rendering; switch to Dispatcher.Post (FIFO preserved, no backpressure).
2026-07-23 16:47:15 +02:00
mika kuns 25922a2768 fix(interactive): forward keyboard input to ConPTY terminal
TerminalView's OnKeyDown/OnTextInput early-return when its private pty connection
is null -- which it always is, since we bypass LaunchProcess() to inject a custom
env -- so keystrokes were silently dropped, and Terminal.DataReceived only carries
terminal auto-replies, never user input. Tunnel KeyDown/TextInput on the control,
translate via the terminal's public GenerateKeyInput/GenerateCharInput, and write
to our own IPtyConnection. Focus the control on start (LaunchProcess would have).
2026-07-23 16:47:15 +02:00
mika kuns 3feb08d9d9 feat(interactive): New session button for ad-hoc ConPTY sessions
Adds a 'New session' header button in Mission Control that opens a folder picker
and starts a task-less embedded ConPTY session in the chosen directory. ConPtyPaneViewModel
TaskId is now nullable (ad-hoc panes have no task and are never deduped) with a
CreateAdHoc factory; the view does the picking, the VM stays picker-agnostic.
2026-07-23 16:47:15 +02:00
mika kuns 9ab48d7094 feat(interactive): fresh-worktree-on-demand + ad-hoc launch specs
BuildForTaskAsync now creates a worktree on demand (via WorktreeManager.CreateAsync,
the same path TaskRunner uses) when a task has a configured working dir but no
Active/Kept worktree, returning a fresh-start spec -- so never-run tasks can be
opened interactively. Adds BuildForDirectoryAsync + GetAdHocLaunchSpec hub/client
for ad-hoc sessions in an arbitrary directory (no task, no worktree, no skill seeding).
2026-07-23 16:47:15 +02:00
mika kuns 0513265c49 feat(interactive): host task-based ConPTY sessions in Command Center
Adds an 'Open ConPTY session' entry that fetches a task's launch spec and hosts
an embedded ConPTY terminal as a Mission Control pane, coexisting with the
streamed-log monitor panes (streaming stack untouched). Introduces IMissionControlPane
+ ConPtyPaneViewModel, a non-destructive Panes mirror (Monitors prefix + ConPtySessions
suffix) so unrelated monitor churn never tears down a live terminal, and a grid<->tabs
layout toggle. Launch failures surface via the footer error strip.
2026-07-23 16:47:15 +02:00
mika kuns d28c63d2df docs(interactive): record ConPTY library + binding decision from spike 2026-07-23 16:47:15 +02:00
mika kuns 5f740c05d8 feat(interactive): embedded ConPTY terminal host in UI
Adds a self-contained terminal host: PtyTerminalSession drives a Porta.Pty child
directly (custom env) and pumps it through Iciclecreek's XTerm.NET renderer via
TerminalControl.Terminal, bypassing LaunchProcess() so a fully-populated
environment can be passed. InteractiveTerminalView/ViewModel host it with an
order-independent attach/start. Adds Iciclecreek.Avalonia.Terminal 2.0.3.
Sets Process="" to suppress the control's auto-launch of a stray shell.
2026-07-23 16:47:15 +02:00
mika kuns 1245e75902 feat(interactive): worker launch-spec for embedded ConPTY sessions
Adds InteractiveLaunchSpecService + GetInteractiveLaunchSpec hub method that
prepares a task worktree (session-skills seeding, run env) and returns a
LaunchSpec {cwd,exe,args,env} for a UI-hosted ConPTY terminal. Reuses
ISessionSkillSeeder, TaskRunner.UnionSkillNames, and WindowsTerminalLauncher
resume-arg/resolve logic. Guards mirror ResumeTaskInTerminal; a never-run task
yields a fresh-start spec instead of an error.
2026-07-23 16:47:15 +02:00
mika kuns d91ad2d635 docs(interactive): ConPTY interactive sessions spec + plan 2026-07-23 16:47:15 +02:00
mika kuns 17235a6cde Fix empty error message 2026-07-23 16:47:15 +02:00
Mika Kuns f33838d028 docs(open): session skills verification items 2026-07-23 16:47:14 +02:00
Mika Kuns 1cfd96c15f test(ui): load Loc.Current in session-skills tab test to fix ordering flake 2026-07-23 16:47:14 +02:00
Mika Kuns 7c3c061428 feat(ui): session skills registry tab + per-level selectors 2026-07-23 16:47:14 +02:00
Mika Kuns b4c58087d2 feat(worker): session skills SignalR surface + per-level persistence 2026-07-23 16:47:14 +02:00
Mika Kuns 4626481359 feat(worker): resolve and seed session skills before each run 2026-07-23 16:47:14 +02:00
Mika Kuns dea2b7db8b feat(worker): session skill registry (install/update/remove, pinned clone) 2026-07-23 16:47:14 +02:00
Mika Kuns 54cdaf89d5 feat(data): session skills entity, repository, and migration 2026-07-23 16:47:14 +02:00
Mika Kuns dbaefe92c6 docs(skills): mark cwd-skill discovery verified in headless mode 2026-07-23 16:47:14 +02:00
Mika Kuns 62b245aaea docs(skills): revise for multi-skill plugin repos (ponytail) 2026-07-23 16:47:14 +02:00
Mika Kuns 4e5057d3f6 docs(skills): spec + plan for per-level session skills 2026-07-23 16:47:14 +02:00
Mika Kuns 1bf08eca27 docs(open): pick up a task's session in a terminal — verification 2026-07-23 16:47:14 +02:00
Mika Kuns eb88dc130c feat(ui): pick up a task's session in a terminal 2026-07-23 16:47:13 +02:00
Mika Kuns 140ae2fda1 feat(worker): resume a task's claude session in a terminal 2026-07-23 16:47:13 +02:00
Mika Kuns 865e12c0de fix(worker): seed planning brief via file to avoid newline truncation 2026-07-23 16:47:13 +02:00
121 changed files with 5621 additions and 2395 deletions
+15 -7
View File
@@ -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 14 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.
+1
View File
@@ -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; }
}
+1
View File
@@ -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
+26 -13
View File
@@ -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" },
+26 -13
View File
@@ -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" },
+2 -2
View File
@@ -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).
+1
View File
@@ -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);
+38 -54
View File
@@ -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="&#x276F;"
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>
+3 -2
View File
@@ -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
View File
@@ -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);
}
-12
View File
@@ -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);
}
+108 -22
View File
@@ -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;
+5 -3
View File
@@ -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();
}
+10
View File
@@ -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();
}
}
+67 -2
View File
@@ -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);
}
}
+9 -34
View File
@@ -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);
+1 -1
View File
@@ -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