Skip to content
30 changes: 21 additions & 9 deletions README.md

Large diffs are not rendered by default.

4 changes: 3 additions & 1 deletion src/Capacitor.App/Services/HostedHarnessCatalog.cs
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ public static class HostedHarnessCatalog {
{ "antigravity", "rpc" },
{ "pi", "rpc" },
{ "mistral-vibe", "pty" },
{ "grok", "acp" },
};

/// The vendors with an EXPLICIT family above — what the guard test reads, since Build's
Expand All @@ -40,7 +41,7 @@ public static class HostedHarnessCatalog {

/// Harnesses kcap records but no daemon hosts: listed only when a daemon advertises one, since
/// the server refuses their launch and an older daemon's silence must not offer them.
static readonly HashSet<string> RecordingOnly = new(StringComparer.OrdinalIgnoreCase) { "mistral-vibe" };
static readonly HashSet<string> RecordingOnly = new(StringComparer.OrdinalIgnoreCase) { "mistral-vibe", "grok" };

public static IReadOnlyList<HarnessOption> Build(string[]? supportedVendors) {
// null = an older daemon that never sent the field: unknown, not empty.
Expand Down Expand Up @@ -176,6 +177,7 @@ public static bool SupportsCodexPosture(string vendor) =>
["antigravity"] = ("An", "#F4B860"),
["pi"] = ("π", "#A994FF"),
["mistral-vibe"] = ("Mv", "#FA520F"),
["grok"] = ("Gk", "#F1F3F7"),
};

/// Glyph + tint for a vendor token; an unmapped token gets its first letter in neutral grey
Expand Down
24 changes: 24 additions & 0 deletions src/Capacitor.Cli.Core/Harness/Grok/GrokHarness.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
namespace Capacitor.Cli.Core.Harness.Grok;

/// <summary>Grok Build as this process sees it.</summary>
public sealed class GrokHarness : IHarness<GrokHarness> {
GrokHarness(GrokPaths paths) => Paths = paths;

/// <summary>Resolves Grok's one override, <c>GROK_HOME</c>, which replaces <c>~/.grok</c>.</summary>
public static GrokHarness FromEnvironment(UserHome home) =>
Over(new(home, Environment.GetEnvironmentVariable("GROK_HOME")));

public static GrokHarness Over(GrokPaths paths) => new(paths);

public static HarnessId Id => HarnessId.Grok;
public static string Label => "Grok";
public static string CliBinary => "grok";

public GrokPaths Paths { get; }

public HarnessSignals Signals => new() {
LaunchSignal = probe => probe.Finds(CliBinary),
UserDataSignal = Paths.HasUserData,
Wired = () => GrokHooksInstaller.IsInstalled(Paths.KcapHooksJson),
};
}
70 changes: 70 additions & 0 deletions src/Capacitor.Cli.Core/Harness/Grok/GrokHooksInstaller.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
using System.Text.Json;
using System.Text.Json.Nodes;

namespace Capacitor.Cli.Core.Harness.Grok;

/// <summary>
/// kcap's user-level Grok hook file. User-level hooks need no project trust. A handler may carry only
/// Grok's own fields: it refuses a file with Claude-only ones.
/// </summary>
public static class GrokHooksInstaller {
public const string HookCommand = "kcap hook --grok";
public const string MarkerFileName = ".kcap-hooks-version";

/// <summary>Start records the session and starts the watcher; Stop restarts a watcher that died. The
/// watcher ends the session once Grok exits, which a 1.5 s session-end hook could not wait for.</summary>
public static readonly string[] Events = ["SessionStart", "Stop"];

/// <summary>Seconds; Grok's default of 5 is too short for the session-start post.</summary>
const int TimeoutSeconds = 30;

static readonly JsonSerializerOptions WriteOptions = new() { WriteIndented = true };

public static bool Install(string hooksPath) {
try {
var hooks = new JsonObject();

foreach (var evt in Events) {
hooks[evt] = new JsonArray(new JsonObject {
["hooks"] = new JsonArray(new JsonObject {
["type"] = "command",
["command"] = HookCommand,
["timeout"] = TimeoutSeconds,
}),
});
}

Directory.CreateDirectory(Path.GetDirectoryName(hooksPath)!);
File.WriteAllText(hooksPath, new JsonObject { ["hooks"] = hooks }.ToJsonString(WriteOptions));
File.WriteAllText(MarkerPath(hooksPath), CapacitorVersion.Current());

return true;
} catch (Exception e) when (e is IOException or UnauthorizedAccessException) {
return false;
}
}

/// <summary>True when the file existed. kcap owns it whole, so nothing in it is the user's.</summary>
public static bool Remove(string hooksPath) {
var existed = File.Exists(hooksPath);

if (existed) File.Delete(hooksPath);
if (File.Exists(MarkerPath(hooksPath))) File.Delete(MarkerPath(hooksPath));

return existed;
}

public static bool IsInstalled(string hooksPath) {
if (!File.Exists(hooksPath)) return false;

try {
return JsonNode.Parse(File.ReadAllText(hooksPath)) is JsonObject { } root
&& root["hooks"] is JsonObject hooks
&& hooks.Any(h => h.Value?.ToJsonString().Contains(HookCommand, StringComparison.Ordinal) == true);
} catch (Exception e) when (e is JsonException or IOException or UnauthorizedAccessException) {
return false;
}
}

static string MarkerPath(string hooksPath) => Path.Combine(Path.GetDirectoryName(hooksPath)!, MarkerFileName);
}
60 changes: 60 additions & 0 deletions src/Capacitor.Cli.Core/Harness/Grok/GrokPaths.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
using System.Text;

namespace Capacitor.Cli.Core.Harness.Grok;

/// <summary>
/// Filesystem layout for Grok Build: everything under <c>$GROK_HOME</c> when set, otherwise
/// <c>~/.grok</c>. Grok merges every <c>*.json</c> in <see cref="HooksDir"/>, so kcap owns
/// <see cref="KcapHooksJson"/> outright.
/// </summary>
public sealed class GrokPaths {
/// <summary>Longest directory name Grok writes before swapping the encoded cwd for a slug.</summary>
const int MaxDirNameBytes = 255;

public GrokPaths(UserHome home, string? grokHome) =>
Home = !string.IsNullOrWhiteSpace(grokHome) ? grokHome : Path.Combine(home.Path, ".grok");

public string Home { get; }

public string HooksDir => Path.Combine(Home, "hooks");

public string KcapHooksJson => Path.Combine(HooksDir, "kcap.json");

public string ConfigToml => Path.Combine(Home, "config.toml");

/// <summary>One directory per cwd, holding one directory per session.</summary>
public string SessionsDir => Path.Combine(Home, "sessions");

/// <summary>
/// The session's <c>updates.jsonl</c>, or null when its directory cannot be named. The directory
/// is the cwd percent-encoded; past <see cref="MaxDirNameBytes"/> Grok uses a slug instead and
/// records the real cwd in that directory's <c>.cwd</c> file.
/// </summary>
public string? UpdatesJsonl(string cwd, string sessionId) {
var encoded = Uri.EscapeDataString(cwd);

if (Encoding.UTF8.GetByteCount(encoded) <= MaxDirNameBytes)
return Path.Combine(SessionsDir, encoded, sessionId, "updates.jsonl");

return SlugDirFor(cwd) is { } dir ? Path.Combine(dir, sessionId, "updates.jsonl") : null;
}

string? SlugDirFor(string cwd) {
if (!Directory.Exists(SessionsDir)) return null;

foreach (var dir in Directory.EnumerateDirectories(SessionsDir)) {
var marker = Path.Combine(dir, ".cwd");

try {
if (File.Exists(marker) && File.ReadAllText(marker).TrimEnd('\n', '\r') == cwd) return dir;
} catch (IOException) { } catch (UnauthorizedAccessException) { }
}

return null;
}

/// <summary>Whether Grok has run here. kcap can create <see cref="HooksDir"/> first, so the root
/// alone does not count.</summary>
public bool HasUserData() =>
Directory.Exists(Home) && (File.Exists(ConfigToml) || Directory.Exists(SessionsDir));
Comment thread
qodo-code-review[bot] marked this conversation as resolved.
}
4 changes: 4 additions & 0 deletions src/Capacitor.Cli.Core/Harness/Grok/GrokSubagentEvent.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
namespace Capacitor.Cli.Core.Harness.Grok;

/// <summary>A parent's record of one of its subagents starting or finishing.</summary>
public sealed record GrokSubagentEvent(string ChildSessionId, string? SubagentType, bool Finished);
40 changes: 40 additions & 0 deletions src/Capacitor.Cli.Core/Harness/Grok/GrokSubagents.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
using System.Text.Json;

namespace Capacitor.Cli.Core.Harness.Grok;

/// <summary>
/// Grok fires no hook for a subagent. It runs as a session of its own beside its parent's, and the
/// parent's <c>updates.jsonl</c> is the only record of it starting and finishing.
/// </summary>
public static class GrokSubagents {
public static IEnumerable<GrokSubagentEvent> Read(IEnumerable<string> parentLines) {
foreach (var line in parentLines) {
if (!line.Contains("subagent_", StringComparison.Ordinal)) continue;

GrokSubagentEvent? found = null;
try {
using var doc = JsonDocument.Parse(line);
var root = doc.RootElement;

if (root.Str("method") == "_x.ai/session/update"
&& root.Obj("params")?.Obj("update") is { } update
// The id names a sibling directory, so a non-GUID one could point outside the sessions dir.
&& update.Str("child_session_id") is { Length: > 0 } child
&& Guid.TryParse(child, out _)) {
found = update.Str("sessionUpdate") switch {
"subagent_spawned" => new(child, update.Str("subagent_type"), Finished: false),
"subagent_finished" => new(child, update.Str("subagent_type"), Finished: true),
_ => null
};
}
} catch (JsonException) { }

if (found is not null) yield return found;
}
}

public static string ChildTranscript(string parentTranscript, string childSessionId) =>
Path.Combine(Path.GetDirectoryName(Path.GetDirectoryName(parentTranscript)!)!, childSessionId, "updates.jsonl");
Comment thread
coderabbitai[bot] marked this conversation as resolved.

public static string AgentId(string childSessionId) => childSessionId.Replace("-", "");
}
1 change: 1 addition & 0 deletions src/Capacitor.Cli.Core/Harness/HarnessId.cs
Original file line number Diff line number Diff line change
Expand Up @@ -17,4 +17,5 @@ public enum HarnessId {
OpenCode,
Antigravity,
MistralVibe,
Grok,
}
2 changes: 2 additions & 0 deletions src/Capacitor.Cli.Core/Harness/HarnessNames.cs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ public static class HarnessNames {
HarnessId.OpenCode => "opencode",
HarnessId.Antigravity => "antigravity",
HarnessId.MistralVibe => "mistral-vibe",
HarnessId.Grok => "grok",
};

/// <summary>The flag that selects this one harness on our commands.</summary>
Expand All @@ -45,6 +46,7 @@ public static class HarnessNames {
"opencode" => HarnessId.OpenCode,
"antigravity" => HarnessId.Antigravity,
"mistral-vibe" => HarnessId.MistralVibe,
"grok" => HarnessId.Grok,
_ => null,
};

Expand Down
3 changes: 2 additions & 1 deletion src/Capacitor.Cli.Core/Harness/HarnessOverrides.cs
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ public static class HarnessOverrides {
HarnessId.OpenCode => "KCAP_OPENCODE_PATH",
HarnessId.Antigravity => "KCAP_ANTIGRAVITY_PATH",
HarnessId.MistralVibe => "KCAP_MISTRAL_VIBE_PATH",
HarnessId.Grok => "KCAP_GROK_PATH",
};

/// <summary>Overrides the model a hosted agent of this vendor runs. Null where we hand the
Expand All @@ -38,7 +39,7 @@ public static class HarnessOverrides {
HarnessId.Antigravity => "KCAP_ANTIGRAVITY_MODEL",

HarnessId.Claude or HarnessId.Codex or HarnessId.Copilot or HarnessId.Gemini
or HarnessId.MistralVibe => null,
or HarnessId.MistralVibe or HarnessId.Grok => null,
};
}
}
3 changes: 3 additions & 0 deletions src/Capacitor.Cli.Core/Harness/HarnessRegistry.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
using Capacitor.Cli.Core.Harness.Copilot;
using Capacitor.Cli.Core.Harness.Cursor;
using Capacitor.Cli.Core.Harness.Gemini;
using Capacitor.Cli.Core.Harness.Grok;
using Capacitor.Cli.Core.Harness.Kiro;
using Capacitor.Cli.Core.Harness.MistralVibe;
using Capacitor.Cli.Core.Harness.OpenCode;
Expand Down Expand Up @@ -50,6 +51,7 @@ public sealed class HarnessRegistry : IReadOnlyList<IHarness>, IHarnessDetection
IdentityOf<OpenCodeHarness>(),
IdentityOf<AntigravityHarness>(),
IdentityOf<MistralVibeHarness>(),
IdentityOf<GrokHarness>(),
Comment thread
qodo-code-review[bot] marked this conversation as resolved.
];

