diff --git a/README.md b/README.md index 0c442b378..83d9ad35e 100644 --- a/README.md +++ b/README.md @@ -168,6 +168,7 @@ kcap import --kiro # only Kiro kcap import --pi # only Pi (badlogic/pi-mono) kcap import --opencode # only OpenCode kcap import --antigravity # only Antigravity +kcap import --dsh # only DeepSeek Harness (experimental — AI-2020) ``` > **Already-running sessions.** On a *first* `plugin install --kiro`, any Kiro session already running loaded no kcap integration, so it isn't captured live — the install names it and where it is. It is not lost: the agent writes its transcript to disk regardless, so `kcap import --kiro` backfills it once it ends. kcap deliberately does not offer to restart it, which would mean killing an interactive session on a terminal it does not own with no way to relaunch it. Nothing is printed when there is no such session, or when you re-run an install you already had — that session started *with* the integration and is being captured. @@ -176,9 +177,11 @@ kcap import --antigravity # only Antigravity > **OpenCode** likewise has no shell hooks: live capture uses a shipped OpenCode plugin. Run `kcap plugin install --opencode` (or accept the `kcap setup` prompt) to write `~/.config/opencode/plugins/kcap.ts`, which `opencode` auto-loads and streams each session live (`vendor=opencode`). Subagents (the `task` tool / `@agent`) are captured too — the plugin fetches each child session via the SDK and streams it, so it nests under the parent in the trace. Historical `kcap import --opencode` reads OpenCode's SQLite database (`~/.local/share/opencode/opencode.db`) and imports every transitive descendant session (children, grandchildren, and so on — see [Loading historical sessions](#loading-historical-sessions)), so it backfills sessions from before the plugin was installed. +> **DeepSeek Harness (`dsh`)** is an **experimental spike** (AI-2020). dsh's session module makes persistence a plugin concern, so the shipped kcap Cordis plugin (`DshExtensionInstaller`; source `deepseek-harness/kcap-dsh.mts`) forwards every `SessionEvent` to `~/.cache/kcap/dsh/{id}.jsonl` and spawns `kcap hook --dsh` so the watcher tails it live (`vendor=dsh`); `kcap import --dsh` replays the same files, and subagents nest under their parent (from the transcript header's `parentSession`). Run `kcap plugin install --dsh` to write the plugin to `$DSH_HOME/kcap-dsh.plugin.mjs` (default `~/.dsh`) and register it in each profile's live-watched `cordis.patch.yml`. (The kcap MCP servers for the dsh agent are documented in `docs/DSH_NORMALIZER.md`.) + > **Codex** collab subagents (Codex CLI 0.146+, the `spawn_agent` collaboration tools) are captured too. Each subagent thread writes its own rollout under `~/.codex/sessions/`; the live watcher discovers children by the parent linkage in their rollout header and streams each one nested under the parent session, and `kcap import --codex` does the same for history — a subagent rollout never imports as a separate top-level session (see [Loading historical sessions](#loading-historical-sessions)). -This backfills your past sessions from `~/.claude/projects/` (Claude), `~/.codex/sessions/` (Codex), `~/.cursor/projects/.../agent-transcripts/` (Cursor), `~/.copilot/session-state/` (Copilot), `~/.gemini/tmp//chats/` (Gemini), `~/.kiro/sessions/cli/` (Kiro), `~/.pi/agent/sessions/` (Pi), `~/.local/share/opencode/opencode.db` (OpenCode), and both `~/.gemini/antigravity/brain/` (GUI) and `~/.gemini/antigravity-cli/brain/` (the `agy` CLI) (Antigravity) so they appear in the dashboard. All agents are discovered automatically — pass `--claude`, `--codex`, `--cursor`, `--copilot`, `--gemini`, `--kiro`, `--pi`, `--opencode`, or `--antigravity` (one or more) to narrow the run. All forms are idempotent — safe to run multiple times. Each run ends with `N imported · N skipped · N failed`, then a breakdown of why each session was skipped. Failures never abort the run or change the exit code: everything that could be imported still is, and because the run is idempotent, re-running retries the failures without re-sending anything already on the server. +This backfills your past sessions from `~/.claude/projects/` (Claude), `~/.codex/sessions/` (Codex), `~/.cursor/projects/.../agent-transcripts/` (Cursor), `~/.copilot/session-state/` (Copilot), `~/.gemini/tmp//chats/` (Gemini), `~/.kiro/sessions/cli/` (Kiro), `~/.pi/agent/sessions/` (Pi), `~/.local/share/opencode/opencode.db` (OpenCode), and both `~/.gemini/antigravity/brain/` (GUI) and `~/.gemini/antigravity-cli/brain/` (the `agy` CLI) (Antigravity) so they appear in the dashboard. All agents are discovered automatically — pass `--claude`, `--codex`, `--cursor`, `--copilot`, `--gemini`, `--kiro`, `--pi`, `--opencode`, `--antigravity`, or `--dsh` (one or more) to narrow the run. All forms are idempotent — safe to run multiple times. Each run ends with `N imported · N skipped · N failed`, then a breakdown of why each session was skipped. Failures never abort the run or change the exit code: everything that could be imported still is, and because the run is idempotent, re-running retries the failures without re-sending anything already on the server. You must pick an explicit scope (`--all`, `--org`, or `--repo`) so personal/private repos aren't uploaded by accident. `--org ` filters by the git-remote owner (GitHub org/user) detected on each session — independent of your profile name, so it behaves identically under GitHub and WorkOS sign-in. A bare `--org` lets you pick an owner from your discovered repos and remembers it for next time. Run with no scope on an interactive terminal to get a picker. See [Loading historical sessions](#loading-historical-sessions) for the full set of flags. diff --git a/src/Capacitor.Cli.Core/Dsh/DshExtensionInstaller.cs b/src/Capacitor.Cli.Core/Dsh/DshExtensionInstaller.cs new file mode 100644 index 000000000..c33e16c04 --- /dev/null +++ b/src/Capacitor.Cli.Core/Dsh/DshExtensionInstaller.cs @@ -0,0 +1,209 @@ +namespace Capacitor.Cli.Core.Dsh; + +/// +/// Installs / removes kcap's live-ingest plugin for DeepSeek Harness (dsh). +/// dsh is a Cordis-based agent whose session module declares "persistence is a plugin +/// concern" — so kcap ships a dependency-free Cordis persistence plugin that forwards +/// every appended SessionEvent to ~/.cache/kcap/dsh/{id}.jsonl, writes the +/// durable header on session/created and a terminal marker on session/disposed, +/// and spawns kcap hook --dsh --event session-start so the watcher tails that file +/// (vendor=dsh). This mirrors the OpenCode plugin; the watcher owns session-end. +/// +/// Install = copy to +/// ($DSH_HOME/kcap-dsh.plugin.mjs) and add an entry to dsh's Cordis config +/// (cordis.yml / the active profile): - name: './kcap-dsh.plugin.mjs'. The +/// copy + version-marker mechanics below are the automatable part; registering the entry in +/// dsh's profile/patch config is left to dsh plugin / a manual one-line edit (see the +/// plugin comment) because that format is dsh-profile-specific. +/// +/// is embedded as a const (no manifest-resource +/// reflection) to stay NativeAOT-safe, mirroring . +/// +public static class DshExtensionInstaller { + public const string MarkerFileName = ".kcap-extension-version"; + + /// + /// The kcap dsh Cordis persistence plugin (plain-JS build, for dsh's --patch install). + /// Dependency-free (only node: builtins) and fail-open — a kcap/server problem must + /// never disrupt the dsh session. Kept byte-for-byte in sync with the source at + /// deepseek-harness/kcap-dsh.mts. + /// + public const string ExtensionContent = + """ + // kcap observer plugin for dsh (plain-JS build for --patch install). Fail-open. + import { appendFileSync, mkdirSync } from 'node:fs' + import { join } from 'node:path' + import { homedir } from 'node:os' + import { spawn } from 'node:child_process' + export const name = 'kcap' + export function apply(ctx) { + const dir = join(homedir(), '.cache', 'kcap', 'dsh') + try { mkdirSync(dir, { recursive: true }) } catch {} + const fileFor = id => join(dir, `${id}.jsonl`) + const write = (id, rec) => { try { appendFileSync(fileFor(id), JSON.stringify(rec) + '\n') } catch {} } + const runHook = (id, event, extra = []) => { + try { + const c = spawn('kcap', ['hook','--dsh','--event',event,'--session',id,'--file',fileFor(id), ...extra], { stdio: 'ignore', detached: true }) + c.on('error', () => {}); c.unref() + } catch {} + } + const hookArgs = (h = {}) => { + // dsh's session header carries cwd (+ id/createdAt/agentPreset) but NOT model/provider; + // its `version` is the schema version, not an app version — so forward only cwd. + const cwd = h.cwd || process.cwd() + return cwd ? ['--cwd', cwd] : [] + } + ctx.on('session/created', s => { write(s.id, { $kcap: 'header', ...s.header }); runHook(s.id, 'session-start', hookArgs(s.header)) }) + ctx.on('session/event', (s, e) => write(s.id, e)) + ctx.on('session/disposed', s => { write(s.id, { $kcap: 'disposed', id: s.id }); runHook(s.id, 'session-end', ['--reason','disposed', ...hookArgs(s.header)]) }) + } + export default apply + """; + + /// + /// True when the plugin (or its marker) is present. Marker covers the case where a + /// user deleted the plugin but kept the dir. + /// + public static bool IsInstalled(string pluginPath) { + if (File.Exists(pluginPath)) return true; + var dir = Path.GetDirectoryName(pluginPath); + return dir is not null && File.Exists(Path.Combine(dir, MarkerFileName)); + } + + public static string? ReadMarker(string pluginPath) { + var dir = Path.GetDirectoryName(pluginPath); + if (string.IsNullOrEmpty(dir)) return null; + var marker = Path.Combine(dir, MarkerFileName); + try { return File.Exists(marker) ? File.ReadAllText(marker).Trim() : null; } + catch { return null; } + } + + public static void WriteMarker(string pluginPath) { + var dir = Path.GetDirectoryName(pluginPath); + if (string.IsNullOrEmpty(dir)) return; + try { + Directory.CreateDirectory(dir); + File.WriteAllText(Path.Combine(dir, MarkerFileName), CapacitorVersion.Current()); + } catch { /* best effort */ } + } + + public static void DeleteMarker(string pluginPath) { + var dir = Path.GetDirectoryName(pluginPath); + if (string.IsNullOrEmpty(dir)) return; + var marker = Path.Combine(dir, MarkerFileName); + try { if (File.Exists(marker)) File.Delete(marker); } catch { } + } + + public static bool Install(string pluginPath) { + try { + Directory.CreateDirectory(Path.GetDirectoryName(pluginPath)!); + File.WriteAllText(pluginPath, ExtensionContent); + WriteMarker(pluginPath); + return true; + } catch { + return false; + } + } + + /// Removes the plugin + marker. Returns true if the plugin existed. + public static bool Remove(string pluginPath) { + var existed = File.Exists(pluginPath); + try { + if (existed) File.Delete(pluginPath); + DeleteMarker(pluginPath); + } catch { + return false; + } + return existed; + } + + // ── Cordis profile registration ────────────────────────────────────────── + // dsh loads plugins from the active profile's live-watched cordis.patch.yml (a top-level YAML + // array of patch entries). We register the observer plugin via an idempotent, marker-delimited + // managed block so `install` can update it and `remove` can strip it without touching the user's + // own entries. Only the dependency-free `file://` observer plugin is registered here; the MCP + // entries (which depend on the dsh-mcp-client bundle and could fail-loud) stay documented. + + const string CordisBeginMarker = "# --- kcap-dsh:begin (kcap plugin install --dsh) — do not edit inside ---"; + const string CordisEndMarker = "# --- kcap-dsh:end ---"; + + /// The managed cordis.patch.yml block that registers the observer plugin. + public static string BuildCordisBlock(string pluginPath) { + var uri = new Uri(pluginPath).AbsoluteUri; // file:///C:/... on Windows, file:///home/... on Unix + return CordisBeginMarker + "\n" + + "- insert:\n" + + " - id: kcap\n" + + $" name: '{uri}'\n" + + CordisEndMarker; + } + + /// Idempotently writes the managed block into a profile's cordis.patch.yml. Preserves + /// the user's own array entries; replaces any prior managed block. Returns true on success. + public static bool RegisterInCordisPatch(string cordisPatchPath, string pluginPath) { + try { + var existing = File.Exists(cordisPatchPath) ? File.ReadAllText(cordisPatchPath) : ""; + var stripped = StripManagedBlock(existing); + var block = BuildCordisBlock(pluginPath); + + var lines = stripped.Replace("\r\n", "\n").Split('\n'); + string result; + if (HasRealEntries(lines)) { + // Append after the user's own block-style entries. + result = stripped.TrimEnd() + "\n" + block + "\n"; + } else { + // Base array is empty (comments / whitespace / a lone `[]` flow literal). A block-style + // array can't follow a `[]` in one document, so drop that literal but keep any comments. + var comments = string.Join("\n", lines.Where(l => l.Trim() != "[]")).TrimEnd(); + result = (comments.Length == 0 ? block : comments + "\n" + block) + "\n"; + } + + Directory.CreateDirectory(Path.GetDirectoryName(cordisPatchPath)!); + File.WriteAllText(cordisPatchPath, result); + return true; + } catch { + return false; + } + } + + /// Strips the managed block; restores an empty array ([]) if no real entries remain + /// (keeping any comments). Returns true if the file existed. + public static bool UnregisterFromCordisPatch(string cordisPatchPath) { + try { + if (!File.Exists(cordisPatchPath)) return false; + var stripped = StripManagedBlock(File.ReadAllText(cordisPatchPath)); + var lines = stripped.Replace("\r\n", "\n").Split('\n'); + string result; + if (HasRealEntries(lines)) { + result = stripped.TrimEnd() + "\n"; + } else { + var comments = string.Join("\n", lines.Where(l => l.Trim() != "[]")).TrimEnd(); + result = (comments.Length == 0 ? "[]" : comments + "\n[]") + "\n"; + } + File.WriteAllText(cordisPatchPath, result); + return true; + } catch { + return false; + } + } + + /// True if the lines contain a real YAML array entry (not just comments, whitespace, + /// or a lone [] flow literal). + static bool HasRealEntries(IEnumerable lines) => + lines.Any(l => { var t = l.Trim(); return t.Length > 0 && t != "[]" && !t.StartsWith('#'); }); + + public static bool IsRegisteredInCordisPatch(string cordisPatchPath) { + try { return File.Exists(cordisPatchPath) && File.ReadAllText(cordisPatchPath).Contains("kcap-dsh:begin"); } + catch { return false; } + } + + static string StripManagedBlock(string content) { + var sb = new System.Text.StringBuilder(); + var inBlock = false; + foreach (var line in content.Replace("\r\n", "\n").Split('\n')) { + if (!inBlock && line.Contains("kcap-dsh:begin")) { inBlock = true; continue; } + if (inBlock) { if (line.Contains("kcap-dsh:end")) inBlock = false; continue; } + sb.Append(line).Append('\n'); + } + return sb.ToString(); + } +} diff --git a/src/Capacitor.Cli.Core/Dsh/DshPaths.cs b/src/Capacitor.Cli.Core/Dsh/DshPaths.cs new file mode 100644 index 000000000..5a139abcb --- /dev/null +++ b/src/Capacitor.Cli.Core/Dsh/DshPaths.cs @@ -0,0 +1,70 @@ +namespace Capacitor.Cli.Core.Dsh; + +/// +/// Filesystem layout for DeepSeek Harness (dsh). dsh is a Cordis-based agent +/// whose session module declares "persistence is a plugin concern". The shipped kcap +/// Cordis plugin () forwards every appended +/// SessionEvent to a per-session JSONL file under the kcap cache, which +/// kcap watch --vendor dsh tails and kcap import --dsh replays — one +/// server-side normalizer serves both feeds. This mirrors OpenCode's +/// ~/.cache/kcap/opencode/<id>.jsonl layout exactly. +/// +public static class DshPaths { + /// dsh's home dir ($DSH_HOME, else ~/.dsh) — the Cordis profile + + /// installed plugin live here. + public static string DshHome(string? home = null) { + var dshHome = Environment.GetEnvironmentVariable("DSH_HOME"); + if (!string.IsNullOrEmpty(dshHome)) return dshHome; + + home ??= PathHelpers.HomeDirectory; + return Path.Combine(home, ".dsh"); + } + + /// Per-session transcript cache the kcap plugin writes and the watcher tails: + /// ~/.cache/kcap/dsh (flat {id}.jsonl). Matches the plugin's path verbatim + /// (homedir()/.cache/kcap/dsh, independent of $DSH_HOME). + public static string SessionsDir(string? home = null) { + home ??= PathHelpers.HomeDirectory; + return Path.Combine(home, ".cache", "kcap", "dsh"); + } + + /// The per-session transcript file (~/.cache/kcap/dsh/{id}.jsonl). + public static string SessionJsonl(string sessionId, string? home = null) => + Path.Combine(SessionsDir(home), $"{sessionId}.jsonl"); + + /// kcap's Cordis plugin, installed into the dsh home + /// ($DSH_HOME/kcap-dsh.plugin.mjs). Loaded by adding an entry to dsh's + /// cordis.yml / profile config. + public static string KcapPlugin(string? home = null) => + Path.Combine(DshHome(home), "kcap-dsh.plugin.mjs"); + + /// Version marker beside the installed plugin (mirrors the OpenCode installer). + public static string KcapPluginMarker(string? home = null) => + Path.Combine(DshHome(home), ".kcap-extension-version"); + + /// dsh profiles root ($DSH_HOME/profiles). Each profile subdir has a + /// package.json + a live-watched cordis.patch.yml where the plugin registers. + public static string ProfilesDir(string? home = null) => + Path.Combine(DshHome(home), "profiles"); + + /// A profile's user patch file (<profile>/cordis.patch.yml). + public static string CordisPatch(string profileDir) => + Path.Combine(profileDir, "cordis.patch.yml"); + + /// Detection: the dsh home exists (callers also OR + /// AgentDetector.IsInstalled("dsh") for binary-name coverage). + public static bool IsInstalled(string? home = null) => Directory.Exists(DshHome(home)); + + // ── Pure (no ambient env) variants for the HarnessCatalog/AgentDetection snapshot ── + // A null dshHome means genuinely unset (→ ~/.dsh under the injected home), never a re-read + // of the real $DSH_HOME. Mirror the other vendors' *Pure helpers. + + public static string DshHomePure(string? home, string? dshHome) => + !string.IsNullOrEmpty(dshHome) ? dshHome : Path.Combine(home ?? PathHelpers.HomeDirectory, ".dsh"); + + public static string KcapPluginPure(string? home, string? dshHome) => + Path.Combine(DshHomePure(home, dshHome), "kcap-dsh.plugin.mjs"); + + public static bool IsInstalledPure(string? home, string? dshHome) => + Directory.Exists(DshHomePure(home, dshHome)); +} diff --git a/src/Capacitor.Cli.Core/Dsh/DshSessionId.cs b/src/Capacitor.Cli.Core/Dsh/DshSessionId.cs new file mode 100644 index 000000000..8028cc34d --- /dev/null +++ b/src/Capacitor.Cli.Core/Dsh/DshSessionId.cs @@ -0,0 +1,36 @@ +using System.Security.Cryptography; +using System.Text; + +namespace Capacitor.Cli.Core.Dsh; + +/// +/// Canonicalizes a DeepSeek Harness session id to the system's ≤36-char, GUID-shaped +/// session-id contract. dsh names sessions session-<guid> / +/// main-session-<guid> (44–49 chars), which the read model's pervasive +/// length(session_id) <= 36 guard filters out of every session query. We extract +/// the embedded GUID as its dashless ("N") form (32 chars) so a dsh session keys exactly +/// like Claude/Codex/Cursor and lists everywhere. +/// +/// Applied identically on the live-hook and import paths so the transcript and lifecycle +/// converge on one stream. Ids already ≤36 with no embedded GUID (e.g. the offline PoC +/// kcap-live-poc-1) pass through unchanged; any other over-length id without a GUID +/// falls back to a stable 32-char hash so the contract always holds. +/// +public static class DshSessionId { + public static string Canonicalize(string rawId) { + if (string.IsNullOrEmpty(rawId)) return rawId; + + // Bare GUID (dashed or dashless) → dashless. + if (Guid.TryParse(rawId, out var whole)) return whole.ToString("N"); + + // dsh "-": the GUID is the trailing 36 chars. + if (rawId.Length >= 36 && Guid.TryParse(rawId[^36..], out var tail)) return tail.ToString("N"); + + // Short, non-GUID ids already satisfy the contract (PoC / synthetic ids). + if (rawId.Length <= 36) return rawId; + + // Over-length with no extractable GUID: stable, deterministic 32-char hash. + var hash = SHA256.HashData(Encoding.UTF8.GetBytes(rawId)); + return Convert.ToHexString(hash.AsSpan(0, 16)).ToLowerInvariant(); + } +} diff --git a/src/Capacitor.Cli.Core/Resources/help-hook.txt b/src/Capacitor.Cli.Core/Resources/help-hook.txt index 1fdb0596e..c555ba5ea 100644 --- a/src/Capacitor.Cli.Core/Resources/help-hook.txt +++ b/src/Capacitor.Cli.Core/Resources/help-hook.txt @@ -52,6 +52,14 @@ Options: kcap acts on the first PreInvocation of a conversation (spawns a watcher tailing its transcript_full.jsonl, vendor=antigravity) and synthesizes session-end on idle. Other events are no-ops. + --dsh Dispatch a DeepSeek Harness lifecycle event. dsh has no shell + hooks; the shipped Cordis plugin invokes this with arguments + (session-start AND session-end — dsh writes its own session.jsonl): + kcap hook --dsh --event session-start \ + --session --file [--cwd ] + kcap hook --dsh --event session-end --session --file + The watcher tails the file directly (vendor=dsh); session-end + kill+drains it then POSTs the terminal. Exit codes: 0 Normal exit (including budget expiry, malformed payload, and diff --git a/src/Capacitor.Cli.Core/Resources/help-import.txt b/src/Capacitor.Cli.Core/Resources/help-import.txt index 4a4ceb638..b89ce3cbd 100644 --- a/src/Capacitor.Cli.Core/Resources/help-import.txt +++ b/src/Capacitor.Cli.Core/Resources/help-import.txt @@ -13,7 +13,8 @@ ship a per-session .jsonl transcript (OpenCode is a SQLite db): Pi — ~/.pi/agent/sessions/.../_.jsonl (badlogic/pi-mono) OpenCode — ~/.local/share/opencode/opencode.db (SQLite; sessions + parts tables) Antigravity — ~/.gemini/antigravity/brain//.system_generated/logs/transcript_full.jsonl -kcap walks all nine the same way. + dsh — ~/.cache/kcap/dsh/.jsonl (written by the kcap Cordis plugin) +kcap walks all ten the same way. Usage: kcap import [vendor-filters] [scope] [options] @@ -31,6 +32,7 @@ Vendor filters (additive — pass one or more to restrict the run): --pi Import Pi (badlogic/pi-mono) session transcripts --opencode Import SST OpenCode sessions (~/.local/share/opencode/opencode.db) --antigravity Import Google Antigravity conversations (~/.gemini/antigravity/brain) + --dsh Import DeepSeek Harness sessions (~/.cache/kcap/dsh) Scope (one of, required for non-interactive use): --all Import every discovered session diff --git a/src/Capacitor.Cli.Core/Resources/help-plugin.txt b/src/Capacitor.Cli.Core/Resources/help-plugin.txt index c019e42d1..a681458ef 100644 --- a/src/Capacitor.Cli.Core/Resources/help-plugin.txt +++ b/src/Capacitor.Cli.Core/Resources/help-plugin.txt @@ -66,6 +66,13 @@ Options: hooks.json). Antigravity has no shell hooks; the GUI loads plugins at startup and streams each conversation. User-wide only — --project has no effect with --antigravity. + --dsh Target DeepSeek Harness (dsh): install the Cordis observer + plugin to $DSH_HOME/kcap-dsh.plugin.mjs (default ~/.dsh) and + register it in each profile's live-watched cordis.patch.yml + (an idempotent managed block). dsh has no shell hooks; the + plugin writes ~/.cache/kcap/dsh/.jsonl and spawns + `kcap hook --dsh` so the watcher captures each session. + (MCP registration is documented in DSH_NORMALIZER.md.) --skills Install ONLY the agent-agnostic skills to ~/.agents/skills/, with no hooks or MCP registration. Every per-vendor install (--codex, --cursor, --copilot, --gemini, --pi, --opencode) diff --git a/src/Capacitor.Cli.Core/Setup/AgentDetection.cs b/src/Capacitor.Cli.Core/Setup/AgentDetection.cs index d753fabb6..5a659b08f 100644 --- a/src/Capacitor.Cli.Core/Setup/AgentDetection.cs +++ b/src/Capacitor.Cli.Core/Setup/AgentDetection.cs @@ -1,3 +1,4 @@ +using Capacitor.Cli.Core.Dsh; using Capacitor.Cli.Core.Harness.Antigravity; using Capacitor.Cli.Core.Harness.Copilot; using Capacitor.Cli.Core.Harness.Cursor; @@ -24,7 +25,8 @@ public sealed record AgentDetectionInputs( string? PathEnv, string? PathExt, bool IsWindows, string? Home, string? KiroHome = null, string? PiAgentDir = null, string? OpenCodeConfigDir = null, string? XdgConfigHome = null, string? XdgDataHome = null, string? GeminiCliHome = null, - string? CopilotHome = null, OsPlatform Platform = OsPlatform.Linux, string? AppData = null); + string? CopilotHome = null, OsPlatform Platform = OsPlatform.Linux, string? AppData = null, + string? DshHome = null); /// /// One vendor's two independent detection signals: a PATH binary probe and a filesystem @@ -39,7 +41,7 @@ public sealed record DetectedAgent(bool BinaryFound, bool InstallSignalFound) { public sealed record AgentDetectionResult( DetectedAgent Claude, DetectedAgent Codex, DetectedAgent Cursor, DetectedAgent Copilot, DetectedAgent Gemini, DetectedAgent Kiro, DetectedAgent Pi, DetectedAgent OpenCode, - DetectedAgent Antigravity); + DetectedAgent Antigravity, DetectedAgent Dsh); /// /// Detects installed coding-agent CLIs by composing a PATH binary probe with each vendor's @@ -83,7 +85,10 @@ public static AgentDetectionResult Detect(AgentDetectionInputs i) { // IsInstalled covers either root; the PATH probes cover a fresh install that has // not created a root yet — and the CLI binary is `agy`, not `antigravity`, so both // names must be probed or an agy-only machine goes undetected. - Antigravity: new(Bin("antigravity") || Bin("agy"), AntigravityPaths.IsInstalledPure(home, i.GeminiCliHome))); + Antigravity: new(Bin("antigravity") || Bin("agy"), AntigravityPaths.IsInstalledPure(home, i.GeminiCliHome)), + // dsh keeps its Cordis profile + plugin under ~/.dsh (relocatable via DSH_HOME); + // the PATH probe covers a fresh install that hasn't created it yet. + Dsh: new(Bin("dsh"), DshPaths.IsInstalledPure(home, i.DshHome))); } /// Current-process defaults: real PATH/PATHEXT/HOME/env, matching what the CLI @@ -104,7 +109,8 @@ public static AgentDetectionResult Detect(AgentDetectionInputs i) { Platform: OperatingSystem.IsMacOS() ? OsPlatform.MacOs : OperatingSystem.IsWindows() ? OsPlatform.Windows : OsPlatform.Linux, - AppData: OperatingSystem.IsWindows() ? Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData) : null); + AppData: OperatingSystem.IsWindows() ? Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData) : null, + DshHome: Environment.GetEnvironmentVariable("DSH_HOME")); /// /// Probes 's PATH for . Returns false on a diff --git a/src/Capacitor.Cli.Core/Setup/HarnessCatalog.cs b/src/Capacitor.Cli.Core/Setup/HarnessCatalog.cs index 9261fdf0d..3e43a460a 100644 --- a/src/Capacitor.Cli.Core/Setup/HarnessCatalog.cs +++ b/src/Capacitor.Cli.Core/Setup/HarnessCatalog.cs @@ -1,3 +1,4 @@ +using Capacitor.Cli.Core.Dsh; using Capacitor.Cli.Core.Harness.Antigravity; using Capacitor.Cli.Core.Harness.Claude; using Capacitor.Cli.Core.Harness.Codex; @@ -57,6 +58,8 @@ public static class HarnessCatalog { i => OpenCodeExtensionInstaller.IsInstalled(OpenCodePaths.KcapPluginPure(i.Home, i.OpenCodeConfigDir, i.XdgConfigHome))), new("antigravity", "Antigravity", "--antigravity", r => r.Antigravity, i => AntigravityHooksInstaller.IsInstalled(AntigravityPaths.GlobalHooksJsonPure(i.Home, i.GeminiCliHome))), + new("dsh", "DeepSeek Harness", "--dsh", r => r.Dsh, + i => DshExtensionInstaller.IsInstalled(DshPaths.KcapPluginPure(i.Home, i.DshHome))), ]; public static KnownHarness? ById(string vendorId) => diff --git a/src/Capacitor.Cli/Commands/DshHookCommand.cs b/src/Capacitor.Cli/Commands/DshHookCommand.cs new file mode 100644 index 000000000..eeb3782fd --- /dev/null +++ b/src/Capacitor.Cli/Commands/DshHookCommand.cs @@ -0,0 +1,213 @@ +using System.Text.Json.Nodes; +using Capacitor.Cli.Core; +using Capacitor.Cli.Core.Config; +using Capacitor.Cli.Core.Dsh; + +namespace Capacitor.Cli.Commands; + +/// +/// Dispatcher for the DeepSeek Harness (dsh) live-ingest plugin. dsh has +/// no shell hooks; the shipped Cordis plugin invokes: +/// kcap hook --dsh --event session-start --session <id> --file <session.jsonl> [--cwd] [--model] [--provider] [--version] +/// kcap hook --dsh --event session-end --session <id> --file <session.jsonl> [--reason] [--cwd] +/// +/// dsh is event-sourced: its on-disk session.jsonl IS its SessionEvent +/// stream, so the watcher tails --file directly (vendor=dsh) — no SDK fetch or +/// JSONL synthesis (unlike OpenCode). session-start POSTs /hooks/session-start/dsh and +/// ensures the watcher; session-end kill+drains the watcher (capped) then POSTs +/// /hooks/session-end/dsh so the server computes stats over the full transcript. The +/// watcher's parent-exit watchdog remains a backstop if the plugin never fires +/// session-end. Fail-open throughout — a kcap/server problem must never disrupt dsh. +/// +static class DshHookCommand { + static readonly TimeSpan PreHookDrainCap = TimeSpan.FromSeconds(8); + + public static async Task Handle(string baseUrl, string[] args) { + var eventName = GetArg(args, "--event"); + if (string.IsNullOrWhiteSpace(eventName)) { + Console.Error.WriteLine( + "kcap hook --dsh requires --event " + + "(the kcap dsh plugin passes it; re-run: kcap plugin install --dsh)"); + return 1; + } + + var sessionIdRaw = GetArg(args, "--session"); + if (string.IsNullOrWhiteSpace(sessionIdRaw)) return 0; + + // Canonicalize to the read model's ≤36-char, GUID-shaped session-id contract. A dsh id + // like "session-" (44 chars) reduces to its embedded GUID (dashless, 32 chars); + // otherwise every read-model query's `length(session_id) <= 36` guard filters the + // session out entirely. The SAME canonical id is used for the lifecycle POST and the + // watcher/transcript stream (and DshImportSource applies it identically), so both + // converge on one stream. + var sessionId = DshSessionId.Canonicalize(sessionIdRaw); + + var file = GetArg(args, "--file"); + if (string.IsNullOrWhiteSpace(file)) return 0; // no transcript path — nothing to tail/drain + + var cwd = GetArg(args, "--cwd"); + + // Disabled-session fast path: `kcap disable` must stop every POST and watcher restart. + if (DisabledSessions.IsDisabled(sessionId)) return 0; + + var spool = new HookSpool(PathHelpers.ConfigPath("spool")); + var activeProfile = await AppConfig.GetActiveProfileAsync(); + + if (activeProfile?.ExcludedPaths is { Length: > 0 } excludedPaths + && PathExclusion.IsExcluded(cwd, excludedPaths)) { + return 0; + } + + return eventName switch { + "session-start" => await HandleSessionStart(baseUrl, sessionId, sessionIdRaw, file, cwd, args, activeProfile, spool), + "session-end" => await HandleSessionEnd(baseUrl, sessionId, sessionIdRaw, file, cwd, args, spool), + _ => 0 + }; + } + + static async Task HandleSessionStart( + string baseUrl, + string sessionId, + string sessionIdRaw, + string file, + string? cwd, + string[] args, + Profile? activeProfile, + HookSpool spool + ) { + var forwarded = new JsonObject { + ["hook_event_name"] = "sessionStart", + ["session_id"] = sessionId, + ["home_dir"] = PathHelpers.HomeDirectory, + ["started_at"] = DateTimeOffset.UtcNow.ToString("O") + }; + + if (cwd is not null) { + forwarded["cwd"] = cwd; + + // best-effort git-root discovery, fail-open (omitted when no repo is found). + if (GitRepository.FindRoot(cwd) is { } workspaceRoot) forwarded["workspace_root"] = workspaceRoot; + } + if (GetArg(args, "--model") is { } model) forwarded["model"] = model; + if (GetArg(args, "--provider") is { } provider) forwarded["provider"] = provider; + if (GetArg(args, "--version") is { } version) forwarded["dsh_version"] = version; + + // Subagent: the child transcript header names its parent (parentSession, origin=subagent). + // Surface it (canonicalized) so the server adopts the child under the parent. + if (TryReadParentSession(file) is { } parent) forwarded["parent_session_id"] = parent; + + if (Environment.GetEnvironmentVariable("KCAP_AGENT_ID") is { } agentHostId) { + forwarded["agent_host_id"] = agentHostId; + } + + // Stamp default visibility BEFORE enrichment so it survives the JsonString round-trip + // (same rationale as the OpenCode/Copilot dispatchers); null lets the server fall back + // to org-repo visibility. + if (activeProfile?.DefaultVisibility is { } visibility) { + forwarded["default_visibility"] = visibility; + } + + var enriched = await RepositoryDetection.EnrichWithRepositoryInfo(forwarded.ToJsonString()); + + if (activeProfile?.ExcludedRepos is { Length: > 0 } excludedRepos + && await RepoExclusion.IsExcludedAsync(enriched, excludedRepos)) { + DisabledSessions.Mark(sessionId); + return 0; + } + + // Spawn-before-post: capture must start on Posted OR Spooled (auth lapse / outage). + var outcome = await AgentHookPoster.PostOrSpoolAsync( + baseUrl, "session-start/dsh", enriched, "dsh-hook", + spool, sessionId, route: "session-start/dsh"); + + if (!AgentHookPoster.ShouldSpawnAfter(outcome, baseUrl)) return 0; + + await WatcherManager.EnsureWatcherRunning( + baseUrl, sessionId, file, + agentId: null, sessionIdOverride: null, cwd: cwd, + skipTitle: false, vendor: "dsh" + ); + + return 0; + } + + static async Task HandleSessionEnd( + string baseUrl, + string sessionId, + string sessionIdRaw, + string file, + string? cwd, + string[] args, + HookSpool spool + ) { + // Kill watcher + inline-drain BEFORE the POST so the server computes stats over the + // full transcript — capped so a slow drain can't starve the session-end POST (mirror + // of the Copilot/Claude pre-drain cap). + try { + var drained = await TimeBudget.RunCappedAsync( + async () => { + await WatcherManager.KillWatcher(sessionId); + await WatcherManager.InlineDrainAsync(baseUrl, sessionId, file, agentId: null, vendor: "dsh"); + }, + PreHookDrainCap + ); + + if (!drained) { + await Console.Error.WriteLineAsync( + $"[kcap] dsh session-end pre-drain cap ({PreHookDrainCap.TotalSeconds:0}s) elapsed; proceeding to POST. " + + $"Transcript tail may be incomplete — recoverable via: kcap import --dsh" + ); + } + } catch (Exception ex) { + Console.Error.WriteLine($"[kcap] dsh session-end pre-hook failed: {ex.Message}"); + } + + var forwarded = new JsonObject { + ["hook_event_name"] = "sessionEnd", + ["session_id"] = sessionId, + ["reason"] = GetArg(args, "--reason") ?? "idle", + ["home_dir"] = PathHelpers.HomeDirectory, + ["ended_at"] = DateTimeOffset.UtcNow.ToString("O") + }; + + if (cwd is not null) forwarded["cwd"] = cwd; + + if (Environment.GetEnvironmentVariable("KCAP_AGENT_ID") is { } agentHostId) { + forwarded["agent_host_id"] = agentHostId; + } + + var outcome = await AgentHookPoster.PostOrSpoolAsync( + baseUrl, "session-end/dsh", forwarded.ToJsonString(), "dsh-hook", + spool, sessionId, route: "session-end/dsh"); + + return outcome == HookPostOutcome.Failed ? 1 : 0; + } + + static string? GetArg(string[] args, string flag) { + var idx = Array.IndexOf(args, flag); + return idx >= 0 && idx + 1 < args.Length ? args[idx + 1] : null; + } + + /// Reads the child's parentSession (canonicalized) from the transcript header + /// ({$kcap:"header", ...} / {type:"session"}), or null. Fail-open. + static string? TryReadParentSession(string file) { + try { + using var stream = new FileStream(file, FileMode.Open, FileAccess.Read, FileShare.ReadWrite); + using var reader = new StreamReader(stream); + for (var i = 0; i < 8; i++) { + var line = reader.ReadLine(); + if (line is null) break; + if (string.IsNullOrWhiteSpace(line)) continue; + + var node = System.Text.Json.Nodes.JsonNode.Parse(line); + if (node?["parentSession"]?.GetValue() is { Length: > 0 } parent) + return DshSessionId.Canonicalize(parent); + + // Header seen without a parent → not a subagent; stop scanning. + if (node?["$kcap"]?.GetValue() == "header" || node?["type"]?.GetValue() == "session") + return null; + } + } catch { /* fail-open */ } + return null; + } +} diff --git a/src/Capacitor.Cli/Commands/DshImportSource.cs b/src/Capacitor.Cli/Commands/DshImportSource.cs new file mode 100644 index 000000000..23c3e3c30 --- /dev/null +++ b/src/Capacitor.Cli/Commands/DshImportSource.cs @@ -0,0 +1,427 @@ +using System.Net; +using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; +using Capacitor.Cli.Core; +using Capacitor.Cli.Core.Dsh; + +namespace Capacitor.Cli.Commands; + +/// +/// Discover + classify + import historical DeepSeek Harness (dsh) sessions from the +/// kcap Cordis plugin's per-session logs at ~/.cache/kcap/dsh/{id}.jsonl. +/// dsh's session module makes persistence a plugin concern — the plugin forwards each +/// SessionEvent to that file, which the live watcher tails too, so live and +/// historical ingest converge on the server's DeepSeekHarnessTranscriptNormalizer. +/// There is no sibling metadata file: cwd / created-at are read from the plugin's +/// {$kcap:"header", ...} line. +/// Completeness is the server transcript watermark (no client ledger), mirroring +/// KiroImportSource (NOT the SQLite-backed OpenCode source). +/// +internal sealed class DshImportSource : IImportSource { + readonly string _sessionsDir; + + public DshImportSource(string? sessionsDirOverride = null) { + _sessionsDir = sessionsDirOverride ?? DshPaths.SessionsDir(); + } + + static StringComparison PathComparison => + OperatingSystem.IsWindows() || OperatingSystem.IsMacOS() + ? StringComparison.OrdinalIgnoreCase + : StringComparison.Ordinal; + + static string NormalizeForComparison(string path) { + try { + return Path.GetFullPath(path).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + } catch { + return path.TrimEnd('/', '\\'); + } + } + + public string Vendor => "dsh"; + + public bool IsAvailable => Directory.Exists(_sessionsDir); + + /// False — an AlreadyLoaded replay re-posts only session-start/end lifecycle, no + /// transcript content. dsh subagents are separate sessions adopted via parentSession, not + /// nested child content re-sent here (mirrors Kiro). + public bool AttachesChildContentOnReplay => false; + + /// True — dsh's session/title line is structural (skipped by the + /// normalizer) and not reliably extractable here, so we let the server's title + /// pipeline name imported sessions. + public bool SupportsTitleGeneration => true; + + public Task> DiscoverAsync(DiscoveryFilters filters, CancellationToken ct) { + var sessionFilter = filters.FilterSession is { } sf ? ImportCommand.NormalizeGuid(sf) : null; + var normalizedCwd = filters.FilterCwd is { } cwd ? NormalizeForComparison(cwd) : null; + var sinceUtc = filters.Since is { } since + ? new DateTimeOffset(since.ToDateTime(TimeOnly.MinValue, DateTimeKind.Utc), TimeSpan.Zero) + : (DateTimeOffset?)null; + + var result = new List(); + + if (!Directory.Exists(_sessionsDir)) + return Task.FromResult>(result); + + // Flat layout: /{id}.jsonl — the kcap dsh Cordis plugin writes + // ~/.cache/kcap/dsh/{id}.jsonl (one file per session), the same pattern OpenCode uses. + foreach (var jsonl in GuardedDiscovery.EnumerateFiles(_sessionsDir, "*.jsonl", recursive: false)) { + ct.ThrowIfCancellationRequested(); + + // Filename stem is the raw dsh session id (the plugin names the file fileFor(session.id)). + // Canonicalize it to the ≤36-char GUID-shaped contract (DshSessionId): a "session-" + // id reduces to its embedded dashless GUID, so it keys like every other vendor and isn't + // filtered out by the read model's length(session_id) <= 36 guard. The SAME canonical id + // feeds transcript + lifecycle (and DshHookCommand applies it identically) → one stream. + var rawId = Path.GetFileNameWithoutExtension(jsonl); + if (string.IsNullOrEmpty(rawId)) continue; + var sessionId = DshSessionId.Canonicalize(rawId); + + // Accept a --session filter given as either the raw id or its canonical form. + if (sessionFilter is not null + && !string.Equals(sessionId, sessionFilter, StringComparison.Ordinal) + && !string.Equals(rawId, sessionFilter, StringComparison.Ordinal)) + continue; + + var header = DshSessionHeader.TryRead(jsonl); + + if (normalizedCwd is not null + && (header?.Cwd is null || !NormalizeForComparison(header.Cwd).Equals(normalizedCwd, PathComparison))) + continue; + + // Session-start proxy: the header createdAt, else the transcript's fs birth time. + var firstTimestamp = header?.CreatedAt; + if (firstTimestamp is null) { + try { firstTimestamp = File.GetCreationTimeUtc(jsonl); } catch { /* best effort */ } + } + + if (sinceUtc is { } cutoff && firstTimestamp is { } ts && ts < cutoff) continue; + + result.Add(new DiscoveredSession( + SessionId: sessionId, + Vendor: Vendor, + Cwd: header?.Cwd, + FirstTimestamp: firstTimestamp, + SourceMeta: new Dictionary { + ["TranscriptPath"] = jsonl, + ["DashedSessionId"] = sessionId, // canonical id for lifecycle + transcript (one stream) + ["Cwd"] = header?.Cwd, + ["ParentSession"] = header?.ParentSession, // subagent parent (canonicalized at import) + })); + } + + return Task.FromResult>(result); + } + + public async Task> ClassifyAsync( + IReadOnlyList sessions, + ClassifyContext ctx, + CancellationToken ct + ) { + var results = new List(sessions.Count); + + foreach (var s in sessions) { + var transcriptPath = (string)s.SourceMeta!["TranscriptPath"]!; + + var meta = new SessionMetadata { + SessionId = s.SessionId, + Cwd = s.Cwd, + FirstTimestamp = s.FirstTimestamp, + }; + + int? lastNonBlankIndex; + int? lastRelevantIndex; + int nonBlankCount; + try { + (lastNonBlankIndex, lastRelevantIndex, nonBlankCount) = await ReadTranscriptStatsAsync(transcriptPath, ct); + } catch { + results.Add(MakeClassification(s, meta, ImportCommand.ClassificationStatus.ProbeError, totalLines: 0, + probeErrorReason: "transcript read failed")); + continue; + } + + if (lastNonBlankIndex is null) { + results.Add(MakeClassification(s, meta, ImportCommand.ClassificationStatus.ProbeError, totalLines: 0, + probeErrorReason: "empty transcript")); + continue; + } + + if (nonBlankCount < ctx.MinLines) { + results.Add(MakeClassification(s, meta, ImportCommand.ClassificationStatus.TooShort, totalLines: nonBlankCount)); + continue; + } + + int? serverLastLine; + try { + serverLastLine = await FetchServerLastLineAsync(ctx.HttpClient, ctx.BaseUrl, s.SessionId, ct); + } catch { + results.Add(MakeClassification(s, meta, ImportCommand.ClassificationStatus.ProbeError, totalLines: nonBlankCount, + probeErrorReason: "watermark probe failed")); + continue; + } + + meta.LastTimestamp ??= TryGetLastWriteUtc(transcriptPath); + + var (excludedRepoKey, excludedPathKey) = ResolveExclusions(s.Cwd, ctx); + + var status = ImportCommand.ClassificationStatus.New; + var resumeFromLn = 0; + + var lastImportable = lastRelevantIndex ?? lastNonBlankIndex.Value; + + if (serverLastLine is { } srv) { + if (srv >= lastImportable) { + status = ImportCommand.ClassificationStatus.AlreadyLoaded; + } else { + status = ImportCommand.ClassificationStatus.Partial; + resumeFromLn = srv + 1; + } + } + + results.Add(new ImportCommand.SessionClassification { + SessionId = s.SessionId, + FilePath = "", // empty ⇒ routed phase (ImportSessionAsync), same as Kiro/Cursor + EncodedCwd = "", + Meta = meta, + Status = status, + Vendor = Vendor, + ResumeFromLine = resumeFromLn, + ExcludedRepoKey = excludedRepoKey, + ExcludedPathKey = excludedPathKey, + TotalLines = nonBlankCount, + SourceMeta = s.SourceMeta, + }); + } + + return results; + } + + public async Task ImportSessionAsync( + ImportCommand.SessionClassification classification, + ImportContext ctx, + CancellationToken ct + ) { + var transcriptPath = (string)classification.SourceMeta!["TranscriptPath"]!; + if (!File.Exists(transcriptPath)) return ImportOutcome.Failed; + + var cwd = classification.SourceMeta!.TryGetValue("Cwd", out var c) ? c as string : null; + var dashed = classification.SourceMeta!.TryGetValue("DashedSessionId", out var d) ? d as string : null; + + // Lifecycle uses the dashed id (matches the live hook so a re-import of a live + // session dedupes); the transcript route uses the dashless id (the stream key). + var lifecycleId = dashed ?? classification.SessionId; + + var startPayload = BuildSessionStartPayload(lifecycleId, cwd, classification.Meta.FirstTimestamp); + if (!ctx.ForcePrivate && classification.Status == ImportCommand.ClassificationStatus.New && ctx.DefaultVisibility is not null) { + startPayload["default_visibility"] = ctx.DefaultVisibility; + } + + // Subagent: adopt the child under its parent (canonicalize the parent id the same way as + // the session id so it keys to the parent's stream). + if (classification.SourceMeta!.TryGetValue("ParentSession", out var ps) && ps is string parentRaw + && !string.IsNullOrWhiteSpace(parentRaw)) { + startPayload["parent_session_id"] = DshSessionId.Canonicalize(parentRaw); + } + + // Enrich with git repo info detected from the captured cwd (adds the "repository" field + // the server records as RepositoryDetectedEvent), so imported dsh sessions group under + // their repo — same path the live hook uses. Fail-open: no cwd/repo → payload unchanged. + var startJson = await RepositoryDetection.EnrichWithRepositoryInfo(startPayload.ToJsonString()); + + var startOk = await PostSyntheticHookAsync(ctx.HttpClient, ctx.BaseUrl, "session-start/dsh", startJson, ct); + if (!startOk) return ImportOutcome.Failed; + + var startLine = classification.Status switch { + ImportCommand.ClassificationStatus.Partial => classification.ResumeFromLine, + ImportCommand.ClassificationStatus.AlreadyLoaded => classification.TotalLines, + _ => 0, + }; + + int sent; + try { + sent = await SessionImporter.SendTranscriptBatches( + httpClient: ctx.HttpClient, + baseUrl: ctx.BaseUrl, + sessionId: classification.SessionId, + filePath: transcriptPath, + agentId: null, + startLine: startLine, + vendor: Vendor); + } catch { + return ImportOutcome.Failed; + } + + var endOk = await PostSyntheticHookAsync( + ctx.HttpClient, ctx.BaseUrl, "session-end/dsh", + BuildSessionEndPayload(lifecycleId, cwd, classification.Meta.LastTimestamp).ToJsonString(), + ct); + if (!endOk) return ImportOutcome.Failed; + + if (sent == 0) return startLine > 0 ? ImportOutcome.Resumed : ImportOutcome.Skipped; + + return startLine > 0 ? ImportOutcome.Resumed : ImportOutcome.Loaded; + } + + static JsonObject BuildSessionStartPayload(string sessionId, string? cwd, DateTimeOffset? startedAt) { + var payload = new JsonObject { + ["hook_event_name"] = "sessionStart", + ["session_id"] = sessionId, + }; + if (cwd is not null) payload["cwd"] = cwd; + if (cwd is not null && GitRepository.FindRoot(cwd) is { } workspaceRoot) payload["workspace_root"] = workspaceRoot; + if (startedAt is { } ts) payload["started_at"] = ts.ToString("O"); + payload["origin"] = ImportOrigins.Historical; + return payload; + } + + static JsonObject BuildSessionEndPayload(string sessionId, string? cwd, DateTimeOffset? endedAt) { + var payload = new JsonObject { + ["hook_event_name"] = "sessionEnd", + ["session_id"] = sessionId, + ["reason"] = "historical-import", + }; + if (cwd is not null) payload["cwd"] = cwd; + if (endedAt is { } ts) payload["ended_at"] = ts.ToString("O"); + payload["origin"] = ImportOrigins.Historical; + return payload; + } + + static async Task PostSyntheticHookAsync( + HttpClient client, string baseUrl, string routeSegment, string json, CancellationToken ct + ) { + try { + using var content = new StringContent(json, Encoding.UTF8, "application/json"); + using var resp = await client.PostWithRetryAsync($"{baseUrl}/hooks/{routeSegment}", content, ct: ct); + return resp.IsSuccessStatusCode; + } catch { + return false; + } + } + + static DateTimeOffset? TryGetLastWriteUtc(string path) { + try { return File.GetLastWriteTimeUtc(path); } catch { return null; } + } + + static ImportCommand.SessionClassification MakeClassification( + DiscoveredSession s, + SessionMetadata meta, + ImportCommand.ClassificationStatus status, + int totalLines, + string? probeErrorReason = null + ) => new() { + SessionId = s.SessionId, + FilePath = "", + EncodedCwd = "", + Meta = meta, + Status = status, + Vendor = "dsh", + ProbeErrorReason = probeErrorReason, + TotalLines = totalLines, + SourceMeta = s.SourceMeta, + }; + + static async Task<(int? LastNonBlankIndex, int? LastRelevantIndex, int NonBlankCount)> ReadTranscriptStatsAsync( + string transcriptPath, CancellationToken ct + ) { + await using var stream = new FileStream(transcriptPath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite); + using var reader = new StreamReader(stream); + + int? lastIdx = null; + int? lastRelevantIdx = null; + var count = 0; + var lineIdx = 0; + + while (await reader.ReadLineAsync(ct) is { } line) { + if (!string.IsNullOrWhiteSpace(line)) { + lastIdx = lineIdx; + count++; + + if (IsImportRelevantLine(line)) lastRelevantIdx = lineIdx; + } + lineIdx++; + } + return (lastIdx, lastRelevantIdx, count); + } + + /// + /// True when the line maps to a canonical event under the server's + /// DeepSeekHarnessTranscriptNormalizer — user/message / assistant/message + /// / tool/result. Other types are skipped server-side and never advance the + /// transcript watermark, so a fully-imported session stays AlreadyLoaded. + /// + internal static bool IsImportRelevantLine(string line) { + try { + using var doc = JsonDocument.Parse(line); + return doc.RootElement.Str("type") is "user/message" or "assistant/message" or "tool/result"; + } catch { + return false; + } + } + + static async Task FetchServerLastLineAsync(HttpClient http, string baseUrl, string sessionId, CancellationToken ct) { + using var resp = await http.GetWithRetryAsync($"{baseUrl}/api/sessions/{sessionId}/last-line", ct: ct); + + if (resp.StatusCode is HttpStatusCode.NotFound or HttpStatusCode.NoContent) return null; + if (!resp.IsSuccessStatusCode) throw new HttpRequestException($"watermark probe returned {(int)resp.StatusCode}"); + + var body = await resp.Content.ReadAsStringAsync(ct); + using var doc = JsonDocument.Parse(body); + + return doc.RootElement.TryGetProperty("last_line_number", out var ln) && ln.ValueKind == JsonValueKind.Number + ? ln.GetInt32() + : null; + } + + static (string? ExcludedRepoKey, string? ExcludedPathKey) ResolveExclusions(string? cwd, ClassifyContext ctx) { + string? excludedPathKey = null; + if (cwd is not null && ctx.ExcludedPaths is { Count: > 0 } paths) { + foreach (var entry in paths) { + if (PathExclusion.IsExcluded(cwd, [entry])) { + excludedPathKey = PathExclusion.Normalize(entry); + break; + } + } + } + return (null, excludedPathKey); + } +} + +/// +/// Minimal reader for dsh's {type:"session"} header line (the first line of a +/// session.jsonl): the few fields import needs (cwd, created-at). A parse +/// failure must never break discovery (returns null / partial data). +/// +internal sealed record DshSessionHeader(string? Cwd, DateTimeOffset? CreatedAt, string? ParentSession) { + public static DshSessionHeader? TryRead(string transcriptPath) { + try { + using var stream = new FileStream(transcriptPath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite); + using var reader = new StreamReader(stream); + + // The header is the first non-blank line; scan a few lines defensively. + for (var i = 0; i < 8; i++) { + var line = reader.ReadLine(); + if (line is null) break; + if (string.IsNullOrWhiteSpace(line)) continue; + + using var doc = JsonDocument.Parse(line); + var root = doc.RootElement; + if (root.ValueKind != JsonValueKind.Object) continue; + // The plugin writes {$kcap:"header", ...session.header}; session.header may or may + // not carry type:"session" (the offline PoC omits it). Accept either marker. + var isHeader = root.Str("$kcap") == "header" || root.Str("type") == "session"; + if (!isHeader) continue; + + DateTimeOffset? createdAt = null; + if (root.TryGetProperty("createdAt", out var ca) && ca.ValueKind == JsonValueKind.Number) + createdAt = DateTimeOffset.FromUnixTimeMilliseconds(ca.GetInt64()); + + // A subagent child's header names its parent inline (parentSession + origin=subagent). + return new DshSessionHeader(root.Str("cwd"), createdAt, root.Str("parentSession")); + } + } catch { + // fall through + } + return null; + } +} diff --git a/src/Capacitor.Cli/Commands/PluginCommand.cs b/src/Capacitor.Cli/Commands/PluginCommand.cs index 5792fef7a..9fb0618f1 100644 --- a/src/Capacitor.Cli/Commands/PluginCommand.cs +++ b/src/Capacitor.Cli/Commands/PluginCommand.cs @@ -3,6 +3,7 @@ using System.Text.Json.Nodes; using Capacitor.Cli.Core; using Capacitor.Cli.Core.Config; +using Capacitor.Cli.Core.Dsh; using Capacitor.Cli.Core.Harness.Antigravity; using Capacitor.Cli.Core.Harness.Claude; using Capacitor.Cli.Core.Harness.Codex; @@ -48,10 +49,10 @@ public static async Task HandleAsync(string[] args, PluginEnvironment? env }; } - static readonly string[] ExclusiveTargetFlags = ["--codex", "--cursor", "--copilot", "--gemini", "--kiro", "--pi", "--opencode", "--antigravity", "--skills"]; + static readonly string[] ExclusiveTargetFlags = ["--codex", "--cursor", "--copilot", "--gemini", "--kiro", "--pi", "--opencode", "--antigravity", "--dsh", "--skills"]; const string MutuallyExclusiveMsg = - "--cursor, --codex, --copilot, --gemini, --kiro, --pi, --opencode, --antigravity, and --skills are mutually exclusive."; + "--cursor, --codex, --copilot, --gemini, --kiro, --pi, --opencode, --antigravity, --dsh, and --skills are mutually exclusive."; static bool HasConflictingTargets(string[] args) => ExclusiveTargetFlags.Count(args.Contains) > 1; @@ -72,6 +73,7 @@ static async Task Install(string[] args, PluginEnvironment env) { if (args.Contains("--pi")) return await InstallPi(args, env); if (args.Contains("--opencode")) return await InstallOpenCode(args, env); if (args.Contains("--antigravity")) return await InstallAntigravity(args, env); + if (args.Contains("--dsh")) return await InstallDsh(args, env); return await InstallClaude(args, env); } @@ -92,10 +94,64 @@ static async Task Remove(string[] args, PluginEnvironment env) { if (args.Contains("--pi")) return await RemovePi(args, env); if (args.Contains("--opencode")) return await RemoveOpenCode(args, env); if (args.Contains("--antigravity")) return await RemoveAntigravity(args, env); + if (args.Contains("--dsh")) return await RemoveDsh(args, env); return await RemoveClaude(args, env); } + // ── DeepSeek Harness (dsh) ──────────────────────────────────────────────── + // dsh has no shell hooks; live capture is the shipped Cordis observer plugin. Install writes + // the plugin to $DSH_HOME/kcap-dsh.plugin.mjs and registers it in each profile's live-watched + // cordis.patch.yml (an idempotent managed block). MCP stays documented (docs/DSH_NORMALIZER.md). + static async Task InstallDsh(string[] args, PluginEnvironment env) { + _ = args; + var pluginPath = DshPaths.KcapPlugin(); + if (!DshExtensionInstaller.Install(pluginPath)) { + await env.Stderr.WriteLineAsync($"Failed to write the dsh plugin at {pluginPath}."); + return 1; + } + await env.Stdout.WriteLineAsync($"dsh plugin installed: {pluginPath}"); + + var profilesDir = DshPaths.ProfilesDir(); + var profiles = Directory.Exists(profilesDir) + ? Directory.EnumerateDirectories(profilesDir).Where(d => File.Exists(Path.Combine(d, "package.json"))).ToList() + : []; + + var registered = 0; + foreach (var dir in profiles) { + if (DshExtensionInstaller.RegisterInCordisPatch(DshPaths.CordisPatch(dir), pluginPath)) { + registered++; + await env.Stdout.WriteLineAsync($" registered in profile '{Path.GetFileName(dir)}'"); + } + } + + if (registered > 0) { + await env.Stdout.WriteLineAsync("Live capture starts on the next dsh session (cordis.patch.yml is live-watched)."); + } else { + await env.Stdout.WriteLineAsync($"No dsh profiles found under {profilesDir}. Add this to your active profile's cordis.patch.yml:"); + await env.Stdout.WriteLineAsync(); + await env.Stdout.WriteLineAsync(DshExtensionInstaller.BuildCordisBlock(pluginPath)); + } + return 0; + } + + static async Task RemoveDsh(string[] args, PluginEnvironment env) { + _ = args; + var pluginPath = DshPaths.KcapPlugin(); + var existed = DshExtensionInstaller.Remove(pluginPath); + + var profilesDir = DshPaths.ProfilesDir(); + if (Directory.Exists(profilesDir)) { + foreach (var dir in Directory.EnumerateDirectories(profilesDir)) { + var patch = DshPaths.CordisPatch(dir); + if (DshExtensionInstaller.IsRegisteredInCordisPatch(patch)) + DshExtensionInstaller.UnregisterFromCordisPatch(patch); + } + } + await env.Stdout.WriteLineAsync(existed ? $"dsh plugin removed: {pluginPath}" : "dsh plugin was not installed."); + return 0; + } + static async Task InstallClaude(string[] args, PluginEnvironment env) { var scope = args.Contains("--project") ? "project" : "user"; diff --git a/src/Capacitor.Cli/Commands/SetupCommand.cs b/src/Capacitor.Cli/Commands/SetupCommand.cs index 7f462ef6c..2c97689c6 100644 --- a/src/Capacitor.Cli/Commands/SetupCommand.cs +++ b/src/Capacitor.Cli/Commands/SetupCommand.cs @@ -779,7 +779,7 @@ static Task DefaultImportRunner(ImportInvocation inv) => autoSkipExclusions: inv.AutoSkipExclusions, defaultVisibility: inv.DefaultVisibility); - /// The nine supported import sources — mirrors Program.cs's `kcap import` construction. + /// The ten supported import sources — mirrors Program.cs's `kcap import` construction. static IReadOnlyList BuildImportSources() => new IImportSource[] { new ClaudeImportSource(), new CodexImportSource(), @@ -790,6 +790,7 @@ static Task DefaultImportRunner(ImportInvocation inv) => new PiImportSource(), new OpenCodeImportSource(), new AntigravityImportSource(), + new DshImportSource(), }; /// diff --git a/src/Capacitor.Cli/Commands/VendorSelection.cs b/src/Capacitor.Cli/Commands/VendorSelection.cs index 28da1e532..0f76bc356 100644 --- a/src/Capacitor.Cli/Commands/VendorSelection.cs +++ b/src/Capacitor.Cli/Commands/VendorSelection.cs @@ -15,7 +15,7 @@ public sealed record Result(IReadOnlySet Vendors, string? Error) { // internal, not private: the driver-schema conformance suite pins its hand-written harness table // against this list, so adding a tenth installable target fails there instead of silently leaving // it uncovered. No enumeration of supported harnesses exists in production code otherwise. - internal static readonly string[] KnownVendorFlags = ["--claude", "--codex", "--cursor", "--copilot", "--gemini", "--kiro", "--pi", "--opencode", "--antigravity"]; + internal static readonly string[] KnownVendorFlags = ["--claude", "--codex", "--cursor", "--copilot", "--gemini", "--kiro", "--pi", "--opencode", "--antigravity", "--dsh"]; public static Result Parse(string[] args) { var vendors = new HashSet(StringComparer.Ordinal); @@ -31,6 +31,7 @@ public static Result Parse(string[] args) { case "--pi": vendors.Add("pi"); break; case "--opencode": vendors.Add("opencode"); break; case "--antigravity": vendors.Add("antigravity"); break; + case "--dsh": vendors.Add("dsh"); break; } } @@ -42,7 +43,7 @@ public static Result Parse(string[] args) { if (!a.StartsWith("--")) continue; if (Array.IndexOf(KnownVendorFlags, a) >= 0) continue; - if (a.StartsWith("--cursor-") || a.StartsWith("--claude-") || a.StartsWith("--codex-") || a.StartsWith("--copilot-") || a.StartsWith("--gemini-") || a.StartsWith("--kiro-") || a.StartsWith("--pi-") || a.StartsWith("--opencode-") || a.StartsWith("--antigravity-")) { + if (a.StartsWith("--cursor-") || a.StartsWith("--claude-") || a.StartsWith("--codex-") || a.StartsWith("--copilot-") || a.StartsWith("--gemini-") || a.StartsWith("--kiro-") || a.StartsWith("--pi-") || a.StartsWith("--opencode-") || a.StartsWith("--antigravity-") || a.StartsWith("--dsh-")) { return new(vendors, $"Unknown source option: {a}."); } } @@ -51,7 +52,7 @@ public static Result Parse(string[] args) { foreach (var a in args) { if (!a.StartsWith("--")) continue; if (Array.IndexOf(KnownVendorFlags, a) >= 0) continue; - if (a.StartsWith("--cursor-") || a.StartsWith("--claude-") || a.StartsWith("--codex-") || a.StartsWith("--copilot-") || a.StartsWith("--gemini-") || a.StartsWith("--kiro-") || a.StartsWith("--pi-") || a.StartsWith("--opencode-") || a.StartsWith("--antigravity-")) continue; + if (a.StartsWith("--cursor-") || a.StartsWith("--claude-") || a.StartsWith("--codex-") || a.StartsWith("--copilot-") || a.StartsWith("--gemini-") || a.StartsWith("--kiro-") || a.StartsWith("--pi-") || a.StartsWith("--opencode-") || a.StartsWith("--antigravity-") || a.StartsWith("--dsh-")) continue; string? hint = null; var bestDist = int.MaxValue; diff --git a/src/Capacitor.Cli/Commands/WatchCommand.cs b/src/Capacitor.Cli/Commands/WatchCommand.cs index 07354e22a..9fe9a0f74 100644 --- a/src/Capacitor.Cli/Commands/WatchCommand.cs +++ b/src/Capacitor.Cli/Commands/WatchCommand.cs @@ -1252,7 +1252,7 @@ internal static async Task BackfillCodexWatcherStateAsync( /// Used to reject unexpected --vendor input before interpolating into the URL /// path (defence-in-depth against path traversal even though the CLI runs locally). /// - static readonly HashSet KnownVendors = new(StringComparer.Ordinal) { "claude", "codex", "copilot", "gemini", "kiro", "pi", "opencode", "antigravity", "cursor" }; + static readonly HashSet KnownVendors = new(StringComparer.Ordinal) { "claude", "codex", "copilot", "gemini", "kiro", "pi", "opencode", "antigravity", "cursor", "dsh" }; /// /// Total time budget for the parent-exit session-end POST. Covers /auth/config @@ -1448,7 +1448,7 @@ internal static bool ShouldEndOnIdle( /// ). Pure so it's unit-testable; see /// 's call site. /// - internal static bool SkipsThresholdBuffering(string vendor) => vendor is "antigravity" or "cursor"; + internal static bool SkipsThresholdBuffering(string vendor) => vendor is "antigravity" or "cursor" or "dsh"; /// /// the idle clock measures against for diff --git a/src/Capacitor.Cli/Program.cs b/src/Capacitor.Cli/Program.cs index 881d4644e..488dc332b 100644 --- a/src/Capacitor.Cli/Program.cs +++ b/src/Capacitor.Cli/Program.cs @@ -70,7 +70,7 @@ // nested headless invocation. if (Environment.GetEnvironmentVariable("KCAP_SKIP") is "1" && command == "hook" - && (args.Contains("--claude") || args.Contains("--cursor") || args.Contains("--copilot") || args.Contains("--gemini") || args.Contains("--kiro") || args.Contains("--pi") || args.Contains("--opencode") || args.Contains("--antigravity"))) { + && (args.Contains("--claude") || args.Contains("--cursor") || args.Contains("--copilot") || args.Contains("--gemini") || args.Contains("--kiro") || args.Contains("--pi") || args.Contains("--opencode") || args.Contains("--antigravity") || args.Contains("--dsh"))) { return 0; } @@ -621,6 +621,7 @@ new PiImportSource(), new OpenCodeImportSource(), new AntigravityImportSource(), + new DshImportSource(), }; IReadOnlyList sources = explicitVendorSelection ? allSources.Where(s => vsel.Vendors.Contains(s.Vendor)).ToList() @@ -674,7 +675,7 @@ discoverJson: discoverJson); } case "watch" when args.Length < 3: - Console.Error.WriteLine("Usage: kcap watch [--agent-id ] [--cwd ] [--skip-title] [--parent-pid ] [--vendor claude|codex|copilot|gemini|kiro|pi|opencode|antigravity|cursor]"); + Console.Error.WriteLine("Usage: kcap watch [--agent-id ] [--cwd ] [--skip-title] [--parent-pid ] [--vendor claude|codex|copilot|gemini|kiro|pi|opencode|antigravity|cursor|dsh]"); return 1; case "watch": { @@ -827,8 +828,11 @@ await AgentHookPoster.DrainSpoolsAsync( if (args.Contains("--antigravity")) { return await AntigravityHookCommand.Handle(baseUrl!, args, hookProcessStart); } + if (args.Contains("--dsh")) { + return await DshHookCommand.Handle(baseUrl!, args); + } Console.Error.WriteLine("kcap hook requires a vendor flag (for example --claude)"); - Console.Error.WriteLine("Supported vendors: --claude, --codex, --cursor, --copilot, --gemini, --kiro, --pi, --opencode, --antigravity"); + Console.Error.WriteLine("Supported vendors: --claude, --codex, --cursor, --copilot, --gemini, --kiro, --pi, --opencode, --antigravity, --dsh"); return 1; } case "cursor": diff --git a/src/Capacitor.Cli/SessionStartMemory/SessionStartMemoryContracts.cs b/src/Capacitor.Cli/SessionStartMemory/SessionStartMemoryContracts.cs index d992f290e..ad23d0190 100644 --- a/src/Capacitor.Cli/SessionStartMemory/SessionStartMemoryContracts.cs +++ b/src/Capacitor.Cli/SessionStartMemory/SessionStartMemoryContracts.cs @@ -11,7 +11,8 @@ internal enum SessionStartHarness { Kiro, Pi, OpenCode, - Antigravity + Antigravity, + Dsh } internal enum SessionLifecycleReason { New, Resume, Reopen, Fork, Compact, RepeatedTurnCallback, Unknown } diff --git a/src/Capacitor.Cli/SessionStartMemory/SessionStartMemoryIdentity.cs b/src/Capacitor.Cli/SessionStartMemory/SessionStartMemoryIdentity.cs index 45a20452b..bcefbeaca 100644 --- a/src/Capacitor.Cli/SessionStartMemory/SessionStartMemoryIdentity.cs +++ b/src/Capacitor.Cli/SessionStartMemory/SessionStartMemoryIdentity.cs @@ -44,6 +44,7 @@ public static string Create(SessionStartHarness harness, string sessionId, strin SessionStartHarness.Pi => "pi", SessionStartHarness.OpenCode => "opencode", SessionStartHarness.Antigravity => "antigravity", + SessionStartHarness.Dsh => "dsh", _ => throw new ArgumentOutOfRangeException(nameof(harness)) }; diff --git a/test/Capacitor.App.Tests.Unit/AgentsImportStepsTests.cs b/test/Capacitor.App.Tests.Unit/AgentsImportStepsTests.cs index 00d5d325f..2295e1a30 100644 --- a/test/Capacitor.App.Tests.Unit/AgentsImportStepsTests.cs +++ b/test/Capacitor.App.Tests.Unit/AgentsImportStepsTests.cs @@ -19,7 +19,7 @@ public static AgentDetectionResult Build(params string[] detected) { return new( Claude: Agent("claude"), Codex: Agent("codex"), Cursor: Agent("cursor"), Copilot: Agent("copilot"), Gemini: Agent("gemini"), Kiro: Agent("kiro"), Pi: Agent("pi"), OpenCode: Agent("opencode"), - Antigravity: Agent("antigravity")); + Antigravity: Agent("antigravity"), Dsh: Agent("dsh")); } } @@ -645,10 +645,10 @@ public async Task The_window_selects_a_template_for_Agents_and_Import_steps() { }); await Assert.That(result.installButton).IsNotNull(); - await Assert.That(result.AgentRows).IsEqualTo(9); + await Assert.That(result.AgentRows).IsEqualTo(10); await Assert.That(result.runButton).IsNotNull(); await Assert.That(result.everythingChoice).IsNotNull(); await Assert.That(result.everythingChoice!.IsChecked).IsTrue(); - await Assert.That(result.ImportRows).IsEqualTo(9); + await Assert.That(result.ImportRows).IsEqualTo(10); } } diff --git a/test/Capacitor.Cli.Core.Tests.Unit/Setup/HarnessCatalogTests.cs b/test/Capacitor.Cli.Core.Tests.Unit/Setup/HarnessCatalogTests.cs index b3c47ff1f..b02bd1824 100644 --- a/test/Capacitor.Cli.Core.Tests.Unit/Setup/HarnessCatalogTests.cs +++ b/test/Capacitor.Cli.Core.Tests.Unit/Setup/HarnessCatalogTests.cs @@ -4,9 +4,9 @@ namespace Capacitor.Cli.Core.Tests.Unit.Setup; public class HarnessCatalogTests { [Test] - public async Task Covers_all_nine_vendors_with_unique_ids() { - await Assert.That(HarnessCatalog.All.Count).IsEqualTo(9); - await Assert.That(HarnessCatalog.All.Select(h => h.VendorId).Distinct().Count()).IsEqualTo(9); + public async Task Covers_all_ten_vendors_with_unique_ids() { + await Assert.That(HarnessCatalog.All.Count).IsEqualTo(10); + await Assert.That(HarnessCatalog.All.Select(h => h.VendorId).Distinct().Count()).IsEqualTo(10); } [Test] @@ -33,6 +33,7 @@ public async Task Install_flag_is_dash_dash_vendor_id_except_flagless_claude() { [Arguments("pi")] [Arguments("opencode")] [Arguments("antigravity")] + [Arguments("dsh")] public async Task Each_selector_maps_to_exactly_one_distinct_detection_field(string vendorId) { var result = DetectionWithOnly(vendorId); var matches = HarnessCatalog.All.Where(h => h.Select(result).Detected).ToList(); @@ -47,6 +48,6 @@ internal static AgentDetectionResult DetectionWithOnly(params string[] detectedV return new( Claude: A("claude"), Codex: A("codex"), Cursor: A("cursor"), Copilot: A("copilot"), Gemini: A("gemini"), Kiro: A("kiro"), Pi: A("pi"), OpenCode: A("opencode"), - Antigravity: A("antigravity")); + Antigravity: A("antigravity"), Dsh: A("dsh")); } } diff --git a/test/Capacitor.Cli.Core.Tests.Unit/Setup/HarnessInventoryTests.cs b/test/Capacitor.Cli.Core.Tests.Unit/Setup/HarnessInventoryTests.cs index 02b292529..e71639d1c 100644 --- a/test/Capacitor.Cli.Core.Tests.Unit/Setup/HarnessInventoryTests.cs +++ b/test/Capacitor.Cli.Core.Tests.Unit/Setup/HarnessInventoryTests.cs @@ -7,7 +7,7 @@ static HarnessOfferLedger LedgerWith(params (string Vendor, HarnessOfferEntry En new() { Vendors = rows.ToDictionary(r => r.Vendor, r => r.Entry, StringComparer.Ordinal) }; [Test] - public async Task Covers_all_nine_vendors_with_detected_and_wired() { + public async Task Covers_all_ten_vendors_with_detected_and_wired() { var inv = HarnessInventory.Evaluate( HarnessCatalogTests.DetectionWithOnly("cursor", "antigravity"), isWired: id => id == "cursor", @@ -15,7 +15,7 @@ public async Task Covers_all_nine_vendors_with_detected_and_wired() { "machine-1"); await Assert.That(inv.MachineId).IsEqualTo("machine-1"); - await Assert.That(inv.Vendors.Count).IsEqualTo(9); + await Assert.That(inv.Vendors.Count).IsEqualTo(10); await Assert.That(inv.Vendors["antigravity"]).IsEqualTo(new HarnessInventoryEntry(Detected: true, Wired: false)); await Assert.That(inv.Vendors["cursor"]).IsEqualTo(new HarnessInventoryEntry(Detected: true, Wired: true)); diff --git a/test/Capacitor.Cli.Daemon.Tests.Unit/Services/DaemonStatusReportTests.cs b/test/Capacitor.Cli.Daemon.Tests.Unit/Services/DaemonStatusReportTests.cs index a61af08bc..2f80dc31d 100644 --- a/test/Capacitor.Cli.Daemon.Tests.Unit/Services/DaemonStatusReportTests.cs +++ b/test/Capacitor.Cli.Daemon.Tests.Unit/Services/DaemonStatusReportTests.cs @@ -38,7 +38,7 @@ public async Task Sent_status_report_carries_harness_inventory() { var report = capture.StatusReports[^1]; await Assert.That(report.HarnessInventory).IsNotNull(); - await Assert.That(report.HarnessInventory!.Vendors.Count).IsEqualTo(9); + await Assert.That(report.HarnessInventory!.Vendors.Count).IsEqualTo(10); await Assert.That(string.IsNullOrEmpty(report.HarnessInventory!.MachineId)).IsFalse(); } } diff --git a/test/Capacitor.Cli.Tests.Integration/SessionStartVisibilityTests.cs b/test/Capacitor.Cli.Tests.Integration/SessionStartVisibilityTests.cs index 480b45379..e611fa78a 100644 --- a/test/Capacitor.Cli.Tests.Integration/SessionStartVisibilityTests.cs +++ b/test/Capacitor.Cli.Tests.Integration/SessionStartVisibilityTests.cs @@ -143,7 +143,7 @@ public async Task Stamps_harness_inventory_onto_session_start_body() { var inv = body["harness_inventory"]; await Assert.That(inv).IsNotNull(); await Assert.That(string.IsNullOrEmpty(inv!["machine_id"]?.GetValue())).IsFalse(); - await Assert.That(inv["vendors"]!.AsObject().Count).IsEqualTo(9); + await Assert.That(inv["vendors"]!.AsObject().Count).IsEqualTo(10); await Assert.That(inv["vendors"]!["claude"]!["wired"]).IsNotNull(); // per-vendor {detected,wired} shape } diff --git a/test/Capacitor.Cli.Tests.Unit/Commands/FlowsDriverSchemaConformanceTests.cs b/test/Capacitor.Cli.Tests.Unit/Commands/FlowsDriverSchemaConformanceTests.cs index 2de4aed88..f42ecbaf6 100644 --- a/test/Capacitor.Cli.Tests.Unit/Commands/FlowsDriverSchemaConformanceTests.cs +++ b/test/Capacitor.Cli.Tests.Unit/Commands/FlowsDriverSchemaConformanceTests.cs @@ -611,6 +611,7 @@ public async Task The_projection_table_covers_every_harness_the_cli_claims_to_su var covered = Arms.Select(a => a.Flag) .Append("--claude") // bundled kcap/.mcp.json, covered by the static-config test .Append("--pi") // no MCP config at all, covered by the bridge test + .Append("--dsh") // ingest-only Cordis plugin; no kcap MCP config, so no driver schema .ToHashSet(StringComparer.Ordinal); foreach (var flag in VendorSelection.KnownVendorFlags) diff --git a/test/Capacitor.Cli.Tests.Unit/Commands/ReplayChildContentCapabilityTests.cs b/test/Capacitor.Cli.Tests.Unit/Commands/ReplayChildContentCapabilityTests.cs index 07d108a30..49b033cd5 100644 --- a/test/Capacitor.Cli.Tests.Unit/Commands/ReplayChildContentCapabilityTests.cs +++ b/test/Capacitor.Cli.Tests.Unit/Commands/ReplayChildContentCapabilityTests.cs @@ -53,6 +53,7 @@ public async Task sources_whose_replay_can_attach_child_content_declare_the_capa [Arguments("kiro")] [Arguments("pi")] [Arguments("opencode")] + [Arguments("dsh")] public async Task sources_whose_replay_cannot_attach_child_content_do_not_declare_the_capability(string vendor) { await Assert.That(MakeSource(vendor).AttachesChildContentOnReplay).IsFalse(); } @@ -65,7 +66,7 @@ public async Task sources_whose_replay_cannot_attach_child_content_do_not_declar [Test] public async Task every_import_source_is_covered_by_this_table() { var declared = new[] { - "cursor", "antigravity", "gemini", "claude", "codex", "copilot", "kiro", "pi", "opencode", + "cursor", "antigravity", "gemini", "claude", "codex", "copilot", "kiro", "pi", "opencode", "dsh", }; var actual = typeof(IImportSource).Assembly.GetTypes() @@ -94,6 +95,7 @@ static IImportSource MakeSource(string vendor) { "pi" => new PiImportSource(), "opencode" => new OpenCodeImportSource(Path.Combine(scratch, "db"), Path.Combine(scratch, "ledger")), "antigravity" => new AntigravityImportSource(home: scratch, geminiCliHome: ""), + "dsh" => new DshImportSource(scratch), _ => throw new ArgumentOutOfRangeException(nameof(vendor), vendor, "unclassified import source"), }; } diff --git a/test/Capacitor.Cli.Tests.Unit/Commands/VendorSelectionTests.cs b/test/Capacitor.Cli.Tests.Unit/Commands/VendorSelectionTests.cs index caf6c7336..d339ee690 100644 --- a/test/Capacitor.Cli.Tests.Unit/Commands/VendorSelectionTests.cs +++ b/test/Capacitor.Cli.Tests.Unit/Commands/VendorSelectionTests.cs @@ -72,6 +72,19 @@ public async Task rejects_opencode_prefixed_unknown_flag() { await Assert.That(r.HasError).IsTrue(); } + [Test] + public async Task parses_dsh_flag() { + var r = VendorSelection.Parse(new[] { "import", "--dsh" }); + await Assert.That(r.HasError).IsFalse(); + await Assert.That(r.Vendors).Contains("dsh"); + } + + [Test] + public async Task rejects_dsh_prefixed_unknown_flag() { + var r = VendorSelection.Parse(new[] { "import", "--dsh-foo" }); + await Assert.That(r.HasError).IsTrue(); + } + [Test] public async Task unknown_pi_prefix_flag_is_rejected() { // --pi is a known vendor flag, but --pi- typos / future options must be diff --git a/test/Capacitor.Cli.Tests.Unit/DshExtensionInstallerTests.cs b/test/Capacitor.Cli.Tests.Unit/DshExtensionInstallerTests.cs new file mode 100644 index 000000000..1d14058e1 --- /dev/null +++ b/test/Capacitor.Cli.Tests.Unit/DshExtensionInstallerTests.cs @@ -0,0 +1,101 @@ +using Capacitor.Cli.Core.Dsh; + +namespace Capacitor.Cli.Tests.Unit; + +/// +/// Covers the install/remove/marker MECHANICS of . +/// The embedded plugin body is a documented placeholder pending dsh's real +/// plugin API, but the file/marker lifecycle is final and asserted here. +/// +public class DshExtensionInstallerTests { + [Test] + public async Task Install_writes_plugin_and_marker_then_Remove_clears_both() { + using var tmp = new TempDir(); + var pluginPath = Path.Combine(tmp.Path, "plugins", "kcap.dsh.js"); + + await Assert.That(DshExtensionInstaller.IsInstalled(pluginPath)).IsFalse(); + + var installed = DshExtensionInstaller.Install(pluginPath); + await Assert.That(installed).IsTrue(); + await Assert.That(File.Exists(pluginPath)).IsTrue(); + await Assert.That(DshExtensionInstaller.IsInstalled(pluginPath)).IsTrue(); + await Assert.That(DshExtensionInstaller.ReadMarker(pluginPath)).IsNotNull(); + + var removed = DshExtensionInstaller.Remove(pluginPath); + await Assert.That(removed).IsTrue(); + await Assert.That(File.Exists(pluginPath)).IsFalse(); + await Assert.That(DshExtensionInstaller.IsInstalled(pluginPath)).IsFalse(); // marker also cleared + } + + [Test] + public async Task IsInstalled_true_when_only_marker_present() { + using var tmp = new TempDir(); + var pluginPath = Path.Combine(tmp.Path, "plugins", "kcap.dsh.js"); + + DshExtensionInstaller.Install(pluginPath); + File.Delete(pluginPath); // user removed the plugin but kept the dir/marker + + await Assert.That(DshExtensionInstaller.IsInstalled(pluginPath)).IsTrue(); + } + + [Test] + public async Task RegisterInCordisPatch_is_idempotent_and_preserves_user_entries() { + using var tmp = new TempDir(); + var patch = Path.Combine(tmp.Path, "cordis.patch.yml"); + var plugin = Path.Combine(tmp.Path, "kcap-dsh.plugin.mjs"); + + // empty array base + await File.WriteAllTextAsync(patch, "[]\n"); + await Assert.That(DshExtensionInstaller.RegisterInCordisPatch(patch, plugin)).IsTrue(); + var once = await File.ReadAllTextAsync(patch); + await Assert.That(once.Contains("id: kcap")).IsTrue(); + await Assert.That(once.Contains("kcap-dsh.plugin.mjs")).IsTrue(); + await Assert.That(DshExtensionInstaller.IsRegisteredInCordisPatch(patch)).IsTrue(); + await Assert.That(once.Contains("[]")).IsFalse(); // [] base replaced, not appended-to + + // re-register → still exactly one managed block + DshExtensionInstaller.RegisterInCordisPatch(patch, plugin); + var twice = await File.ReadAllTextAsync(patch); + await Assert.That(CountOccurrences(twice, "kcap-dsh:begin")).IsEqualTo(1); + + // unregister → block gone, empty array restored + await Assert.That(DshExtensionInstaller.UnregisterFromCordisPatch(patch)).IsTrue(); + await Assert.That(DshExtensionInstaller.IsRegisteredInCordisPatch(patch)).IsFalse(); + await Assert.That((await File.ReadAllTextAsync(patch)).Trim()).IsEqualTo("[]"); + } + + [Test] + public async Task RegisterInCordisPatch_preserves_existing_block_style_entries() { + using var tmp = new TempDir(); + var patch = Path.Combine(tmp.Path, "cordis.patch.yml"); + var plugin = Path.Combine(tmp.Path, "kcap-dsh.plugin.mjs"); + + await File.WriteAllTextAsync(patch, "- id: directory-picker\n disabled: true\n"); + DshExtensionInstaller.RegisterInCordisPatch(patch, plugin); + var content = await File.ReadAllTextAsync(patch); + await Assert.That(content.Contains("directory-picker")).IsTrue(); // user entry preserved + await Assert.That(content.Contains("id: kcap")).IsTrue(); + + DshExtensionInstaller.UnregisterFromCordisPatch(patch); + var after = await File.ReadAllTextAsync(patch); + await Assert.That(after.Contains("directory-picker")).IsTrue(); // still there after remove + await Assert.That(after.Contains("id: kcap")).IsFalse(); + } + + static int CountOccurrences(string s, string sub) { + int n = 0, i = 0; + while ((i = s.IndexOf(sub, i, StringComparison.Ordinal)) >= 0) { n++; i += sub.Length; } + return n; + } + + sealed class TempDir : IDisposable { + public string Path { get; } = System.IO.Path.Combine( + System.IO.Path.GetTempPath(), + $"kcap-dsh-installer-test-{Guid.NewGuid().ToString("N")[..8]}" + ); + public TempDir() => Directory.CreateDirectory(Path); + public void Dispose() { + try { Directory.Delete(Path, true); } catch { /* best effort */ } + } + } +} diff --git a/test/Capacitor.Cli.Tests.Unit/DshImportSourceTests.cs b/test/Capacitor.Cli.Tests.Unit/DshImportSourceTests.cs new file mode 100644 index 000000000..ab7e7dcda --- /dev/null +++ b/test/Capacitor.Cli.Tests.Unit/DshImportSourceTests.cs @@ -0,0 +1,102 @@ +using Capacitor.Cli.Commands; + +namespace Capacitor.Cli.Tests.Unit; + +/// +/// Covers discovery from the flat +/// ~/.cache/kcap/dsh/{id}.jsonl layout the kcap Cordis plugin writes (cwd read from +/// the plugin's {$kcap:"header", ...} line) and the import-relevance line filter that +/// keeps the watermark in sync with the server's DeepSeekHarnessTranscriptNormalizer. +/// +public class DshImportSourceTests { + // The plugin's real header line: {$kcap:"header", ...session.header}. The PoC omits + // type:"session"; a real dsh session includes it. Either must be recognized. + const string Header = """{"$kcap":"header","version":0,"id":"sess-abc","createdAt":1785730000000,"cwd":"/work"}"""; + const string UserLine = """{"type":"user/message","seq":2,"time":1785730000100,"data":{"id":"u1","content":[{"type":"text","text":"hi"}]}}"""; + + [Test] + public async Task discovery_reads_flat_jsonl_and_header_cwd() { + using var tmp = new TempDir(); + await File.WriteAllTextAsync(Path.Combine(tmp.Path, "sess-abc.jsonl"), Header + "\n" + UserLine + "\n"); + + var src = new DshImportSource(sessionsDirOverride: tmp.Path); + await Assert.That(src.IsAvailable).IsTrue(); + + var found = await src.DiscoverAsync(new DiscoveryFilters(null, null, null, 1), CancellationToken.None); + + await Assert.That(found.Count).IsEqualTo(1); + var s = found[0]; + // RAW id (dashes kept): dsh ids are non-GUID, so the server leaves them dashed; the + // CLI must send the SAME raw id for transcript + lifecycle or they split into two streams. + await Assert.That(s.SessionId).IsEqualTo("sess-abc"); + await Assert.That(s.Vendor).IsEqualTo("dsh"); + await Assert.That(s.Cwd).IsEqualTo("/work"); // read from the $kcap header + await Assert.That(s.SourceMeta!["DashedSessionId"]).IsEqualTo("sess-abc"); + } + + [Test] + public async Task discovery_canonicalizes_a_session_guid_id_to_the_36_char_contract() { + using var tmp = new TempDir(); + // Real dsh id shape: session- (44 chars). The file keeps the raw name; the + // discovered SessionId must be the embedded dashless GUID (<=36) so it lists. + const string rawId = "session-e1d79e8a-9b62-4b23-b576-7e7493c09dba"; + await File.WriteAllTextAsync(Path.Combine(tmp.Path, rawId + ".jsonl"), Header + "\n" + UserLine + "\n"); + + var found = await new DshImportSource(sessionsDirOverride: tmp.Path) + .DiscoverAsync(new DiscoveryFilters(null, null, null, 1), CancellationToken.None); + + await Assert.That(found.Count).IsEqualTo(1); + await Assert.That(found[0].SessionId).IsEqualTo("e1d79e8a9b624b23b5767e7493c09dba"); + await Assert.That(found[0].SessionId.Length).IsLessThanOrEqualTo(36); + } + + [Test] + public async Task discovery_surfaces_subagent_parent_from_header() { + using var tmp = new TempDir(); + const string childHeader = """{"$kcap":"header","version":0,"id":"session-child","cwd":"/work","parentSession":"session-parent-abc","origin":"subagent"}"""; + await File.WriteAllTextAsync(Path.Combine(tmp.Path, "session-child.jsonl"), childHeader + "\n" + UserLine + "\n"); + + var found = await new DshImportSource(sessionsDirOverride: tmp.Path) + .DiscoverAsync(new DiscoveryFilters(null, null, null, 1), CancellationToken.None); + + await Assert.That(found.Count).IsEqualTo(1); + await Assert.That(found[0].SourceMeta!["ParentSession"]).IsEqualTo("session-parent-abc"); + } + + [Test] + public async Task discovery_session_filter_matches_dashless_id() { + using var tmp = new TempDir(); + await File.WriteAllTextAsync(Path.Combine(tmp.Path, "sess-abc.jsonl"), Header + "\n" + UserLine + "\n"); + + var src = new DshImportSource(sessionsDirOverride: tmp.Path); + + var match = await src.DiscoverAsync(new DiscoveryFilters(null, "sess-abc", null, 1), CancellationToken.None); + await Assert.That(match.Count).IsEqualTo(1); + + var miss = await src.DiscoverAsync(new DiscoveryFilters(null, "nomatch", null, 1), CancellationToken.None); + await Assert.That(miss.Count).IsEqualTo(0); + } + + [Test] + [Arguments("""{"type":"user/message","data":{}}""", true)] + [Arguments("""{"type":"assistant/message","data":{}}""", true)] + [Arguments("""{"type":"tool/result","data":{}}""", true)] + [Arguments("""{"type":"assistant/chunk","data":{}}""", false)] + [Arguments("""{"$kcap":"header","id":"s"}""", false)] + [Arguments("""{"$kcap":"disposed","id":"s"}""", false)] + [Arguments("""not json""", false)] + public async Task is_import_relevant_line(string line, bool expected) { + await Assert.That(DshImportSource.IsImportRelevantLine(line)).IsEqualTo(expected); + } + + sealed class TempDir : IDisposable { + public string Path { get; } = System.IO.Path.Combine( + System.IO.Path.GetTempPath(), + $"kcap-dsh-import-test-{Guid.NewGuid().ToString("N")[..8]}" + ); + public TempDir() => Directory.CreateDirectory(Path); + public void Dispose() { + try { Directory.Delete(Path, true); } catch { /* best effort */ } + } + } +} diff --git a/test/Capacitor.Cli.Tests.Unit/DshPathsTests.cs b/test/Capacitor.Cli.Tests.Unit/DshPathsTests.cs new file mode 100644 index 000000000..cc700e932 --- /dev/null +++ b/test/Capacitor.Cli.Tests.Unit/DshPathsTests.cs @@ -0,0 +1,30 @@ +using Capacitor.Cli.Core.Dsh; + +namespace Capacitor.Cli.Tests.Unit; + +public class DshPathsTests { + // Parallel-safe: asserts invariant layout relationships that hold regardless of how the + // roots resolve (DSH_HOME / home), so no env mutation is needed. Segment-name assertions + // avoid Path.Combine-vs-GetDirectoryName separator differences on Windows. + + [Test] + public async Task SessionJsonl_is_flat_id_jsonl_under_the_kcap_cache() { + var jsonl = DshPaths.SessionJsonl("abc123", home: "/fake/home"); + + // ~/.cache/kcap/dsh/{id}.jsonl (flat — one file per session, like OpenCode's cache). + await Assert.That(Path.GetFileName(jsonl)).IsEqualTo("abc123.jsonl"); + var dir = Path.GetDirectoryName(jsonl)!; + await Assert.That(Path.GetFileName(dir)).IsEqualTo("dsh"); + await Assert.That(Path.GetFileName(Path.GetDirectoryName(dir)!)).IsEqualTo("kcap"); + } + + [Test] + public async Task KcapPlugin_lives_in_the_dsh_home() { + var plugin = DshPaths.KcapPlugin(home: "/fake/home"); + var dshHome = DshPaths.DshHome(home: "/fake/home"); + + await Assert.That(Path.GetFileName(plugin)).IsEqualTo("kcap-dsh.plugin.mjs"); + // plugin sits directly in the dsh home dir (compare leaf names — separator-independent) + await Assert.That(Path.GetFileName(Path.GetDirectoryName(plugin)!)).IsEqualTo(Path.GetFileName(dshHome)); + } +} diff --git a/test/Capacitor.Cli.Tests.Unit/DshSessionIdTests.cs b/test/Capacitor.Cli.Tests.Unit/DshSessionIdTests.cs new file mode 100644 index 000000000..dc363ccd1 --- /dev/null +++ b/test/Capacitor.Cli.Tests.Unit/DshSessionIdTests.cs @@ -0,0 +1,23 @@ +using Capacitor.Cli.Core.Dsh; + +namespace Capacitor.Cli.Tests.Unit; + +public class DshSessionIdTests { + [Test] + [Arguments("session-e1d79e8a-9b62-4b23-b576-7e7493c09dba", "e1d79e8a9b624b23b5767e7493c09dba")] // session- + [Arguments("main-session-3bc87030-2c61-4183-9711-358334dd48d3", "3bc870302c6141839711358334dd48d3")] // main-session- + [Arguments("e1d79e8a-9b62-4b23-b576-7e7493c09dba", "e1d79e8a9b624b23b5767e7493c09dba")] // bare dashed guid + [Arguments("e1d79e8a9b624b23b5767e7493c09dba", "e1d79e8a9b624b23b5767e7493c09dba")] // bare dashless guid + [Arguments("kcap-live-poc-1", "kcap-live-poc-1")] // short non-guid passthrough + public async Task Canonicalize_extracts_guid_or_passes_through(string raw, string expected) { + await Assert.That(DshSessionId.Canonicalize(raw)).IsEqualTo(expected); + } + + [Test] + [Arguments("session-e1d79e8a-9b62-4b23-b576-7e7493c09dba")] + [Arguments("main-session-3bc87030-2c61-4183-9711-358334dd48d3")] + [Arguments("an-absurdly-long-non-guid-id-that-exceeds-the-thirty-six-character-contract")] + public async Task Canonicalize_always_satisfies_the_36_char_contract(string raw) { + await Assert.That(DshSessionId.Canonicalize(raw).Length).IsLessThanOrEqualTo(36); + } +} diff --git a/test/Capacitor.Cli.Tests.Unit/HarnessNudgeEmitterTests.cs b/test/Capacitor.Cli.Tests.Unit/HarnessNudgeEmitterTests.cs index 01ebac478..cede871f3 100644 --- a/test/Capacitor.Cli.Tests.Unit/HarnessNudgeEmitterTests.cs +++ b/test/Capacitor.Cli.Tests.Unit/HarnessNudgeEmitterTests.cs @@ -13,7 +13,7 @@ static AgentDetectionResult DetectionWithOnly(params string[] ids) { var set = new HashSet(ids, StringComparer.Ordinal); DetectedAgent A(string id) => new(BinaryFound: set.Contains(id), InstallSignalFound: false); return new(A("claude"), A("codex"), A("cursor"), A("copilot"), A("gemini"), - A("kiro"), A("pi"), A("opencode"), A("antigravity")); + A("kiro"), A("pi"), A("opencode"), A("antigravity"), A("dsh")); } static string? Fragment(TempDir tmp, AgentDetectionResult detected, Func isWired, bool optedOut = false) =>