Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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/<project>/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/<project>/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 <owner>` 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.

Expand Down
209 changes: 209 additions & 0 deletions src/Capacitor.Cli.Core/Dsh/DshExtensionInstaller.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,209 @@
namespace Capacitor.Cli.Core.Dsh;

/// <summary>
/// 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 <c>SessionEvent</c> to <c>~/.cache/kcap/dsh/{id}.jsonl</c>, writes the
/// durable header on <c>session/created</c> and a terminal marker on <c>session/disposed</c>,
/// and spawns <c>kcap hook --dsh --event session-start</c> so the watcher tails that file
/// (vendor=dsh). This mirrors the OpenCode plugin; the watcher owns session-end.
///
/// <para><b>Install</b> = copy <see cref="ExtensionContent"/> to <see cref="DshPaths.KcapPlugin"/>
/// (<c>$DSH_HOME/kcap-dsh.plugin.mjs</c>) and add an entry to dsh's Cordis config
/// (<c>cordis.yml</c> / the active profile): <c>- name: './kcap-dsh.plugin.mjs'</c>. The
/// copy + version-marker mechanics below are the automatable part; registering the entry in
/// dsh's profile/patch config is left to <c>dsh plugin</c> / a manual one-line edit (see the
/// plugin comment) because that format is dsh-profile-specific.</para>
///
/// <para><see cref="ExtensionContent"/> is embedded as a const (no manifest-resource
/// reflection) to stay NativeAOT-safe, mirroring <see cref="Harness.OpenCode.OpenCodeExtensionInstaller"/>.</para>
/// </summary>
public static class DshExtensionInstaller {
public const string MarkerFileName = ".kcap-extension-version";

/// <summary>
/// The kcap dsh Cordis persistence plugin (plain-JS build, for dsh's <c>--patch install</c>).
/// Dependency-free (only <c>node:</c> builtins) and fail-open — a kcap/server problem must
/// never disrupt the dsh session. Kept byte-for-byte in sync with the source at
/// <c>deepseek-harness/kcap-dsh.mts</c>.
/// </summary>
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
""";

/// <summary>
/// True when the plugin (or its marker) is present. Marker covers the case where a
/// user deleted the plugin but kept the dir.
/// </summary>
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;
}
}

/// <summary>Removes the plugin + marker. Returns true if the plugin existed.</summary>
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 ---";

/// <summary>The managed cordis.patch.yml block that registers the observer plugin.</summary>
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;
}

/// <summary>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.</summary>
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;
}
}

/// <summary>Strips the managed block; restores an empty array (<c>[]</c>) if no real entries remain
/// (keeping any comments). Returns true if the file existed.</summary>
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;
}
}

/// <summary>True if the lines contain a real YAML array entry (not just comments, whitespace,
/// or a lone <c>[]</c> flow literal).</summary>
static bool HasRealEntries(IEnumerable<string> 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();
}
}
70 changes: 70 additions & 0 deletions src/Capacitor.Cli.Core/Dsh/DshPaths.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
namespace Capacitor.Cli.Core.Dsh;

/// <summary>
/// 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 (<see cref="DshExtensionInstaller"/>) forwards every appended
/// <c>SessionEvent</c> to a per-session JSONL file under the kcap cache, which
/// <c>kcap watch --vendor dsh</c> tails and <c>kcap import --dsh</c> replays — one
/// server-side normalizer serves both feeds. This mirrors OpenCode's
/// <c>~/.cache/kcap/opencode/&lt;id&gt;.jsonl</c> layout exactly.
/// </summary>
public static class DshPaths {
/// <summary>dsh's home dir (<c>$DSH_HOME</c>, else <c>~/.dsh</c>) — the Cordis profile +
/// installed plugin live here.</summary>
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");
}

/// <summary>Per-session transcript cache the kcap plugin writes and the watcher tails:
/// <c>~/.cache/kcap/dsh</c> (flat <c>{id}.jsonl</c>). Matches the plugin's path verbatim
/// (<c>homedir()/.cache/kcap/dsh</c>, independent of <c>$DSH_HOME</c>).</summary>
public static string SessionsDir(string? home = null) {
home ??= PathHelpers.HomeDirectory;
return Path.Combine(home, ".cache", "kcap", "dsh");
}

/// <summary>The per-session transcript file (<c>~/.cache/kcap/dsh/{id}.jsonl</c>).</summary>
public static string SessionJsonl(string sessionId, string? home = null) =>
Path.Combine(SessionsDir(home), $"{sessionId}.jsonl");

/// <summary>kcap's Cordis plugin, installed into the dsh home
/// (<c>$DSH_HOME/kcap-dsh.plugin.mjs</c>). Loaded by adding an entry to dsh's
/// <c>cordis.yml</c> / profile config.</summary>
public static string KcapPlugin(string? home = null) =>
Path.Combine(DshHome(home), "kcap-dsh.plugin.mjs");

/// <summary>Version marker beside the installed plugin (mirrors the OpenCode installer).</summary>
public static string KcapPluginMarker(string? home = null) =>
Path.Combine(DshHome(home), ".kcap-extension-version");

/// <summary>dsh profiles root (<c>$DSH_HOME/profiles</c>). Each profile subdir has a
/// <c>package.json</c> + a live-watched <c>cordis.patch.yml</c> where the plugin registers.</summary>
public static string ProfilesDir(string? home = null) =>
Path.Combine(DshHome(home), "profiles");

/// <summary>A profile's user patch file (<c>&lt;profile&gt;/cordis.patch.yml</c>).</summary>
public static string CordisPatch(string profileDir) =>
Path.Combine(profileDir, "cordis.patch.yml");

/// <summary>Detection: the dsh home exists (callers also OR
/// <c>AgentDetector.IsInstalled("dsh")</c> for binary-name coverage).</summary>
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));
}
Loading