static HarnessIdentity IdentityOf<TSelf>() where TSelf : IHarness<TSelf> => new(TSelf.Id, TSelf.Label);
Expand All @@ -76,6 +78,7 @@ public static HarnessRegistry FromEnvironment(UserHome home, BinaryProbe binarie
OpenCodeHarness.FromEnvironment(home),
AntigravityHarness.Over(gemini),
MistralVibeHarness.FromEnvironment(home),
GrokHarness.FromEnvironment(home),
Comment thread
qodo-code-review[bot] marked this conversation as resolved.
], binaries);
}

Expand Down
8 changes: 7 additions & 1 deletion src/Capacitor.Cli.Core/Resources/help-hook.txt
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ Description:
dispatch is argument-driven (no stdin payload) — see --pi / --opencode below.

The command is registered via 'kcap setup' or
'kcap plugin install [--claude|--codex|--cursor|--copilot|--gemini|--kiro|--pi|--opencode|--antigravity|--mistral-vibe]',
'kcap plugin install [--claude|--codex|--cursor|--copilot|--gemini|--kiro|--pi|--opencode|--antigravity|--mistral-vibe|--grok]',
which writes the appropriate hook configuration (or, for Pi/OpenCode/Antigravity,
the plugin/hooks-block) for the vendor.

