Skip to content
Merged
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
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -232,6 +232,8 @@ see [targeted capture recovery](#targeted-capture-recovery) for limits and exit

Transcript uploads redact secrets before sending. Large records are preserved within the 4 MiB UTF-8 limit; records that cannot be safely processed produce a capture-loss warning and a numbered marker, so file totals or plans may be incomplete.

> **Kiro Crew.** Kiro Crew runs your installed `kiro-cli` under agent profiles it regenerates at every start, keeping only executable `~/.kiro/hooks/*.sh` scripts as hooks and reading skills only from `~/.kiro/crew/skills`. So when Crew is present (`~/.kiro/crew`, or `KIROCREW_HOME`), `kcap setup` and `kcap plugin install --kiro` also write `~/.kiro/hooks/kcap-spawn.sh` and copy kcap's skills into `~/.kiro/crew/skills`; restart Kiro Crew once afterwards so its agents pick the hook up. A Crew sub-agent is nested under the chat session that spawned it. A `kcap-spawn.sh` kcap did not write is left alone. `kcap setup --skip-kiro-hooks` skips the hook (an explicit `plugin install --kiro` always installs it), `--skip-kiro-skills` skips the skills on either command, `kcap plugin remove --kiro` removes both, and the upgrade-time refresh never brings back a hook or skills you deleted. Not on Windows, where Crew cannot run shell hooks.

> **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.

> **Pi** has no shell hooks, so live capture uses a shipped Pi extension rather than a hooks file: run `kcap plugin install --pi` (or accept the `kcap setup` prompt) to write `~/.pi/agent/extensions/kcap.ts`, which `pi` auto-loads and streams each session live. Because Pi also ships no built-in MCP, the same command installs an MCP-bridge extension (`~/.pi/agent/extensions/kcap-mcp.ts`, opt out `--skip-pi-mcp`) that exposes the kcap MCP servers as native Pi tools, plus a steering block in `~/.pi/agent/AGENTS.md` (opt out `--skip-pi-instructions`). Historical `kcap import --pi` works with or without any of it.
Expand Down
8 changes: 4 additions & 4 deletions src/Capacitor.Cli.Core/AgentsSkillsInstaller.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,10 @@ namespace Capacitor.Cli.Core;
/// legacy <c>~/.codex/skills/kcap-*</c> folders left by prior installer versions.
/// </summary>
/// <remarks>
/// The target is a parameter because there are three: the agent-agnostic <c>~/.agents/skills/</c>,
/// plus <c>~/.kiro/skills/</c> and <c>~/.gemini/skills/</c> for the two vendors that read their own
/// tree instead. So <see cref="SourceNames"/> decides what all three receive — a skill added here
/// reaches Kiro and Antigravity as well.
/// The target is a parameter because there are several: the agent-agnostic <c>~/.agents/skills/</c>,
/// plus <c>~/.kiro/skills/</c>, <c>~/.kiro/crew/skills/</c> and <c>~/.gemini/skills/</c> for the
/// vendors that read their own tree instead. So <see cref="SourceNames"/> decides what every target
/// receives — a skill added here reaches Kiro, Kiro Crew and Antigravity as well.
/// </remarks>
public static class AgentsSkillsInstaller {
/// <summary>
Expand Down
104 changes: 104 additions & 0 deletions src/Capacitor.Cli.Core/Harness/Kiro/KiroCrewHookInstaller.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
namespace Capacitor.Cli.Core.Harness.Kiro;

/// <summary>
/// Writes and removes kcap's Kiro Crew hook script. Crew accepts a hook only as an absolute path to
/// an executable <c>.sh</c> file with no arguments, and takes its event from the <c># event:</c>
/// header, so the script is a thin wrapper over <c>kcap hook --kiro</c>.
/// </summary>
public static class KiroCrewHookInstaller {
/// <summary><see cref="Unowned"/>: a script of that name exists and kcap did not write it, so it is left alone.</summary>
public enum Outcome { Unchanged, Written, Removed, Unowned, Unsupported, Failed }

/// <summary>Identifies a script as kcap's, so kcap never overwrites or deletes a user's own hook.</summary>
const string OwnershipLine = "# kcap: records Kiro Crew sessions in Kurrent Capacitor. Remove with: kcap plugin remove --kiro";

/// <summary>Records that kcap installed the script, so a refresh can tell a Crew installed since from
/// a script the user deleted. Crew imports only <c>*.sh</c>, so it ignores this file.</summary>
const string InstalledMarker = ".kcap-crew-hook";

/// <summary>
/// The script body. Crew launches <c>kiro-cli</c> with its own PATH rather than the user's shell
/// PATH, so <paramref name="kcapDir"/> (where <c>kcap</c> resolved at install) is the fallback. It
/// is single-quoted, so the shell reads it as data.
/// </summary>
public static string Render(string? kcapDir) {
var fallback = string.IsNullOrEmpty(kcapDir)
? ""
: $"command -v kcap >/dev/null 2>&1 || PATH={ShellQuote(kcapDir)}:\"$PATH\"\n";

return "#!/bin/sh\n"
+ "# event: agentSpawn\n"
+ OwnershipLine + "\n"
+ fallback
+ "exec kcap hook --kiro --event agentSpawn\n";
}

/// <summary>Whether kcap installed the script here and the user has since deleted it.</summary>
public static bool WasRemoved(string scriptPath) =>
File.Exists(MarkerFor(scriptPath)) && !File.Exists(scriptPath);

public static bool IsInstalled(string scriptPath) {
try {
return File.Exists(scriptPath) && File.ReadAllText(scriptPath).Contains(OwnershipLine, StringComparison.Ordinal);
} catch {
return false;
}
}

public static Outcome Install(string scriptPath, string? kcapDir) {
// Crew runs hooks through /bin/sh and checks the execute bit; neither exists on Windows.
if (OperatingSystem.IsWindows()) return Outcome.Unsupported;

var body = Render(kcapDir);

try {
if (File.Exists(scriptPath)) {
if (!IsInstalled(scriptPath)) return Outcome.Unowned;

if (File.ReadAllText(scriptPath) == body) {
EnsureExecutable(scriptPath);
File.WriteAllText(MarkerFor(scriptPath), "");

return Outcome.Unchanged;
}
}

Directory.CreateDirectory(Path.GetDirectoryName(scriptPath)!);
File.WriteAllText(scriptPath, body);
EnsureExecutable(scriptPath);
Comment thread
qodo-code-review[bot] marked this conversation as resolved.
File.WriteAllText(MarkerFor(scriptPath), "");

return Outcome.Written;
} catch {
return Outcome.Failed;
}
}

public static Outcome Remove(string scriptPath) {
try {
if (File.Exists(MarkerFor(scriptPath))) File.Delete(MarkerFor(scriptPath));

if (!File.Exists(scriptPath) || !IsInstalled(scriptPath)) return Outcome.Unchanged;

File.Delete(scriptPath);

return Outcome.Removed;
} catch {
return Outcome.Failed;
}
}

static string MarkerFor(string scriptPath) => Path.Combine(Path.GetDirectoryName(scriptPath)!, InstalledMarker);

static string ShellQuote(string value) => "'" + value.Replace("'", "'\\''") + "'";

static void EnsureExecutable(string scriptPath) {
if (OperatingSystem.IsWindows()) return;

const UnixFileMode mode = UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute
| UnixFileMode.GroupRead | UnixFileMode.GroupExecute
| UnixFileMode.OtherRead | UnixFileMode.OtherExecute;

if (File.GetUnixFileMode(scriptPath) != mode) File.SetUnixFileMode(scriptPath, mode);
}
}
176 changes: 176 additions & 0 deletions src/Capacitor.Cli.Core/Harness/Kiro/KiroCrewParentResolver.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
using System.Collections.Frozen;
using System.Globalization;
using System.Text.Json;
using System.Text.Json.Nodes;

namespace Capacitor.Cli.Core.Harness.Kiro;

/// <summary>
/// Finds the Kiro session that spawned a Kiro Crew sub-agent. Crew gives the child's hooks nothing
/// about its parent, so the link is joined from Crew's own files: the sub-agent's record names its
/// Kiro session, its parent chat and when it started; the session map names that chat's current and
/// previous Kiro sessions; and each Kiro session's metadata says when it was created.
/// </summary>
/// <remarks>
/// A chat can move to a new Kiro session after spawning a child, so the parent is the newest of the
/// chat's sessions created before the child started. When neither mapped session qualifies — the
/// chat has moved more than once since — no parent is named rather than a wrong one.
/// </remarks>
public static class KiroCrewParentResolver {
/// <summary>Bounds the live hook's scan on a machine with a long sub-agent history; the newest are
/// checked first.</summary>
public const int LiveScanLimit = 256;

/// <summary>The most children the server takes on one session-start; more go out in further batches.</summary>
public const int MaxChildrenPerStart = 64;

/// <summary>Crew's records are a few KB; anything far larger is not one and is not parsed.</summary>
const long MaxRecordBytes = 1024 * 1024;

/// <summary>The parent's dashed session id, or null when the session is not a Crew sub-agent or Crew
/// has not recorded it yet. Scans at most <see cref="LiveScanLimit"/> sub-agents.</summary>
public static string? ParentOf(KiroCrewPaths crew, string sessionsDir, string sessionId) {
if (!Guid.TryParse(sessionId, out var child) || !crew.IsPresent()) return null;

foreach (var record in SubagentRecords(crew, LiveScanLimit)) {
if (record.Session != child) continue;

return ParentFor(record, ReadObject(crew.SessionMapJson), sessionsDir);
}

return null;
}

/// <summary>A JSON array of session ids, built by parsing: assigning strings into a
/// <see cref="JsonArray"/> throws under NativeAOT.</summary>
public static JsonArray SessionIdArray(IEnumerable<string> ids) =>
(JsonArray)JsonNode.Parse("[" + string.Join(",", ids.Select(id => $"\"{JsonEncodedText.Encode(id)}\"")) + "]")!;

/// <summary>The dashed ids of the recorded sub-agents this session spawned, newest first. Scans at
/// most <see cref="LiveScanLimit"/> sub-agents.</summary>
public static IReadOnlyList<string> ChildrenOf(KiroCrewPaths crew, string sessionsDir, string sessionId) {
if (!Guid.TryParse(sessionId, out var parent) || !crew.IsPresent()) return [];

var map = ReadObject(crew.SessionMapJson);
var children = new List<string>();

foreach (var record in SubagentRecords(crew, LiveScanLimit)) {
if (ParentFor(record, map, sessionsDir) is { } p && Guid.Parse(p) == parent) children.Add(record.Session.ToString("D"));
}

return children;
}

/// <summary>Every recorded sub-agent's parent, keyed by the child's session, for a historical import
/// that must reach records of any age.</summary>
public static IReadOnlyDictionary<Guid, string> AllParents(KiroCrewPaths crew, string sessionsDir) {
if (!crew.IsPresent()) return FrozenDictionary<Guid, string>.Empty;

var map = ReadObject(crew.SessionMapJson);
var parents = new Dictionary<Guid, string>();

foreach (var record in SubagentRecords(crew, int.MaxValue)) {
if (parents.ContainsKey(record.Session)) continue;
if (ParentFor(record, map, sessionsDir) is { } parent) parents[record.Session] = parent;
}

return parents;
}

/// <summary>Whether the session is, or was, a Crew chat's own session rather than a sub-agent's.</summary>
public static bool IsChatSession(KiroCrewPaths crew, string sessionId) {
if (!Guid.TryParse(sessionId, out var id) || ReadObject(crew.SessionMapJson) is not { } map) return false;

foreach (var (_, node) in map) {
if (node is not JsonObject entry) continue;
if (GuidOf(entry, "sid") == id || GuidOf(entry, "discarded_sid") == id) return true;
}

return false;
}

readonly record struct SubagentRecord(Guid Session, string Chat, DateTimeOffset Started);

static IEnumerable<SubagentRecord> SubagentRecords(KiroCrewPaths crew, int limit) {
List<DirectoryInfo> dirs;

try {
dirs = new DirectoryInfo(crew.SubagentsDir).EnumerateDirectories()
.OrderByDescending(d => d.LastWriteTimeUtc)
.Take(limit)
.ToList();
} catch {
yield break;
}

foreach (var dir in dirs) {
// A finished sub-agent keeps its state.json; tombstone.json covers one whose state is gone or incomplete.
if ((RecordFrom(Path.Combine(dir.FullName, "state.json")) ?? RecordFrom(Path.Combine(dir.FullName, "tombstone.json"))) is { } record)
yield return record;
}
}

static SubagentRecord? RecordFrom(string path) =>
ReadObject(path) is { } obj
&& GuidOf(obj, "session_id") is { } session
&& StringOf(obj, "parent_session") is { Length: > 0 } chat
&& EpochOf(obj, "started") is { } started
? new SubagentRecord(session, chat, started)
: null;

static string? ParentFor(SubagentRecord record, JsonObject? map, string sessionsDir) {
if (map?[record.Chat] is not JsonObject entry) return null;

Guid? parent = null;
DateTimeOffset? created = null;

foreach (var candidate in new[] { GuidOf(entry, "sid"), GuidOf(entry, "discarded_sid") }) {
if (candidate is not { } id || id == record.Session) continue;
if (CreatedAt(sessionsDir, id) is not { } at || at > record.Started) continue;
if (created is { } newest && at <= newest) continue;

parent = id;
created = at;
}

return parent?.ToString("D");
}

static DateTimeOffset? CreatedAt(string sessionsDir, Guid session) =>
ReadObject(Path.Combine(sessionsDir, $"{session:D}.json")) is { } meta
&& StringOf(meta, "created_at") is { } raw
&& DateTimeOffset.TryParse(raw, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal, out var at)
? at
: null;

/// <summary>Reads a file an agent may be rewriting: shared read-write, so the reader never blocks
/// Crew's own write on Windows, and anything unparseable (a half-written file) is simply absent.</summary>
static JsonObject? ReadObject(string path) {
try {
using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete);
if (stream.Length > MaxRecordBytes) return null;

using var reader = new StreamReader(stream);

return JsonNode.Parse(reader.ReadToEnd()) as JsonObject;
} catch {
return null;
}
}

static string? StringOf(JsonObject obj, string key) =>
obj[key] is JsonValue v && v.TryGetValue<string>(out var s) ? s : null;

static Guid? GuidOf(JsonObject obj, string key) =>
Guid.TryParse(StringOf(obj, key), out var g) ? g : null;

static DateTimeOffset? EpochOf(JsonObject obj, string key) {
if (obj[key] is not JsonValue v || !v.TryGetValue<double>(out var seconds) || double.IsNaN(seconds) || double.IsInfinity(seconds)) return null;

try {
return DateTimeOffset.FromUnixTimeMilliseconds((long)(seconds * 1000));
} catch (ArgumentOutOfRangeException) {
return null;
}
}
}
41 changes: 41 additions & 0 deletions src/Capacitor.Cli.Core/Harness/Kiro/KiroCrewPaths.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
namespace Capacitor.Cli.Core.Harness.Kiro;

/// <summary>
/// Filesystem layout for Kiro Crew, the desktop app that runs <c>kiro-cli acp</c> for each chat and
/// sub-agent under profiles it generates. Crew resets those profiles' hooks at every gateway start,
/// keeping only the executable scripts in <see cref="HooksDir"/>, and reads skills only from
/// <see cref="SkillsDir"/>.
/// </summary>
public sealed class KiroCrewPaths {
/// <param name="kiroRoot">The <c>~/.kiro</c> Crew resolves from the user's home.</param>
/// <param name="crewHome"><c>KIROCREW_HOME</c>, when set.</param>
public KiroCrewPaths(string kiroRoot, string? crewHome) {
Root = !string.IsNullOrEmpty(crewHome) ? crewHome : Path.Combine(kiroRoot, "crew");
HooksDir = Path.Combine(kiroRoot, "hooks");
}

/// <summary>Crew's state root (<c>~/.kiro/crew</c>), or <c>KIROCREW_HOME</c> when set.</summary>
public string Root { get; }

/// <summary>
/// Crew's default hook-script directory (<c>~/.kiro/hooks</c>), fixed under the user's home rather
/// than <c>KIRO_HOME</c>. A user who points Crew's <c>agent.kiro_hooks_dir</c> elsewhere is not
/// covered.
/// </summary>
public string HooksDir { get; }

/// <summary>kcap's <c>agentSpawn</c> script, which Crew merges into every profile it generates.</summary>
public string SpawnHookScript => Path.Combine(HooksDir, "kcap-spawn.sh");

/// <summary>The skills root Crew reads (<c>&lt;root&gt;/skills/&lt;name&gt;/SKILL.md</c>).</summary>
public string SkillsDir => Path.Combine(Root, "skills");

/// <summary>One directory per sub-agent, holding <c>state.json</c> and, once it finishes, <c>tombstone.json</c>.</summary>
public string SubagentsDir => Path.Combine(Root, "subagents");

/// <summary>Crew's chat-to-Kiro-session map: <c>{chat key: {sid, discarded_sid}}</c>.</summary>
public string SessionMapJson => Path.Combine(Root, "session_map.json");

/// <summary>Whether Crew has run here — it creates its root on first launch.</summary>
public bool IsPresent() => Directory.Exists(Root);
}
17 changes: 13 additions & 4 deletions src/Capacitor.Cli.Core/Harness/Kiro/KiroHarness.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,19 @@ namespace Capacitor.Cli.Core.Harness.Kiro;

/// <summary>Kiro as this process sees it.</summary>
public sealed class KiroHarness : IHarness<KiroHarness> {
KiroHarness(KiroPaths paths) => Paths = paths;
KiroHarness(KiroPaths paths, KiroCrewPaths crew) {
Paths = paths;
Crew = crew;
}

/// <summary>Resolves Kiro's one override, <c>KIRO_HOME</c>.</summary>
public static KiroHarness FromEnvironment(UserHome home) => Over(new(home, Environment.GetEnvironmentVariable("KIRO_HOME")));
/// <summary>Resolves Kiro's override, <c>KIRO_HOME</c>, and Kiro Crew's, <c>KIROCREW_HOME</c>. Crew
/// resolves its own tree from the user's home, not from <c>KIRO_HOME</c>.</summary>
public static KiroHarness FromEnvironment(UserHome home) => new(
new(home, Environment.GetEnvironmentVariable("KIRO_HOME")),
new(Path.Combine(home.Path, ".kiro"), Environment.GetEnvironmentVariable("KIROCREW_HOME")));

/// <summary>Over a layout resolved elsewhere — a reviewer's isolated home, or a test's.</summary>
public static KiroHarness Over(KiroPaths paths) => new(paths);
public static KiroHarness Over(KiroPaths paths) => new(paths, new(paths.ConfigRoot, null));

public static HarnessId Id => HarnessId.Kiro;
public static string Label => "Kiro";
Expand All @@ -23,6 +29,9 @@ public sealed class KiroHarness : IHarness<KiroHarness> {
/// override a second time.</summary>
public KiroPaths Paths { get; }

/// <summary>Kiro Crew's layout. Crew runs this same <c>kiro-cli</c>, so it is part of Kiro.</summary>
public KiroCrewPaths Crew { get; }

public HarnessSignals Signals => new() {
LaunchSignal = probe => probe.Finds(CliBinary) || probe.Finds(IdeBinary),
UserDataSignal = Paths.HasUserData,
Expand Down
Loading
Loading