Repository navigation
Record Kiro Crew sessions and nest their sub-agents #1149
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
11 commits
Select commit
Hold shift + click to select a range
d702a53
Install kcap's hook and skills where Kiro Crew reads them
realtonyyoung 67bdbd6
Name a Kiro Crew sub-agent's parent on its session start
realtonyyoung 0cfedf4
Keep a user's own or deleted Kiro Crew hook as they left it
realtonyyoung 21cda6b
Pick a Crew sub-agent's parent by when each session was created
realtonyyoung 27605b7
Document Kiro Crew capture and fall back to a complete tombstone
realtonyyoung e595aae
Say which command takes --skip-kiro-hooks for Kiro Crew
realtonyyoung d9501a9
Name a Kiro Crew session's sub-agents on its session start
realtonyyoung b382571
Name every Crew child on import, in batches the server accepts
realtonyyoung 199e000
Build Kiro Crew child-id arrays by parsing, for NativeAOT
realtonyyoung 32eee70
Wait for a Crew parent only in sessions Crew launched
realtonyyoung 4729f25
Skip the unowned-hook install test on Windows
realtonyyoung File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
104 changes: 104 additions & 0 deletions
104
src/Capacitor.Cli.Core/Harness/Kiro/KiroCrewHookInstaller.cs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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); | ||
| 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
176
src/Capacitor.Cli.Core/Harness/Kiro/KiroCrewParentResolver.cs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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; | ||
| } | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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><root>/skills/<name>/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); | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.