Expand Down Expand Up @@ -60,6 +60,12 @@ Options:
(vendor=mistral-vibe) and POSTs session-start; later hooks are
no-ops. Always exits 0 with empty stdout (a passthrough under
Vibe's hook contract).
--grok
Dispatch a Grok Build hook event (session_start / stop, from the
payload's hookEventName). session_start POSTs session-start and
spawns the watcher on the session's updates.jsonl (vendor=grok);
stop restarts a watcher that has gone. Always exits 0 with empty
stdout.

Exit codes:
0 Normal exit (including budget expiry, malformed payload, and
Expand Down
6 changes: 4 additions & 2 deletions src/Capacitor.Cli.Core/Resources/help-import.txt
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
kcap import — Import local sessions from every detected coding agent

Discovers Claude, Codex, Cursor, Copilot, Gemini, Kiro, Pi, OpenCode, Antigravity,
and Mistral Vibe sessions on this machine and imports them in a single run. Most agents
Mistral Vibe, and Grok sessions on this machine and imports them in a single run. Most agents
ship a per-session .jsonl transcript (OpenCode is a SQLite db):
Claude — ~/.claude/projects/<encoded-cwd>/<sid>.jsonl
Codex — ~/.codex/sessions/.../rollout-<sid>.jsonl
Expand All @@ -15,7 +15,8 @@ ship a per-session .jsonl transcript (OpenCode is a SQLite db):
Antigravity — ~/.gemini/antigravity/brain/<id>/.system_generated/logs/transcript_full.jsonl
Vibe — ~/.vibe/logs/session/unified/<id>/ (current store) and the legacy
~/.vibe/logs/session/session_<ts>/messages.jsonl (honours $VIBE_HOME)
kcap walks all ten the same way.
Grok — ~/.grok/sessions/<encoded-cwd>/<sid>/updates.jsonl (honours $GROK_HOME)
kcap walks all eleven the same way.

Usage: kcap import [vendor-filters] [scope] [options]

Expand All @@ -34,6 +35,7 @@ Vendor filters (additive — pass one or more to restrict the run):
--opencode Import SST OpenCode sessions (~/.local/share/opencode/opencode.db)
--antigravity Import Google Antigravity conversations (~/.gemini/antigravity/brain)
--mistral-vibe Import Mistral Vibe sessions (~/.vibe/logs/session)
--grok Import Grok Build sessions and their subagents (~/.grok/sessions)

Scope (one of, required for non-interactive use):
--all Import every discovered session
Expand Down
9 changes: 9 additions & 0 deletions src/Capacitor.Cli.Core/Resources/help-plugin.txt
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,13 @@ Options:
--skip-mistral-vibe-skills
With --mistral-vibe: skip installing the kcap skills into
~/.agents/skills.
--grok Target Grok Build: write kcap's own ~/.grok/hooks/kcap.json
(honours $GROK_HOME) for SessionStart/Stop and install the
agent skills. Writes the bare `kcap hook --grok` command, so
kcap must be on PATH. Grok loads hooks at startup — restart
any running `grok` session.
--skip-grok-skills With --grok: skip installing the kcap skills into
~/.agents/skills.
--tools-only With --cursor, --copilot, --gemini, --kiro, --pi, --opencode,
--antigravity or --mistral-vibe: install only the tools (MCP
servers, skills, steering instructions; the
Expand Down Expand Up @@ -299,6 +306,7 @@ Examples:
kcap plugin install --opencode # Install OpenCode plugin (~/.config/opencode/plugins/kcap.ts)
kcap plugin install --antigravity # Install Antigravity capture plugin (~/.gemini/config/plugins/kcap/)
kcap plugin install --mistral-vibe # Install Mistral Vibe hooks (merge into ~/.vibe/hooks.toml) + MCP
kcap plugin install --grok # Write ~/.grok/hooks/kcap.json + agent skills
kcap plugin install --cursor --tools-only # Cursor MCP servers + agent skills, no hooks
kcap plugin install --kiro --tools-only # Kiro MCP servers + skills, default agent unchanged
kcap plugin install --skills --if-installed # Refresh skills if previously installed (used by npm postinstall)
Expand All @@ -312,4 +320,5 @@ Examples:
kcap plugin remove --opencode # Delete ~/.config/opencode/plugins/kcap.ts
kcap plugin remove --antigravity # Remove the kcap plugin from ~/.gemini/config/plugins/kcap/
kcap plugin remove --mistral-vibe # Remove kcap hooks + MCP from ~/.vibe/hooks.toml and config.toml
kcap plugin remove --grok # Delete ~/.grok/hooks/kcap.json
kcap plugin remove --skills # Remove agent skills + legacy ~/.codex/skills
2 changes: 2 additions & 0 deletions src/Capacitor.Cli.Core/Resources/help-setup.txt
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,8 @@ Options:
Antigravity (the shared ~/.gemini/GEMINI.md).
--skip-antigravity-skills Skip copying the kcap skills into ~/.gemini/skills
(where Antigravity reads them, not ~/.agents/skills).
--skip-grok-hooks Don't install the Grok hooks even if Grok is
detected (~/.grok/ or grok on PATH).
--use-provider-api-key <v> In --no-prompt mode, set whether kcap keeps
ANTHROPIC_API_KEY / OPENAI_API_KEY in headless
agent spawns. Accepts true/1/yes/on or
Expand Down
4 changes: 2 additions & 2 deletions src/Capacitor.Cli.Core/Resources/help-usage.txt
Original file line number Diff line number Diff line change
Expand Up @@ -95,8 +95,8 @@ MCP servers (stdio):
mcp knowledge Read facts and skills and fine-tune skills from agents

Plugin:
plugin install [--project] [--codex|--cursor|--copilot|--gemini|--kiro|--pi|--opencode|--antigravity|--mistral-vibe|--skills] Register hooks/skills (Claude default; flag picks the vendor)
plugin remove [--project] [--codex|--cursor|--copilot|--gemini|--kiro|--pi|--opencode|--antigravity|--mistral-vibe|--skills] Remove hooks/skills (Claude default; flag picks the vendor)
plugin install [--project] [--codex|--cursor|--copilot|--gemini|--kiro|--pi|--opencode|--antigravity|--mistral-vibe|--grok|--skills] Register hooks/skills (Claude default; flag picks the vendor)
plugin remove [--project] [--codex|--cursor|--copilot|--gemini|--kiro|--pi|--opencode|--antigravity|--mistral-vibe|--grok|--skills] Remove hooks/skills (Claude default; flag picks the vendor)

Maintenance:
update Check for and install updates
Expand Down
4 changes: 4 additions & 0 deletions src/Capacitor.Cli.Daemon/DaemonConfig.cs
Original file line number Diff line number Diff line change
Expand Up @@ -417,6 +417,10 @@ public BinaryProbe Binaries {
/// <c>kcap config</c> a slot for the binary path; it is not read by any launcher.</summary>
public string MistralVibePath { get; set; } = Core.Harness.MistralVibe.MistralVibeHarness.CliBinary;

/// <summary>Path or bare command for Grok Build. Overridable via <c>KCAP_GROK_PATH</c>. Grok is
/// recording-only until the daemon hosts it, so no launcher reads this yet.</summary>
public string GrokPath { get; set; } = Core.Harness.Grok.GrokHarness.CliBinary;

/// <summary>
/// Opt-in, off-by-default ACP wire/content debug logging (<c>KCAP_ACP_DEBUG_FRAMES</c>). When
/// <see langword="false"/> (the default), the ACP layers (<c>AcpEventTranslator</c>,
Expand Down
Loading
Loading