From aa99a79e5cd80675eeab53bf17beb12fc2868c40 Mon Sep 17 00:00:00 2001 From: Alexey Zimarev Date: Thu, 3 Sep 2026 09:15:43 +0200 Subject: [PATCH 01/10] Parse owner/name or hash repo references in one place (#752) --- src/Capacitor.Cli.Core/RepoHashHelper.cs | 30 ++++++++++++++++- src/Capacitor.Cli/Commands/RecapCommand.cs | 10 +----- .../RepoRefTests.cs | 32 +++++++++++++++++++ 3 files changed, 62 insertions(+), 10 deletions(-) create mode 100644 test/Capacitor.Cli.Core.Tests.Unit/RepoRefTests.cs diff --git a/src/Capacitor.Cli.Core/RepoHashHelper.cs b/src/Capacitor.Cli.Core/RepoHashHelper.cs index ae04d23e00..f751e3c42b 100644 --- a/src/Capacitor.Cli.Core/RepoHashHelper.cs +++ b/src/Capacitor.Cli.Core/RepoHashHelper.cs @@ -1,13 +1,41 @@ using System.Security.Cryptography; using System.Text; +using System.Text.RegularExpressions; namespace Capacitor.Cli.Core; -public static class RepoHashHelper { +public static partial class RepoHashHelper { public static string ComputeRepoHash(string owner, string repoName) { var input = $"{owner}/{repoName}".ToLowerInvariant(); var hash = SHA256.HashData(Encoding.UTF8.GetBytes(input)); return Convert.ToHexStringLower(hash)[..16]; } + + /// Accepts the two shapes a caller may name a repo by: owner/name, hashed here, + /// or a 16-character lowercase hex hash, passed through. Uppercase hex is rejected rather than + /// folded so a hash copied from the wrong place fails loudly. + public static bool TryParseRepoRef(string value, out string repoHash) { + repoHash = ""; + + if (string.IsNullOrWhiteSpace(value)) return false; + + if (Hash().IsMatch(value)) { + repoHash = value; + + return true; + } + + var parts = value.Split('/'); + + if (parts.Length != 2 || parts[0].Length == 0 || parts[1].Length == 0) return false; + if (parts[0].Any(char.IsWhiteSpace) || parts[1].Any(char.IsWhiteSpace)) return false; + + repoHash = ComputeRepoHash(parts[0], parts[1]); + + return true; + } + + [GeneratedRegex("^[0-9a-f]{16}$")] + private static partial Regex Hash(); } diff --git a/src/Capacitor.Cli/Commands/RecapCommand.cs b/src/Capacitor.Cli/Commands/RecapCommand.cs index f99f7d012d..727bfaa266 100644 --- a/src/Capacitor.Cli/Commands/RecapCommand.cs +++ b/src/Capacitor.Cli/Commands/RecapCommand.cs @@ -1,4 +1,3 @@ -using System.Security.Cryptography; using System.Text; using System.Text.Json; using Capacitor.Cli.Core; @@ -20,7 +19,7 @@ public async Task HandleRepoRecap(int limit = 10) { return 1; } - var hash = ComputeRepoHash(repo.Owner, repo.RepoName); + var hash = RepoHashHelper.ComputeRepoHash(repo.Owner, repo.RepoName); using var httpClient = await HttpClientExtensions.CreateAuthenticatedClientAsync(config, profiles, baseUrl); @@ -73,13 +72,6 @@ public async Task HandleRepoRecap(int limit = 10) { return 0; } - static string ComputeRepoHash(string owner, string repoName) { - var input = $"{owner}/{repoName}".ToLowerInvariant(); - var hash = SHA256.HashData(Encoding.UTF8.GetBytes(input)); - - return Convert.ToHexStringLower(hash)[..16]; - } - public async Task HandleRecap(string sessionId, bool chain, bool full = false) { var baseUrl = profiles.Resolution.ServerUrl!; using var httpClient = await HttpClientExtensions.CreateAuthenticatedClientAsync(config, profiles, baseUrl); diff --git a/test/Capacitor.Cli.Core.Tests.Unit/RepoRefTests.cs b/test/Capacitor.Cli.Core.Tests.Unit/RepoRefTests.cs new file mode 100644 index 0000000000..63d11f0ec7 --- /dev/null +++ b/test/Capacitor.Cli.Core.Tests.Unit/RepoRefTests.cs @@ -0,0 +1,32 @@ +namespace Capacitor.Cli.Core.Tests.Unit; + +public class RepoRefTests { + [Test] + public async Task Owner_slash_name_hashes_like_ComputeRepoHash() { + var ok = RepoHashHelper.TryParseRepoRef("Kurrent-IO/kcap-cli", out var hash); + + await Assert.That(ok).IsTrue(); + await Assert.That(hash).IsEqualTo(RepoHashHelper.ComputeRepoHash("kurrent-io", "kcap-cli")); + } + + [Test] + public async Task Sixteen_lowercase_hex_passes_through() { + var ok = RepoHashHelper.TryParseRepoRef("da9c523c68aee2f1", out var hash); + + await Assert.That(ok).IsTrue(); + await Assert.That(hash).IsEqualTo("da9c523c68aee2f1"); + } + + [Test] + [Arguments("all")] + [Arguments("owner")] + [Arguments("a/b/c")] + [Arguments("DA9C523C68AEE2F1")] + [Arguments("da9c523c")] + [Arguments("owner /name")] + [Arguments("/name")] + [Arguments("")] + public async Task Other_shapes_are_rejected(string value) { + await Assert.That(RepoHashHelper.TryParseRepoRef(value, out _)).IsFalse(); + } +} From 59b6c32a8fcefa5e498bef921c48f6aefad6c7f4 Mon Sep 17 00:00:00 2001 From: Alexey Zimarev Date: Thu, 3 Sep 2026 09:24:36 +0200 Subject: [PATCH 02/10] Add the repo session listing wire records (#752) --- src/Capacitor.Cli.Core/Models.cs | 36 +++++++++++++++ .../RepoSessionsModelsTests.cs | 44 +++++++++++++++++++ 2 files changed, 80 insertions(+) create mode 100644 test/Capacitor.Cli.Core.Tests.Unit/RepoSessionsModelsTests.cs diff --git a/src/Capacitor.Cli.Core/Models.cs b/src/Capacitor.Cli.Core/Models.cs index d7c9130843..7bb7cc80a1 100644 --- a/src/Capacitor.Cli.Core/Models.cs +++ b/src/Capacitor.Cli.Core/Models.cs @@ -297,6 +297,41 @@ record RepoRecapEntry( string Summary ); +record RepoSessionOwnerDto( + string UserId, + string? Username, + string? DisplayName, + string? AvatarUrl + ); + +record RepoSessionDto( + string SessionId, + string? Slug, + string? Title, + RepoSessionOwnerDto? Owner, + string? Vendor, + string Status, + string AccessLevel, + bool Stale, + DateTimeOffset StartedAt, + DateTimeOffset? EndedAt, + DateTimeOffset LastActivityAt, + string? PrimaryRepoHash, + bool IsPrimary, + string? Branch, + string? Cwd, + string? LastPrompt, + string[] WriteAttemptPaths, + int WriteAttemptCount + ); + +record RepoSessionsResponse( + List Items, + int Total, + int Limit, + int Offset + ); + // ── Eval command types — see DEV-1433 ───────────────────────────────────── // Response shape from GET /api/sessions/{id}/eval-context. Only the fields @@ -1126,6 +1161,7 @@ public sealed record CurationApplyResponse { [JsonSerializable(typeof(Capacitor.Cli.Core.Commands.FeedbackSubmitRequest))] [JsonSerializable(typeof(Capacitor.Cli.Core.Commands.FeedbackSubmitContext))] [JsonSerializable(typeof(Capacitor.Cli.Core.Commands.FeedbackSubmitResponse))] +[JsonSerializable(typeof(RepoSessionsResponse))] [JsonSourceGenerationOptions( PropertyNamingPolicy = JsonKnownNamingPolicy.SnakeCaseLower, UseStringEnumConverter = true diff --git a/test/Capacitor.Cli.Core.Tests.Unit/RepoSessionsModelsTests.cs b/test/Capacitor.Cli.Core.Tests.Unit/RepoSessionsModelsTests.cs new file mode 100644 index 0000000000..cd37bbfadc --- /dev/null +++ b/test/Capacitor.Cli.Core.Tests.Unit/RepoSessionsModelsTests.cs @@ -0,0 +1,44 @@ +using System.Text.Json; + +namespace Capacitor.Cli.Core.Tests.Unit; + +/// Pins the wire names the server emits for the repo listing; a renamed property here +/// would deserialize to null silently. +public class RepoSessionsModelsTests { + const string Body = """ + {"items":[{"session_id":"abc","slug":"abc-slug","title":"Fix it","owner":{"user_id":"github:1","username":"alice","display_name":"Alice","avatar_url":null}, + "vendor":"claude","status":"active","access_level":"full","stale":true,"started_at":"2026-09-02T09:00:00+00:00","ended_at":null, + "last_activity_at":"2026-09-02T10:00:00+00:00","primary_repo_hash":"da9c523c68aee2f1","is_primary":false,"branch":"main","cwd":"/work", + "last_prompt":"do the thing","write_attempt_paths":["/work/a.cs"],"write_attempt_count":1}, + {"session_id":"def","slug":null,"title":null,"owner":null,"vendor":null,"status":"ended","access_level":"overview","stale":false, + "started_at":"2026-09-01T09:00:00+00:00","ended_at":"2026-09-01T10:00:00+00:00","last_activity_at":"2026-09-01T10:00:00+00:00", + "primary_repo_hash":null,"is_primary":true,"branch":null,"cwd":null,"last_prompt":null,"write_attempt_paths":[],"write_attempt_count":0}], + "total":2,"limit":20,"offset":0} + """; + + [Test] + public async Task Deserializes_every_field_and_tolerates_nulls() { + var page = JsonSerializer.Deserialize(Body, CapacitorJsonContext.Default.RepoSessionsResponse)!; + + await Assert.That(page.Total).IsEqualTo(2); + await Assert.That(page.Limit).IsEqualTo(20); + await Assert.That(page.Items.Count).IsEqualTo(2); + + var first = page.Items[0]; + await Assert.That(first.SessionId).IsEqualTo("abc"); + await Assert.That(first.Owner!.Username).IsEqualTo("alice"); + await Assert.That(first.Status).IsEqualTo("active"); + await Assert.That(first.AccessLevel).IsEqualTo("full"); + await Assert.That(first.Stale).IsTrue(); + await Assert.That(first.IsPrimary).IsFalse(); + await Assert.That(first.WriteAttemptPaths).IsEquivalentTo(new[] { "/work/a.cs" }); + await Assert.That(first.WriteAttemptCount).IsEqualTo(1); + await Assert.That(first.LastActivityAt.Hour).IsEqualTo(10); + + var second = page.Items[1]; + await Assert.That(second.Owner).IsNull(); + await Assert.That(second.Branch).IsNull(); + await Assert.That(second.WriteAttemptPaths).IsEmpty(); + await Assert.That(second.EndedAt).IsNotNull(); + } +} From 054b8d068b769151510e3f57464c9a629568e3a9 Mon Sep 17 00:00:00 2001 From: Alexey Zimarev Date: Thu, 3 Sep 2026 09:31:34 +0200 Subject: [PATCH 03/10] Pin every repo session listing wire field in the model test (#752) --- src/Capacitor.Cli.Core/Models.cs | 2 +- .../RepoSessionsModelsTests.cs | 33 +++++++++++++++++-- 2 files changed, 31 insertions(+), 4 deletions(-) diff --git a/src/Capacitor.Cli.Core/Models.cs b/src/Capacitor.Cli.Core/Models.cs index 7bb7cc80a1..b98c069fb5 100644 --- a/src/Capacitor.Cli.Core/Models.cs +++ b/src/Capacitor.Cli.Core/Models.cs @@ -960,6 +960,7 @@ public sealed record CurationApplyResponse { [JsonSerializable(typeof(List))] [JsonSerializable(typeof(List))] +[JsonSerializable(typeof(RepoSessionsResponse))] [JsonSerializable(typeof(PlanArtifactDto))] [JsonSerializable(typeof(PlanArtifactsResponseDto))] [JsonSerializable(typeof(EvalContextResult))] @@ -1161,7 +1162,6 @@ public sealed record CurationApplyResponse { [JsonSerializable(typeof(Capacitor.Cli.Core.Commands.FeedbackSubmitRequest))] [JsonSerializable(typeof(Capacitor.Cli.Core.Commands.FeedbackSubmitContext))] [JsonSerializable(typeof(Capacitor.Cli.Core.Commands.FeedbackSubmitResponse))] -[JsonSerializable(typeof(RepoSessionsResponse))] [JsonSourceGenerationOptions( PropertyNamingPolicy = JsonKnownNamingPolicy.SnakeCaseLower, UseStringEnumConverter = true diff --git a/test/Capacitor.Cli.Core.Tests.Unit/RepoSessionsModelsTests.cs b/test/Capacitor.Cli.Core.Tests.Unit/RepoSessionsModelsTests.cs index cd37bbfadc..d1adbfef81 100644 --- a/test/Capacitor.Cli.Core.Tests.Unit/RepoSessionsModelsTests.cs +++ b/test/Capacitor.Cli.Core.Tests.Unit/RepoSessionsModelsTests.cs @@ -22,23 +22,50 @@ public async Task Deserializes_every_field_and_tolerates_nulls() { await Assert.That(page.Total).IsEqualTo(2); await Assert.That(page.Limit).IsEqualTo(20); + await Assert.That(page.Offset).IsEqualTo(0); await Assert.That(page.Items.Count).IsEqualTo(2); var first = page.Items[0]; await Assert.That(first.SessionId).IsEqualTo("abc"); - await Assert.That(first.Owner!.Username).IsEqualTo("alice"); + await Assert.That(first.Slug).IsEqualTo("abc-slug"); + await Assert.That(first.Title).IsEqualTo("Fix it"); + await Assert.That(first.Owner!.UserId).IsEqualTo("github:1"); + await Assert.That(first.Owner.Username).IsEqualTo("alice"); + await Assert.That(first.Owner.DisplayName).IsEqualTo("Alice"); + await Assert.That(first.Owner.AvatarUrl).IsNull(); + await Assert.That(first.Vendor).IsEqualTo("claude"); await Assert.That(first.Status).IsEqualTo("active"); await Assert.That(first.AccessLevel).IsEqualTo("full"); await Assert.That(first.Stale).IsTrue(); + await Assert.That(first.StartedAt).IsEqualTo(new DateTimeOffset(2026, 9, 2, 9, 0, 0, TimeSpan.Zero)); + await Assert.That(first.EndedAt).IsNull(); + await Assert.That(first.LastActivityAt).IsEqualTo(new DateTimeOffset(2026, 9, 2, 10, 0, 0, TimeSpan.Zero)); + await Assert.That(first.PrimaryRepoHash).IsEqualTo("da9c523c68aee2f1"); await Assert.That(first.IsPrimary).IsFalse(); + await Assert.That(first.Branch).IsEqualTo("main"); + await Assert.That(first.Cwd).IsEqualTo("/work"); + await Assert.That(first.LastPrompt).IsEqualTo("do the thing"); await Assert.That(first.WriteAttemptPaths).IsEquivalentTo(new[] { "/work/a.cs" }); await Assert.That(first.WriteAttemptCount).IsEqualTo(1); - await Assert.That(first.LastActivityAt.Hour).IsEqualTo(10); var second = page.Items[1]; + await Assert.That(second.SessionId).IsEqualTo("def"); + await Assert.That(second.Slug).IsNull(); + await Assert.That(second.Title).IsNull(); await Assert.That(second.Owner).IsNull(); + await Assert.That(second.Vendor).IsNull(); + await Assert.That(second.Status).IsEqualTo("ended"); + await Assert.That(second.AccessLevel).IsEqualTo("overview"); + await Assert.That(second.Stale).IsFalse(); + await Assert.That(second.StartedAt).IsEqualTo(new DateTimeOffset(2026, 9, 1, 9, 0, 0, TimeSpan.Zero)); + await Assert.That(second.EndedAt).IsEqualTo(new DateTimeOffset(2026, 9, 1, 10, 0, 0, TimeSpan.Zero)); + await Assert.That(second.LastActivityAt).IsEqualTo(new DateTimeOffset(2026, 9, 1, 10, 0, 0, TimeSpan.Zero)); + await Assert.That(second.PrimaryRepoHash).IsNull(); + await Assert.That(second.IsPrimary).IsTrue(); await Assert.That(second.Branch).IsNull(); + await Assert.That(second.Cwd).IsNull(); + await Assert.That(second.LastPrompt).IsNull(); await Assert.That(second.WriteAttemptPaths).IsEmpty(); - await Assert.That(second.EndedAt).IsNotNull(); + await Assert.That(second.WriteAttemptCount).IsEqualTo(0); } } From 2c1a63742ffb28d1fe118eb39e48ef3002957fa5 Mon Sep 17 00:00:00 2001 From: Alexey Zimarev Date: Thu, 3 Sep 2026 09:37:39 +0200 Subject: [PATCH 04/10] Add list_repo_sessions to the sessions MCP server (#752) Named apart from the review server's list_sessions: both load into one agent and Codex-style clients show bare tool names. --- src/Capacitor.Cli.Core/KcapMcpRegistry.cs | 2 +- .../Commands/McpSessionsServer.cs | 63 ++++++++++++++++ .../McpSessionsServerTests.cs | 28 ++++++- .../Commands/McpSessionsServerTests.cs | 73 +++++++++++++++++++ 4 files changed, 163 insertions(+), 3 deletions(-) diff --git a/src/Capacitor.Cli.Core/KcapMcpRegistry.cs b/src/Capacitor.Cli.Core/KcapMcpRegistry.cs index f97d00244e..e94e10ea51 100644 --- a/src/Capacitor.Cli.Core/KcapMcpRegistry.cs +++ b/src/Capacitor.Cli.Core/KcapMcpRegistry.cs @@ -97,7 +97,7 @@ public static class KcapMcpRegistry { "list_sessions", "get_transcript", }, ["kcap-sessions"] = new HashSet(StringComparer.Ordinal) { - "search_sessions", "get_session_summary", "get_session_transcript", + "search_sessions", "list_repo_sessions", "get_session_summary", "get_session_transcript", "get_turn", "list_turns", }, }; diff --git a/src/Capacitor.Cli/Commands/McpSessionsServer.cs b/src/Capacitor.Cli/Commands/McpSessionsServer.cs index fcd4fc0b14..338c8768b3 100644 --- a/src/Capacitor.Cli/Commands/McpSessionsServer.cs +++ b/src/Capacitor.Cli/Commands/McpSessionsServer.cs @@ -180,6 +180,7 @@ internal async Task HandleToolCallAsync( "get_session_transcript" => await SendWithRefreshRetryAsync(client, baseUrl, c => c.GetAsync(BuildTranscriptUrl(baseUrl, arguments))), "get_turn" => await SendWithRefreshRetryAsync(client, baseUrl, c => c.GetAsync(BuildTurnDetailUrl(baseUrl, arguments))), "list_turns" => await SendWithRefreshRetryAsync(client, baseUrl, c => c.GetAsync(BuildTurnsUrl(baseUrl, arguments))), + "list_repo_sessions" => await SendWithRefreshRetryAsync(client, baseUrl, c => c.GetAsync(BuildRepoSessionsUrl(baseUrl, arguments, cwdRepoHash))), _ => throw new ArgumentException($"Unknown tool: {toolName}") }; @@ -303,6 +304,52 @@ async Task SendWithRefreshRetryAsync(HttpClient client, str return await send(client); } + const string RepoShapeMessage = + "`repo` must be \"/\" or a 16-hex repo hash. This tool is repo-scoped, so \"all\" is not accepted."; + + internal static string BuildRepoSessionsUrl(string baseUrl, JsonObject? args, string? cwdRepoHash) { + var explicitRepo = ReadString(args, "repo", RepoShapeMessage); + if (string.IsNullOrWhiteSpace(explicitRepo)) explicitRepo = null; + + string repoHash; + + if (explicitRepo is null) { + repoHash = cwdRepoHash ?? throw new ArgumentException( + "Cannot resolve the current repository owner/name from git metadata (e.g. a missing or " + + "unparseable 'origin' remote). Pass repo: \"/\" or a 16-hex repo hash."); + } else if (!RepoHashHelper.TryParseRepoRef(explicitRepo, out repoHash)) { + throw new ArgumentException(RepoShapeMessage); + } + + var state = ReadString(args, "state", "`state` must be a string: active, ended or all.") ?? "active"; + + if (state is not ("active" or "ended" or "all")) + throw new ArgumentException("`state` must be active, ended or all."); + + var qs = new List { $"state={state}" }; + + if (ReadString(args, "owner", "`owner` must be a string.") is { Length: > 0 } owner) + qs.Add($"owner={Uri.EscapeDataString(owner)}"); + + if (ReadString(args, "touching_path", "`touching_path` must be a string.") is { Length: > 0 } touching) + qs.Add($"touching_path={Uri.EscapeDataString(touching)}"); + + if (TryReadInt(args, "limit", out var limit)) qs.Add($"limit={limit}"); + + if (TryReadInt(args, "offset", out var offset)) qs.Add($"offset={offset}"); + + return $"{baseUrl}/api/repositories/{repoHash}/sessions?" + string.Join("&", qs); + } + + // A non-string JSON value must surface as a validation error, not as the generic internal + // error the outer guard produces for an InvalidOperationException from GetValue(). + static string? ReadString(JsonObject? args, string key, string shapeMessage) => + args?[key] switch { + null => null, + JsonValue v when v.TryGetValue(out string? s) => s, + _ => throw new ArgumentException(shapeMessage) + }; + internal static string BuildSearchUrl(string baseUrl, JsonObject? args, string? cwdRepoHash) { var url = $"{baseUrl}/api/sessions/search"; var qs = new List(); @@ -698,6 +745,22 @@ static McpTool[] BuildToolsList() => [ [] ) ), + new( + "list_repo_sessions", + "List the sessions on a repository that you are allowed to see, running ones first, ordered by last activity. Each row carries session_id, owner, vendor, status, access_level, stale, started_at, last_activity_at, branch, cwd, last_prompt, write_attempt_paths and write_attempt_count. Rows are visibility-filtered: a teammate who is missing may simply have a private session. Below access_level \"full\" the branch, cwd, prompt and paths are blank, and touching_path only ever matches sessions you hold at \"full\". stale means no activity for over an hour. write_attempt_paths are Edit/Write tool inputs recorded at invocation time: attempts, not confirmed writes; first call per event only; paths as the tool received them; nothing from Bash, MultiEdit, NotebookEdit, apply_patch, MCP file tools or subagents. On a running session, list_turns works at \"activity\" and above while get_turn and get_session_transcript need \"full\": for a full row, the latest closed turn is get_turn on the last index list_turns returns; an activity row stops at list_turns. Reach for this when you find unexplained state in a checkout and need to know which session is doing it.", + new( + "object", + new() { + ["repo"] = new("string", "Optional: \"/\" or a 16-hex repo hash. Defaults to the current repo (resolved from cwd at server startup). \"all\" is not accepted; the tool is repo-scoped."), + ["state"] = new("string", "Optional: active (default), ended, or all."), + ["owner"] = new("string", "Optional: \"me\" or a canonical user id. Absent means everyone visible."), + ["touching_path"] = new("string", "Optional: substring matched against the stored write-attempt paths as the tool received them."), + ["limit"] = new("integer", "Default 20, max 100."), + ["offset"] = new("integer", "Default 0, max 500.") + }, + [] + ) + ), new( "get_session_summary", "Get a concise summary of a past session: the 'what was done' narrative (summary_text) and the plan (if any). Use this to orient yourself before drilling into the full transcript.", diff --git a/test/Capacitor.Cli.Tests.Integration/McpSessionsServerTests.cs b/test/Capacitor.Cli.Tests.Integration/McpSessionsServerTests.cs index bd4d8d1653..c1095ab07f 100644 --- a/test/Capacitor.Cli.Tests.Integration/McpSessionsServerTests.cs +++ b/test/Capacitor.Cli.Tests.Integration/McpSessionsServerTests.cs @@ -166,14 +166,14 @@ public async Task Initialize_returns_server_info_with_correct_name() { } [Test] - public async Task Tools_list_returns_five_tools_with_correct_names() { + public async Task Tools_list_returns_six_tools_with_correct_names() { using var proc = SpawnMcpServer(); try { var response = await SendRequest(proc, ToolsListRequest(2)); var tools = response["result"]?["tools"]?.AsArray(); await Assert.That(tools).IsNotNull(); - await Assert.That(tools!.Count).IsEqualTo(5); + await Assert.That(tools!.Count).IsEqualTo(6); var names = tools.Select(t => t?["name"]?.GetValue()).ToHashSet(); await Assert.That(names.Contains("search_sessions")).IsTrue(); @@ -181,6 +181,7 @@ public async Task Tools_list_returns_five_tools_with_correct_names() { await Assert.That(names.Contains("get_session_transcript")).IsTrue(); await Assert.That(names.Contains("get_turn")).IsTrue(); await Assert.That(names.Contains("list_turns")).IsTrue(); + await Assert.That(names.Contains("list_repo_sessions")).IsTrue(); // Hard gate: search_sessions carries the comparative routing cue. var searchDesc = tools.First(t => t?["name"]?.GetValue() == "search_sessions")!["description"]!.GetValue(); @@ -190,6 +191,29 @@ public async Task Tools_list_returns_five_tools_with_correct_names() { } } + [Test] + public async Task List_repo_sessions_hits_the_repo_route_for_the_cwd_repo() { + using var repo = CwdRepo("acme", "widgets"); + var hash = RepoHashHelper.ComputeRepoHash("acme", "widgets"); + + _server.Given(Request.Create().WithPath($"/api/repositories/{hash}/sessions").UsingGet().WithParam("state", "active")) + .RespondWith(Response.Create().WithStatusCode(200).WithBody( + """{"items":[{"session_id":"s-1","slug":null,"title":"Fix","owner":null,"vendor":"claude","status":"active","access_level":"full","stale":false,"started_at":"2026-09-02T09:00:00+00:00","ended_at":null,"last_activity_at":"2026-09-02T10:00:00+00:00","primary_repo_hash":null,"is_primary":true,"branch":"main","cwd":"/w","last_prompt":null,"write_attempt_paths":[],"write_attempt_count":0}],"total":1,"limit":20,"offset":0}""")); + + using var proc = SpawnMcpServer(workingDirectory: repo.Path); + try { + await SendRequest(proc, InitializeRequest(1)); + + var response = await SendRequest(proc, ToolsCallRequest(2, "list_repo_sessions", new JsonObject())); + var text = response["result"]?["content"]?[0]?["text"]?.GetValue(); + + await Assert.That(response["result"]?["isError"]).IsNull(); + await Assert.That(text).Contains("\"session_id\":\"s-1\""); + } finally { + await ShutdownAsync(proc); + } + } + [Test] public async Task Search_sessions_calls_server_and_passes_through_response() { const string stubbedBody = """{"hits":[{"session_id":"abc","title":"Batch import","snippet":"batch …"}]}"""; diff --git a/test/Capacitor.Cli.Tests.Unit/Commands/McpSessionsServerTests.cs b/test/Capacitor.Cli.Tests.Unit/Commands/McpSessionsServerTests.cs index 3d99bfa146..2d3ccb5a72 100644 --- a/test/Capacitor.Cli.Tests.Unit/Commands/McpSessionsServerTests.cs +++ b/test/Capacitor.Cli.Tests.Unit/Commands/McpSessionsServerTests.cs @@ -1,10 +1,83 @@ using System.Text.Json; using System.Text.Json.Nodes; using Capacitor.Cli.Commands; +using Capacitor.Cli.Core; namespace Capacitor.Cli.Tests.Unit.Commands; public class McpSessionsServerTests { + const string CwdHash = "da9c523c68aee2f1"; + + [Test] + public async Task BuildRepoSessionsUrl_no_repo_uses_cwd_hash_and_defaults_state_to_active() { + var url = McpSessionsServer.BuildRepoSessionsUrl("http://srv", args: null, cwdRepoHash: CwdHash); + + await Assert.That(url).IsEqualTo($"http://srv/api/repositories/{CwdHash}/sessions?state=active"); + } + + [Test] + public async Task BuildRepoSessionsUrl_blank_repo_is_treated_as_absent() { + var url = McpSessionsServer.BuildRepoSessionsUrl("http://srv", new JsonObject { ["repo"] = " " }, CwdHash); + + await Assert.That(url).Contains($"/api/repositories/{CwdHash}/sessions"); + } + + [Test] + public async Task BuildRepoSessionsUrl_no_repo_and_no_cwd_hash_fails_closed_without_offering_all() { + var ex = await Assert.That(() => McpSessionsServer.BuildRepoSessionsUrl("http://srv", args: null, cwdRepoHash: null)) + .Throws(); + + await Assert.That(ex!.Message).Contains("/"); + await Assert.That(ex.Message).DoesNotContain("\"all\""); + } + + [Test] + public async Task BuildRepoSessionsUrl_owner_name_is_hashed_locally_and_hash_passes_through() { + var byName = McpSessionsServer.BuildRepoSessionsUrl("http://srv", new JsonObject { ["repo"] = "kurrent-io/kcap-server" }, null); + var byHash = McpSessionsServer.BuildRepoSessionsUrl("http://srv", new JsonObject { ["repo"] = CwdHash }, null); + + await Assert.That(byName).Contains($"/api/repositories/{RepoHashHelper.ComputeRepoHash("kurrent-io", "kcap-server")}/sessions"); + await Assert.That(byHash).Contains($"/api/repositories/{CwdHash}/sessions"); + } + + [Test] + [Arguments("all")] + [Arguments("owner")] + [Arguments("a/b/c")] + [Arguments("DA9C523C68AEE2F1")] + [Arguments("da9c523c")] + public async Task BuildRepoSessionsUrl_rejects_malformed_repo(string repo) { + await Assert.That(() => McpSessionsServer.BuildRepoSessionsUrl("http://srv", new JsonObject { ["repo"] = repo }, CwdHash)) + .Throws(); + } + + [Test] + public async Task BuildRepoSessionsUrl_rejects_non_string_repo_and_bad_state() { + await Assert.That(() => McpSessionsServer.BuildRepoSessionsUrl("http://srv", new JsonObject { ["repo"] = 42 }, CwdHash)) + .Throws(); + await Assert.That(() => McpSessionsServer.BuildRepoSessionsUrl("http://srv", new JsonObject { ["state"] = "running" }, CwdHash)) + .Throws(); + } + + [Test] + public async Task BuildRepoSessionsUrl_encodes_owner_and_touching_path_and_passes_paging() { + var url = McpSessionsServer.BuildRepoSessionsUrl( + "http://srv", + new JsonObject { + ["state"] = "ended", + ["owner"] = "user_01ABC DEF", + ["touching_path"] = "src/Foo Bar.cs", + ["limit"] = 5, + ["offset"] = 10 + }, + CwdHash); + + await Assert.That(url).Contains("state=ended"); + await Assert.That(url).Contains("owner=user_01ABC%20DEF"); + await Assert.That(url).Contains("touching_path=src%2FFoo%20Bar.cs"); + await Assert.That(url).Contains("limit=5"); + await Assert.That(url).Contains("offset=10"); + } [Test] public async Task BuildSearchUrl_no_args_no_cwd_hash_fails_closed() { // Superseded fail-open expectation: with no repo resolvable and none requested, this now From f6fe8d38bdfe6d852b89948741215a72bd728809 Mon Sep 17 00:00:00 2001 From: Alexey Zimarev Date: Thu, 3 Sep 2026 09:53:42 +0200 Subject: [PATCH 05/10] Add kcap sessions to list a repository's visible sessions (#752) --- .../Resources/help-sessions.txt | 18 +++ .../Resources/help-usage.txt | 1 + .../Telemetry/CommandEvents.cs | 2 +- src/Capacitor.Cli/Commands/SessionsArgs.cs | 78 +++++++++++ src/Capacitor.Cli/Commands/SessionsCommand.cs | 130 ++++++++++++++++++ src/Capacitor.Cli/Program.cs | 2 + .../Telemetry/CommandEventsTests.cs | 1 + .../SessionsCommandTests.cs | 82 +++++++++++ .../Commands/SessionsArgsTests.cs | 80 +++++++++++ .../Commands/SessionsCommandRenderTests.cs | 39 ++++++ 10 files changed, 432 insertions(+), 1 deletion(-) create mode 100644 src/Capacitor.Cli.Core/Resources/help-sessions.txt create mode 100644 src/Capacitor.Cli/Commands/SessionsArgs.cs create mode 100644 src/Capacitor.Cli/Commands/SessionsCommand.cs create mode 100644 test/Capacitor.Cli.Tests.Integration/SessionsCommandTests.cs create mode 100644 test/Capacitor.Cli.Tests.Unit/Commands/SessionsArgsTests.cs create mode 100644 test/Capacitor.Cli.Tests.Unit/Commands/SessionsCommandRenderTests.cs diff --git a/src/Capacitor.Cli.Core/Resources/help-sessions.txt b/src/Capacitor.Cli.Core/Resources/help-sessions.txt new file mode 100644 index 0000000000..80d392d3c1 --- /dev/null +++ b/src/Capacitor.Cli.Core/Resources/help-sessions.txt @@ -0,0 +1,18 @@ +kcap sessions — Sessions on a repository + +Usage: kcap sessions [--active | --ended | --all] [--repo ] [--mine] [--touching ] [--limit ] [--json] + +Lists the sessions on a repository that you are allowed to see, running +ones first, ordered by last activity. Below full access a row shows no +branch, cwd, prompt or paths. + +Options: + --active Running sessions only (default) + --ended Ended sessions only + --all Both + --repo Another repository (default: the current checkout's origin) + --mine Only your own sessions + --touching Only sessions with an Edit/Write attempt whose path contains + (full-access rows only; shell-applied edits leave no evidence) + --limit Rows to show, 1-100 (default 20) + --json Print the server response as JSON diff --git a/src/Capacitor.Cli.Core/Resources/help-usage.txt b/src/Capacitor.Cli.Core/Resources/help-usage.txt index 8f2bdd341c..de60513c7a 100644 --- a/src/Capacitor.Cli.Core/Resources/help-usage.txt +++ b/src/Capacitor.Cli.Core/Resources/help-usage.txt @@ -57,6 +57,7 @@ Session: errors [--chain] [id] List tool call errors for a session recap [--chain] [--full] [id] Session summary (--full for raw transcript) recap --repo Recent session summaries for current repo + sessions [--active|--ended|--all] [--repo R] [--mine] Sessions on the current repo, running first validate-plan [id] Validate plan completion for a session eval [--model N] [--chain] [id] Run LLM-as-judge evaluation over a session generate-whats-done [--codex] Generate what's-done summary (--codex uses `codex exec`) diff --git a/src/Capacitor.Cli.Core/Telemetry/CommandEvents.cs b/src/Capacitor.Cli.Core/Telemetry/CommandEvents.cs index f7ca649737..b519e0df9b 100644 --- a/src/Capacitor.Cli.Core/Telemetry/CommandEvents.cs +++ b/src/Capacitor.Cli.Core/Telemetry/CommandEvents.cs @@ -37,7 +37,7 @@ public static partial class CommandEvents { // verb is still a KNOWN one. static readonly HashSet KnownVerbs = new(StringComparer.Ordinal) { "--help", "-h", "help", "--version", "-v", - "errors", "recap", "validate-plan", "eval", "login", "logout", "whoami", + "errors", "recap", "sessions", "validate-plan", "eval", "login", "logout", "whoami", "daemon", "agent", "setup", "plugin", "profile", "use", "status", "config", "ignore", "remap", "repos", "projects", "project", "update", "review", "mcp", "curate", "cleanup", "uninstall", "disable", "hide", "import", "watch", diff --git a/src/Capacitor.Cli/Commands/SessionsArgs.cs b/src/Capacitor.Cli/Commands/SessionsArgs.cs new file mode 100644 index 0000000000..a653d301fc --- /dev/null +++ b/src/Capacitor.Cli/Commands/SessionsArgs.cs @@ -0,0 +1,78 @@ +using Capacitor.Cli.Core; + +namespace Capacitor.Cli.Commands; + +internal sealed record SessionsOptions(string State, string? Repo, bool Mine, string? Touching, int Limit, bool Json); + +/// Parses kcap sessions flags. The generic top-level flag helper returns the first +/// value and validates nothing, so exclusivity, value presence and shapes are checked here. +internal static class SessionsArgs { + public const string Usage = + "Usage: kcap sessions [--active | --ended | --all] [--repo ] [--mine] [--touching ] [--limit ] [--json]"; + + public static SessionsOptions? Parse(string[] args, out string? error) { + error = null; + + string? state = null; + string? repo = null; + var mine = false; + string? touching = null; + var limit = 20; + var json = false; + + for (var i = 1; i < args.Length; i++) { + switch (args[i]) { + case "--active" or "--ended" or "--all": + if (state is not null) { + error = $"choose one of --active, --ended or --all (got {state} and {args[i]})"; + + return null; + } + + state = args[i]; + + break; + case "--mine": mine = true; break; + case "--json": json = true; break; + case "--repo": + if (!TryValue(args, ref i, out repo)) { error = "--repo needs a value"; return null; } + + if (!RepoHashHelper.TryParseRepoRef(repo, out _)) { + error = "--repo must be / or a 16-hex repo hash"; + + return null; + } + + break; + case "--touching": + if (!TryValue(args, ref i, out touching)) { error = "--touching needs a value"; return null; } + + break; + case "--limit": + if (!TryValue(args, ref i, out var raw) || !int.TryParse(raw, out limit) || limit is < 1 or > 100) { + error = "--limit must be a number from 1 to 100"; + + return null; + } + + break; + default: + error = $"unknown flag {args[i]}"; + + return null; + } + } + + return new(state?.TrimStart('-') ?? "active", repo, mine, touching, limit, json); + } + + static bool TryValue(string[] args, ref int i, out string value) { + value = ""; + + if (i + 1 >= args.Length || args[i + 1].StartsWith("--", StringComparison.Ordinal)) return false; + + value = args[++i]; + + return true; + } +} diff --git a/src/Capacitor.Cli/Commands/SessionsCommand.cs b/src/Capacitor.Cli/Commands/SessionsCommand.cs new file mode 100644 index 0000000000..cb73599fec --- /dev/null +++ b/src/Capacitor.Cli/Commands/SessionsCommand.cs @@ -0,0 +1,130 @@ +using System.Net; +using System.Text; +using System.Text.Json; +using Capacitor.Cli.Core; +using Capacitor.Cli.Core.Config; + +namespace Capacitor.Cli.Commands; + +class SessionsCommand(ConfigRoot config, ProfileContext profiles) { + public async Task HandleAsync(string[] args) { + var options = SessionsArgs.Parse(args, out var error); + + if (options is null) { + await Console.Error.WriteLineAsync($"kcap sessions: {error}"); + await Console.Error.WriteLineAsync(SessionsArgs.Usage); + + return 1; + } + + string repoHash; + string label; + + if (options.Repo is null) { + var repo = await RepositoryDetection.DetectRepositoryAsync(config, Directory.GetCurrentDirectory()); + + if (repo?.Owner is null || repo.RepoName is null) { + await Console.Error.WriteLineAsync("Not in a git repository with a remote origin."); + + return 1; + } + + repoHash = RepoHashHelper.ComputeRepoHash(repo.Owner, repo.RepoName); + label = $"{repo.Owner}/{repo.RepoName}"; + } else if (!RepoHashHelper.TryParseRepoRef(options.Repo, out repoHash)) { + await Console.Error.WriteLineAsync("kcap sessions: --repo must be / or a 16-hex repo hash"); + + return 1; + } else { + label = options.Repo; + } + + var baseUrl = profiles.Resolution.ServerUrl!; + using var httpClient = await HttpClientExtensions.CreateAuthenticatedClientAsync(config, profiles, baseUrl); + + HttpResponseMessage resp; + + try { + resp = await httpClient.GetWithRetryAsync(BuildUrl(baseUrl, repoHash, options)); + } catch (HttpRequestException ex) { + HttpClientExtensions.WriteUnreachableError(baseUrl, ex); + + return 1; + } + + if (await HttpClientExtensions.HandleUnauthorizedAsync(resp)) return 1; + + if (resp.StatusCode == HttpStatusCode.NotFound) { + await Console.Error.WriteLineAsync("Session listing needs a newer server; ask your admin to update."); + + return 1; + } + + var body = await resp.Content.ReadAsStringAsync(); + + if (!resp.IsSuccessStatusCode) { + await Console.Error.WriteLineAsync($"HTTP {(int)resp.StatusCode}: {body}"); + + return 1; + } + + if (options.Json) { + await Console.Out.WriteLineAsync(body); + + return 0; + } + + var page = JsonSerializer.Deserialize(body, CapacitorJsonContext.Default.RepoSessionsResponse); + + if (page is null) { + await Console.Error.WriteLineAsync("Unexpected response from the server."); + + return 1; + } + + await Console.Out.WriteAsync(Render(page, label, options.State)); + + return 0; + } + + internal static string BuildUrl(string baseUrl, string repoHash, SessionsOptions options) { + var qs = new List { $"state={options.State}", $"limit={options.Limit}" }; + + if (options.Mine) qs.Add("owner=me"); + + if (options.Touching is { Length: > 0 } touching) qs.Add($"touching_path={Uri.EscapeDataString(touching)}"); + + return $"{baseUrl}/api/repositories/{repoHash}/sessions?" + string.Join("&", qs); + } + + internal static string Render(RepoSessionsResponse page, string repoLabel, string state) { + var sb = new StringBuilder(); + + if (page.Items.Count == 0) { + sb.AppendLine($"No {state} sessions visible to you on {repoLabel}."); + + return sb.ToString(); + } + + sb.AppendLine($"{"SESSION",-33} {"STATUS",-7} {"ACCESS",-9} {"OWNER",-14} {"VENDOR",-8} {"BRANCH",-24} {"LAST ACTIVITY",-17} TITLE"); + + foreach (var row in page.Items) { + var status = row.Status == "active" && row.Stale ? "stale" : row.Status; + var owner = row.Owner?.Username ?? row.Owner?.UserId ?? ""; + var last = row.LastActivityAt.ToLocalTime().ToString("yyyy-MM-dd HH:mm"); + + sb.AppendLine( + $"{Fit(row.SessionId, 32),-33} {status,-7} {row.AccessLevel,-9} {Fit(owner, 13),-14} {Fit(row.Vendor ?? "", 7),-8} {Fit(row.Branch ?? "", 23),-24} {last,-17} {row.Title ?? "(untitled)"}"); + } + + if (page.Total > page.Items.Count) + sb.AppendLine($"Showing {page.Items.Count} of {page.Total}; raise --limit or narrow with --mine / --touching."); + + sb.AppendLine(); + sb.AppendLine("Details (full access): kcap recap --full "); + + return sb.ToString(); + } + + static string Fit(string value, int width) => value.Length <= width ? value : value[..(width - 1)] + "…"; +} diff --git a/src/Capacitor.Cli/Program.cs b/src/Capacitor.Cli/Program.cs index 3dfe5722bf..e2cd2559a5 100644 --- a/src/Capacitor.Cli/Program.cs +++ b/src/Capacitor.Cli/Program.cs @@ -264,6 +264,8 @@ return await new RecapCommand(config, profiles).HandleRecap(recapSessionId, useChain, useFull); } + case "sessions": + return await new SessionsCommand(config, profiles).HandleAsync(args); case "validate-plan": { var vpSessionId = ResolveSessionId(args); diff --git a/test/Capacitor.Cli.Core.Tests.Unit/Telemetry/CommandEventsTests.cs b/test/Capacitor.Cli.Core.Tests.Unit/Telemetry/CommandEventsTests.cs index 19d18b6d98..5c053e5770 100644 --- a/test/Capacitor.Cli.Core.Tests.Unit/Telemetry/CommandEventsTests.cs +++ b/test/Capacitor.Cli.Core.Tests.Unit/Telemetry/CommandEventsTests.cs @@ -178,6 +178,7 @@ public async Task Longest_real_flag_is_accepted() { [Arguments("status")] [Arguments("hook")] // known-but-denylisted verbs are still KNOWN verbs — the two lists are independent [Arguments("uninstall")] + [Arguments("sessions")] public async Task Known_verbs_report_themselves(string command) { await Assert.That(CommandEvents.ReportableCommand(command)).IsEqualTo(command); } diff --git a/test/Capacitor.Cli.Tests.Integration/SessionsCommandTests.cs b/test/Capacitor.Cli.Tests.Integration/SessionsCommandTests.cs new file mode 100644 index 0000000000..d0cd2312b6 --- /dev/null +++ b/test/Capacitor.Cli.Tests.Integration/SessionsCommandTests.cs @@ -0,0 +1,82 @@ +using System.Diagnostics; +using Capacitor.Cli.Core; +using WireMock.RequestBuilders; +using WireMock.ResponseBuilders; +using WireMock.Server; + +namespace Capacitor.Cli.Tests.Integration; + +/// Spawns the built CLI for kcap sessions against a stubbed server: the table and +/// the raw JSON render, an older server's 404 becomes one line, and a checkout without an origin +/// fails closed before any request is made. +public class SessionsCommandTests : IDisposable { + [TempDaemonPaths] public required TempDaemonStore Daemons { get; init; } + [TempConfigRoot] public required TempConfigRoot Config { get; init; } + [TempDir] public required TempDir Tmp { get; init; } + + readonly WireMockServer _server = WireMockServer.Start(); + + public void Dispose() => _server.Stop(); + + const string Page = + """{"items":[{"session_id":"s-1","slug":null,"title":"Fix the widget","owner":{"user_id":"github:1","username":"alice","display_name":"Alice","avatar_url":null},"vendor":"claude","status":"active","access_level":"full","stale":false,"started_at":"2026-09-02T09:00:00+00:00","ended_at":null,"last_activity_at":"2026-09-02T10:00:00+00:00","primary_repo_hash":"x","is_primary":true,"branch":"main","cwd":"/w","last_prompt":null,"write_attempt_paths":[],"write_attempt_count":0}],"total":1,"limit":20,"offset":0}"""; + + async Task<(int ExitCode, string StdOut, string StdErr)> RunAsync(string workingDirectory, params string[] args) { + _server.Given(Request.Create().WithPath("/auth/config").UsingGet()) + .RespondWith(Response.Create().WithStatusCode(200).WithBody("""{"provider":"None"}""")); + + var psi = KcapProcess.StartInfo(Daemons.Store, Config.Root, args); + psi.WorkingDirectory = workingDirectory; + psi.Environment["KCAP_URL"] = _server.Url!; + + using var proc = Process.Start(psi) ?? throw new InvalidOperationException("Failed to start kcap process"); + var stdout = await proc.StandardOutput.ReadToEndAsync(); + var stderr = await proc.StandardError.ReadToEndAsync(); + await proc.WaitForExitAsync(); + + return (proc.ExitCode, stdout, stderr); + } + + [Test] + public async Task Table_and_json_render_for_the_cwd_repo() { + using var repo = GitRepo.Create(); + repo.AddRemote("https://github.com/acme/widgets.git"); + var hash = RepoHashHelper.ComputeRepoHash("acme", "widgets"); + + _server.Given(Request.Create().WithPath($"/api/repositories/{hash}/sessions").UsingGet()) + .RespondWith(Response.Create().WithStatusCode(200).WithBody(Page)); + + var table = await RunAsync(repo.Path, "sessions"); + await Assert.That(table.ExitCode).IsEqualTo(0); + await Assert.That(table.StdOut).Contains("s-1"); + await Assert.That(table.StdOut).Contains("alice"); + await Assert.That(table.StdOut).Contains("Fix the widget"); + + var json = await RunAsync(repo.Path, "sessions", "--json"); + await Assert.That(json.ExitCode).IsEqualTo(0); + await Assert.That(json.StdOut.Trim()).IsEqualTo(Page); + } + + [Test] + public async Task Older_server_404_is_one_line_and_exit_1() { + using var repo = GitRepo.Create(); + repo.AddRemote("https://github.com/acme/widgets.git"); + + _server.Given(Request.Create().WithPath("/api/repositories/*/sessions").UsingGet()) + .RespondWith(Response.Create().WithStatusCode(404)); + + var result = await RunAsync(repo.Path, "sessions"); + + await Assert.That(result.ExitCode).IsEqualTo(1); + await Assert.That(result.StdErr).Contains("Session listing needs a newer server; ask your admin to update."); + } + + [Test] + public async Task No_origin_and_no_repo_flag_fails_closed() { + var result = await RunAsync(Tmp.Path, "sessions"); + + await Assert.That(result.ExitCode).IsEqualTo(1); + await Assert.That(result.StdErr).Contains("Not in a git repository with a remote origin."); + await Assert.That(_server.LogEntries.Any(e => e.RequestMessage.Path.Contains("/sessions"))).IsFalse(); + } +} diff --git a/test/Capacitor.Cli.Tests.Unit/Commands/SessionsArgsTests.cs b/test/Capacitor.Cli.Tests.Unit/Commands/SessionsArgsTests.cs new file mode 100644 index 0000000000..7628fa4cb7 --- /dev/null +++ b/test/Capacitor.Cli.Tests.Unit/Commands/SessionsArgsTests.cs @@ -0,0 +1,80 @@ +using Capacitor.Cli.Commands; + +namespace Capacitor.Cli.Tests.Unit.Commands; + +public class SessionsArgsTests { + [Test] + public async Task Defaults_are_active_current_repo_everyone_limit_twenty_table() { + var opts = SessionsArgs.Parse(["sessions"], out var error); + + await Assert.That(error).IsNull(); + await Assert.That(opts!.State).IsEqualTo("active"); + await Assert.That(opts.Repo).IsNull(); + await Assert.That(opts.Mine).IsFalse(); + await Assert.That(opts.Touching).IsNull(); + await Assert.That(opts.Limit).IsEqualTo(20); + await Assert.That(opts.Json).IsFalse(); + } + + [Test] + public async Task All_flags_parse() { + var opts = SessionsArgs.Parse(["sessions", "--ended", "--repo", "acme/widgets", "--mine", "--touching", "src/Foo", "--limit", "5", "--json"], out var error); + + await Assert.That(error).IsNull(); + await Assert.That(opts!.State).IsEqualTo("ended"); + await Assert.That(opts.Repo).IsEqualTo("acme/widgets"); + await Assert.That(opts.Mine).IsTrue(); + await Assert.That(opts.Touching).IsEqualTo("src/Foo"); + await Assert.That(opts.Limit).IsEqualTo(5); + await Assert.That(opts.Json).IsTrue(); + } + + [Test] + public async Task Two_state_flags_is_a_usage_error() { + var opts = SessionsArgs.Parse(["sessions", "--active", "--all"], out var error); + + await Assert.That(opts).IsNull(); + await Assert.That(error).Contains("--active"); + } + + [Test] + [Arguments("all")] + [Arguments("owner")] + [Arguments("DA9C523C68AEE2F1")] + public async Task Malformed_repo_is_a_usage_error(string repo) { + var opts = SessionsArgs.Parse(["sessions", "--repo", repo], out var error); + + await Assert.That(opts).IsNull(); + await Assert.That(error).Contains("--repo"); + } + + [Test] + [Arguments("--repo")] + [Arguments("--touching")] + [Arguments("--limit")] + public async Task Value_flag_without_a_value_is_a_usage_error(string flag) { + var opts = SessionsArgs.Parse(["sessions", flag], out var error); + + await Assert.That(opts).IsNull(); + await Assert.That(error).Contains(flag); + } + + [Test] + [Arguments("0")] + [Arguments("101")] + [Arguments("ten")] + public async Task Limit_outside_one_to_hundred_is_a_usage_error(string limit) { + var opts = SessionsArgs.Parse(["sessions", "--limit", limit], out var error); + + await Assert.That(opts).IsNull(); + await Assert.That(error).Contains("--limit"); + } + + [Test] + public async Task Unknown_flag_is_a_usage_error() { + var opts = SessionsArgs.Parse(["sessions", "--everyone"], out var error); + + await Assert.That(opts).IsNull(); + await Assert.That(error).Contains("--everyone"); + } +} diff --git a/test/Capacitor.Cli.Tests.Unit/Commands/SessionsCommandRenderTests.cs b/test/Capacitor.Cli.Tests.Unit/Commands/SessionsCommandRenderTests.cs new file mode 100644 index 0000000000..4298ab852a --- /dev/null +++ b/test/Capacitor.Cli.Tests.Unit/Commands/SessionsCommandRenderTests.cs @@ -0,0 +1,39 @@ +using Capacitor.Cli.Commands; +using Capacitor.Cli.Core; + +namespace Capacitor.Cli.Tests.Unit.Commands; + +public class SessionsCommandRenderTests { + static RepoSessionDto Row(string id, string status, string access, string? branch, bool stale = false) => + new(id, null, "Title " + id, new("github:1", "alice", "Alice", null), "claude", status, access, stale, + new DateTimeOffset(2026, 9, 2, 9, 0, 0, TimeSpan.Zero), null, new DateTimeOffset(2026, 9, 2, 10, 0, 0, TimeSpan.Zero), + "da9c523c68aee2f1", true, branch, "/w", null, [], 0); + + [Test] + public async Task Table_shows_stale_in_place_of_active_and_blanks_branch_below_full() { + var page = new RepoSessionsResponse([Row("s-1", "active", "full", "main", stale: true), Row("s-2", "active", "overview", null)], 2, 20, 0); + + var text = SessionsCommand.Render(page, "acme/widgets", "active"); + + await Assert.That(text).Contains("SESSION"); + await Assert.That(text).Contains("s-1"); + await Assert.That(text).Contains("stale"); + await Assert.That(text).Contains("main"); + await Assert.That(text).Contains("overview"); + await Assert.That(text).Contains("kcap recap --full "); + } + + [Test] + public async Task Empty_page_says_so_with_the_state_and_repo() { + var text = SessionsCommand.Render(new RepoSessionsResponse([], 0, 20, 0), "acme/widgets", "ended"); + + await Assert.That(text).Contains("No ended sessions visible to you on acme/widgets."); + } + + [Test] + public async Task Url_maps_mine_to_owner_me_and_encodes_touching() { + var url = SessionsCommand.BuildUrl("http://srv", "da9c523c68aee2f1", new("all", null, true, "src/Foo Bar", 7, false)); + + await Assert.That(url).IsEqualTo("http://srv/api/repositories/da9c523c68aee2f1/sessions?state=all&limit=7&owner=me&touching_path=src%2FFoo%20Bar"); + } +} From e766e5320a3b65492d9f2324872403c08906c4d2 Mon Sep 17 00:00:00 2001 From: Alexey Zimarev Date: Thu, 3 Sep 2026 10:16:06 +0200 Subject: [PATCH 06/10] Document kcap sessions and list_repo_sessions (#752) --- README.md | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 5f7bacd984..d5261c8043 100644 --- a/README.md +++ b/README.md @@ -283,6 +283,7 @@ At a glance — each links to its section below: | [`kcap setup`](#initial-setup) | Interactive wizard — server, auth, agent hooks, daemon | | [`kcap import`](#loading-historical-sessions) | Backfill past sessions from every detected agent | | [`kcap recap`](#session-recap) | AI summary + per-turn outline of a session | +| [`kcap sessions`](#listing-sessions-on-a-repository) | List a repo's sessions you can see, running first | | [`kcap validate-plan`](#plan-validation) | Check that every planned item was completed | | [`kcap hide`](#hide-session) | Mark a session owner-only | | [`kcap disable`](#disable-recording) | Stop recording and delete server-side data | @@ -374,6 +375,18 @@ If the kcap plugin is installed, you can also use the `/kcap:recap` skill inside Recap session c4de7fbe-cff5-4e2c-bf80-9858d02f58be and propose what should be done next. ``` +### Listing sessions on a repository + +```bash +kcap sessions # running sessions on the current repo, everyone you can see +kcap sessions --all --mine # your own, running and ended +kcap sessions --repo acme/widgets # another repository, by owner/name or 16-hex hash +kcap sessions --touching src/Foo # sessions with an Edit/Write attempt on a matching path +kcap sessions --json # the raw server response +``` + +Answers "which session is doing this, and is it still running?" for a checkout. Rows are visibility-filtered, so a teammate's private session simply does not appear. Each row shows status (`active`, `stale` after an hour of silence, or `ended`), your access level on it, owner, vendor, branch, last activity and title; below full access the branch is blank and `--touching` cannot match the row. Path evidence comes from Edit/Write tool inputs at invocation time, so edits applied through a shell script leave nothing to match. Drill in with `kcap recap --full ` (full access only). + ### Plan validation Verify that all items in a session's plan were completed. @@ -462,12 +475,14 @@ kcap mcp sessions Stdio MCP server that exposes past Capacitor sessions to coding agents (Claude Code, Codex, Cursor, Copilot, Gemini, Antigravity) so they can search and recall prior work without leaving the chat. **Claude Code:** auto-registered via the plugin's `.mcp.json`. **Codex CLI:** `kcap setup` / `kcap plugin install --codex` register it (alongside `kcap-review`) directly in `~/.codex/config.toml` under `[mcp_servers]`, so there's nothing extra to do — launch Codex from your repo directory so the server resolves the right repo. Enabling the kcap plugin through Codex's native plugin manager (`codex plugin add`) also provides them via the plugin's `.codex-mcp.json` descriptor. **Cursor:** `kcap setup` / `kcap plugin install --cursor` register it (alongside the other three kcap servers) in `~/.cursor/mcp.json`; opt out with `--skip-cursor-mcp`. **GitHub Copilot CLI:** `kcap setup` / `kcap plugin install --copilot` register it (alongside the other three kcap servers) in `~/.copilot/mcp-config.json`; opt out with `--skip-copilot-mcp`. **Gemini CLI:** `kcap setup` / `kcap plugin install --gemini` register it (alongside the other three kcap servers) in the shared `~/.gemini/settings.json`; opt out with `--skip-gemini-mcp`. **Google Antigravity:** `kcap setup` / `kcap plugin install --antigravity` register it (alongside the other three kcap servers) in `~/.gemini/config/mcp_config.json` — Antigravity's own MCP file, not the Gemini CLI's `settings.json`; opt out with `--skip-antigravity-mcp`. -It provides four tools: +It provides six tools: - **`search_sessions`** — free-text search over past sessions (and subagent transcripts), searching the current repo first and automatically widening to every visible repo when results come back thin (the response then carries `widened_to_all_repos: true`, and each hit includes its own repo). Pass `repo: "all"` to search across every repo you can see up front, or `repo: "owner/name"` for a different one — an explicit `repo` (including `"all"`) never auto-widens. Filter by `author` / `author_github_id`. Returns ranked hits with `session_id`, snippet, and (for transcript hits) `hit_event_index` + `agent_id` for drilling in. +- **`list_repo_sessions`** — the sessions on a repository you are allowed to see, running first, ordered by last activity, with `access_level`, `stale`, branch, cwd, last prompt and Edit/Write attempt paths (blank below full access). `repo` defaults to the current repo and accepts `owner/name` or a 16-hex hash; `state` is `active` (default), `ended` or `all`; `owner` is `me` or a canonical id; `touching_path` matches attempt paths on full-access rows only. - **`get_session_summary`** — concise `summary_text` + `plan` for a session. Use this to orient before reading the transcript. - **`get_session_transcript`** — speaker-tagged events from a session. Pair `around_event` (and `agent_id` if the hit was in a subagent) with the values returned by `search_sessions` to fetch the exact decision context. - **`get_turn`** — the full event transcript for one turn (user prompt, tool calls + results, assistant text) by `session_id` + `turn_index`. A turn is one user message and the assistant's full response up to the next user message. +- **`list_turns`** — every turn of a session with its prose summary, prompt, tools, files and token counts; works on running sessions at `activity` access and above. The server is repo-aware — it resolves the current working directory to a repo hash at startup, and `search_sessions` defaults its `repo` filter to that hash, auto-widening to all repos only when that pinned search comes back thin. **If the current repo can't be resolved** (run outside a git checkout, or a missing/unparseable `origin` remote), `search_sessions` returns an error asking you to pass `repo: "owner/name"` or `repo: "all"` — it will not silently search across all repos. From dd93963434276bd78544f4de969a958f859c931b Mon Sep 17 00:00:00 2001 From: Alexey Zimarev Date: Thu, 3 Sep 2026 10:35:14 +0200 Subject: [PATCH 07/10] Guard the sessions MCP tool set and tighten kcap sessions tests (#752) A tool added to the server without its registry entry now fails a test instead of a reviewer's permission prompt. --- .../Commands/McpSessionsServer.cs | 2 +- src/Capacitor.Cli/Commands/SessionsArgs.cs | 9 ++++++--- src/Capacitor.Cli/Commands/SessionsCommand.cs | 7 ++----- .../Commands/KcapMcpRegistryReviewFlowTests.cs | 8 ++++++++ .../Commands/McpSessionsServerTests.cs | 1 + .../Commands/SessionsArgsTests.cs | 2 ++ .../Commands/SessionsCommandRenderTests.cs | 18 +++++++++++++++++- 7 files changed, 37 insertions(+), 10 deletions(-) diff --git a/src/Capacitor.Cli/Commands/McpSessionsServer.cs b/src/Capacitor.Cli/Commands/McpSessionsServer.cs index 338c8768b3..cc71a7f816 100644 --- a/src/Capacitor.Cli/Commands/McpSessionsServer.cs +++ b/src/Capacitor.Cli/Commands/McpSessionsServer.cs @@ -728,7 +728,7 @@ static string ToResponse(JsonNode id, T result, JsonTypeInfo typeInfo) { return envelope.ToJsonString(); } - static McpTool[] BuildToolsList() => [ + internal static McpTool[] BuildToolsList() => [ new( "search_sessions", "Search past Kurrent Capacitor sessions by free-text question and/or author name. Searches the current repo first and AUTOMATICALLY widens to all visible repos when results are thin (response then carries widened_to_all_repos: true); every hit includes its repo, so check it before assuming a hit is from this repo. Pass repo: \"all\" to search everywhere explicitly, or repo: \"/\" to pin another repo (explicit repo never widens). Returns ranked hits with session_id, title, owner, snippet, and (for transcript hits) hit_event_index + agent_id for drilling into the exact moment with get_session_transcript. For 'have we done this before / why did we / who decided X / when did we work on Y' questions, search here before grepping the code or git log — it searches the reasoning across past sessions, not just the code.", diff --git a/src/Capacitor.Cli/Commands/SessionsArgs.cs b/src/Capacitor.Cli/Commands/SessionsArgs.cs index a653d301fc..d9ed34f078 100644 --- a/src/Capacitor.Cli/Commands/SessionsArgs.cs +++ b/src/Capacitor.Cli/Commands/SessionsArgs.cs @@ -2,7 +2,7 @@ namespace Capacitor.Cli.Commands; -internal sealed record SessionsOptions(string State, string? Repo, bool Mine, string? Touching, int Limit, bool Json); +internal sealed record SessionsOptions(string State, string? Repo, string? RepoHash, bool Mine, string? Touching, int Limit, bool Json); /// Parses kcap sessions flags. The generic top-level flag helper returns the first /// value and validates nothing, so exclusivity, value presence and shapes are checked here. @@ -15,6 +15,7 @@ internal static class SessionsArgs { string? state = null; string? repo = null; + string? repoHash = null; var mine = false; string? touching = null; var limit = 20; @@ -37,12 +38,14 @@ internal static class SessionsArgs { case "--repo": if (!TryValue(args, ref i, out repo)) { error = "--repo needs a value"; return null; } - if (!RepoHashHelper.TryParseRepoRef(repo, out _)) { + if (!RepoHashHelper.TryParseRepoRef(repo, out var hash)) { error = "--repo must be / or a 16-hex repo hash"; return null; } + repoHash = hash; + break; case "--touching": if (!TryValue(args, ref i, out touching)) { error = "--touching needs a value"; return null; } @@ -63,7 +66,7 @@ internal static class SessionsArgs { } } - return new(state?.TrimStart('-') ?? "active", repo, mine, touching, limit, json); + return new(state?.TrimStart('-') ?? "active", repo, repoHash, mine, touching, limit, json); } static bool TryValue(string[] args, ref int i, out string value) { diff --git a/src/Capacitor.Cli/Commands/SessionsCommand.cs b/src/Capacitor.Cli/Commands/SessionsCommand.cs index cb73599fec..dc5c33ffad 100644 --- a/src/Capacitor.Cli/Commands/SessionsCommand.cs +++ b/src/Capacitor.Cli/Commands/SessionsCommand.cs @@ -31,12 +31,9 @@ public async Task HandleAsync(string[] args) { repoHash = RepoHashHelper.ComputeRepoHash(repo.Owner, repo.RepoName); label = $"{repo.Owner}/{repo.RepoName}"; - } else if (!RepoHashHelper.TryParseRepoRef(options.Repo, out repoHash)) { - await Console.Error.WriteLineAsync("kcap sessions: --repo must be / or a 16-hex repo hash"); - - return 1; } else { - label = options.Repo; + repoHash = options.RepoHash!; + label = options.Repo; } var baseUrl = profiles.Resolution.ServerUrl!; diff --git a/test/Capacitor.Cli.Tests.Unit/Commands/KcapMcpRegistryReviewFlowTests.cs b/test/Capacitor.Cli.Tests.Unit/Commands/KcapMcpRegistryReviewFlowTests.cs index d10d96402a..981ca41adb 100644 --- a/test/Capacitor.Cli.Tests.Unit/Commands/KcapMcpRegistryReviewFlowTests.cs +++ b/test/Capacitor.Cli.Tests.Unit/Commands/KcapMcpRegistryReviewFlowTests.cs @@ -118,6 +118,14 @@ public async Task Flow_result_server_advertises_exactly_the_reserved_channel_cat await Assert.That(advertised.SequenceEqual(catalog, StringComparer.Ordinal)).IsTrue(); } + [Test] + public async Task Sessions_server_advertises_exactly_its_unattended_safe_tool_set() { + var advertised = McpSessionsServer.BuildToolsList().Select(t => t.Name).ToHashSet(StringComparer.Ordinal); + var classified = KcapMcpRegistry.ReviewFlowUnattendedSafeTools["kcap-sessions"]; + + await Assert.That(advertised.SetEquals(classified)).IsTrue(); + } + [Test] public async Task Unattended_safe_set_is_exactly_the_catalogs_safe_names() { var safeNames = KcapMcpRegistry.ReservedResultChannelTools diff --git a/test/Capacitor.Cli.Tests.Unit/Commands/McpSessionsServerTests.cs b/test/Capacitor.Cli.Tests.Unit/Commands/McpSessionsServerTests.cs index 2d3ccb5a72..09c328e282 100644 --- a/test/Capacitor.Cli.Tests.Unit/Commands/McpSessionsServerTests.cs +++ b/test/Capacitor.Cli.Tests.Unit/Commands/McpSessionsServerTests.cs @@ -78,6 +78,7 @@ public async Task BuildRepoSessionsUrl_encodes_owner_and_touching_path_and_passe await Assert.That(url).Contains("limit=5"); await Assert.That(url).Contains("offset=10"); } + [Test] public async Task BuildSearchUrl_no_args_no_cwd_hash_fails_closed() { // Superseded fail-open expectation: with no repo resolvable and none requested, this now diff --git a/test/Capacitor.Cli.Tests.Unit/Commands/SessionsArgsTests.cs b/test/Capacitor.Cli.Tests.Unit/Commands/SessionsArgsTests.cs index 7628fa4cb7..90e294a2d5 100644 --- a/test/Capacitor.Cli.Tests.Unit/Commands/SessionsArgsTests.cs +++ b/test/Capacitor.Cli.Tests.Unit/Commands/SessionsArgsTests.cs @@ -1,4 +1,5 @@ using Capacitor.Cli.Commands; +using Capacitor.Cli.Core; namespace Capacitor.Cli.Tests.Unit.Commands; @@ -23,6 +24,7 @@ public async Task All_flags_parse() { await Assert.That(error).IsNull(); await Assert.That(opts!.State).IsEqualTo("ended"); await Assert.That(opts.Repo).IsEqualTo("acme/widgets"); + await Assert.That(opts.RepoHash).IsEqualTo(RepoHashHelper.ComputeRepoHash("acme", "widgets")); await Assert.That(opts.Mine).IsTrue(); await Assert.That(opts.Touching).IsEqualTo("src/Foo"); await Assert.That(opts.Limit).IsEqualTo(5); diff --git a/test/Capacitor.Cli.Tests.Unit/Commands/SessionsCommandRenderTests.cs b/test/Capacitor.Cli.Tests.Unit/Commands/SessionsCommandRenderTests.cs index 4298ab852a..ad09ce247d 100644 --- a/test/Capacitor.Cli.Tests.Unit/Commands/SessionsCommandRenderTests.cs +++ b/test/Capacitor.Cli.Tests.Unit/Commands/SessionsCommandRenderTests.cs @@ -21,6 +21,22 @@ public async Task Table_shows_stale_in_place_of_active_and_blanks_branch_below_f await Assert.That(text).Contains("main"); await Assert.That(text).Contains("overview"); await Assert.That(text).Contains("kcap recap --full "); + + var lines = text.Split('\n'); + var branchCol = lines[0].IndexOf("BRANCH", StringComparison.Ordinal); + var s2Line = lines.Single(l => l.Contains("s-2")); + + await Assert.That(s2Line).DoesNotContain("main"); + await Assert.That(s2Line.Substring(branchCol, 24).Trim()).IsEmpty(); + } + + [Test] + public async Task Table_shows_the_overflow_line_when_total_exceeds_the_page() { + var page = new RepoSessionsResponse([Row("s-1", "active", "full", "main")], 5, 1, 0); + + var text = SessionsCommand.Render(page, "acme/widgets", "active"); + + await Assert.That(text).Contains("Showing 1 of 5"); } [Test] @@ -32,7 +48,7 @@ public async Task Empty_page_says_so_with_the_state_and_repo() { [Test] public async Task Url_maps_mine_to_owner_me_and_encodes_touching() { - var url = SessionsCommand.BuildUrl("http://srv", "da9c523c68aee2f1", new("all", null, true, "src/Foo Bar", 7, false)); + var url = SessionsCommand.BuildUrl("http://srv", "da9c523c68aee2f1", new("all", null, null, true, "src/Foo Bar", 7, false)); await Assert.That(url).IsEqualTo("http://srv/api/repositories/da9c523c68aee2f1/sessions?state=all&limit=7&owner=me&touching_path=src%2FFoo%20Bar"); } From 1c895d97887478939b5607c47da428325ae958a1 Mon Sep 17 00:00:00 2001 From: Alexey Zimarev Date: Thu, 3 Sep 2026 10:35:21 +0200 Subject: [PATCH 08/10] Document the remaining kcap sessions flags and the new MCP tool (#752) --- README.md | 1 + kcap/skills/recap/SKILL.md | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index d5261c8043..3efdf63c7a 100644 --- a/README.md +++ b/README.md @@ -381,6 +381,7 @@ Recap session c4de7fbe-cff5-4e2c-bf80-9858d02f58be and propose what should be do kcap sessions # running sessions on the current repo, everyone you can see kcap sessions --all --mine # your own, running and ended kcap sessions --repo acme/widgets # another repository, by owner/name or 16-hex hash +kcap sessions --ended --limit 50 # the last 50 ended sessions kcap sessions --touching src/Foo # sessions with an Edit/Write attempt on a matching path kcap sessions --json # the raw server response ``` diff --git a/kcap/skills/recap/SKILL.md b/kcap/skills/recap/SKILL.md index 5b9da6f638..9c074131da 100644 --- a/kcap/skills/recap/SKILL.md +++ b/kcap/skills/recap/SKILL.md @@ -15,7 +15,7 @@ description: >- kcap-sessions MCP tools. --- -> **For agents:** When the `kcap-sessions` MCP server is available, prefer its tools (`search_sessions`, `get_session_summary`, `list_turns`, `get_turn`, `get_session_transcript`) for retrieving past sessions. This CLI-wrapped skill remains a fallback for shell use and when MCP isn't installed. +> **For agents:** When the `kcap-sessions` MCP server is available, prefer its tools (`search_sessions`, `list_repo_sessions`, `get_session_summary`, `list_turns`, `get_turn`, `get_session_transcript`) for retrieving past sessions. This CLI-wrapped skill remains a fallback for shell use and when MCP isn't installed. # Session Recap From 19c3927c2d837ec5f37000cc7edda23085c4820d Mon Sep 17 00:00:00 2001 From: Alexey Zimarev Date: Thu, 3 Sep 2026 13:06:56 +0200 Subject: [PATCH 09/10] Accept nested GitLab group owners in repo references (#752) --- src/Capacitor.Cli.Core/RepoHashHelper.cs | 17 ++++++++++++----- .../RepoRefTests.cs | 11 ++++++++++- .../Commands/McpSessionsServerTests.cs | 10 +++++++++- .../Commands/SessionsArgsTests.cs | 8 ++++++++ 4 files changed, 39 insertions(+), 7 deletions(-) diff --git a/src/Capacitor.Cli.Core/RepoHashHelper.cs b/src/Capacitor.Cli.Core/RepoHashHelper.cs index f751e3c42b..7c8105c8cd 100644 --- a/src/Capacitor.Cli.Core/RepoHashHelper.cs +++ b/src/Capacitor.Cli.Core/RepoHashHelper.cs @@ -12,7 +12,8 @@ public static string ComputeRepoHash(string owner, string repoName) { return Convert.ToHexStringLower(hash)[..16]; } - /// Accepts the two shapes a caller may name a repo by: owner/name, hashed here, + /// Accepts the two shapes a caller may name a repo by: owner/name, split on the + /// last slash so a nested-group owner (e.g. GitLab subgroups) keeps its own slashes, hashed here, /// or a 16-character lowercase hex hash, passed through. Uppercase hex is rejected rather than /// folded so a hash copied from the wrong place fails loudly. public static bool TryParseRepoRef(string value, out string repoHash) { @@ -26,12 +27,18 @@ public static bool TryParseRepoRef(string value, out string repoHash) { return true; } - var parts = value.Split('/'); + if (value.Any(char.IsWhiteSpace)) return false; - if (parts.Length != 2 || parts[0].Length == 0 || parts[1].Length == 0) return false; - if (parts[0].Any(char.IsWhiteSpace) || parts[1].Any(char.IsWhiteSpace)) return false; + var lastSlash = value.LastIndexOf('/'); - repoHash = ComputeRepoHash(parts[0], parts[1]); + if (lastSlash <= 0 || lastSlash == value.Length - 1) return false; + + var owner = value[..lastSlash]; + var name = value[(lastSlash + 1)..]; + + if (owner.Split('/').Any(segment => segment.Length == 0)) return false; + + repoHash = ComputeRepoHash(owner, name); return true; } diff --git a/test/Capacitor.Cli.Core.Tests.Unit/RepoRefTests.cs b/test/Capacitor.Cli.Core.Tests.Unit/RepoRefTests.cs index 63d11f0ec7..02ff289442 100644 --- a/test/Capacitor.Cli.Core.Tests.Unit/RepoRefTests.cs +++ b/test/Capacitor.Cli.Core.Tests.Unit/RepoRefTests.cs @@ -20,13 +20,22 @@ public async Task Sixteen_lowercase_hex_passes_through() { [Test] [Arguments("all")] [Arguments("owner")] - [Arguments("a/b/c")] [Arguments("DA9C523C68AEE2F1")] [Arguments("da9c523c")] [Arguments("owner /name")] [Arguments("/name")] + [Arguments("a//b")] + [Arguments("owner/")] [Arguments("")] public async Task Other_shapes_are_rejected(string value) { await Assert.That(RepoHashHelper.TryParseRepoRef(value, out _)).IsFalse(); } + + [Test] + public async Task Nested_group_owner_keeps_the_group_path_in_the_hash() { + var ok = RepoHashHelper.TryParseRepoRef("group/subgroup/project", out var hash); + + await Assert.That(ok).IsTrue(); + await Assert.That(hash).IsEqualTo(RepoHashHelper.ComputeRepoHash("group/subgroup", "project")); + } } diff --git a/test/Capacitor.Cli.Tests.Unit/Commands/McpSessionsServerTests.cs b/test/Capacitor.Cli.Tests.Unit/Commands/McpSessionsServerTests.cs index 09c328e282..fe9f57e13e 100644 --- a/test/Capacitor.Cli.Tests.Unit/Commands/McpSessionsServerTests.cs +++ b/test/Capacitor.Cli.Tests.Unit/Commands/McpSessionsServerTests.cs @@ -43,7 +43,7 @@ public async Task BuildRepoSessionsUrl_owner_name_is_hashed_locally_and_hash_pas [Test] [Arguments("all")] [Arguments("owner")] - [Arguments("a/b/c")] + [Arguments("a//b")] [Arguments("DA9C523C68AEE2F1")] [Arguments("da9c523c")] public async Task BuildRepoSessionsUrl_rejects_malformed_repo(string repo) { @@ -51,6 +51,14 @@ public async Task BuildRepoSessionsUrl_rejects_malformed_repo(string repo) { .Throws(); } + [Test] + public async Task BuildRepoSessionsUrl_nested_group_owner_resolves_repo_hash() { + var url = McpSessionsServer.BuildRepoSessionsUrl( + "http://srv", new JsonObject { ["repo"] = "group/subgroup/project" }, null); + + await Assert.That(url).Contains($"/api/repositories/{RepoHashHelper.ComputeRepoHash("group/subgroup", "project")}/sessions"); + } + [Test] public async Task BuildRepoSessionsUrl_rejects_non_string_repo_and_bad_state() { await Assert.That(() => McpSessionsServer.BuildRepoSessionsUrl("http://srv", new JsonObject { ["repo"] = 42 }, CwdHash)) diff --git a/test/Capacitor.Cli.Tests.Unit/Commands/SessionsArgsTests.cs b/test/Capacitor.Cli.Tests.Unit/Commands/SessionsArgsTests.cs index 90e294a2d5..23b1f090c8 100644 --- a/test/Capacitor.Cli.Tests.Unit/Commands/SessionsArgsTests.cs +++ b/test/Capacitor.Cli.Tests.Unit/Commands/SessionsArgsTests.cs @@ -31,6 +31,14 @@ public async Task All_flags_parse() { await Assert.That(opts.Json).IsTrue(); } + [Test] + public async Task Nested_group_owner_repo_resolves_the_group_path_hash() { + var opts = SessionsArgs.Parse(["sessions", "--repo", "group/subgroup/project"], out var error); + + await Assert.That(error).IsNull(); + await Assert.That(opts!.RepoHash).IsEqualTo(RepoHashHelper.ComputeRepoHash("group/subgroup", "project")); + } + [Test] public async Task Two_state_flags_is_a_usage_error() { var opts = SessionsArgs.Parse(["sessions", "--active", "--all"], out var error); From 2993de8f268e77393534495330defd08843f015d Mon Sep 17 00:00:00 2001 From: Alexey Zimarev Date: Thu, 3 Sep 2026 13:07:02 +0200 Subject: [PATCH 10/10] Skip PR discovery when kcap sessions resolves the cwd repo (#752) --- src/Capacitor.Cli/Commands/SessionsCommand.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/Capacitor.Cli/Commands/SessionsCommand.cs b/src/Capacitor.Cli/Commands/SessionsCommand.cs index dc5c33ffad..71cb946684 100644 --- a/src/Capacitor.Cli/Commands/SessionsCommand.cs +++ b/src/Capacitor.Cli/Commands/SessionsCommand.cs @@ -21,7 +21,8 @@ public async Task HandleAsync(string[] args) { string label; if (options.Repo is null) { - var repo = await RepositoryDetection.DetectRepositoryAsync(config, Directory.GetCurrentDirectory()); + var repo = await RepositoryDetection.DetectRepositoryAsync( + config, Directory.GetCurrentDirectory(), detectPullRequest: false); if (repo?.Owner is null || repo.RepoName is null) { await Console.Error.WriteLineAsync("Not in a git repository with a remote origin.");