Repository navigation
[AI-2193] CLI: hand Claude's SessionEnd hook off to a detached continuation #649
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
alexeyzimarev
merged 2 commits into
main
from
alexeyzimarev/ai-2193-kcap-hook-claude-session-end-is-killed-by-claude-codes-15s
Aug 23, 2026
Merged
Changes from all commits
Commits
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
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
113 changes: 113 additions & 0 deletions
113
src/Capacitor.Cli/Harness/Claude/ClaudeSessionEndHandoff.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,113 @@ | ||
| using System.Diagnostics; | ||
| using System.Text.Json.Nodes; | ||
| using Capacitor.Cli.Core; | ||
|
|
||
| namespace Capacitor.Cli.Harness.Claude; | ||
|
|
||
| /// <summary> | ||
| /// Hands the Claude <c>SessionEnd</c> event to a detached re-invocation of this hook so the hook | ||
| /// itself returns at once: Claude Code caps a plugin's SessionEnd hook at 1.5 s regardless of | ||
| /// the <c>hooks.json</c> timeout, and the session-end path cannot fit. The continuation runs that | ||
| /// path unchanged under its 15 s budget. Reasoning in <c>docs/CHANGES.md</c>. | ||
| /// </summary> | ||
| static class ClaudeSessionEndHandoff { | ||
| public const string DetachedFlag = "--detached"; | ||
|
|
||
| public static bool IsDetached(string[] args) => args.Contains(DetachedFlag); | ||
|
|
||
| public static bool ShouldHandOff(string[] args, string body) => !IsDetached(args) && IsSessionEnd(body); | ||
|
|
||
| static bool IsSessionEnd(string body) { | ||
| try { | ||
| var name = JsonNode.Parse(body)?["hook_event_name"]?.GetValue<string>(); | ||
|
|
||
| return name is not null | ||
| && name.Replace("-", "").Replace("_", "").Equals("sessionend", StringComparison.OrdinalIgnoreCase); | ||
| } catch { | ||
| return false; | ||
| } | ||
| } | ||
|
|
||
| /// <summary> | ||
| /// Starts the detached continuation and feeds it <paramref name="body"/>. False when that did | ||
| /// not fully happen — the caller then runs the event inline, as before. | ||
| /// </summary> | ||
| public static bool TrySpawn(string[] args, string body) { | ||
| Process? process = null; | ||
|
|
||
| try { | ||
| var psi = new ProcessStartInfo(Environment.ProcessPath ?? "kcap") { | ||
| RedirectStandardInput = true, | ||
| RedirectStandardOutput = true, | ||
| RedirectStandardError = true, | ||
| UseShellExecute = false, | ||
| CreateNoWindow = true, | ||
| }; | ||
|
|
||
| foreach (var arg in args) psi.ArgumentList.Add(arg); | ||
| psi.ArgumentList.Add(DetachedFlag); | ||
|
|
||
| // Same pipe-leak hazard as the watcher spawn: the child must not hold Claude's hook | ||
| // pipes open, or Claude waits on them past the hook's own exit. | ||
| ProcessHelpers.PreventInheritedHandles(); | ||
|
|
||
| process = WatcherManager.StartProcess(psi); | ||
|
|
||
| if (process is null) { | ||
| Console.Error.WriteLine("[kcap] session-end hand-off: failed to start the detached continuation; running inline"); | ||
|
|
||
| return false; | ||
| } | ||
|
|
||
| process.StandardInput.Write(body); | ||
| process.StandardInput.Close(); | ||
| process.StandardOutput.Close(); | ||
| process.StandardError.Close(); | ||
|
|
||
| return true; | ||
| } catch (Exception ex) { | ||
| Console.Error.WriteLine($"[kcap] session-end hand-off failed: {ex.Message}; running inline"); | ||
|
|
||
| // A child that started but never got the full payload must not outlive this failure: | ||
| // the inline path is about to do the work, and two owners would double-post. | ||
| try { process?.Kill(entireProcessTree: true); } catch { } | ||
|
|
||
| return false; | ||
| } finally { | ||
| process?.Dispose(); | ||
| } | ||
| } | ||
|
|
||
| /// <summary> | ||
| /// The continuation's own setup: output to the session log (its pipes are already closed) and | ||
| /// out of the terminal's session so a closing window cannot SIGHUP it mid-drain. | ||
| /// </summary> | ||
| public static void EnterDetached(string body) { | ||
| TextWriter writer; | ||
|
|
||
| try { | ||
| var logDir = PathHelpers.ConfigPath("logs"); | ||
| Directory.CreateDirectory(logDir); | ||
| writer = new StreamWriter(Path.Combine(logDir, $"{LogName(body)}.log"), append: true) { AutoFlush = true }; | ||
| } catch { | ||
| writer = TextWriter.Null; | ||
| } | ||
|
|
||
| Console.SetOut(writer); | ||
| Console.SetError(writer); | ||
|
|
||
| ProcessHelpers.DetachFromControllingTerminal(); | ||
| } | ||
|
|
||
| // The watcher's log name for a well-formed id, so the two interleave in one file; anything | ||
| // else (the id is payload data) stays inside the logs directory under a fixed name. | ||
| internal static string LogName(string body) { | ||
| try { | ||
| var sessionId = JsonNode.Parse(body)?["session_id"]?.GetValue<string>()?.Replace("-", ""); | ||
|
|
||
| if (sessionId is { Length: > 0 } && sessionId.All(char.IsAsciiLetterOrDigit)) return sessionId; | ||
| } catch { } | ||
|
|
||
| return "claude-session-end"; | ||
| } | ||
| } | ||
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
89 changes: 89 additions & 0 deletions
89
test/Capacitor.Cli.Tests.Integration/ClaudeSessionEndHandoffTests.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,89 @@ | ||
| using System.Diagnostics; | ||
| using System.Text.Json.Nodes; | ||
| using Capacitor.Cli.Core; | ||
| using WireMock.RequestBuilders; | ||
| using WireMock.ResponseBuilders; | ||
| using WireMock.Server; | ||
|
|
||
| namespace Capacitor.Cli.Tests.Integration; | ||
|
|
||
| /// <summary> | ||
| /// Real-binary proof that the Claude SessionEnd hook returns inside Claude Code's 1.5 s grace | ||
| /// while the session-end still reaches the server. The server stalls <c>/hooks/session-end</c> | ||
| /// for longer than the hook is allowed to live, so the only way the POST can arrive is from a | ||
| /// process that outlived the hook. | ||
| /// </summary> | ||
| public class ClaudeSessionEndHandoffTests : IDisposable { | ||
| static readonly TimeSpan ServerStall = TimeSpan.FromSeconds(3); | ||
|
|
||
| [TempDir] public required TempDir Tmp { get; init; } | ||
| [TempDaemonPaths] public required TempDaemonStore Daemons { get; init; } | ||
|
|
||
| readonly WireMockServer _server = WireMockServer.Start(); | ||
|
|
||
| public void Dispose() => _server.Stop(); | ||
|
|
||
| [Test] | ||
| public async Task Hook_returns_at_once_and_the_detached_continuation_posts_session_end() { | ||
| var sid = Guid.NewGuid().ToString("N"); | ||
| var transcript = Tmp.CreateFile("t.jsonl", """{"type":"user","uuid":"u1","message":{"role":"user","content":"hi"}}""" + "\n"); | ||
| var configDir = Tmp.CreateDir("cfg"); | ||
|
|
||
| _server.Given(Request.Create().WithPath("/hooks/session-end").UsingPost()) | ||
| .RespondWith(Response.Create().WithStatusCode(200).WithBody("{}").WithDelay(ServerStall)); | ||
| _server.Given(Request.Create().WithPath("/hooks/transcript").UsingPost()) | ||
| .RespondWith(Response.Create().WithStatusCode(200).WithBody("""{"processed":0}""")); | ||
|
|
||
| var psi = KcapProcess.StartInfo(Daemons.Store, "hook", "--claude", "--no-update-check"); | ||
| psi.WorkingDirectory = Tmp.Path; | ||
| psi.Environment["KCAP_CONFIG_DIR"] = configDir; | ||
| psi.Environment["KCAP_URL"] = _server.Url!; | ||
|
|
||
| var payload = new JsonObject { | ||
| ["hook_event_name"] = "SessionEnd", | ||
| ["session_id"] = sid, | ||
| ["transcript_path"] = transcript, | ||
| ["cwd"] = Tmp.Path, | ||
| ["reason"] = "exit", | ||
| }.ToJsonString(); | ||
|
|
||
| var clock = Stopwatch.StartNew(); | ||
| using var hook = Process.Start(psi) ?? throw new InvalidOperationException("Failed to start kcap"); | ||
| await hook.StandardInput.WriteAsync(payload); | ||
| hook.StandardInput.Close(); | ||
| var stdout = hook.StandardOutput.ReadToEndAsync(); | ||
| var stderr = hook.StandardError.ReadToEndAsync(); | ||
| await hook.WaitForExitAsync(); | ||
| clock.Stop(); | ||
|
|
||
| await Assert.That(hook.ExitCode).IsEqualTo(0).Because(await stderr); | ||
| await Assert.That(await stdout).IsEmpty(); | ||
| // The ticket's bar: well inside the 1.5 s grace, on a session with a transcript to drain. | ||
| await Assert.That(clock.Elapsed).IsLessThan(TimeSpan.FromSeconds(1)).Because($"stderr: {await stderr}"); | ||
|
|
||
| // The hook is gone; the continuation has today's 15 s budget and must wait out the stall. | ||
| var posted = await WaitForSessionEndAsync(TimeSpan.FromSeconds(20)); | ||
|
|
||
| await Assert.That(posted).IsNotNull().Because("no /hooks/session-end reached the server"); | ||
| await Assert.That(posted!["session_id"]?.GetValue<string>()).IsEqualTo(sid); | ||
| await Assert.That(posted["reason"]?.GetValue<string>()).IsEqualTo("exit"); | ||
| await Assert.That(posted["ended_at"]?.GetValue<string>()).IsNotNull(); | ||
|
|
||
| // The continuation's output lands in the session log, where the watcher's already goes. | ||
| var log = Path.Combine(configDir, "logs", $"{sid}.log"); | ||
| await Assert.That(File.Exists(log)).IsTrue(); | ||
| await Assert.That(SharedFileText.ReadAllText(log)).Contains($"Inline drain for {sid}"); | ||
| } | ||
|
qodo-code-review[bot] marked this conversation as resolved.
Resolved
|
||
|
|
||
| async Task<JsonNode?> WaitForSessionEndAsync(TimeSpan budget) { | ||
| var deadline = DateTime.UtcNow + budget; | ||
|
|
||
| while (DateTime.UtcNow < deadline) { | ||
| var entries = _server.FindLogEntries(Request.Create().WithPath("/hooks/session-end").UsingPost()); | ||
| if (entries.Count > 0) return JsonNode.Parse(entries[0].RequestMessage.Body!); | ||
| await Task.Delay(100); | ||
| } | ||
|
|
||
| return null; | ||
| } | ||
| } | ||
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.