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
18 changes: 17 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down Expand Up @@ -374,6 +375,19 @@ 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 --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
```

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 <session-id>` (full access only).

### Plan validation

Verify that all items in a session's plan were completed.
Expand Down Expand Up @@ -462,12 +476,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.

Expand Down
2 changes: 1 addition & 1 deletion kcap/skills/recap/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion src/Capacitor.Cli.Core/KcapMcpRegistry.cs
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,7 @@ public static class KcapMcpRegistry {
"list_sessions", "get_transcript",
},
["kcap-sessions"] = new HashSet<string>(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",
},
};
Expand Down
36 changes: 36 additions & 0 deletions src/Capacitor.Cli.Core/Models.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<RepoSessionDto> 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
Expand Down Expand Up @@ -925,6 +960,7 @@ public sealed record CurationApplyResponse {

[JsonSerializable(typeof(List<RecapEntry>))]
[JsonSerializable(typeof(List<RepoRecapEntry>))]
[JsonSerializable(typeof(RepoSessionsResponse))]
[JsonSerializable(typeof(PlanArtifactDto))]
[JsonSerializable(typeof(PlanArtifactsResponseDto))]
[JsonSerializable(typeof(EvalContextResult))]
Expand Down
37 changes: 36 additions & 1 deletion src/Capacitor.Cli.Core/RepoHashHelper.cs
Original file line number Diff line number Diff line change
@@ -1,13 +1,48 @@
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];
}

/// <summary>Accepts the two shapes a caller may name a repo by: <c>owner/name</c>, 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.</summary>
public static bool TryParseRepoRef(string value, out string repoHash) {
repoHash = "";

if (string.IsNullOrWhiteSpace(value)) return false;

if (Hash().IsMatch(value)) {
repoHash = value;

return true;
}

if (value.Any(char.IsWhiteSpace)) return false;

var lastSlash = value.LastIndexOf('/');

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;
}

[GeneratedRegex("^[0-9a-f]{16}$")]
private static partial Regex Hash();
}
18 changes: 18 additions & 0 deletions src/Capacitor.Cli.Core/Resources/help-sessions.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
kcap sessions — Sessions on a repository

Usage: kcap sessions [--active | --ended | --all] [--repo <owner/name|hash>] [--mine] [--touching <path>] [--limit <n>] [--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 <owner/name|hash> Another repository (default: the current checkout's origin)
--mine Only your own sessions
--touching <path> Only sessions with an Edit/Write attempt whose path contains <path>
(full-access rows only; shell-applied edits leave no evidence)
--limit <n> Rows to show, 1-100 (default 20)
--json Print the server response as JSON
1 change: 1 addition & 0 deletions src/Capacitor.Cli.Core/Resources/help-usage.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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] <id> Generate what's-done summary (--codex uses `codex exec`)
Expand Down
2 changes: 1 addition & 1 deletion src/Capacitor.Cli.Core/Telemetry/CommandEvents.cs
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ public static partial class CommandEvents {
// verb is still a KNOWN one.
static readonly HashSet<string> 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",
Expand Down
65 changes: 64 additions & 1 deletion src/Capacitor.Cli/Commands/McpSessionsServer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,7 @@ internal async Task<string> 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}")
};

Expand Down Expand Up @@ -303,6 +304,52 @@ async Task<HttpResponseMessage> SendWithRefreshRetryAsync(HttpClient client, str
return await send(client);
}

const string RepoShapeMessage =
"`repo` must be \"<owner>/<name>\" 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: \"<owner>/<name>\" 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<string> { $"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<string>().
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<string>();
Expand Down Expand Up @@ -681,7 +728,7 @@ static string ToResponse<T>(JsonNode id, T result, JsonTypeInfo<T> 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: \"<owner>/<name>\" 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.",
Expand All @@ -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: \"<owner>/<name>\" 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.",
Expand Down
Loading