diff --git a/dictionary.txt b/dictionary.txt index 6f37e1b9d89..adfd0090434 100644 --- a/dictionary.txt +++ b/dictionary.txt @@ -315,6 +315,7 @@ upvote URQL Usercentrics uuuu +venti vnext vsix VXNlcjox diff --git a/src/Nitro/CommandLine/src/CommandLine/Commands/Agent/Hook/Claude/AgentNamingHookCommand.cs b/src/Nitro/CommandLine/src/CommandLine/Commands/Agent/Hook/Claude/AgentNamingHookCommand.cs new file mode 100644 index 00000000000..a70c835eb25 --- /dev/null +++ b/src/Nitro/CommandLine/src/CommandLine/Commands/Agent/Hook/Claude/AgentNamingHookCommand.cs @@ -0,0 +1,22 @@ +using ChilliCream.Nitro.CommandLine.Services.Hook; +using ChilliCream.Nitro.CommandLine.Services.Workspace; + +namespace ChilliCream.Nitro.CommandLine.Commands.Agent.Hook.Claude; + +/// +/// Adapts Venti's AgentNaming hook: registers a new main session and answers with its +/// actor name as the agent name. Payload JSON on stdin; {} on stdout for any other agent. +/// +internal sealed class AgentNamingHookCommand : Command +{ + public AgentNamingHookCommand(ClaudeHooksTemplate template) : base("agent-naming") + { + Description = $"Adapt {AgentHarnessDisplay.Name(template.Harness)}'s AgentNaming hook: " + + "name a new main session after its actor."; + + this.SetHookAction( + template.Harness, + ClaudeHooksTemplate.AgentNamingEvent, + (handler, payload, ct) => handler.HandleAgentNamingAsync(payload, skipSessionFileLookup: false, ct)); + } +} diff --git a/src/Nitro/CommandLine/src/CommandLine/Commands/Agent/Hook/Claude/ClaudeHookCommand.cs b/src/Nitro/CommandLine/src/CommandLine/Commands/Agent/Hook/Claude/ClaudeHookCommand.cs index 96ead63835a..1da522a04ec 100644 --- a/src/Nitro/CommandLine/src/CommandLine/Commands/Agent/Hook/Claude/ClaudeHookCommand.cs +++ b/src/Nitro/CommandLine/src/CommandLine/Commands/Agent/Hook/Claude/ClaudeHookCommand.cs @@ -1,15 +1,26 @@ +using ChilliCream.Nitro.CommandLine.Services.Hook; +using ChilliCream.Nitro.CommandLine.Services.Workspace; + namespace ChilliCream.Nitro.CommandLine.Commands.Agent.Hook.Claude; +/// +/// Groups the turn-boundary hook adapters of one harness speaking Claude Code's hook contract. +/// internal sealed class ClaudeHookCommand : Command { - public ClaudeHookCommand() : base("claude") + public ClaudeHookCommand(ClaudeHooksTemplate template) : base(template.CommandName) { - Description = "Adapt Claude Code turn-boundary hook events."; + Description = $"Adapt {AgentHarnessDisplay.Name(template.Harness)} turn-boundary hook events."; + + if (template.Events.Contains(ClaudeHooksTemplate.AgentNamingEvent)) + { + Subcommands.Add(new AgentNamingHookCommand(template)); + } - Subcommands.Add(new SessionStartHookCommand()); - Subcommands.Add(new UserPromptSubmitHookCommand()); - Subcommands.Add(new StopHookCommand()); - Subcommands.Add(new NotificationHookCommand()); - Subcommands.Add(new SessionEndHookCommand()); + Subcommands.Add(new SessionStartHookCommand(template)); + Subcommands.Add(new UserPromptSubmitHookCommand(template)); + Subcommands.Add(new StopHookCommand(template)); + Subcommands.Add(new NotificationHookCommand(template)); + Subcommands.Add(new SessionEndHookCommand(template)); } } diff --git a/src/Nitro/CommandLine/src/CommandLine/Commands/Agent/Hook/Claude/NotificationHookCommand.cs b/src/Nitro/CommandLine/src/CommandLine/Commands/Agent/Hook/Claude/NotificationHookCommand.cs index b41f38692a7..11c433dff12 100644 --- a/src/Nitro/CommandLine/src/CommandLine/Commands/Agent/Hook/Claude/NotificationHookCommand.cs +++ b/src/Nitro/CommandLine/src/CommandLine/Commands/Agent/Hook/Claude/NotificationHookCommand.cs @@ -1,17 +1,22 @@ +using ChilliCream.Nitro.CommandLine.Services.Hook; +using ChilliCream.Nitro.CommandLine.Services.Workspace; + namespace ChilliCream.Nitro.CommandLine.Commands.Agent.Hook.Claude; /// -/// Adapts Claude Code's Notification hook: touches the session's last-seen -/// time on an idle-prompt notification. Payload JSON on stdin, {} on stdout, -/// always. +/// Adapts the Notification hook of a harness speaking Claude Code's hook +/// contract: touches the session's last-seen time on an idle-prompt notification. +/// Payload JSON on stdin, {} on stdout, always. /// internal sealed class NotificationHookCommand : Command { - public NotificationHookCommand() : base("notification") + public NotificationHookCommand(ClaudeHooksTemplate template) : base("notification") { - Description = "Adapt Claude Code's Notification hook: touch last-seen on an idle-prompt notification."; + Description = $"Adapt {AgentHarnessDisplay.Name(template.Harness)}'s Notification hook: " + + "touch last-seen on an idle-prompt notification."; this.SetHookAction( + template.Harness, "Notification", (handler, payload, ct) => handler.HandleNotificationAsync(payload, skipSessionFileLookup: false, ct)); } diff --git a/src/Nitro/CommandLine/src/CommandLine/Commands/Agent/Hook/Claude/SessionEndHookCommand.cs b/src/Nitro/CommandLine/src/CommandLine/Commands/Agent/Hook/Claude/SessionEndHookCommand.cs index a56088ef938..205ecfbaa8d 100644 --- a/src/Nitro/CommandLine/src/CommandLine/Commands/Agent/Hook/Claude/SessionEndHookCommand.cs +++ b/src/Nitro/CommandLine/src/CommandLine/Commands/Agent/Hook/Claude/SessionEndHookCommand.cs @@ -1,16 +1,22 @@ +using ChilliCream.Nitro.CommandLine.Services.Hook; +using ChilliCream.Nitro.CommandLine.Services.Workspace; + namespace ChilliCream.Nitro.CommandLine.Commands.Agent.Hook.Claude; /// -/// Adapts Claude Code's SessionEnd hook: marks this session's agent -/// row as ended. Payload JSON on stdin, {} on stdout, always. +/// Adapts the SessionEnd hook of a harness speaking Claude Code's hook +/// contract: marks this session's agent row as ended. Payload JSON on stdin, +/// {} on stdout, always. /// internal sealed class SessionEndHookCommand : Command { - public SessionEndHookCommand() : base("session-end") + public SessionEndHookCommand(ClaudeHooksTemplate template) : base("session-end") { - Description = "Adapt Claude Code's SessionEnd hook: mark this session's agent row as ended."; + Description = $"Adapt {AgentHarnessDisplay.Name(template.Harness)}'s SessionEnd hook: " + + "mark this session's agent row as ended."; this.SetHookAction( + template.Harness, "SessionEnd", (handler, payload, ct) => handler.HandleSessionEndAsync(payload, skipSessionFileLookup: false, ct)); } diff --git a/src/Nitro/CommandLine/src/CommandLine/Commands/Agent/Hook/Claude/SessionStartHookCommand.cs b/src/Nitro/CommandLine/src/CommandLine/Commands/Agent/Hook/Claude/SessionStartHookCommand.cs index a5474f41fcf..a2eee51a29c 100644 --- a/src/Nitro/CommandLine/src/CommandLine/Commands/Agent/Hook/Claude/SessionStartHookCommand.cs +++ b/src/Nitro/CommandLine/src/CommandLine/Commands/Agent/Hook/Claude/SessionStartHookCommand.cs @@ -1,16 +1,22 @@ +using ChilliCream.Nitro.CommandLine.Services.Hook; +using ChilliCream.Nitro.CommandLine.Services.Workspace; + namespace ChilliCream.Nitro.CommandLine.Commands.Agent.Hook.Claude; /// -/// Adapts Claude Code's SessionStart hook: upserts this session's -/// presence row. Payload JSON on stdin, {} on stdout, always. +/// Adapts the SessionStart hook of a harness speaking Claude Code's hook +/// contract: upserts this session's presence row. Payload JSON on stdin, +/// {} on stdout, always. /// internal sealed class SessionStartHookCommand : Command { - public SessionStartHookCommand() : base("session-start") + public SessionStartHookCommand(ClaudeHooksTemplate template) : base("session-start") { - Description = "Adapt Claude Code's SessionStart hook: upsert this session's presence row."; + Description = $"Adapt {AgentHarnessDisplay.Name(template.Harness)}'s SessionStart hook: " + + "upsert this session's presence row."; this.SetHookAction( + template.Harness, "SessionStart", (handler, payload, ct) => handler.HandleSessionStartAsync(payload, skipSessionFileLookup: false, ct)); } diff --git a/src/Nitro/CommandLine/src/CommandLine/Commands/Agent/Hook/Claude/StopHookCommand.cs b/src/Nitro/CommandLine/src/CommandLine/Commands/Agent/Hook/Claude/StopHookCommand.cs index 4af9b01a11d..76ef1dfb6a6 100644 --- a/src/Nitro/CommandLine/src/CommandLine/Commands/Agent/Hook/Claude/StopHookCommand.cs +++ b/src/Nitro/CommandLine/src/CommandLine/Commands/Agent/Hook/Claude/StopHookCommand.cs @@ -1,18 +1,23 @@ +using ChilliCream.Nitro.CommandLine.Services.Hook; +using ChilliCream.Nitro.CommandLine.Services.Workspace; + namespace ChilliCream.Nitro.CommandLine.Commands.Agent.Hook.Claude; /// -/// Adapts Claude Code's Stop hook: blocks the turn from ending while -/// unread mail not yet delivered on the gate channel exists, honoring -/// reentrancy and the per-turn block budget. Payload JSON on stdin, -/// {decision, reason} or {} on stdout, always. +/// Adapts the Stop hook of a harness speaking Claude Code's hook contract: +/// blocks the turn from ending while unread mail not yet delivered on the gate +/// channel exists, honoring reentrancy and the per-turn block budget. Payload JSON +/// on stdin, {decision, reason} or {} on stdout, always. /// internal sealed class StopHookCommand : Command { - public StopHookCommand() : base("stop") + public StopHookCommand(ClaudeHooksTemplate template) : base("stop") { - Description = "Adapt Claude Code's Stop hook: block the turn while unread mail is undelivered."; + Description = $"Adapt {AgentHarnessDisplay.Name(template.Harness)}'s Stop hook: " + + "block the turn while unread mail is undelivered."; this.SetHookAction( + template.Harness, "Stop", (handler, payload, ct) => handler.HandleStopAsync(payload, skipSessionFileLookup: false, ct)); } diff --git a/src/Nitro/CommandLine/src/CommandLine/Commands/Agent/Hook/Claude/UserPromptSubmitHookCommand.cs b/src/Nitro/CommandLine/src/CommandLine/Commands/Agent/Hook/Claude/UserPromptSubmitHookCommand.cs index 218c133f670..7df89e8f774 100644 --- a/src/Nitro/CommandLine/src/CommandLine/Commands/Agent/Hook/Claude/UserPromptSubmitHookCommand.cs +++ b/src/Nitro/CommandLine/src/CommandLine/Commands/Agent/Hook/Claude/UserPromptSubmitHookCommand.cs @@ -1,18 +1,22 @@ +using ChilliCream.Nitro.CommandLine.Services.Hook; +using ChilliCream.Nitro.CommandLine.Services.Workspace; + namespace ChilliCream.Nitro.CommandLine.Commands.Agent.Hook.Claude; /// -/// Adapts Claude Code's UserPromptSubmit hook: resets the Stop -/// gate's per-turn block budget and injects the unread-mail digest. -/// Payload JSON on stdin, harness-shaped JSON on stdout, always. +/// Adapts the UserPromptSubmit hook of a harness speaking Claude Code's +/// hook contract: resets the Stop gate's per-turn block budget and injects the +/// unread-mail digest. Payload JSON on stdin, harness-shaped JSON on stdout, always. /// internal sealed class UserPromptSubmitHookCommand : Command { - public UserPromptSubmitHookCommand() : base("user-prompt-submit") + public UserPromptSubmitHookCommand(ClaudeHooksTemplate template) : base("user-prompt-submit") { - Description = "Adapt Claude Code's UserPromptSubmit hook: reset the block budget and " - + "inject the unread-mail digest."; + Description = $"Adapt {AgentHarnessDisplay.Name(template.Harness)}'s UserPromptSubmit hook: " + + "reset the block budget and inject the unread-mail digest."; this.SetHookAction( + template.Harness, "UserPromptSubmit", (handler, payload, ct) => handler.HandleUserPromptSubmitAsync(payload, skipSessionFileLookup: false, ct)); } diff --git a/src/Nitro/CommandLine/src/CommandLine/Commands/Agent/Hook/ClaudeHookCommandExtensions.cs b/src/Nitro/CommandLine/src/CommandLine/Commands/Agent/Hook/ClaudeHookCommandExtensions.cs index a17ac0aa5ae..3b6e4dc44e7 100644 --- a/src/Nitro/CommandLine/src/CommandLine/Commands/Agent/Hook/ClaudeHookCommandExtensions.cs +++ b/src/Nitro/CommandLine/src/CommandLine/Commands/Agent/Hook/ClaudeHookCommandExtensions.cs @@ -4,20 +4,21 @@ namespace ChilliCream.Nitro.CommandLine.Commands.Agent.Hook; /// -/// Binds a Claude hook command to stdin payload handling through -/// . +/// Binds a Claude-shaped hook command to stdin payload handling through +/// and the hook handler registered for its harness. /// internal static class ClaudeHookCommandExtensions { public static Command SetHookAction( this Command command, + string harness, string hookEventName, Func> handle) { command.SetAction(async (parseResult, cancellationToken) => { var services = CommandExecutionContext.Services; - var handler = services.GetRequiredService(); + var handler = services.GetRequiredKeyedService(harness); var environmentVariables = services.GetRequiredService(); return await ClaudeHookExecutor.RunAsync( environmentVariables, diff --git a/src/Nitro/CommandLine/src/CommandLine/Commands/Agent/Hook/HookCommand.cs b/src/Nitro/CommandLine/src/CommandLine/Commands/Agent/Hook/HookCommand.cs index 1c533e1e5d5..dc6cc349755 100644 --- a/src/Nitro/CommandLine/src/CommandLine/Commands/Agent/Hook/HookCommand.cs +++ b/src/Nitro/CommandLine/src/CommandLine/Commands/Agent/Hook/HookCommand.cs @@ -1,6 +1,7 @@ using ChilliCream.Nitro.CommandLine.Commands.Agent.Hook.Claude; using ChilliCream.Nitro.CommandLine.Commands.Agent.Hook.Codex; using ChilliCream.Nitro.CommandLine.Commands.Agent.Hook.Opencode; +using ChilliCream.Nitro.CommandLine.Services.Hook; namespace ChilliCream.Nitro.CommandLine.Commands.Agent.Hook; @@ -16,8 +17,9 @@ public HookCommand() : base("hook") Description = "Translate harness turn-boundary hook payloads into digest and gate behavior."; Hidden = true; - Subcommands.Add(new ClaudeHookCommand()); + Subcommands.Add(new ClaudeHookCommand(ClaudeHooksTemplate.Claude)); Subcommands.Add(new CodexHookCommand()); Subcommands.Add(new OpencodeHookCommand()); + Subcommands.Add(new ClaudeHookCommand(ClaudeHooksTemplate.Venti)); } } diff --git a/src/Nitro/CommandLine/src/CommandLine/Commands/Agent/Hooks/Claude/ClaudeHooksCommand.cs b/src/Nitro/CommandLine/src/CommandLine/Commands/Agent/Hooks/Claude/ClaudeHooksCommand.cs index 0213fe84492..31d484c0726 100644 --- a/src/Nitro/CommandLine/src/CommandLine/Commands/Agent/Hooks/Claude/ClaudeHooksCommand.cs +++ b/src/Nitro/CommandLine/src/CommandLine/Commands/Agent/Hooks/Claude/ClaudeHooksCommand.cs @@ -1,17 +1,21 @@ +using ChilliCream.Nitro.CommandLine.Services.Hook; +using ChilliCream.Nitro.CommandLine.Services.Workspace; + namespace ChilliCream.Nitro.CommandLine.Commands.Agent.Hooks.Claude; /// -/// Installs, inspects, and removes this CLI's Claude Code turn-boundary hook -/// entries in settings.json. +/// Installs, inspects, and removes this CLI's turn-boundary hook entries in the +/// settings.json of one harness speaking Claude Code's hook format. /// internal sealed class ClaudeHooksCommand : Command { - public ClaudeHooksCommand() : base("claude") + public ClaudeHooksCommand(ClaudeHooksTemplate template, Option scopeOption) : base(template.CommandName) { - Description = "Install, inspect, and remove Nitro's Claude Code hook entries."; + Description = + $"Install, inspect, and remove Nitro's {AgentHarnessDisplay.Name(template.Harness)} hook entries."; - Subcommands.Add(new InstallClaudeHooksCommand()); - Subcommands.Add(new StatusClaudeHooksCommand()); - Subcommands.Add(new UninstallClaudeHooksCommand()); + Subcommands.Add(new InstallClaudeHooksCommand(template, scopeOption)); + Subcommands.Add(new StatusClaudeHooksCommand(template, scopeOption)); + Subcommands.Add(new UninstallClaudeHooksCommand(template, scopeOption)); } } diff --git a/src/Nitro/CommandLine/src/CommandLine/Commands/Agent/Hooks/Claude/HookSwitchGuidance.cs b/src/Nitro/CommandLine/src/CommandLine/Commands/Agent/Hooks/Claude/HookSwitchGuidance.cs new file mode 100644 index 00000000000..b5ce98021bf --- /dev/null +++ b/src/Nitro/CommandLine/src/CommandLine/Commands/Agent/Hooks/Claude/HookSwitchGuidance.cs @@ -0,0 +1,34 @@ +using ChilliCream.Nitro.CommandLine.Helpers; + +namespace ChilliCream.Nitro.CommandLine.Commands.Agent.Hooks.Claude; + +/// +/// Tells the user which of Nitro's hook entries their harness's own switches keep from running +/// and how to switch them on. Nitro never changes those switches itself. +/// +internal static class HookSwitchGuidance +{ + public static void Write( + INitroConsole console, + string harnessName, + string settingsPath, + IReadOnlyList disabledEvents, + string? allHooksDisabledIn) + { + if (disabledEvents.Count > 0) + { + console.MarkupLine( + ($"{harnessName} skips Nitro's {string.Join(", ", disabledEvents)} hooks because their entries in " + + $"'{settingsPath}' set \"enabled\": false. Remove that flag or set it to true to let them run.") + .AsWarning()); + } + + if (allHooksDisabledIn is not null) + { + console.MarkupLine( + ($"{harnessName} runs no hooks, Nitro's included, while '{allHooksDisabledIn}' sets " + + "\"disableAllHooks\": true. Set it to false or remove it to let them run.") + .AsWarning()); + } + } +} diff --git a/src/Nitro/CommandLine/src/CommandLine/Commands/Agent/Hooks/Claude/InstallClaudeHooksCommand.cs b/src/Nitro/CommandLine/src/CommandLine/Commands/Agent/Hooks/Claude/InstallClaudeHooksCommand.cs index e253d058cc4..5e6527ebdda 100644 --- a/src/Nitro/CommandLine/src/CommandLine/Commands/Agent/Hooks/Claude/InstallClaudeHooksCommand.cs +++ b/src/Nitro/CommandLine/src/CommandLine/Commands/Agent/Hooks/Claude/InstallClaudeHooksCommand.cs @@ -1,40 +1,48 @@ -using ChilliCream.Nitro.CommandLine.Commands.Agent.Hooks.Options; using ChilliCream.Nitro.CommandLine.Helpers; using ChilliCream.Nitro.CommandLine.Results; using ChilliCream.Nitro.CommandLine.Services.Hook; +using ChilliCream.Nitro.CommandLine.Services.Workspace; namespace ChilliCream.Nitro.CommandLine.Commands.Agent.Hooks.Claude; /// /// Idempotent: installing twice writes the same entries, reporting the -/// second run as unchanged. Only ever adds or replaces the single -/// Nitro-owned hook group per event; foreign entries (another tool's hook -/// under the same event) are left untouched. +/// second run as unchanged. Leaves exactly one Nitro-owned hook handler per +/// event; foreign handlers (another tool's hook under the same event, even in +/// the same group) are left untouched. /// internal sealed class InstallClaudeHooksCommand : Command { - public InstallClaudeHooksCommand() : base("install") + public InstallClaudeHooksCommand(ClaudeHooksTemplate template, Option scopeOption) : base("install") { - Description = "Add or update this CLI's Claude Code turn-boundary hook entries."; + Description = $"Add or update this CLI's {AgentHarnessDisplay.Name(template.Harness)} " + + "turn-boundary hook entries."; - Options.Add(Opt.Instance); + Options.Add(scopeOption); Options.Add(Opt.Instance); - this.AddExamples("agent hooks claude install", "agent hooks claude install --scope project"); + this.AddExamples( + $"agent hooks {template.CommandName} install", + $"agent hooks {template.CommandName} install --scope project"); - this.SetActionWithExceptionHandling(ExecuteAsync); + this.SetActionWithExceptionHandling( + (services, parseResult, cancellationToken) => + ExecuteAsync(services, parseResult, template, scopeOption, cancellationToken)); } internal static async Task ExecuteAsync( ICommandServices services, ParseResult parseResult, + ClaudeHooksTemplate template, + Option scopeOption, CancellationToken cancellationToken) { var console = services.GetRequiredService(); - var installer = services.GetRequiredService(); + var installer = services.GetRequiredKeyedService(template.Harness); var resultHolder = services.GetRequiredService(); - var scope = parseResult.GetRequiredValue(Opt.Instance); + var scope = parseResult.GetRequiredValue(scopeOption); + var harnessName = AgentHarnessDisplay.Name(template.Harness); var report = await installer.InstallAsync(scope, cancellationToken); @@ -44,13 +52,20 @@ internal static async Task ExecuteAsync( return ExitCodes.Success; } - console.OkLine($"Installed Claude Code hooks in '{report.SettingsPath.EscapeMarkup()}'."); + console.OkLine($"Installed {harnessName} hooks in '{report.SettingsPath.EscapeMarkup()}'."); foreach (var eventResult in report.Events) { console.WriteLine($" {eventResult.Event,-18}{Describe(eventResult.Outcome)}"); } + HookSwitchGuidance.Write( + console, + harnessName, + report.SettingsPath, + [.. report.Events.Where(e => e.Outcome == HookInstallOutcome.Disabled).Select(e => e.Event)], + report.AllHooksDisabledIn); + return ExitCodes.Success; } @@ -59,14 +74,17 @@ internal static async Task ExecuteAsync( HookInstallOutcome.Installed => "installed", HookInstallOutcome.Updated => "updated", HookInstallOutcome.Unchanged => "unchanged", + HookInstallOutcome.Disabled => "disabled", _ => outcome.ToString() }; private static HooksInstallResult ToResult(ClaudeHooksInstallReport report) => new( report.SettingsPath, - [.. report.Events.Select(e => new HookEventResult(e.Event, e.Outcome.ToString()))]); + [.. report.Events.Select(e => new HookEventInstallResult(e.Event, e.Outcome.ToString()))], + report.AllHooksDisabledIn); - public sealed record HookEventResult(string Event, string Outcome); + public sealed record HookEventInstallResult(string Event, string Outcome); - public sealed record HooksInstallResult(string SettingsPath, IReadOnlyList Events); + public sealed record HooksInstallResult( + string SettingsPath, IReadOnlyList Events, string? AllHooksDisabledIn); } diff --git a/src/Nitro/CommandLine/src/CommandLine/Commands/Agent/Hooks/Claude/StatusClaudeHooksCommand.cs b/src/Nitro/CommandLine/src/CommandLine/Commands/Agent/Hooks/Claude/StatusClaudeHooksCommand.cs index 55201aeff99..adab1b206e9 100644 --- a/src/Nitro/CommandLine/src/CommandLine/Commands/Agent/Hooks/Claude/StatusClaudeHooksCommand.cs +++ b/src/Nitro/CommandLine/src/CommandLine/Commands/Agent/Hooks/Claude/StatusClaudeHooksCommand.cs @@ -1,42 +1,51 @@ -using ChilliCream.Nitro.CommandLine.Commands.Agent.Hooks.Options; using ChilliCream.Nitro.CommandLine.Helpers; using ChilliCream.Nitro.CommandLine.Results; using ChilliCream.Nitro.CommandLine.Services.Hook; +using ChilliCream.Nitro.CommandLine.Services.Workspace; namespace ChilliCream.Nitro.CommandLine.Commands.Agent.Hooks.Claude; /// /// Read-only: reports, per managed event, whether a Nitro-owned entry is missing, -/// installed and current, or installed but outdated (its command text differs from what -/// install would write today). +/// installed and current, installed but outdated (it differs from what install +/// would write today), or switched off by the harness's own enabled flag. /// internal sealed class StatusClaudeHooksCommand : Command { - public StatusClaudeHooksCommand() : base("status") + public StatusClaudeHooksCommand(ClaudeHooksTemplate template, Option scopeOption) : base("status") { - Description = "Show whether this CLI's Claude Code hook entries are missing, current, or outdated."; + Description = $"Show whether this CLI's {AgentHarnessDisplay.Name(template.Harness)} " + + "hook entries are missing, current, or outdated."; - Options.Add(Opt.Instance); + Options.Add(scopeOption); Options.Add(Opt.Instance); - this.AddExamples("agent hooks claude status", "agent hooks claude status --scope project"); + this.AddExamples( + $"agent hooks {template.CommandName} status", + $"agent hooks {template.CommandName} status --scope project"); - this.SetActionWithExceptionHandling(ExecuteAsync); + this.SetActionWithExceptionHandling( + (services, parseResult, cancellationToken) => + ExecuteAsync(services, parseResult, template, scopeOption, cancellationToken)); } internal static async Task ExecuteAsync( ICommandServices services, ParseResult parseResult, + ClaudeHooksTemplate template, + Option scopeOption, CancellationToken cancellationToken) { var console = services.GetRequiredService(); - var installer = services.GetRequiredService(); + var installer = services.GetRequiredKeyedService(template.Harness); var resultHolder = services.GetRequiredService(); - var scope = parseResult.GetRequiredValue(Opt.Instance); + var scope = parseResult.GetRequiredValue(scopeOption); + var harnessName = AgentHarnessDisplay.Name(template.Harness); var report = await installer.StatusAsync(scope, cancellationToken); - var current = report.Events.All(e => e.Outcome == HookStatusOutcome.Installed); + var current = report.Events.All(e => e.Outcome == HookStatusOutcome.Installed) + && report.AllHooksDisabledIn is null; if (!console.IsHumanReadable) { @@ -44,13 +53,20 @@ internal static async Task ExecuteAsync( return current ? ExitCodes.Success : ExitCodes.Error; } - console.WriteLine($"Claude Code hooks in '{report.SettingsPath.EscapeMarkup()}':"); + console.WriteLine($"{harnessName} hooks in '{report.SettingsPath.EscapeMarkup()}':"); foreach (var eventResult in report.Events) { console.WriteLine($" {eventResult.Event,-18}{Describe(eventResult.Outcome)}"); } + HookSwitchGuidance.Write( + console, + harnessName, + report.SettingsPath, + [.. report.Events.Where(e => e.Outcome == HookStatusOutcome.Disabled).Select(e => e.Event)], + report.AllHooksDisabledIn); + return current ? ExitCodes.Success : ExitCodes.Error; } @@ -59,16 +75,21 @@ internal static async Task ExecuteAsync( HookStatusOutcome.Missing => "missing", HookStatusOutcome.Installed => "installed", HookStatusOutcome.Outdated => "outdated", + HookStatusOutcome.Disabled => "disabled", _ => outcome.ToString() }; private static HooksStatusResult ToResult(ClaudeHooksStatusReport report, bool current) => new( report.SettingsPath, current, - [.. report.Events.Select(e => new HookEventStatusResult(e.Event, e.Outcome.ToString(), e.InstalledCommand))]); + [.. report.Events.Select(e => new HookEventStatusResult(e.Event, e.Outcome.ToString(), e.InstalledCommand))], + report.AllHooksDisabledIn); public sealed record HookEventStatusResult(string Event, string Outcome, string? InstalledCommand); public sealed record HooksStatusResult( - string SettingsPath, bool Current, IReadOnlyList Events); + string SettingsPath, + bool Current, + IReadOnlyList Events, + string? AllHooksDisabledIn); } diff --git a/src/Nitro/CommandLine/src/CommandLine/Commands/Agent/Hooks/Claude/UninstallClaudeHooksCommand.cs b/src/Nitro/CommandLine/src/CommandLine/Commands/Agent/Hooks/Claude/UninstallClaudeHooksCommand.cs index 9e1d8e0b5af..e0fdfaec827 100644 --- a/src/Nitro/CommandLine/src/CommandLine/Commands/Agent/Hooks/Claude/UninstallClaudeHooksCommand.cs +++ b/src/Nitro/CommandLine/src/CommandLine/Commands/Agent/Hooks/Claude/UninstallClaudeHooksCommand.cs @@ -1,7 +1,7 @@ -using ChilliCream.Nitro.CommandLine.Commands.Agent.Hooks.Options; using ChilliCream.Nitro.CommandLine.Helpers; using ChilliCream.Nitro.CommandLine.Results; using ChilliCream.Nitro.CommandLine.Services.Hook; +using ChilliCream.Nitro.CommandLine.Services.Workspace; namespace ChilliCream.Nitro.CommandLine.Commands.Agent.Hooks.Claude; @@ -13,28 +13,36 @@ namespace ChilliCream.Nitro.CommandLine.Commands.Agent.Hooks.Claude; /// internal sealed class UninstallClaudeHooksCommand : Command { - public UninstallClaudeHooksCommand() : base("uninstall") + public UninstallClaudeHooksCommand(ClaudeHooksTemplate template, Option scopeOption) : base("uninstall") { - Description = "Remove this CLI's Claude Code turn-boundary hook entries."; + Description = $"Remove this CLI's {AgentHarnessDisplay.Name(template.Harness)} " + + "turn-boundary hook entries."; - Options.Add(Opt.Instance); + Options.Add(scopeOption); Options.Add(Opt.Instance); - this.AddExamples("agent hooks claude uninstall", "agent hooks claude uninstall --scope project"); + this.AddExamples( + $"agent hooks {template.CommandName} uninstall", + $"agent hooks {template.CommandName} uninstall --scope project"); - this.SetActionWithExceptionHandling(ExecuteAsync); + this.SetActionWithExceptionHandling( + (services, parseResult, cancellationToken) => + ExecuteAsync(services, parseResult, template, scopeOption, cancellationToken)); } internal static async Task ExecuteAsync( ICommandServices services, ParseResult parseResult, + ClaudeHooksTemplate template, + Option scopeOption, CancellationToken cancellationToken) { var console = services.GetRequiredService(); - var installer = services.GetRequiredService(); + var installer = services.GetRequiredKeyedService(template.Harness); var resultHolder = services.GetRequiredService(); - var scope = parseResult.GetRequiredValue(Opt.Instance); + var scope = parseResult.GetRequiredValue(scopeOption); + var harnessName = AgentHarnessDisplay.Name(template.Harness); var report = await installer.UninstallAsync(scope, cancellationToken); @@ -44,7 +52,7 @@ internal static async Task ExecuteAsync( return ExitCodes.Success; } - console.OkLine($"Removed Nitro's Claude Code hooks from '{report.SettingsPath.EscapeMarkup()}'."); + console.OkLine($"Removed Nitro's {harnessName} hooks from '{report.SettingsPath.EscapeMarkup()}'."); foreach (var eventResult in report.Events) { @@ -63,9 +71,9 @@ internal static async Task ExecuteAsync( private static HooksUninstallResult ToResult(ClaudeHooksUninstallReport report) => new( report.SettingsPath, - [.. report.Events.Select(e => new HookEventResult(e.Event, e.Outcome.ToString()))]); + [.. report.Events.Select(e => new HookEventUninstallResult(e.Event, e.Outcome.ToString()))]); - public sealed record HookEventResult(string Event, string Outcome); + public sealed record HookEventUninstallResult(string Event, string Outcome); - public sealed record HooksUninstallResult(string SettingsPath, IReadOnlyList Events); + public sealed record HooksUninstallResult(string SettingsPath, IReadOnlyList Events); } diff --git a/src/Nitro/CommandLine/src/CommandLine/Commands/Agent/Hooks/HooksCommand.cs b/src/Nitro/CommandLine/src/CommandLine/Commands/Agent/Hooks/HooksCommand.cs index dd32acbb186..74def999b29 100644 --- a/src/Nitro/CommandLine/src/CommandLine/Commands/Agent/Hooks/HooksCommand.cs +++ b/src/Nitro/CommandLine/src/CommandLine/Commands/Agent/Hooks/HooksCommand.cs @@ -1,9 +1,12 @@ +using ChilliCream.Nitro.CommandLine.Commands.Agent.Hooks.Options; +using ChilliCream.Nitro.CommandLine.Services.Hook; + namespace ChilliCream.Nitro.CommandLine.Commands.Agent.Hooks; /// /// Installs, inspects, and removes this CLI's turn-boundary hook entries, /// one nested group per harness (claude, codex, -/// copilot). Distinct from nitro agent hook (singular): that +/// opencode, venti). Distinct from nitro agent hook (singular): that /// group IS the hook adapters each harness invokes; this group manages the /// config entries that wire a harness up to them. /// @@ -13,8 +16,11 @@ public HooksCommand() : base("hooks") { Description = "Install, inspect, and remove Nitro's turn-boundary hook entries per harness."; - Subcommands.Add(new Claude.ClaudeHooksCommand()); + Subcommands.Add( + new Claude.ClaudeHooksCommand(ClaudeHooksTemplate.Claude, Opt.Instance)); Subcommands.Add(new Codex.CodexHooksCommand()); Subcommands.Add(new Opencode.OpencodeHooksCommand()); + Subcommands.Add( + new Claude.ClaudeHooksCommand(ClaudeHooksTemplate.Venti, Opt.Instance)); } } diff --git a/src/Nitro/CommandLine/src/CommandLine/Commands/Agent/Hooks/Options/VentiHookInstallScopeOption.cs b/src/Nitro/CommandLine/src/CommandLine/Commands/Agent/Hooks/Options/VentiHookInstallScopeOption.cs new file mode 100644 index 00000000000..5ad320f81a3 --- /dev/null +++ b/src/Nitro/CommandLine/src/CommandLine/Commands/Agent/Hooks/Options/VentiHookInstallScopeOption.cs @@ -0,0 +1,15 @@ +using ChilliCream.Nitro.CommandLine.Services.Hook; + +namespace ChilliCream.Nitro.CommandLine.Commands.Agent.Hooks.Options; + +internal sealed class VentiHookInstallScopeOption : Option +{ + public VentiHookInstallScopeOption() : base("--scope") + { + Description = "Where the Venti settings file lives: 'user' (~/.venti/settings.json) " + + "or 'project' (./.venti/settings.json, read when Venti is launched from the current directory)"; + Required = false; + DefaultValueFactory = _ => HookInstallScopes.User; + AcceptOnlyFromAmong(HookInstallScopes.User, HookInstallScopes.Project); + } +} diff --git a/src/Nitro/CommandLine/src/CommandLine/Extensions/ServiceCollectionExtensions.cs b/src/Nitro/CommandLine/src/CommandLine/Extensions/ServiceCollectionExtensions.cs index b180385486b..4024cd1f405 100644 --- a/src/Nitro/CommandLine/src/CommandLine/Extensions/ServiceCollectionExtensions.cs +++ b/src/Nitro/CommandLine/src/CommandLine/Extensions/ServiceCollectionExtensions.cs @@ -41,6 +41,7 @@ public static IServiceCollection AddNitroServices(this IServiceCollection servic services.TryAddSingleton(); services.TryAddSingleton(); services.TryAddSingleton(); + services.TryAddKeyedSingleton(AgentSessionHarness.Venti); services.TryAddSingleton(); services.TryAddSingleton(); services.TryAddSingleton(); @@ -59,15 +60,35 @@ public static IServiceCollection AddNitroServices(this IServiceCollection servic // Inert until a caller invokes IMailWakeDaemonCoordinator.StartAsync. services.TryAddSingleton(); - services.TryAddSingleton(); + services.TryAddKeyedSingleton( + AgentSessionHarness.ClaudeCode, + static (provider, _) => CreateClaudeHookHandler( + provider, AgentSessionHarness.ClaudeCode, provider.GetRequiredService())); + services.TryAddKeyedSingleton( + AgentSessionHarness.Venti, + static (provider, _) => CreateClaudeHookHandler( + provider, + AgentSessionHarness.Venti, + provider.GetRequiredKeyedService(AgentSessionHarness.Venti))); services.TryAddSingleton(); services.TryAddSingleton(); services.TryAddSingleton(); services.TryAddSingleton(); services.TryAddSingleton(); services.TryAddSingleton(); + services.TryAddKeyedSingleton( + AgentSessionHarness.Venti); services.TryAddSingleton(); - services.TryAddSingleton(); + services.TryAddKeyedSingleton( + AgentSessionHarness.ClaudeCode, + static (provider, _) => CreateClaudeHooksInstallerService( + provider, provider.GetRequiredService(), ClaudeHooksTemplate.Claude)); + services.TryAddKeyedSingleton( + AgentSessionHarness.Venti, + static (provider, _) => CreateClaudeHooksInstallerService( + provider, + provider.GetRequiredKeyedService(AgentSessionHarness.Venti), + ClaudeHooksTemplate.Venti)); services.TryAddSingleton(); services.TryAddSingleton(); services.TryAddSingleton(); @@ -82,4 +103,29 @@ public static IServiceCollection AddNitroServices(this IServiceCollection servic return services; } + + private static ClaudeHookHandler CreateClaudeHookHandler( + IServiceProvider provider, + string harness, + IClaudeSessionFileReader sessionFileReader) + => new( + provider.GetRequiredService(), + provider.GetRequiredService(), + provider.GetRequiredService(), + provider.GetRequiredService(), + provider.GetRequiredService(), + harness, + sessionFileReader); + + private static ClaudeHooksInstallerService CreateClaudeHooksInstallerService( + IServiceProvider provider, + IClaudeSettingsPathResolver pathResolver, + ClaudeHooksTemplate template) + => new( + provider.GetRequiredService(), + pathResolver, + provider.GetRequiredService(), + provider.GetRequiredService(), + provider.GetRequiredService(), + template); } diff --git a/src/Nitro/CommandLine/src/CommandLine/Services/CommandServices.cs b/src/Nitro/CommandLine/src/CommandLine/Services/CommandServices.cs index 8941e4be481..56af4705426 100644 --- a/src/Nitro/CommandLine/src/CommandLine/Services/CommandServices.cs +++ b/src/Nitro/CommandLine/src/CommandLine/Services/CommandServices.cs @@ -4,4 +4,7 @@ internal sealed class CommandServices(IServiceProvider services) : ICommandServi { public T GetRequiredService() where T : notnull => services.GetRequiredService(); + + public T GetRequiredKeyedService(object key) where T : notnull + => services.GetRequiredKeyedService(key); } diff --git a/src/Nitro/CommandLine/src/CommandLine/Services/Hook/ClaudeHookExecutor.cs b/src/Nitro/CommandLine/src/CommandLine/Services/Hook/ClaudeHookExecutor.cs index d1c7d63ff08..20d001cd0e2 100644 --- a/src/Nitro/CommandLine/src/CommandLine/Services/Hook/ClaudeHookExecutor.cs +++ b/src/Nitro/CommandLine/src/CommandLine/Services/Hook/ClaudeHookExecutor.cs @@ -118,6 +118,18 @@ private static ClaudeHookResponse ToResponse(ClaudeHookOutcome outcome, string h return new ClaudeHookResponse { Decision = "block", Reason = outcome.BlockReason }; } + if (outcome.AgentName is { Length: > 0 } agentName) + { + return new ClaudeHookResponse + { + HookSpecificOutput = new ClaudeHookSpecificOutput + { + HookEventName = hookEventName, + AgentName = agentName + } + }; + } + if (outcome.AdditionalContext is { Length: > 0 } context) { return new ClaudeHookResponse diff --git a/src/Nitro/CommandLine/src/CommandLine/Services/Hook/ClaudeHookHandler.cs b/src/Nitro/CommandLine/src/CommandLine/Services/Hook/ClaudeHookHandler.cs index c246655d8ab..07f65e4f162 100644 --- a/src/Nitro/CommandLine/src/CommandLine/Services/Hook/ClaudeHookHandler.cs +++ b/src/Nitro/CommandLine/src/CommandLine/Services/Hook/ClaudeHookHandler.cs @@ -4,12 +4,17 @@ namespace ChilliCream.Nitro.CommandLine.Services.Hook; +/// +/// Handles the Claude Code hook contract for , whose sessions +/// looks up. +/// internal sealed class ClaudeHookHandler( IFileSystem fileSystem, TimeProvider timeProvider, IAgentStore agentStore, IAgentDeliveryLedger ledger, IMailStore mailStore, + string harness, IClaudeSessionFileReader sessionFileReader) : IClaudeHookHandler { /// @@ -139,6 +144,30 @@ public async Task HandleNotificationAsync( : ClaudeHookOutcome.Neutral; } + public async Task HandleAgentNamingAsync( + ClaudeHookPayload payload, bool skipSessionFileLookup, CancellationToken cancellationToken) + { + // A subagent's naming event carries its parent's session id, so only the main kind may + // register a session. + if (payload.AgentKind != "main") + { + return ClaudeHookOutcome.Neutral; + } + + var resolved = Resolve(payload, skipSessionFileLookup); + + if (resolved is null) + { + return ClaudeHookOutcome.Neutral; + } + + var result = await agentStore.StartSessionAsync(BuildStartRequest(payload, resolved), cancellationToken); + + return result.Kind == AgentSessionStartKind.Ignored + ? ClaudeHookOutcome.Neutral + : new ClaudeHookOutcome { AgentName = result.Row!.Name }; + } + public async Task HandleSessionEndAsync( ClaudeHookPayload payload, bool skipSessionFileLookup, CancellationToken cancellationToken) { @@ -151,7 +180,7 @@ public async Task HandleSessionEndAsync( if (resolved is not null) { - await agentStore.EndSessionAsync(AgentSessionHarness.ClaudeCode, payload.SessionId!, cancellationToken); + await agentStore.EndSessionAsync(harness, payload.SessionId!, cancellationToken); } return ClaudeHookOutcome.Neutral; @@ -174,8 +203,7 @@ public async Task HandleSessionEndAsync( return null; } - var row = await agentStore.FindBySessionAsync( - AgentSessionHarness.ClaudeCode, payload.SessionId!, cancellationToken); + var row = await agentStore.FindBySessionAsync(harness, payload.SessionId!, cancellationToken); if (row is not null) { @@ -184,7 +212,15 @@ public async Task HandleSessionEndAsync( return null; } - await agentStore.TouchSessionAsync(AgentSessionHarness.ClaudeCode, payload.SessionId!, cancellationToken); + await agentStore.TouchSessionAsync(harness, payload.SessionId!, cancellationToken); + + var (endpointKind, endpointAddr) = ResolveEndpoint(resolved); + + if (row.EndpointKind == AgentSessionEndpointKind.None && endpointKind != AgentSessionEndpointKind.None) + { + // The session advertised its endpoint after the row was last started. + await agentStore.SetEndpointAsync(row.Name, endpointKind, endpointAddr, null, cancellationToken); + } return new ResolvedRow(row, Minted: false); } @@ -285,16 +321,18 @@ private static bool IsSubagentSession(ClaudeHookPayload payload) return new ResolvedSession(payloadWorkspace, session?.Name, session?.Version ?? string.Empty); } - private static AgentSessionStartRequest BuildStartRequest(ClaudeHookPayload payload, ResolvedSession resolved) + private static (string Kind, string Addr) ResolveEndpoint(ResolvedSession resolved) + => resolved.EndpointName is { Length: > 0 } name && EndpointAddress.IsValid(name) + ? (AgentSessionEndpointKind.ClaudePeer, name) + : (AgentSessionEndpointKind.None, string.Empty); + + private AgentSessionStartRequest BuildStartRequest(ClaudeHookPayload payload, ResolvedSession resolved) { - var (endpointKind, endpointAddr) = resolved.EndpointName is { Length: > 0 } name - && EndpointAddress.IsValid(name) - ? (AgentSessionEndpointKind.ClaudePeer, name) - : (AgentSessionEndpointKind.None, string.Empty); + var (endpointKind, endpointAddr) = ResolveEndpoint(resolved); return new AgentSessionStartRequest { - Harness = AgentSessionHarness.ClaudeCode, + Harness = harness, SessionId = payload.SessionId!, HarnessVersion = resolved.HarnessVersion, Cwd = payload.Cwd!, diff --git a/src/Nitro/CommandLine/src/CommandLine/Services/Hook/ClaudeHookOutcome.cs b/src/Nitro/CommandLine/src/CommandLine/Services/Hook/ClaudeHookOutcome.cs index 9da155f83f1..bc3788c76ad 100644 --- a/src/Nitro/CommandLine/src/CommandLine/Services/Hook/ClaudeHookOutcome.cs +++ b/src/Nitro/CommandLine/src/CommandLine/Services/Hook/ClaudeHookOutcome.cs @@ -1,12 +1,12 @@ namespace ChilliCream.Nitro.CommandLine.Services.Hook; /// -/// The context or Stop decision returned by a Claude hook handler. +/// The context, Stop decision, or agent name returned by a Claude hook handler. /// internal sealed record ClaudeHookOutcome { /// - /// An outcome with no additional context and no Stop block. + /// An outcome with no additional context, no Stop block, and no agent name. /// public static readonly ClaudeHookOutcome Neutral = new(); @@ -26,4 +26,9 @@ internal sealed record ClaudeHookOutcome /// is false. /// public string? BlockReason { get; init; } + + /// + /// The name to give the agent an AgentNaming event names, or null for none. + /// + public string? AgentName { get; init; } } diff --git a/src/Nitro/CommandLine/src/CommandLine/Services/Hook/ClaudeHookPayload.cs b/src/Nitro/CommandLine/src/CommandLine/Services/Hook/ClaudeHookPayload.cs index 471d6e4fe4c..f14dbb1b124 100644 --- a/src/Nitro/CommandLine/src/CommandLine/Services/Hook/ClaudeHookPayload.cs +++ b/src/Nitro/CommandLine/src/CommandLine/Services/Hook/ClaudeHookPayload.cs @@ -6,8 +6,8 @@ namespace ChilliCream.Nitro.CommandLine.Services.Hook; /// The fields this adapter reads from a Claude Code hook's stdin JSON: /// session_id and cwd on every event, agent_id when the /// event fires inside a subagent session, plus stop_hook_active on -/// Stop and notification_type on Notification. Other -/// fields are left unparsed. +/// Stop, notification_type on Notification, and +/// agent_kind on Venti's AgentNaming. Other fields are left unparsed. /// internal sealed record ClaudeHookPayload { @@ -25,4 +25,7 @@ internal sealed record ClaudeHookPayload [JsonPropertyName("notification_type")] public string? NotificationType { get; init; } + + [JsonPropertyName("agent_kind")] + public string? AgentKind { get; init; } } diff --git a/src/Nitro/CommandLine/src/CommandLine/Services/Hook/ClaudeHookResponse.cs b/src/Nitro/CommandLine/src/CommandLine/Services/Hook/ClaudeHookResponse.cs index 3b617dcbcaf..5213357f1d1 100644 --- a/src/Nitro/CommandLine/src/CommandLine/Services/Hook/ClaudeHookResponse.cs +++ b/src/Nitro/CommandLine/src/CommandLine/Services/Hook/ClaudeHookResponse.cs @@ -2,7 +2,7 @@ namespace ChilliCream.Nitro.CommandLine.Services.Hook; /// /// The Claude Code hook response envelope written to stdout: either -/// (context injection) or +/// (context injection or an agent name) or /// / (the Stop gate), never both. /// Every property serializes as camelCase and every null property is /// omitted, so the neutral, fail-open response is exactly {}. diff --git a/src/Nitro/CommandLine/src/CommandLine/Services/Hook/ClaudeHookSpecificOutput.cs b/src/Nitro/CommandLine/src/CommandLine/Services/Hook/ClaudeHookSpecificOutput.cs index e1674ab13eb..273c87e5bc2 100644 --- a/src/Nitro/CommandLine/src/CommandLine/Services/Hook/ClaudeHookSpecificOutput.cs +++ b/src/Nitro/CommandLine/src/CommandLine/Services/Hook/ClaudeHookSpecificOutput.cs @@ -4,5 +4,7 @@ internal sealed record ClaudeHookSpecificOutput { public required string HookEventName { get; init; } - public required string AdditionalContext { get; init; } + public string? AdditionalContext { get; init; } + + public string? AgentName { get; init; } } diff --git a/src/Nitro/CommandLine/src/CommandLine/Services/Hook/ClaudeHooksEditor.cs b/src/Nitro/CommandLine/src/CommandLine/Services/Hook/ClaudeHooksEditor.cs index 7af9d45d0c6..33b96b88dbc 100644 --- a/src/Nitro/CommandLine/src/CommandLine/Services/Hook/ClaudeHooksEditor.cs +++ b/src/Nitro/CommandLine/src/CommandLine/Services/Hook/ClaudeHooksEditor.cs @@ -4,15 +4,22 @@ namespace ChilliCream.Nitro.CommandLine.Services.Hook; /// -/// Edits Nitro-owned hook groups in Claude settings JSON. +/// Edits Nitro-owned hook handlers in settings JSON following Claude Code's hook format. A +/// handler is Nitro-owned when its command contains the template's ownership marker, in any +/// group, including groups that also hold other hooks. /// internal static class ClaudeHooksEditor { private const string HooksKey = "hooks"; private const string GroupHooksKey = "hooks"; + private const string MatcherKey = "matcher"; private const string TypeKey = "type"; private const string CommandKey = "command"; private const string TimeoutKey = "timeout"; + private const string AsyncKey = "async"; + private const string EnabledKey = "enabled"; + private const string StatusMessageKey = "statusMessage"; + private const string DisableAllHooksKey = "disableAllHooks"; private const string CommandType = "command"; public sealed record InstallResult( @@ -26,50 +33,83 @@ public sealed record UninstallResult( IReadOnlyList Outcomes); /// - /// Adds a Nitro hook group for each managed event or updates the first existing - /// Nitro-owned group. A nonempty group is Nitro-owned when every hook command - /// contains the ownership marker. + /// Leaves exactly one Nitro-owned handler per managed event with the expected command, timeout, + /// and matcher, preserving sibling hooks and group metadata. A retained disabled handler stays + /// disabled and is reported as . /// public static InstallResult Install( string? existingSettingsJson, + ClaudeHooksTemplate template, LaunchDescriptor descriptor, DateTimeOffset now) { var root = ParseOrEmpty(existingSettingsJson); var hooksNode = GetOrCreateHooksObject(root); - var outcomes = new List(ClaudeHooksTemplate.Events.Count); + var outcomes = new List(template.Events.Count); var sidecar = new Dictionary(); - foreach (var claudeEvent in ClaudeHooksTemplate.Events) + foreach (var hookEvent in template.Events) { - var eventArray = GetOrCreateEventArray(hooksNode, claudeEvent); - var desiredCommand = ClaudeHooksTemplate.BuildCommand(descriptor, claudeEvent); + var eventArray = GetOrCreateEventArray(hooksNode, hookEvent); + var before = eventArray.DeepClone(); + var desiredCommand = template.BuildCommand(descriptor, hookEvent); + var desiredMatcher = ClaudeHooksTemplate.Matcher(hookEvent); const int desiredTimeout = ClaudeHooksTemplate.TimeoutSeconds; - var ownedIndex = FindOwnedGroupIndex(eventArray); + var owned = FindOwnedHandlers(eventArray, template.CommandMarker, recordedCommand: null); + var kept = owned.FirstOrDefault(handler => HasMatcher(handler.Group, desiredMatcher)) + ?? owned.FirstOrDefault(); - if (ownedIndex < 0) + RemoveHandlers(eventArray, owned.Where(handler => !ReferenceEquals(handler, kept))); + + if (kept is null) { - AppendGroup(eventArray, BuildGroup(desiredCommand, desiredTimeout)); - outcomes.Add(new HookInstallEventResult(claudeEvent, HookInstallOutcome.Installed)); + AppendGroup( + eventArray, + BuildGroup( + desiredMatcher, + new JsonObject + { + [TypeKey] = CommandType, + [CommandKey] = desiredCommand, + [TimeoutKey] = desiredTimeout + })); } else { - var (existingCommand, existingTimeout) = ReadFirstHook((JsonObject)eventArray[ownedIndex]!); - - if (existingCommand == desiredCommand && existingTimeout == desiredTimeout) + if (!HasMatcher(kept.Group, desiredMatcher)) { - outcomes.Add(new HookInstallEventResult(claudeEvent, HookInstallOutcome.Unchanged)); + RemoveHandlers(eventArray, [kept]); + AppendGroup(eventArray, BuildGroup(desiredMatcher, kept.Hook)); } - else + + if (!IsCurrent(kept.Hook, template, desiredCommand)) { - eventArray[ownedIndex] = BuildGroup(desiredCommand, desiredTimeout); - outcomes.Add(new HookInstallEventResult(claudeEvent, HookInstallOutcome.Updated)); + Rewrite(kept.Hook, template, desiredCommand, desiredTimeout); } } - sidecar[claudeEvent] = new ClaudeHooksSidecarEntry( + HookInstallOutcome outcome; + + if (kept is not null && IsSwitchedOff(kept.Hook, template)) + { + outcome = HookInstallOutcome.Disabled; + } + else if (owned.Count == 0) + { + outcome = HookInstallOutcome.Installed; + } + else + { + outcome = JsonNode.DeepEquals(before, eventArray) + ? HookInstallOutcome.Unchanged + : HookInstallOutcome.Updated; + } + + outcomes.Add(new HookInstallEventResult(hookEvent, outcome)); + + sidecar[hookEvent] = new ClaudeHooksSidecarEntry( desiredCommand, desiredTimeout, ClaudeHooksSidecarEntry.ComputeHash(desiredCommand, desiredTimeout), @@ -80,84 +120,90 @@ public static InstallResult Install( } /// - /// Reports Installed when the first hook in the first Nitro-owned group has the - /// expected command and timeout, Outdated when either differs, or Missing when - /// no owned group exists. + /// Reports Missing when an event has no Nitro-owned handler, Installed when it has exactly + /// one with the expected command and timeout in a group with the event's expected matcher, + /// Disabled when that one handler is switched off, and Outdated otherwise. /// public static IReadOnlyList Status( - string? existingSettingsJson, LaunchDescriptor descriptor) + string? existingSettingsJson, ClaudeHooksTemplate template, LaunchDescriptor descriptor) { var root = ParseOrEmpty(existingSettingsJson); var hooksNode = root[HooksKey] as JsonObject; - var results = new List(ClaudeHooksTemplate.Events.Count); + var results = new List(template.Events.Count); - foreach (var claudeEvent in ClaudeHooksTemplate.Events) + foreach (var hookEvent in template.Events) { - var eventArray = hooksNode?[claudeEvent] as JsonArray; - var ownedIndex = eventArray is null ? -1 : FindOwnedGroupIndex(eventArray); + var owned = hooksNode?[hookEvent] is JsonArray eventArray + ? FindOwnedHandlers(eventArray, template.CommandMarker, recordedCommand: null) + : []; - if (ownedIndex < 0) + if (owned.Count == 0) { - results.Add(new HookStatusEventResult(claudeEvent, HookStatusOutcome.Missing, null)); + results.Add(new HookStatusEventResult(hookEvent, HookStatusOutcome.Missing, null)); continue; } - var (command, timeout) = ReadFirstHook((JsonObject)eventArray![ownedIndex]!); - var desiredCommand = ClaudeHooksTemplate.BuildCommand(descriptor, claudeEvent); + var first = owned[0]; + HookStatusOutcome outcome; - var outcome = command == desiredCommand && timeout == ClaudeHooksTemplate.TimeoutSeconds - ? HookStatusOutcome.Installed - : HookStatusOutcome.Outdated; + if (owned.Count > 1 + || !HasMatcher(first.Group, ClaudeHooksTemplate.Matcher(hookEvent)) + || !IsCurrent(first.Hook, template, template.BuildCommand(descriptor, hookEvent))) + { + outcome = HookStatusOutcome.Outdated; + } + else + { + outcome = IsSwitchedOff(first.Hook, template) + ? HookStatusOutcome.Disabled + : HookStatusOutcome.Installed; + } - results.Add(new HookStatusEventResult(claudeEvent, outcome, command)); + results.Add(new HookStatusEventResult(hookEvent, outcome, ReadString(first.Hook, CommandKey))); } return results; } /// - /// Removes one matching group per managed event, preferring the recorded command - /// and falling back to the ownership marker. Other groups are preserved. + /// Removes every Nitro-owned handler of each managed event, and every handler with the + /// recorded command, keeping sibling hooks and their group metadata. A group or event + /// the removal leaves without hooks is removed too. /// public static UninstallResult Uninstall( string? existingSettingsJson, + ClaudeHooksTemplate template, IReadOnlyDictionary priorSidecar) { var root = ParseOrEmpty(existingSettingsJson); var hooksNode = root[HooksKey] as JsonObject; - var outcomes = new List(ClaudeHooksTemplate.Events.Count); + var outcomes = new List(template.Events.Count); - foreach (var claudeEvent in ClaudeHooksTemplate.Events) + foreach (var hookEvent in template.Events) { - if (hooksNode?[claudeEvent] is not JsonArray eventArray) + if (hooksNode?[hookEvent] is not JsonArray eventArray) { - outcomes.Add(new HookUninstallEventResult(claudeEvent, HookUninstallOutcome.NotPresent)); + outcomes.Add(new HookUninstallEventResult(hookEvent, HookUninstallOutcome.NotPresent)); continue; } - var removeIndex = priorSidecar.TryGetValue(claudeEvent, out var recorded) - ? FindGroupIndexByCommand(eventArray, recorded.Command) - : -1; + var recordedCommand = priorSidecar.TryGetValue(hookEvent, out var recorded) ? recorded.Command : null; + var owned = FindOwnedHandlers(eventArray, template.CommandMarker, recordedCommand); - if (removeIndex < 0) + if (owned.Count == 0) { - removeIndex = FindOwnedGroupIndex(eventArray); - } - - if (removeIndex < 0) - { - outcomes.Add(new HookUninstallEventResult(claudeEvent, HookUninstallOutcome.NotPresent)); + outcomes.Add(new HookUninstallEventResult(hookEvent, HookUninstallOutcome.NotPresent)); continue; } - eventArray.RemoveAt(removeIndex); - outcomes.Add(new HookUninstallEventResult(claudeEvent, HookUninstallOutcome.Removed)); + RemoveHandlers(eventArray, owned); + outcomes.Add(new HookUninstallEventResult(hookEvent, HookUninstallOutcome.Removed)); if (eventArray.Count == 0) { - hooksNode.Remove(claudeEvent); + hooksNode.Remove(hookEvent); } } @@ -170,6 +216,152 @@ public static UninstallResult Uninstall( Serialize(root), new Dictionary(), outcomes); } + /// + /// Returns, in settings order, every hook event with at least one hook whose command + /// contains , in any group. + /// + public static IReadOnlyList FindEventsWithCommandMarker( + string? existingSettingsJson, string commandMarker) + { + if (ParseOrEmpty(existingSettingsJson)[HooksKey] is not JsonObject hooksNode) + { + return []; + } + + var events = new List(); + + foreach (var (hookEvent, node) in hooksNode) + { + if (node is JsonArray groups + && FindOwnedHandlers(groups, commandMarker, recordedCommand: null).Count > 0) + { + events.Add(hookEvent); + } + } + + return events; + } + + /// + /// Returns whether the settings set the top-level disableAllHooks flag to true. + /// + public static bool DisablesAllHooks(string? existingSettingsJson) + => ParseOrEmpty(existingSettingsJson)[DisableAllHooksKey] is JsonValue value + && value.TryGetValue(out var disabled) + && disabled; + + private static List FindOwnedHandlers( + JsonArray eventArray, string commandMarker, string? recordedCommand) + { + var owned = new List(); + + foreach (var node in eventArray) + { + if (node is not JsonObject group || group[GroupHooksKey] is not JsonArray hooks) + { + continue; + } + + foreach (var hookNode in hooks) + { + if (hookNode is JsonObject hook + && ReadString(hook, CommandKey) is { } command + && (command.Contains(commandMarker, StringComparison.Ordinal) || command == recordedCommand)) + { + owned.Add(new OwnedHandler(group, hook)); + } + } + } + + return owned; + } + + /// + /// Removes from their groups, and each group the removal empties. + /// + private static void RemoveHandlers(JsonArray eventArray, IEnumerable handlers) + { + foreach (var handler in handlers.ToList()) + { + var hooks = (JsonArray)handler.Group[GroupHooksKey]!; + hooks.Remove(handler.Hook); + + if (hooks.Count == 0) + { + eventArray.Remove(handler.Group); + } + } + } + + /// + /// Whether has the matcher . A null + /// stands for every occurrence of the event, which a matcher that + /// is absent, empty, or * selects; a matcher that is present but not text, + /// null included, keeps the group from running at all. + /// + private static bool HasMatcher(JsonObject group, string? matcher) + => matcher is null + ? IsAbsentOr(group, MatcherKey, node => ReadText(node) is "" or "*") + : ReadString(group, MatcherKey) == matcher; + + /// + /// Whether is a synchronous command handler running + /// with the expected timeout, a status message that is + /// absent or text and, for a harness with hook switches, an enabled flag that is + /// absent or a boolean. A property set to null is present, not absent. + /// + private static bool IsCurrent(JsonObject hook, ClaudeHooksTemplate template, string desiredCommand) + => ReadString(hook, TypeKey) == CommandType + && ReadString(hook, CommandKey) == desiredCommand + && hook[TimeoutKey] is JsonValue timeout + && timeout.TryGetValue(out var seconds) + && seconds == ClaudeHooksTemplate.TimeoutSeconds + && IsAbsentOr(hook, AsyncKey, node => node is JsonValue value + && value.TryGetValue(out var runsAsync) + && !runsAsync) + && IsAbsentOr(hook, StatusMessageKey, node => ReadText(node) is not null) + && (!template.HasHookSwitches || IsAbsentOr(hook, EnabledKey, IsBoolean)); + + /// + /// Sets the expected type, command, and timeout on and removes what + /// keeps it from running synchronously or at all, keeping a boolean enabled flag, a + /// text status message, and any other property. + /// + private static void Rewrite(JsonObject hook, ClaudeHooksTemplate template, string command, int timeoutSeconds) + { + hook[TypeKey] = CommandType; + hook[CommandKey] = command; + hook[TimeoutKey] = timeoutSeconds; + hook.Remove(AsyncKey); + + if (!IsAbsentOr(hook, StatusMessageKey, node => ReadText(node) is not null)) + { + hook.Remove(StatusMessageKey); + } + + if (template.HasHookSwitches && !IsAbsentOr(hook, EnabledKey, IsBoolean)) + { + hook.Remove(EnabledKey); + } + } + + private static bool IsSwitchedOff(JsonObject hook, ClaudeHooksTemplate template) + => template.HasHookSwitches + && hook[EnabledKey] is JsonValue value + && value.TryGetValue(out var enabled) + && !enabled; + + private static bool IsAbsentOr(JsonObject node, string key, Func isValid) + => !node.TryGetPropertyValue(key, out var value) || isValid(value); + + private static bool IsBoolean(JsonNode? node) + => node is JsonValue value && value.TryGetValue(out _); + + private static string? ReadText(JsonNode? node) + => node is JsonValue value && value.TryGetValue(out var text) ? text : null; + + private static string? ReadString(JsonObject node, string key) => ReadText(node[key]); + private static JsonObject ParseOrEmpty(string? json) { if (string.IsNullOrWhiteSpace(json)) @@ -211,15 +403,15 @@ private static JsonObject GetOrCreateHooksObject(JsonObject root) return created; } - private static JsonArray GetOrCreateEventArray(JsonObject hooksNode, string claudeEvent) + private static JsonArray GetOrCreateEventArray(JsonObject hooksNode, string hookEvent) { - if (hooksNode[claudeEvent] is JsonArray existing) + if (hooksNode[hookEvent] is JsonArray existing) { return existing; } var created = new JsonArray(); - hooksNode[claudeEvent] = created; + hooksNode[hookEvent] = created; return created; } @@ -227,76 +419,27 @@ private static JsonArray GetOrCreateEventArray(JsonObject hooksNode, string clau private static void AppendGroup(JsonArray array, JsonObject group) => ((IList)array).Add(group); - private static JsonObject BuildGroup(string command, int timeoutSeconds) => new() + private static JsonObject BuildGroup(string? matcher, JsonObject hook) { - [GroupHooksKey] = new JsonArray( - new JsonObject - { - [TypeKey] = CommandType, - [CommandKey] = command, - [TimeoutKey] = timeoutSeconds - }) - }; + var group = new JsonObject(); - private static int FindOwnedGroupIndex(JsonArray array) - { - for (var i = 0; i < array.Count; i++) + if (matcher is not null) { - if (array[i] is not JsonObject group - || group[GroupHooksKey] is not JsonArray hooks - || hooks.Count == 0) - { - continue; - } - - var allOwned = true; - - foreach (var hook in hooks) - { - var command = (hook as JsonObject)?[CommandKey]?.GetValue(); - - if (command is null || !command.Contains(ClaudeHooksTemplate.CommandMarker, StringComparison.Ordinal)) - { - allOwned = false; - break; - } - } - - if (allOwned) - { - return i; - } + group[MatcherKey] = matcher; } - return -1; - } + group[GroupHooksKey] = new JsonArray(hook); - private static int FindGroupIndexByCommand(JsonArray array, string command) - { - for (var i = 0; i < array.Count; i++) - { - if (array[i] is JsonObject group - && group[GroupHooksKey] is JsonArray hooks - && hooks.Count == 1 - && (hooks[0] as JsonObject)?[CommandKey]?.GetValue() == command) - { - return i; - } - } - - return -1; + return group; } - private static (string? Command, int? Timeout) ReadFirstHook(JsonObject group) + private static string Serialize(JsonObject root) + => root.ToJsonString(new JsonSerializerOptions { WriteIndented = true }); + + private sealed class OwnedHandler(JsonObject group, JsonObject hook) { - if (group[GroupHooksKey] is not JsonArray hooks || hooks.Count == 0 || hooks[0] is not JsonObject hook) - { - return (null, null); - } + public JsonObject Group { get; } = group; - return (hook[CommandKey]?.GetValue(), hook[TimeoutKey]?.GetValue()); + public JsonObject Hook { get; } = hook; } - - private static string Serialize(JsonObject root) - => root.ToJsonString(new JsonSerializerOptions { WriteIndented = true }); } diff --git a/src/Nitro/CommandLine/src/CommandLine/Services/Hook/ClaudeHooksInstallReport.cs b/src/Nitro/CommandLine/src/CommandLine/Services/Hook/ClaudeHooksInstallReport.cs index f9c530c1f0e..6d72cf9c7bd 100644 --- a/src/Nitro/CommandLine/src/CommandLine/Services/Hook/ClaudeHooksInstallReport.cs +++ b/src/Nitro/CommandLine/src/CommandLine/Services/Hook/ClaudeHooksInstallReport.cs @@ -1,3 +1,11 @@ namespace ChilliCream.Nitro.CommandLine.Services.Hook; -internal sealed record ClaudeHooksInstallReport(string SettingsPath, IReadOnlyList Events); +/// +/// What an install did to each managed entry in . +/// is the settings file whose disableAllHooks +/// flag keeps the harness from running any hook, or null when no such flag is set. +/// +internal sealed record ClaudeHooksInstallReport( + string SettingsPath, + IReadOnlyList Events, + string? AllHooksDisabledIn); diff --git a/src/Nitro/CommandLine/src/CommandLine/Services/Hook/ClaudeHooksInstallerService.cs b/src/Nitro/CommandLine/src/CommandLine/Services/Hook/ClaudeHooksInstallerService.cs index f0bc9bd8192..19fc5e06567 100644 --- a/src/Nitro/CommandLine/src/CommandLine/Services/Hook/ClaudeHooksInstallerService.cs +++ b/src/Nitro/CommandLine/src/CommandLine/Services/Hook/ClaudeHooksInstallerService.cs @@ -3,12 +3,17 @@ namespace ChilliCream.Nitro.CommandLine.Services.Hook; +/// +/// Manages 's entries in the settings files +/// resolves. +/// internal sealed class ClaudeHooksInstallerService( IFileSystem fileSystem, IClaudeSettingsPathResolver pathResolver, ILaunchDescriptorResolver launchDescriptorResolver, IClaudeHooksSidecarStore sidecarStore, - TimeProvider timeProvider) : IClaudeHooksInstallerService + TimeProvider timeProvider, + ClaudeHooksTemplate template) : IClaudeHooksInstallerService { public async Task InstallAsync(string scope, CancellationToken cancellationToken) { @@ -17,7 +22,9 @@ public async Task InstallAsync(string scope, Cancellat var (textAtRead, hashAtRead) = await ReadWithHashAsync(path, cancellationToken); - var result = ClaudeHooksEditor.Install(textAtRead, descriptor, timeProvider.GetUtcNow()); + var allHooksDisabledIn = await InspectCombinedSettingsAsync(path, textAtRead, cancellationToken); + + var result = ClaudeHooksEditor.Install(textAtRead, template, descriptor, timeProvider.GetUtcNow()); await WriteIfUnchangedSinceReadAsync(path, hashAtRead, result.SettingsJson, cancellationToken); @@ -25,7 +32,7 @@ await UpdateSidecarAsync( s => s.Files[path] = new Dictionary(result.Sidecar), cancellationToken); - return new ClaudeHooksInstallReport(path, result.Outcomes); + return new ClaudeHooksInstallReport(path, result.Outcomes, allHooksDisabledIn); } public async Task StatusAsync(string scope, CancellationToken cancellationToken) @@ -33,9 +40,12 @@ public async Task StatusAsync(string scope, Cancellatio var path = pathResolver.Resolve(scope); var descriptor = launchDescriptorResolver.Resolve(); - var text = fileSystem.FileExists(path) ? await fileSystem.ReadAllTextAsync(path, cancellationToken) : null; + var text = await ReadOrNullAsync(path, cancellationToken); + + var allHooksDisabledIn = await InspectCombinedSettingsAsync(path, text, cancellationToken); - return new ClaudeHooksStatusReport(path, ClaudeHooksEditor.Status(text, descriptor)); + return new ClaudeHooksStatusReport( + path, ClaudeHooksEditor.Status(text, template, descriptor), allHooksDisabledIn); } public async Task UninstallAsync(string scope, CancellationToken cancellationToken) @@ -46,7 +56,7 @@ public async Task UninstallAsync(string scope, Cance var priorEntries = (await sidecarStore.ReadAsync(cancellationToken)).EntriesFor(path); - var result = ClaudeHooksEditor.Uninstall(textAtRead, priorEntries); + var result = ClaudeHooksEditor.Uninstall(textAtRead, template, priorEntries); await WriteIfUnchangedSinceReadAsync(path, hashAtRead, result.SettingsJson, cancellationToken); @@ -55,9 +65,55 @@ public async Task UninstallAsync(string scope, Cance return new ClaudeHooksUninstallReport(path, result.Outcomes); } + /// + /// Throws when , or another settings file whose hooks run in the same + /// sessions, holds entries of the conflicting template. Returns the user settings file when + /// it switches every hook off, or null. + /// + private async Task InspectCombinedSettingsAsync( + string path, string? text, CancellationToken cancellationToken) + { + if (template.Conflicting is null && !template.HasHookSwitches) + { + return null; + } + + var userPath = pathResolver.Resolve(HookInstallScopes.User); + string? allHooksDisabledIn = null; + + foreach (var settingsPath in pathResolver.ResolveForCurrentDirectory().Append(path).Distinct()) + { + var settingsText = settingsPath == path ? text : await ReadOrNullAsync(settingsPath, cancellationToken); + + if (template.Conflicting is { } conflicting + && ClaudeHooksEditor.FindEventsWithCommandMarker(settingsText, conflicting.CommandMarker) + is { Count: > 0 } conflictingEvents) + { + throw ThrowHelper.ConflictingHookEntries( + settingsPath, + path, + conflicting.CommandMarker.TrimEnd(), + template.CommandMarker.TrimEnd(), + conflictingEvents); + } + + if (template.HasHookSwitches + && settingsPath == userPath + && ClaudeHooksEditor.DisablesAllHooks(settingsText)) + { + allHooksDisabledIn = settingsPath; + } + } + + return allHooksDisabledIn; + } + + private async Task ReadOrNullAsync(string path, CancellationToken cancellationToken) + => fileSystem.FileExists(path) ? await fileSystem.ReadAllTextAsync(path, cancellationToken) : null; + private async Task<(string? Text, string Hash)> ReadWithHashAsync(string path, CancellationToken cancellationToken) { - var text = fileSystem.FileExists(path) ? await fileSystem.ReadAllTextAsync(path, cancellationToken) : null; + var text = await ReadOrNullAsync(path, cancellationToken); return (text, Hash(text)); } diff --git a/src/Nitro/CommandLine/src/CommandLine/Services/Hook/ClaudeHooksStatusReport.cs b/src/Nitro/CommandLine/src/CommandLine/Services/Hook/ClaudeHooksStatusReport.cs index beb96476d1a..6921fdb844d 100644 --- a/src/Nitro/CommandLine/src/CommandLine/Services/Hook/ClaudeHooksStatusReport.cs +++ b/src/Nitro/CommandLine/src/CommandLine/Services/Hook/ClaudeHooksStatusReport.cs @@ -1,3 +1,11 @@ namespace ChilliCream.Nitro.CommandLine.Services.Hook; -internal sealed record ClaudeHooksStatusReport(string SettingsPath, IReadOnlyList Events); +/// +/// The state of each managed entry in . +/// is the settings file whose disableAllHooks +/// flag keeps the harness from running any hook, or null when no such flag is set. +/// +internal sealed record ClaudeHooksStatusReport( + string SettingsPath, + IReadOnlyList Events, + string? AllHooksDisabledIn); diff --git a/src/Nitro/CommandLine/src/CommandLine/Services/Hook/ClaudeHooksTemplate.cs b/src/Nitro/CommandLine/src/CommandLine/Services/Hook/ClaudeHooksTemplate.cs index 3e8b024404a..f8abcb4999b 100644 --- a/src/Nitro/CommandLine/src/CommandLine/Services/Hook/ClaudeHooksTemplate.cs +++ b/src/Nitro/CommandLine/src/CommandLine/Services/Hook/ClaudeHooksTemplate.cs @@ -1,43 +1,129 @@ +using ChilliCream.Nitro.CommandLine.Services.Workspace; + namespace ChilliCream.Nitro.CommandLine.Services.Hook; /// -/// The desired Claude Code settings.json hook entry for each -/// turn-boundary event this CLI adapts, built from a +/// The desired settings.json hook entry for each turn-boundary event this CLI +/// adapts for a harness speaking Claude Code's hook contract, built from a /// . /// -internal static class ClaudeHooksTemplate +internal sealed class ClaudeHooksTemplate { /// - /// Every entry this CLI ever writes contains this literal substring in - /// its command text. + /// The entries nitro agent hooks claude manages in Claude Code settings. + /// + public static ClaudeHooksTemplate Claude { get; } = + new( + AgentSessionHarness.ClaudeCode, + "claude", + null, + ["SessionStart", "UserPromptSubmit", "Stop", "Notification", "SessionEnd"], + hasHookSwitches: false, + runsWindowsHooksInPowerShell: false); + + /// + /// The entries nitro agent hooks venti manages in Venti settings, which must not + /// share a settings file with entries. /// - public const string CommandMarker = "agent hook claude "; + public static ClaudeHooksTemplate Venti { get; } = + new( + AgentSessionHarness.Venti, + "venti", + Claude, + [AgentNamingEvent, "SessionStart", "UserPromptSubmit", "Stop", "Notification", "SessionEnd"], + hasHookSwitches: true, + runsWindowsHooksInPowerShell: true); public const int TimeoutSeconds = 10; /// - /// Claude Code hook event names this installer manages, in the order + /// Venti's event that asks a handler for the name of a new agent. + /// + public const string AgentNamingEvent = "AgentNaming"; + + private ClaudeHooksTemplate( + string harness, + string commandName, + ClaudeHooksTemplate? conflicting, + IReadOnlyList events, + bool hasHookSwitches, + bool runsWindowsHooksInPowerShell) + { + Harness = harness; + CommandName = commandName; + Conflicting = conflicting; + Events = events; + HasHookSwitches = hasHookSwitches; + RunsWindowsHooksInPowerShell = runsWindowsHooksInPowerShell; + } + + /// + /// The agents.harness value of the sessions these entries register. + /// + public string Harness { get; } + + /// + /// The nitro agent hook subcommand these entries invoke. + /// + public string CommandName { get; } + + /// + /// Every entry this template writes contains this literal substring in its command text. + /// + public string CommandMarker => $"agent hook {CommandName} "; + + /// + /// The template whose entries must not share a settings file with this template's, + /// or null when none conflicts. + /// + public ClaudeHooksTemplate? Conflicting { get; } + + /// + /// Hook event names this template manages, in the order /// hooks install and hooks status report them. /// - public static readonly IReadOnlyList Events = - ["SessionStart", "UserPromptSubmit", "Stop", "Notification", "SessionEnd"]; + public IReadOnlyList Events { get; } + + /// + /// Whether the harness skips a handler whose enabled flag is false, and every + /// handler while its user settings file sets disableAllHooks to true. + /// + public bool HasHookSwitches { get; } - public static string EventCommand(string claudeEvent) => claudeEvent switch + /// + /// Whether the harness runs hook commands through PowerShell on Windows instead of a POSIX shell. + /// + public bool RunsWindowsHooksInPowerShell { get; } + + /// + /// The matcher of the group that runs 's entry, or null when the + /// entry runs on every occurrence of the event. + /// + public static string? Matcher(string hookEvent) => hookEvent == AgentNamingEvent ? "main" : null; + + public static string EventCommand(string hookEvent) => hookEvent switch { + AgentNamingEvent => "agent-naming", "SessionStart" => "session-start", "UserPromptSubmit" => "user-prompt-submit", "Stop" => "stop", "Notification" => "notification", "SessionEnd" => "session-end", _ => throw new ArgumentOutOfRangeException( - nameof(claudeEvent), claudeEvent, "Not a hook event this installer manages.") + nameof(hookEvent), hookEvent, "Not a hook event this installer manages.") }; /// /// The exact command text hooks install writes (and - /// hooks status compares against) for + /// hooks status compares against) for /// given . /// - public static string BuildCommand(LaunchDescriptor descriptor, string claudeEvent) - => descriptor.BuildCommandLine(["agent", "hook", "claude", EventCommand(claudeEvent)]); + public string BuildCommand(LaunchDescriptor descriptor, string hookEvent) + { + string[] argv = ["agent", "hook", CommandName, EventCommand(hookEvent)]; + + return RunsWindowsHooksInPowerShell && OperatingSystem.IsWindows() + ? descriptor.BuildPowerShellCommandLine(argv) + : descriptor.BuildCommandLine(argv); + } } diff --git a/src/Nitro/CommandLine/src/CommandLine/Services/Hook/ClaudeSettingsPathResolver.cs b/src/Nitro/CommandLine/src/CommandLine/Services/Hook/ClaudeSettingsPathResolver.cs index 83ff4231304..c5ccec67a06 100644 --- a/src/Nitro/CommandLine/src/CommandLine/Services/Hook/ClaudeSettingsPathResolver.cs +++ b/src/Nitro/CommandLine/src/CommandLine/Services/Hook/ClaudeSettingsPathResolver.cs @@ -1,5 +1,9 @@ namespace ChilliCream.Nitro.CommandLine.Services.Hook; +/// +/// Resolves ~/.claude/settings.json for user scope or +/// <checkout-root>/.claude/settings.json for project scope. +/// internal sealed class ClaudeSettingsPathResolver(IFileSystem fileSystem) : IClaudeSettingsPathResolver { public string Resolve(string scope) => scope switch @@ -8,6 +12,11 @@ internal sealed class ClaudeSettingsPathResolver(IFileSystem fileSystem) : IClau _ => Path.Combine(ResolveUserHome(), ".claude", "settings.json") }; + public IReadOnlyList ResolveForCurrentDirectory() + => Workspace.AgentWorkspace.FindLocation(fileSystem, fileSystem.GetCurrentDirectory()) is not null + ? [Resolve(HookInstallScopes.User), Resolve(HookInstallScopes.Project)] + : [Resolve(HookInstallScopes.User)]; + /// /// Returns the checkout directory containing the current directory, or throws /// when no agent workspace is found. diff --git a/src/Nitro/CommandLine/src/CommandLine/Services/Hook/HookInstallOutcome.cs b/src/Nitro/CommandLine/src/CommandLine/Services/Hook/HookInstallOutcome.cs index f45a8c7879f..623f8c588a5 100644 --- a/src/Nitro/CommandLine/src/CommandLine/Services/Hook/HookInstallOutcome.cs +++ b/src/Nitro/CommandLine/src/CommandLine/Services/Hook/HookInstallOutcome.cs @@ -1,3 +1,7 @@ namespace ChilliCream.Nitro.CommandLine.Services.Hook; -internal enum HookInstallOutcome { Installed, Updated, Unchanged } +/// +/// What an install did to one managed hook entry. is an entry left +/// current but switched off, because its own enabled flag is false. +/// +internal enum HookInstallOutcome { Installed, Updated, Unchanged, Disabled } diff --git a/src/Nitro/CommandLine/src/CommandLine/Services/Hook/HookStatusOutcome.cs b/src/Nitro/CommandLine/src/CommandLine/Services/Hook/HookStatusOutcome.cs index ca8c958eaa8..4773eab6993 100644 --- a/src/Nitro/CommandLine/src/CommandLine/Services/Hook/HookStatusOutcome.cs +++ b/src/Nitro/CommandLine/src/CommandLine/Services/Hook/HookStatusOutcome.cs @@ -1,3 +1,7 @@ namespace ChilliCream.Nitro.CommandLine.Services.Hook; -internal enum HookStatusOutcome { Missing, Installed, Outdated } +/// +/// The state of one managed hook entry. is a current entry the harness +/// skips because its own enabled flag is false. +/// +internal enum HookStatusOutcome { Missing, Installed, Outdated, Disabled } diff --git a/src/Nitro/CommandLine/src/CommandLine/Services/Hook/IClaudeHookHandler.cs b/src/Nitro/CommandLine/src/CommandLine/Services/Hook/IClaudeHookHandler.cs index fe0fff95553..cb3db27a72d 100644 --- a/src/Nitro/CommandLine/src/CommandLine/Services/Hook/IClaudeHookHandler.cs +++ b/src/Nitro/CommandLine/src/CommandLine/Services/Hook/IClaudeHookHandler.cs @@ -40,6 +40,14 @@ Task HandleStopAsync( Task HandleNotificationAsync( ClaudeHookPayload payload, bool skipSessionFileLookup, CancellationToken cancellationToken); + /// + /// Registers the session exactly like and returns its + /// actor name as the agent name when is + /// main; any other kind leaves the store untouched and returns a neutral outcome. + /// + Task HandleAgentNamingAsync( + ClaudeHookPayload payload, bool skipSessionFileLookup, CancellationToken cancellationToken); + /// /// Stamps the session's agent row as ended, keeping it in place. /// diff --git a/src/Nitro/CommandLine/src/CommandLine/Services/Hook/IClaudeHooksInstallerService.cs b/src/Nitro/CommandLine/src/CommandLine/Services/Hook/IClaudeHooksInstallerService.cs index 3b9f440f0d7..8aa2ef3f57b 100644 --- a/src/Nitro/CommandLine/src/CommandLine/Services/Hook/IClaudeHooksInstallerService.cs +++ b/src/Nitro/CommandLine/src/CommandLine/Services/Hook/IClaudeHooksInstallerService.cs @@ -1,9 +1,11 @@ namespace ChilliCream.Nitro.CommandLine.Services.Hook; /// -/// Installs, inspects, and removes Claude hooks and their installation records. -/// Settings writes are abandoned when a hash comparison immediately before writing -/// detects a change since the initial read. +/// Installs, inspects, and removes one harness's Claude-shaped hooks and their installation +/// records. Settings writes are abandoned when a hash comparison immediately before writing +/// detects a change since the initial read. Install and status fail without writing when the +/// settings file, or another settings file whose hooks run in the same sessions, holds entries +/// of a conflicting template. /// internal interface IClaudeHooksInstallerService { diff --git a/src/Nitro/CommandLine/src/CommandLine/Services/Hook/IClaudeSettingsPathResolver.cs b/src/Nitro/CommandLine/src/CommandLine/Services/Hook/IClaudeSettingsPathResolver.cs index da31610f3e8..425b31180eb 100644 --- a/src/Nitro/CommandLine/src/CommandLine/Services/Hook/IClaudeSettingsPathResolver.cs +++ b/src/Nitro/CommandLine/src/CommandLine/Services/Hook/IClaudeSettingsPathResolver.cs @@ -1,10 +1,16 @@ namespace ChilliCream.Nitro.CommandLine.Services.Hook; /// -/// Resolves the Claude settings path to ~/.claude/settings.json for user -/// scope or <checkout-root>/.claude/settings.json for project scope. +/// Resolves the settings file a hook install scope edits for a harness speaking Claude Code's +/// hook format. /// internal interface IClaudeSettingsPathResolver { string Resolve(string scope); + + /// + /// The settings file of each install scope whose hooks the harness runs for a session started + /// in the current directory, the user scope's file first. + /// + IReadOnlyList ResolveForCurrentDirectory(); } diff --git a/src/Nitro/CommandLine/src/CommandLine/Services/Hook/LaunchDescriptor.cs b/src/Nitro/CommandLine/src/CommandLine/Services/Hook/LaunchDescriptor.cs index ca08131400f..04c49c25e61 100644 --- a/src/Nitro/CommandLine/src/CommandLine/Services/Hook/LaunchDescriptor.cs +++ b/src/Nitro/CommandLine/src/CommandLine/Services/Hook/LaunchDescriptor.cs @@ -22,6 +22,56 @@ public string BuildCommandLine(IReadOnlyList argv) return string.Join(' ', tokens.Select(ShellQuote)); } + /// + /// Builds a PowerShell command from the executable, argument prefix, and + /// , invoking a quoted executable through the call operator. + /// + public string BuildPowerShellCommandLine(IReadOnlyList argv) + { + var executable = PowerShellQuote(Executable); + var tokens = new List(2 + ArgumentPrefix.Count + argv.Count); + + if (executable != Executable) + { + tokens.Add("&"); + } + + tokens.Add(executable); + tokens.AddRange(ArgumentPrefix.Select(PowerShellQuote)); + tokens.AddRange(argv.Select(PowerShellQuote)); + + return string.Join(' ', tokens); + } + + /// + /// Quotes a token for use as one PowerShell argument, including an empty token. + /// + internal static string PowerShellQuote(string token) + { + if (token.Length > 0 && token.All(IsSafeUnquotedInPowerShell)) + { + return token; + } + + var builder = new StringBuilder(token.Length + 2); + builder.Append('\''); + + foreach (var ch in token) + { + // PowerShell ends a single-quoted string at any of its single-quote characters. + if (ch is '\'' or '\u2018' or '\u2019' or '\u201A' or '\u201B') + { + builder.Append(ch); + } + + builder.Append(ch); + } + + builder.Append('\''); + + return builder.ToString(); + } + /// /// Quotes a token for use as one POSIX shell argument, including an empty token. /// @@ -55,4 +105,7 @@ internal static string ShellQuote(string token) private static bool IsSafeUnquoted(char ch) => char.IsAsciiLetterOrDigit(ch) || ch is '-' or '_' or '.' or '/' or ':' or '+' or ','; + + private static bool IsSafeUnquotedInPowerShell(char ch) + => char.IsAsciiLetterOrDigit(ch) || ch is '-' or '_' or '.' or '/' or '\\' or ':'; } diff --git a/src/Nitro/CommandLine/src/CommandLine/Services/Hook/VentiSettingsPathResolver.cs b/src/Nitro/CommandLine/src/CommandLine/Services/Hook/VentiSettingsPathResolver.cs new file mode 100644 index 00000000000..d3fb3a768f2 --- /dev/null +++ b/src/Nitro/CommandLine/src/CommandLine/Services/Hook/VentiSettingsPathResolver.cs @@ -0,0 +1,40 @@ +namespace ChilliCream.Nitro.CommandLine.Services.Hook; + +/// +/// Resolves ~/.venti/settings.json for user scope or .venti/settings.json under +/// the current directory for project scope, which Venti reads only when launched from that +/// directory. Project scope requires the current directory to be inside an agent workspace. +/// +internal sealed class VentiSettingsPathResolver(IFileSystem fileSystem) : IClaudeSettingsPathResolver +{ + public string Resolve(string scope) => scope switch + { + HookInstallScopes.Project => Path.Combine(ResolveProjectDirectory(), ".venti", "settings.json"), + _ => Path.Combine(ResolveUserHome(), ".venti", "settings.json") + }; + + public IReadOnlyList ResolveForCurrentDirectory() + => + [ + Resolve(HookInstallScopes.User), + Path.Combine(fileSystem.GetCurrentDirectory(), ".venti", "settings.json") + ]; + + private string ResolveProjectDirectory() + { + var currentDirectory = fileSystem.GetCurrentDirectory(); + + return Workspace.AgentWorkspace.FindLocation(fileSystem, currentDirectory) is not null + ? currentDirectory + : throw ThrowHelper.NoAgentWorkspaceFound(); + } + + private static string ResolveUserHome() + { + var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + + return string.IsNullOrEmpty(home) + ? throw ThrowHelper.UserHomeDirectoryNotResolved() + : home; + } +} diff --git a/src/Nitro/CommandLine/src/CommandLine/Services/ICommandServices.cs b/src/Nitro/CommandLine/src/CommandLine/Services/ICommandServices.cs index 53d088f841e..a8bca19d2e4 100644 --- a/src/Nitro/CommandLine/src/CommandLine/Services/ICommandServices.cs +++ b/src/Nitro/CommandLine/src/CommandLine/Services/ICommandServices.cs @@ -3,4 +3,6 @@ namespace ChilliCream.Nitro.CommandLine; internal interface ICommandServices { T GetRequiredService() where T : notnull; + + T GetRequiredKeyedService(object key) where T : notnull; } diff --git a/src/Nitro/CommandLine/src/CommandLine/Services/Notify/ActorWakeDispatcher.cs b/src/Nitro/CommandLine/src/CommandLine/Services/Notify/ActorWakeDispatcher.cs index 1db35d044df..df66451ab86 100644 --- a/src/Nitro/CommandLine/src/CommandLine/Services/Notify/ActorWakeDispatcher.cs +++ b/src/Nitro/CommandLine/src/CommandLine/Services/Notify/ActorWakeDispatcher.cs @@ -230,7 +230,7 @@ and not AgentSessionEndpointKind.CodexThread } if (row.EndpointKind is AgentSessionEndpointKind.ClaudePeer or AgentSessionEndpointKind.OpencodeServer - && row.SessionId is null) + && (row.SessionId is null || row.Harness is null)) { return await RecordSkippedAsync(batchId, target, ownerId, batchAttemptId, "unreachable"); } @@ -277,15 +277,17 @@ and not AgentSessionEndpointKind.CodexThread var attemptDeadline = ClampDeadline(now, batchDeadline); - var outcome = (row.EndpointKind, row.SessionId) switch + var outcome = (row.EndpointKind, row.Harness, row.SessionId) switch { - (AgentSessionEndpointKind.ClaudePeer, { } sessionId) => await executor.ExecuteClaudePeerAsync( - target, sessionId, pingAttemptId, held.Slot, attemptDeadline, dispatchToken), - (AgentSessionEndpointKind.CodexThread, _) => await executor.ExecuteCodexThreadAsync( + (AgentSessionEndpointKind.ClaudePeer, { } harness, { } sessionId) => + await executor.ExecuteClaudePeerAsync( + target, harness, sessionId, pingAttemptId, held.Slot, attemptDeadline, dispatchToken), + (AgentSessionEndpointKind.CodexThread, _, _) => await executor.ExecuteCodexThreadAsync( target, row.EndpointAddr, pingAttemptId, held.Slot, attemptDeadline, dispatchToken), - (AgentSessionEndpointKind.OpencodeServer, { } sessionId) => await executor.ExecuteOpencodeServerAsync( - target, sessionId, row.EndpointAddr, row.EndpointSecret, pingAttemptId, held.Slot, - attemptDeadline, dispatchToken), + (AgentSessionEndpointKind.OpencodeServer, _, { } sessionId) => + await executor.ExecuteOpencodeServerAsync( + target, sessionId, row.EndpointAddr, row.EndpointSecret, pingAttemptId, held.Slot, + attemptDeadline, dispatchToken), _ => throw new UnreachableException( $"Endpoint kind '{row.EndpointKind}' passed the earlier supported-kind guard.") }; diff --git a/src/Nitro/CommandLine/src/CommandLine/Services/Notify/ClaudePeerClient.cs b/src/Nitro/CommandLine/src/CommandLine/Services/Notify/ClaudePeerClient.cs index 928f7230aca..1d74049d12f 100644 --- a/src/Nitro/CommandLine/src/CommandLine/Services/Notify/ClaudePeerClient.cs +++ b/src/Nitro/CommandLine/src/CommandLine/Services/Notify/ClaudePeerClient.cs @@ -2,13 +2,22 @@ using System.IO.Pipes; using System.Net.Sockets; using System.Runtime.InteropServices; +using System.Runtime.Versioning; +using System.Security.AccessControl; +using System.Security.Principal; using System.Text.Json; +using ChilliCream.Nitro.CommandLine.Services.Workspace; namespace ChilliCream.Nitro.CommandLine.Services.Notify; /// -/// Sends messages using protocol 1 to a Claude Code session's advertised local endpoint. -/// Authentication uses a key matching the target process when one is present. +/// Sends messages using Claude Code's peer protocol 1 to the local endpoint a Claude Code or +/// Venti session advertises in its harness's registry directory. Claude Code authenticates +/// with a key matching the target process when one is present, and always on Windows. Venti +/// always requires one that only the user can access, and a Venti send it does not reject is +/// , because the protocol sends no acknowledgement. +/// A Venti send that does not finish writing the message within a short time budget is +/// . /// internal sealed class ClaudePeerClient : IClaudePeerClient { @@ -19,31 +28,66 @@ internal sealed class ClaudePeerClient : IClaudePeerClient private const int MaxRegistryBytes = 64 * 1024; private const int MaxKeyBytes = 4 * 1024; private const string WindowsPipePrefix = @"\\.\pipe\"; + private const int GenericAll = 0x10000000; + private const int GenericWrite = 0x40000000; + private const uint GenericRead = 0x80000000; - private readonly string _sessionDirectory; + // A Venti endpoint closes the connection right after reading a wrong token and gives no + // other answer, so a close within this window is a rejection; later ones go unobserved. + private static readonly TimeSpan s_rejectionWindow = TimeSpan.FromMilliseconds(250); + + // A Windows client waits for a missing pipe instead of being refused, and a peer that stops + // reading stalls the write, so a Venti send that has not finished by then is abandoned. + private static readonly TimeSpan s_ventiTransportBudget = TimeSpan.FromSeconds(2); + + private readonly string _claudeSessionDirectory; + private readonly string _ventiPeerDirectory; public ClaudePeerClient() - : this(Path.Combine( - Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), - ".claude", - "sessions")) + : this( + Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".claude", "sessions"), + VentiPeerRegistryReader.DefaultDirectory) { } - internal ClaudePeerClient(string sessionDirectory) + internal ClaudePeerClient(string claudeSessionDirectory, string ventiPeerDirectory) { - _sessionDirectory = sessionDirectory; + _claudeSessionDirectory = claudeSessionDirectory; + _ventiPeerDirectory = ventiPeerDirectory; } public async Task SendAsync( + string harness, string sessionId, string message, CancellationToken cancellationToken) { + string registryDirectory; + bool requiresKey; + bool authenticatesStrictly; + + switch (harness) + { + case AgentSessionHarness.ClaudeCode: + registryDirectory = _claudeSessionDirectory; + requiresKey = RuntimeInformation.IsOSPlatform(OSPlatform.Windows); + authenticatesStrictly = false; + break; + + case AgentSessionHarness.Venti: + registryDirectory = _ventiPeerDirectory; + requiresKey = true; + authenticatesStrictly = true; + break; + + default: + return ClaudePeerSendOutcome.Unsupported; + } + try { // Reads current registry files to find the session's advertised endpoint. - var registryJson = await FindRegistryAsync(sessionId, cancellationToken); + var registryJson = await FindRegistryAsync(registryDirectory, sessionId, cancellationToken); if (registryJson is null) { @@ -67,23 +111,37 @@ public async Task SendAsync( return ClaudePeerSendOutcome.Unsupported; } - var key = await ResolveKeyAsync(pid, procStart, cancellationToken); + var key = await ResolveKeyAsync( + registryDirectory, pid, procStart, authenticatesStrictly, cancellationToken); - if (!key.Success) + if (!key.Success || (requiresKey && key.Token is null)) { return ClaudePeerSendOutcome.InvalidAuth; } - if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows) && key.Token is null) + var payload = BuildPayload(sessionId, message, key.Token); + + if (!authenticatesStrictly) { - // Windows endpoints require an authentication key. - return ClaudePeerSendOutcome.InvalidAuth; + await SendPayloadAsync( + endpoint, payload, observesRejection: false, cancellationToken, cancellationToken); + return ClaudePeerSendOutcome.Ok; } - var payload = BuildPayload(sessionId, message, key.Token); - await SendPayloadAsync(endpoint, payload, cancellationToken); + using var budget = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + budget.CancelAfter(s_ventiTransportBudget); - return ClaudePeerSendOutcome.Ok; + try + { + return await SendPayloadAsync( + endpoint, payload, observesRejection: true, budget.Token, cancellationToken) + ? ClaudePeerSendOutcome.Unconfirmed + : ClaudePeerSendOutcome.InvalidAuth; + } + catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) + { + return ClaudePeerSendOutcome.Timeout; + } } catch (OperationCanceledException) { @@ -141,9 +199,12 @@ public async Task SendAsync( /// Returns the registry JSON matching , or null if none exists. /// Throws a JSON parsing error when no match is found and a registry file contains malformed JSON. /// - private async Task FindRegistryAsync(string sessionId, CancellationToken cancellationToken) + private static async Task FindRegistryAsync( + string registryDirectory, + string sessionId, + CancellationToken cancellationToken) { - if (!Directory.Exists(_sessionDirectory)) + if (!Directory.Exists(registryDirectory)) { return null; } @@ -151,7 +212,7 @@ public async Task SendAsync( JsonException? malformed = null; foreach (var path in Directory.EnumerateFiles( - _sessionDirectory, "*.json", SearchOption.TopDirectoryOnly)) + registryDirectory, "*.json", SearchOption.TopDirectoryOnly)) { var json = await ReadBoundedTextAsync(path, MaxRegistryBytes, cancellationToken); @@ -223,18 +284,20 @@ private static bool TryReadRegistry( return procStart.Length > 0; } - private async Task ResolveKeyAsync( + private static async Task ResolveKeyAsync( + string registryDirectory, int pid, string procStart, + bool requiresOwnerOnly, CancellationToken cancellationToken) { - if (!Directory.Exists(_sessionDirectory)) + if (!Directory.Exists(registryDirectory)) { return KeyResolution.NoKey; } var keyPaths = Directory - .EnumerateFiles(_sessionDirectory, $"{pid}.*.key", SearchOption.TopDirectoryOnly) + .EnumerateFiles(registryDirectory, $"{pid}.*.key", SearchOption.TopDirectoryOnly) .Order(StringComparer.Ordinal) .ToArray(); @@ -247,7 +310,7 @@ private async Task ResolveKeyAsync( foreach (var keyPath in keyPaths) { - if (!IsSecureKeyFile(keyPath)) + if (!IsSecureKeyFile(keyPath, requiresOwnerOnly)) { return KeyResolution.Invalid; } @@ -287,7 +350,12 @@ private async Task ResolveKeyAsync( : new KeyResolution(true, matchedToken); } - private static bool IsSecureKeyFile(string path) + /// + /// Whether the key file is a regular file only the user can read or write. With + /// , it must also be owned by the current user, and on + /// Windows and macOS its access control list may grant no other account access. + /// + private static bool IsSecureKeyFile(string path, bool requiresOwnerOnly) { var info = new FileInfo(path); @@ -296,9 +364,9 @@ private static bool IsSecureKeyFile(string path) return false; } - if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + if (OperatingSystem.IsWindows()) { - return true; + return !requiresOwnerOnly || IsOwnerOnlyOnWindows(info); } var mode = File.GetUnixFileMode(path); @@ -310,7 +378,68 @@ private static bool IsSecureKeyFile(string path) | UnixFileMode.OtherWrite | UnixFileMode.OtherExecute; - return (mode & disallowed) == 0; + if ((mode & disallowed) != 0) + { + return false; + } + + if (!requiresOwnerOnly) + { + return true; + } + + // A Linux access control list's mask shows in the group bits checked above, while a + // macOS one can grant access the mode bits do not show. + return UnixFileOwner.IsCurrentUser(path) + && (!OperatingSystem.IsMacOS() || MacOSFileAcl.GrantsOnlyCurrentUser(path)); + } + + /// + /// Whether the file is owned by the current user, the local system, or the administrators, + /// and grants no other account access to its contents or its access control list. + /// + [SupportedOSPlatform("windows")] + private static bool IsOwnerOnlyOnWindows(FileInfo file) + { + const FileSystemRights exposingRights = + FileSystemRights.ReadData + | FileSystemRights.WriteData + | FileSystemRights.AppendData + | FileSystemRights.ChangePermissions + | FileSystemRights.TakeOwnership + | (FileSystemRights)GenericAll + | (FileSystemRights)GenericWrite + | unchecked((FileSystemRights)GenericRead); + + using var identity = WindowsIdentity.GetCurrent(); + SecurityIdentifier?[] trusted = + [ + identity.User, + new SecurityIdentifier(WellKnownSidType.LocalSystemSid, null), + new SecurityIdentifier(WellKnownSidType.BuiltinAdministratorsSid, null), + // OWNER RIGHTS grants only what the owner, already checked, holds. + new SecurityIdentifier("S-1-3-4") + ]; + + var security = file.GetAccessControl(); + + if (security.GetOwner(typeof(SecurityIdentifier)) is not SecurityIdentifier owner + || !trusted.Contains(owner)) + { + return false; + } + + foreach (FileSystemAccessRule rule in security.GetAccessRules(true, true, typeof(SecurityIdentifier))) + { + if (rule.AccessControlType == AccessControlType.Allow + && (rule.FileSystemRights & exposingRights) != 0 + && !trusted.Contains(rule.IdentityReference as SecurityIdentifier)) + { + return false; + } + } + + return true; } private static string? ReadScalar(JsonElement element) => element.ValueKind switch @@ -380,10 +509,17 @@ private static void AppendNewline(ArrayBufferWriter buffer) buffer.Advance(1); } - private static async Task SendPayloadAsync( + /// + /// Writes to the endpoint within . With + /// , returns false when the endpoint closes the + /// connection within the rejection window; otherwise returns true, which confirms no delivery. + /// + private static async Task SendPayloadAsync( string endpoint, ReadOnlyMemory payload, - CancellationToken cancellationToken) + bool observesRejection, + CancellationToken transportToken, + CancellationToken callerToken) { if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) { @@ -395,11 +531,15 @@ private static async Task SendPayloadAsync( var pipeName = endpoint[WindowsPipePrefix.Length..]; await using var pipe = new NamedPipeClientStream( - ".", pipeName, PipeDirection.Out, PipeOptions.Asynchronous); - await pipe.ConnectAsync(cancellationToken); - await pipe.WriteAsync(payload, cancellationToken); - await pipe.FlushAsync(cancellationToken); - return; + ".", + pipeName, + observesRejection ? PipeDirection.InOut : PipeDirection.Out, + PipeOptions.Asynchronous); + await pipe.ConnectAsync(transportToken); + await pipe.WriteAsync(payload, transportToken); + await pipe.FlushAsync(transportToken); + + return !observesRejection || await StaysOpenAsync(pipe, transportToken, callerToken); } if (!Path.IsPathFullyQualified(endpoint)) @@ -408,11 +548,51 @@ private static async Task SendPayloadAsync( } using var socket = new Socket(AddressFamily.Unix, SocketType.Stream, ProtocolType.Unspecified); - await socket.ConnectAsync(new UnixDomainSocketEndPoint(endpoint), cancellationToken); + await socket.ConnectAsync(new UnixDomainSocketEndPoint(endpoint), transportToken); await using var stream = new NetworkStream(socket, ownsSocket: false); - await stream.WriteAsync(payload, cancellationToken); - await stream.FlushAsync(cancellationToken); + await stream.WriteAsync(payload, transportToken); + await stream.FlushAsync(transportToken); + + if (observesRejection) + { + return await StaysOpenAsync(stream, transportToken, callerToken); + } + socket.Shutdown(SocketShutdown.Send); + return true; + } + + /// + /// Returns true when the peer neither closes the connection nor writes to it within the + /// rejection window or before is cancelled, and throws when + /// is cancelled. + /// + internal static async Task StaysOpenAsync( + Stream stream, + CancellationToken transportToken, + CancellationToken callerToken) + { + using var window = CancellationTokenSource.CreateLinkedTokenSource(transportToken); + window.CancelAfter(s_rejectionWindow); + + try + { + // A Venti endpoint never writes, so any completed read means it closed the connection. + _ = await stream.ReadAtLeastAsync(new byte[1], 1, throwOnEndOfStream: false, window.Token); + return false; + } + catch (OperationCanceledException) when (!callerToken.IsCancellationRequested) + { + return true; + } + catch (IOException) + { + return false; + } + catch (SocketException) + { + return false; + } } private static bool IsGone(SocketError error) diff --git a/src/Nitro/CommandLine/src/CommandLine/Services/Notify/ClaudePeerSendOutcome.cs b/src/Nitro/CommandLine/src/CommandLine/Services/Notify/ClaudePeerSendOutcome.cs index 864186c5038..b6dc7a89c0d 100644 --- a/src/Nitro/CommandLine/src/CommandLine/Services/Notify/ClaudePeerSendOutcome.cs +++ b/src/Nitro/CommandLine/src/CommandLine/Services/Notify/ClaudePeerSendOutcome.cs @@ -23,6 +23,12 @@ internal sealed record ClaudePeerSendOutcome(ClaudePeerSendReason Reason, bool R public static ClaudePeerSendOutcome AccessDenied { get; } = new(ClaudePeerSendReason.AccessDenied, Retryable: false, "peer socket connect denied (access-denied)"); + public static ClaudePeerSendOutcome Unconfirmed { get; } = + new(ClaudePeerSendReason.Unconfirmed, Retryable: false, "peer sent no delivery acknowledgement"); + + public static ClaudePeerSendOutcome Timeout { get; } = + new(ClaudePeerSendReason.Timeout, Retryable: true, "peer transport exceeded its time budget"); + public static ClaudePeerSendOutcome TransportError(string detail) => new(ClaudePeerSendReason.TransportError, Retryable: true, detail); } diff --git a/src/Nitro/CommandLine/src/CommandLine/Services/Notify/ClaudePeerSendReason.cs b/src/Nitro/CommandLine/src/CommandLine/Services/Notify/ClaudePeerSendReason.cs index 6dd7f6d5b7e..274f0986c60 100644 --- a/src/Nitro/CommandLine/src/CommandLine/Services/Notify/ClaudePeerSendReason.cs +++ b/src/Nitro/CommandLine/src/CommandLine/Services/Notify/ClaudePeerSendReason.cs @@ -26,5 +26,18 @@ internal enum ClaudePeerSendReason /// Any other transport, protocol, or local I/O failure not covered by a /// more specific reason above. /// - TransportError + TransportError, + + /// + /// The complete protocol payload was written to an endpoint that requires + /// authentication and did not reject it, but the protocol sends no + /// acknowledgement, so delivery is not confirmed. + /// + Unconfirmed, + + /// + /// The endpoint transport did not connect, take the payload, and pass its rejection window + /// within the time budget, so delivery is unknown. + /// + Timeout } diff --git a/src/Nitro/CommandLine/src/CommandLine/Services/Notify/IClaudePeerClient.cs b/src/Nitro/CommandLine/src/CommandLine/Services/Notify/IClaudePeerClient.cs index 434a2debd0b..032e10d273b 100644 --- a/src/Nitro/CommandLine/src/CommandLine/Services/Notify/IClaudePeerClient.cs +++ b/src/Nitro/CommandLine/src/CommandLine/Services/Notify/IClaudePeerClient.cs @@ -1,12 +1,18 @@ namespace ChilliCream.Nitro.CommandLine.Services.Notify; /// -/// Sends one machine-generated digest to a local Claude Code interactive +/// Sends one machine-generated digest to a local Claude Code or Venti interactive /// session through its advertised peer-protocol endpoint. /// internal interface IClaudePeerClient { + /// + /// Sends to the session of + /// , returning + /// for a harness without peer endpoints. + /// Task SendAsync( + string harness, string sessionId, string message, CancellationToken cancellationToken); diff --git a/src/Nitro/CommandLine/src/CommandLine/Services/Notify/IPingSessionExecutor.cs b/src/Nitro/CommandLine/src/CommandLine/Services/Notify/IPingSessionExecutor.cs index dab59c6dcd5..e8d1a79d309 100644 --- a/src/Nitro/CommandLine/src/CommandLine/Services/Notify/IPingSessionExecutor.cs +++ b/src/Nitro/CommandLine/src/CommandLine/Services/Notify/IPingSessionExecutor.cs @@ -20,11 +20,13 @@ Task ExecuteCodexThreadAsync( CancellationToken cancellationToken); /// - /// Returns a Claude peer ping outcome under the deadline, cancellation, and - /// best-effort cleanup contract of . + /// Returns a peer-protocol ping outcome for the session + /// under the deadline, cancellation, and best-effort + /// cleanup contract of . /// Task ExecuteClaudePeerAsync( string actorName, + string harness, string sessionId, string attemptId, int slot, diff --git a/src/Nitro/CommandLine/src/CommandLine/Services/Notify/MacOSFileAcl.cs b/src/Nitro/CommandLine/src/CommandLine/Services/Notify/MacOSFileAcl.cs new file mode 100644 index 00000000000..a6cd540c2e9 --- /dev/null +++ b/src/Nitro/CommandLine/src/CommandLine/Services/Notify/MacOSFileAcl.cs @@ -0,0 +1,139 @@ +using System.Runtime.InteropServices; +using System.Runtime.Versioning; + +namespace ChilliCream.Nitro.CommandLine.Services.Notify; + +/// +/// Reads the extended access control list of a file on macOS. +/// +[SupportedOSPlatform("macos")] +internal static class MacOSFileAcl +{ + private const string LibSystem = "/usr/lib/libSystem.dylib"; + private const int AclTypeExtended = 0x00000100; + private const int AclFirstEntry = 0; + private const int AclNextEntry = -1; + private const int AclExtendedAllow = 1; + private const int NoEntryError = 2; + private const int GuidLength = 16; + + private static readonly int[] s_exposingPermissions = + [ + 1 << 1, // ACL_READ_DATA + 1 << 2, // ACL_WRITE_DATA + 1 << 5, // ACL_APPEND_DATA + 1 << 12, // ACL_WRITE_SECURITY + 1 << 13 // ACL_CHANGE_OWNER + ]; + + /// + /// Whether no entry of the file's access control list grants an account other than the + /// current user access to its contents, its access control list, or its ownership. False + /// when the list cannot be read. + /// + public static bool GrantsOnlyCurrentUser(string path) + { + var acl = NativeMethods.acl_get_file(path, AclTypeExtended); + + if (acl == IntPtr.Zero) + { + // ENOENT reports a file without an access control list. + return Marshal.GetLastPInvokeError() == NoEntryError; + } + + try + { + var currentUser = new byte[GuidLength]; + + if (NativeMethods.mbr_uid_to_uuid(NativeMethods.geteuid(), currentUser) != 0) + { + return false; + } + + var entryId = AclFirstEntry; + + while (NativeMethods.acl_get_entry(acl, entryId, out var entry) == 0) + { + entryId = AclNextEntry; + + if (NativeMethods.acl_get_tag_type(entry, out var tag) != 0) + { + return false; + } + + if (tag != AclExtendedAllow) + { + continue; + } + + if (NativeMethods.acl_get_permset(entry, out var permissions) != 0) + { + return false; + } + + if (s_exposingPermissions.Any(permission => NativeMethods.acl_get_perm_np(permissions, permission) != 0) + && !IsQualifiedBy(entry, currentUser)) + { + return false; + } + } + + return true; + } + finally + { + _ = NativeMethods.acl_free(acl); + } + } + + private static bool IsQualifiedBy(IntPtr entry, byte[] user) + { + var qualifier = NativeMethods.acl_get_qualifier(entry); + + if (qualifier == IntPtr.Zero) + { + return false; + } + + try + { + var guid = new byte[GuidLength]; + Marshal.Copy(qualifier, guid, 0, GuidLength); + return guid.AsSpan().SequenceEqual(user); + } + finally + { + _ = NativeMethods.acl_free(qualifier); + } + } + + private static class NativeMethods + { + [DllImport(LibSystem, SetLastError = true)] + public static extern IntPtr acl_get_file([MarshalAs(UnmanagedType.LPUTF8Str)] string path, int type); + + [DllImport(LibSystem)] + public static extern int acl_get_entry(IntPtr acl, int entryId, out IntPtr entry); + + [DllImport(LibSystem)] + public static extern int acl_get_tag_type(IntPtr entry, out int tag); + + [DllImport(LibSystem)] + public static extern int acl_get_permset(IntPtr entry, out IntPtr permissions); + + [DllImport(LibSystem)] + public static extern int acl_get_perm_np(IntPtr permissions, int permission); + + [DllImport(LibSystem)] + public static extern IntPtr acl_get_qualifier(IntPtr entry); + + [DllImport(LibSystem)] + public static extern int acl_free(IntPtr value); + + [DllImport(LibSystem)] + public static extern uint geteuid(); + + [DllImport(LibSystem)] + public static extern int mbr_uid_to_uuid(uint uid, [Out] byte[] uuid); + } +} diff --git a/src/Nitro/CommandLine/src/CommandLine/Services/Notify/MailNudge.cs b/src/Nitro/CommandLine/src/CommandLine/Services/Notify/MailNudge.cs index daf95ab0437..6f22c5a7dbf 100644 --- a/src/Nitro/CommandLine/src/CommandLine/Services/Notify/MailNudge.cs +++ b/src/Nitro/CommandLine/src/CommandLine/Services/Notify/MailNudge.cs @@ -91,17 +91,17 @@ private async Task NudgeCoreAsync( /// /// Reports whether can deliver to the given agent's endpoint: - /// a Claude peer with a session, or a Codex thread. + /// a peer-protocol endpoint with a harness session, or a Codex thread. /// private static bool CanTransport(AgentRow row) => row.EndpointKind switch { - AgentSessionEndpointKind.ClaudePeer => row.SessionId is not null, + AgentSessionEndpointKind.ClaudePeer => row is { Harness: not null, SessionId: not null }, AgentSessionEndpointKind.CodexThread => true, _ => false }; /// - /// Sends through a Claude peer or Codex thread endpoint, skipping other endpoint kinds. + /// Sends through a peer-protocol or Codex thread endpoint, skipping other endpoint kinds. /// Cancellation propagates; other transport failures are ignored. /// private async Task SendAsync( @@ -114,12 +114,12 @@ private async Task SendAsync( switch (row.EndpointKind) { case AgentSessionEndpointKind.ClaudePeer: - if (row.SessionId is null) + if (row is not { Harness: { } harness, SessionId: { } sessionId }) { break; } - await claudePeerClient.SendAsync(row.SessionId, text, cancellationToken); + await claudePeerClient.SendAsync(harness, sessionId, text, cancellationToken); break; case AgentSessionEndpointKind.CodexThread: diff --git a/src/Nitro/CommandLine/src/CommandLine/Services/Notify/PingAttemptReason.cs b/src/Nitro/CommandLine/src/CommandLine/Services/Notify/PingAttemptReason.cs index b4da153ae32..01f3285a6dc 100644 --- a/src/Nitro/CommandLine/src/CommandLine/Services/Notify/PingAttemptReason.cs +++ b/src/Nitro/CommandLine/src/CommandLine/Services/Notify/PingAttemptReason.cs @@ -29,5 +29,11 @@ internal enum PingAttemptReason /// a generic Claude peer I/O or protocol failure, or an unexpected /// exception this attempt caught. /// - TransportError + TransportError, + + /// + /// A Claude peer endpoint that requires authentication took the digest + /// without rejecting it, but sent no acknowledgement of delivery. + /// + Unconfirmed } diff --git a/src/Nitro/CommandLine/src/CommandLine/Services/Notify/PingSessionExecutor.cs b/src/Nitro/CommandLine/src/CommandLine/Services/Notify/PingSessionExecutor.cs index 586bffaec0a..7cb334cb07b 100644 --- a/src/Nitro/CommandLine/src/CommandLine/Services/Notify/PingSessionExecutor.cs +++ b/src/Nitro/CommandLine/src/CommandLine/Services/Notify/PingSessionExecutor.cs @@ -38,6 +38,7 @@ public Task ExecuteCodexThreadAsync( public Task ExecuteClaudePeerAsync( string actorName, + string harness, string sessionId, string attemptId, int slot, @@ -50,7 +51,7 @@ public Task ExecuteClaudePeerAsync( deadline, async (digest, token) => digest is null ? new TransportOutcome(PingAttemptReason.Ok, null) - : MapClaudePeerResult(await claudePeerClient.SendAsync(sessionId, digest, token)), + : MapClaudePeerResult(await claudePeerClient.SendAsync(harness, sessionId, digest, token)), cancellationToken); public Task ExecuteOpencodeServerAsync( @@ -276,6 +277,8 @@ private async Task ReleaseLeaseAsync(int slot, string attemptId) ClaudePeerSendReason.EndpointGone => PingAttemptReason.EndpointGone, ClaudePeerSendReason.InvalidAuth => PingAttemptReason.InvalidAuth, ClaudePeerSendReason.AccessDenied => PingAttemptReason.AccessDenied, + ClaudePeerSendReason.Unconfirmed => PingAttemptReason.Unconfirmed, + ClaudePeerSendReason.Timeout => PingAttemptReason.Timeout, _ => PingAttemptReason.TransportError }, outcome.Detail); diff --git a/src/Nitro/CommandLine/src/CommandLine/Services/Notify/UnixFileOwner.cs b/src/Nitro/CommandLine/src/CommandLine/Services/Notify/UnixFileOwner.cs new file mode 100644 index 00000000000..7333c3c43d3 --- /dev/null +++ b/src/Nitro/CommandLine/src/CommandLine/Services/Notify/UnixFileOwner.cs @@ -0,0 +1,40 @@ +using System.Runtime.InteropServices; +using System.Runtime.Versioning; + +namespace ChilliCream.Nitro.CommandLine.Services.Notify; + +/// +/// Reads the owner of a file on Unix. +/// +[UnsupportedOSPlatform("windows")] +internal static class UnixFileOwner +{ + private const string SystemNative = "libSystem.Native"; + + /// + /// Whether the effective user of this process owns the entry at , + /// without following a final symbolic link. False when the entry cannot be read. + /// + public static bool IsCurrentUser(string path) + => NativeMethods.LStat(path, out var status) == 0 && status.Uid == NativeMethods.GetEUid(); + + // The leading fields of the runtime's FileStatus, whose layout has only grown at its end; + // the size leaves room for every later field. + [StructLayout(LayoutKind.Sequential, Size = 256)] + private struct FileStatus + { + public int Flags; + public int Mode; + public uint Uid; + public uint Gid; + } + + private static class NativeMethods + { + [DllImport(SystemNative, EntryPoint = "SystemNative_LStat", SetLastError = true)] + public static extern int LStat([MarshalAs(UnmanagedType.LPUTF8Str)] string path, out FileStatus output); + + [DllImport(SystemNative, EntryPoint = "SystemNative_GetEUid")] + public static extern uint GetEUid(); + } +} diff --git a/src/Nitro/CommandLine/src/CommandLine/Services/Results/JsonSourceGenerationContext.cs b/src/Nitro/CommandLine/src/CommandLine/Services/Results/JsonSourceGenerationContext.cs index aa60067000d..e39d87c8dbd 100644 --- a/src/Nitro/CommandLine/src/CommandLine/Services/Results/JsonSourceGenerationContext.cs +++ b/src/Nitro/CommandLine/src/CommandLine/Services/Results/JsonSourceGenerationContext.cs @@ -8,6 +8,7 @@ using ChilliCream.Nitro.CommandLine.Commands.Clients.Components; using ChilliCream.Nitro.CommandLine.Commands.Environments.Components; using ChilliCream.Nitro.CommandLine.Commands.Fusion.Publish; +using ChilliCream.Nitro.CommandLine.Commands.Agent.Hooks.Claude; using ChilliCream.Nitro.CommandLine.Commands.Agent.Hooks.Opencode; using ChilliCream.Nitro.CommandLine.Services.Mail; using ChilliCream.Nitro.CommandLine.Commands.Agent.Tasks.Config; @@ -87,6 +88,9 @@ namespace ChilliCream.Nitro.CommandLine.Results; [JsonSerializable(typeof(ListResult))] [JsonSerializable(typeof(RegisterAgentCommand.AgentRegisterResult))] [JsonSerializable(typeof(ListResult))] +[JsonSerializable(typeof(InstallClaudeHooksCommand.HooksInstallResult))] +[JsonSerializable(typeof(StatusClaudeHooksCommand.HooksStatusResult))] +[JsonSerializable(typeof(UninstallClaudeHooksCommand.HooksUninstallResult))] [JsonSerializable(typeof(InstallOpencodeHooksCommand.OpencodeHooksInstallResult))] [JsonSerializable(typeof(StatusOpencodeHooksCommand.OpencodeHooksStatusResult))] [JsonSerializable(typeof(TakeoverAgentCommand.AgentTakeoverResult))] diff --git a/src/Nitro/CommandLine/src/CommandLine/Services/Workspace/AgentDatabase.cs b/src/Nitro/CommandLine/src/CommandLine/Services/Workspace/AgentDatabase.cs index 3d263eef17c..c5f280a39c4 100644 --- a/src/Nitro/CommandLine/src/CommandLine/Services/Workspace/AgentDatabase.cs +++ b/src/Nitro/CommandLine/src/CommandLine/Services/Workspace/AgentDatabase.cs @@ -14,7 +14,7 @@ internal sealed class AgentDatabase /// /// The current unified schema version. /// - public const int CurrentVersion = 18; + public const int CurrentVersion = 19; /// /// The schema version at which messages.sender and @@ -24,11 +24,17 @@ internal sealed class AgentDatabase /// private const int MailForeignKeysRemovedVersion = 16; + /// + /// The oldest schema version whose agent-domain rows survive an upgrade. A database + /// below this version has its agent-domain tables reset instead. + /// + private const int AgentRowsPreservedVersion = 18; + /// /// Schema versions upgrades in place /// instead of rejecting. /// - private static readonly int[] s_upgradableVersions = [2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17]; + private static readonly int[] s_upgradableVersions = [2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18]; /// /// True for a schema version upgrades in @@ -74,10 +80,14 @@ public async Task InitializeAsync( await RemoveMailAgentForeignKeysAsync(connection, cancellationToken); } - if (version < CurrentVersion) + if (version < AgentRowsPreservedVersion) { await ResetAgentDomainTablesAsync(connection, cancellationToken); } + else if (version < CurrentVersion) + { + await RebuildAgentsTableAsync(connection, cancellationToken); + } await using var transaction = await connection.BeginTransactionAsync(cancellationToken); @@ -147,6 +157,30 @@ await connection.ExecuteAsync( } } + /// + /// Rebuilds agents with the current constraints, preserving every agent row and + /// every row that references one. + /// + private static async Task RebuildAgentsTableAsync( + SqliteConnection connection, + CancellationToken cancellationToken) + { + await connection.ExecuteAsync("PRAGMA foreign_keys = OFF;"); + + try + { + await using var transaction = await connection.BeginTransactionAsync(cancellationToken); + + await connection.ExecuteAsync(AgentRegistrySchema.Rebuild, transaction: transaction); + + await transaction.CommitAsync(cancellationToken); + } + finally + { + await connection.ExecuteAsync("PRAGMA foreign_keys = ON;"); + } + } + /// /// Rebuilds messages and message_recipients so neither /// column carries a foreign key to agents, preserving every row. diff --git a/src/Nitro/CommandLine/src/CommandLine/Services/Workspace/AgentHarnessDisplay.cs b/src/Nitro/CommandLine/src/CommandLine/Services/Workspace/AgentHarnessDisplay.cs index 9b3e58e9b69..88716a40f4d 100644 --- a/src/Nitro/CommandLine/src/CommandLine/Services/Workspace/AgentHarnessDisplay.cs +++ b/src/Nitro/CommandLine/src/CommandLine/Services/Workspace/AgentHarnessDisplay.cs @@ -14,6 +14,7 @@ internal static class AgentHarnessDisplay "codex" => "Codex", "copilot" => "Copilot", "opencode" => "OpenCode", + "venti" => "Venti", _ => "-" }; } diff --git a/src/Nitro/CommandLine/src/CommandLine/Services/Workspace/AgentRegistrySchema.cs b/src/Nitro/CommandLine/src/CommandLine/Services/Workspace/AgentRegistrySchema.cs index db562dd03af..bc884eff449 100644 --- a/src/Nitro/CommandLine/src/CommandLine/Services/Workspace/AgentRegistrySchema.cs +++ b/src/Nitro/CommandLine/src/CommandLine/Services/Workspace/AgentRegistrySchema.cs @@ -2,15 +2,12 @@ namespace ChilliCream.Nitro.CommandLine.Services.Workspace; internal static class AgentRegistrySchema { - /// - /// Creates the unified agents table if it does not exist; existing tables are unchanged. - /// - public const string Create = + private const string TableDefinition = """ - CREATE TABLE IF NOT EXISTS agents ( + ( name TEXT PRIMARY KEY, role TEXT NOT NULL DEFAULT '', - harness TEXT NULL CHECK (harness IN ('claude-code', 'codex', 'copilot', 'opencode')), + harness TEXT NULL CHECK (harness IN ('claude-code', 'codex', 'copilot', 'opencode', 'venti')), harness_version TEXT NOT NULL DEFAULT '', session_id TEXT NULL, cwd TEXT NOT NULL DEFAULT '', @@ -30,8 +27,49 @@ last_ping_result TEXT NULL CHECK (last_ping_result IN ('ok', 'spawn-failed', 'en last_ping_detail TEXT NULL CHECK (last_ping_detail IS NULL OR length(last_ping_detail) <= 200), announcement_pending INTEGER NOT NULL DEFAULT 0, idle_push_armed INTEGER NOT NULL DEFAULT 0 - ); + ) + """; + + private const string ColumnNames = + """ + name, role, harness, harness_version, session_id, cwd, workspace_path, + registered_at, started_at, last_seen_at, ended_at, deleted_at, + endpoint_kind, endpoint_addr, endpoint_secret, block_budget_used, + last_ping_at, last_ping_attempt, last_ping_result, last_ping_detail, + announcement_pending, idle_push_armed + """; + private const string CreateSessionIndex = + """ CREATE UNIQUE INDEX IF NOT EXISTS idx_agents_session ON agents (harness, session_id) WHERE harness IS NOT NULL; """; + + /// + /// Creates the unified agents table if it does not exist; existing tables are unchanged. + /// + public const string Create = + $""" + CREATE TABLE IF NOT EXISTS agents {TableDefinition}; + + {CreateSessionIndex} + """; + + /// + /// Replaces an agents table that already has every current column with one carrying the + /// current constraints, copying every row unchanged. Requires foreign key enforcement to be + /// off, so tables referencing agents (name) keep their rows and reference the replacement. + /// + public const string Rebuild = + $""" + CREATE TABLE agents_rebuild {TableDefinition}; + + INSERT INTO agents_rebuild ({ColumnNames}) + SELECT {ColumnNames} FROM agents; + + DROP TABLE agents; + + ALTER TABLE agents_rebuild RENAME TO agents; + + {CreateSessionIndex} + """; } diff --git a/src/Nitro/CommandLine/src/CommandLine/Services/Workspace/AgentSessionHarness.cs b/src/Nitro/CommandLine/src/CommandLine/Services/Workspace/AgentSessionHarness.cs index 1bfded52758..60c4cbb1c2d 100644 --- a/src/Nitro/CommandLine/src/CommandLine/Services/Workspace/AgentSessionHarness.cs +++ b/src/Nitro/CommandLine/src/CommandLine/Services/Workspace/AgentSessionHarness.cs @@ -10,11 +10,13 @@ internal static class AgentSessionHarness public const string Codex = "codex"; public const string Copilot = "copilot"; public const string Opencode = "opencode"; + public const string Venti = "venti"; /// /// Returns true when is one of the coding agent harnesses - /// (, , or ). + /// (, , , or + /// ). /// public static bool IsAgentHarness(string harness) - => harness is ClaudeCode or Codex or Copilot or Opencode; + => harness is ClaudeCode or Codex or Copilot or Opencode or Venti; } diff --git a/src/Nitro/CommandLine/src/CommandLine/Services/Workspace/IClaudeSessionFileReader.cs b/src/Nitro/CommandLine/src/CommandLine/Services/Workspace/IClaudeSessionFileReader.cs index a6ff0e69f82..42ce73b3a92 100644 --- a/src/Nitro/CommandLine/src/CommandLine/Services/Workspace/IClaudeSessionFileReader.cs +++ b/src/Nitro/CommandLine/src/CommandLine/Services/Workspace/IClaudeSessionFileReader.cs @@ -1,12 +1,12 @@ namespace ChilliCream.Nitro.CommandLine.Services.Workspace; /// -/// Finds Claude session metadata by the session id supplied by a hook event. +/// Finds a Claude-shaped harness session's metadata by the session id supplied by a hook event. /// internal interface IClaudeSessionFileReader { /// - /// The session Claude Code recorded under , + /// The session the harness recorded under , /// or null when no session file carries it. /// ClaudeSessionFile? Find(string sessionId); diff --git a/src/Nitro/CommandLine/src/CommandLine/Services/Workspace/VentiPeerRegistryReader.cs b/src/Nitro/CommandLine/src/CommandLine/Services/Workspace/VentiPeerRegistryReader.cs new file mode 100644 index 00000000000..61fa07aa40d --- /dev/null +++ b/src/Nitro/CommandLine/src/CommandLine/Services/Workspace/VentiPeerRegistryReader.cs @@ -0,0 +1,68 @@ +using System.Text.Json; +using ChilliCream.Nitro.CommandLine.Services.Notify; + +namespace ChilliCream.Nitro.CommandLine.Services.Workspace; + +/// +/// Finds the peer endpoint a Venti session advertises in Venti's peer registry +/// (~/.venti/peers/<session id>.json). Only an interactive root session speaking +/// peer protocol 1 advertises one; its session id is the endpoint name, and the version is empty. +/// +internal sealed class VentiPeerRegistryReader : IClaudeSessionFileReader +{ + /// + /// Venti's peer registry directory for the current user. + /// + public static string DefaultDirectory { get; } = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".venti", "peers"); + + private readonly string _directory; + + public VentiPeerRegistryReader() : this(DefaultDirectory) + { + } + + internal VentiPeerRegistryReader(string directory) + { + _directory = directory; + } + + public ClaudeSessionFile? Find(string sessionId) + { + if (!EndpointAddress.IsValid(sessionId)) + { + return null; + } + + try + { + using var document = JsonDocument.Parse(File.ReadAllText(Path.Combine(_directory, sessionId + ".json"))); + var root = document.RootElement; + + if (root.ValueKind != JsonValueKind.Object + || !root.TryGetProperty("sessionId", out var id) + || id.ValueKind != JsonValueKind.String + || id.GetString() != sessionId + || !root.TryGetProperty("kind", out var kind) + || kind.ValueKind != JsonValueKind.String + || kind.GetString() != "interactive" + || !root.TryGetProperty("messagingSocketPath", out var endpoint) + || endpoint.ValueKind != JsonValueKind.String + || string.IsNullOrEmpty(endpoint.GetString()) + || !root.TryGetProperty("peerProtocol", out var protocol) + || protocol.ValueKind != JsonValueKind.Number + || !protocol.TryGetInt32(out var protocolVersion) + || protocolVersion != ClaudePeerClient.SupportedProtocol) + { + return null; + } + + return new ClaudeSessionFile(sessionId, Cwd: string.Empty, Name: sessionId, Version: string.Empty); + } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException or JsonException) + { + // A missing, unreadable, or half-written registry file advertises no endpoint. + return null; + } + } +} diff --git a/src/Nitro/CommandLine/src/CommandLine/ThrowHelper.cs b/src/Nitro/CommandLine/src/CommandLine/ThrowHelper.cs index 9b3ff3ca296..359ec5b056a 100644 --- a/src/Nitro/CommandLine/src/CommandLine/ThrowHelper.cs +++ b/src/Nitro/CommandLine/src/CommandLine/ThrowHelper.cs @@ -63,6 +63,25 @@ public static ExitException NoReplyRecipientsRemaining(IEnumerable<(string Name, return Exit($"No recipients left: {string.Join(", ", reasons)}."); } + public static ExitException NoAgentWorkspaceFound() + => Exit("No agent workspace found. Run `nitro agent init` first."); + + public static ExitException UserHomeDirectoryNotResolved() + => Exit("Could not resolve the current user's home directory."); + + public static ExitException ConflictingHookEntries( + string conflictingSettingsPath, + string settingsPath, + string conflictingCommand, + string command, + IReadOnlyList events) + => Exit( + $"'{conflictingSettingsPath}' already runs Nitro's `{conflictingCommand}` hooks under " + + $"{string.Join(", ", events)}. Next to the `{command}` hooks in '{settingsPath}' they would " + + $"handle every event twice. Remove the `{conflictingCommand}` entries from " + + $"'{conflictingSettingsPath}', keeping any other hooks, then run the command again. " + + "Nothing was written."); + public static ExitException NoAgentWorkspaceToBackUp() => Exit( "No agent workspace found to back up. Neither a '.nitro' directory nor a " diff --git a/src/Nitro/CommandLine/test/CommandLine.Tests/Commands/Agent/BackupAgentCommandTests.cs b/src/Nitro/CommandLine/test/CommandLine.Tests/Commands/Agent/BackupAgentCommandTests.cs index e0911e55c9e..3de91c57b90 100644 --- a/src/Nitro/CommandLine/test/CommandLine.Tests/Commands/Agent/BackupAgentCommandTests.cs +++ b/src/Nitro/CommandLine/test/CommandLine.Tests/Commands/Agent/BackupAgentCommandTests.cs @@ -75,7 +75,7 @@ public async Task Execute_Should_ArchiveBothRoots_When_ProjectAndGitDirectoriesE "formatVersion": 1, "cliVersion": "", "createdAt": "2026-01-01T00:00:00+00:00", - "databaseVersion": 18, + "databaseVersion": 19, "roots": [ "repo/.nitro", "git/nitro" @@ -105,7 +105,7 @@ public async Task Execute_Should_ArchiveOnlyGitDirectory_When_ProjectDirectoryIs "formatVersion": 1, "cliVersion": "", "createdAt": "2026-01-01T00:00:00+00:00", - "databaseVersion": 18, + "databaseVersion": 19, "roots": [ "git/nitro" ] @@ -140,7 +140,7 @@ public async Task Execute_Should_SkipSymbolicLinks_When_TheWorkspaceLinksToOutsi "formatVersion": 1, "cliVersion": "", "createdAt": "2026-01-01T00:00:00+00:00", - "databaseVersion": 18, + "databaseVersion": 19, "roots": [ "repo/.nitro", "git/nitro" @@ -189,7 +189,7 @@ public async Task Execute_Should_ArchiveOnlyProjectDirectory_When_NoGitRepositor "formatVersion": 1, "cliVersion": "", "createdAt": "2026-01-01T00:00:00+00:00", - "databaseVersion": 18, + "databaseVersion": 19, "roots": [ "repo/.nitro" ] @@ -255,7 +255,7 @@ public async Task Execute_Should_ReplaceArchive_When_ForceIsGiven() "formatVersion": 1, "cliVersion": "", "createdAt": "2026-01-01T00:00:00+00:00", - "databaseVersion": 18, + "databaseVersion": 19, "roots": [ "repo/.nitro" ] diff --git a/src/Nitro/CommandLine/test/CommandLine.Tests/Commands/Agent/HooksCommandTests.cs b/src/Nitro/CommandLine/test/CommandLine.Tests/Commands/Agent/HooksCommandTests.cs index 13610a1eb84..e0f5c090fdb 100644 --- a/src/Nitro/CommandLine/test/CommandLine.Tests/Commands/Agent/HooksCommandTests.cs +++ b/src/Nitro/CommandLine/test/CommandLine.Tests/Commands/Agent/HooksCommandTests.cs @@ -166,4 +166,55 @@ public async Task InstallStatusUninstall_ClaudeGroup_ProjectScope_RoundTripsThro Assert.DoesNotContain("outdated", statusAfterUninstall.StdOut, StringComparison.Ordinal); Assert.DoesNotContain("installed", statusAfterUninstall.StdOut, StringComparison.Ordinal); } + + [Fact] + public async Task Status_Should_WriteAJsonResult_When_ClaudeJsonOutputIsRequested() + { + // arrange + SetupGlobalConfigDirectory(Path.Combine(WorkingDirectory, "..", "app-data")); + await InitWorkspaceAsync(); + var settingsPath = Path.Combine(WorkingDirectory, ".claude", "settings.json"); + + // act + var result = await ExecuteCommandAsync( + "agent", "hooks", "claude", "status", "--scope", "project", "--output", "json"); + + // assert + Assert.Equal(1, result.ExitCode); + result.StdOut.Replace(settingsPath, "").MatchInlineSnapshot( + """ + { + "settingsPath": "", + "current": false, + "events": [ + { + "event": "SessionStart", + "outcome": "Missing", + "installedCommand": null + }, + { + "event": "UserPromptSubmit", + "outcome": "Missing", + "installedCommand": null + }, + { + "event": "Stop", + "outcome": "Missing", + "installedCommand": null + }, + { + "event": "Notification", + "outcome": "Missing", + "installedCommand": null + }, + { + "event": "SessionEnd", + "outcome": "Missing", + "installedCommand": null + } + ], + "allHooksDisabledIn": null + } + """); + } } diff --git a/src/Nitro/CommandLine/test/CommandLine.Tests/Commands/Agent/VentiHookCommandTests.cs b/src/Nitro/CommandLine/test/CommandLine.Tests/Commands/Agent/VentiHookCommandTests.cs new file mode 100644 index 00000000000..158b62d5ea2 --- /dev/null +++ b/src/Nitro/CommandLine/test/CommandLine.Tests/Commands/Agent/VentiHookCommandTests.cs @@ -0,0 +1,240 @@ +using System.Text.Json; +using ChilliCream.Nitro.CommandLine.Services.Mail; +using ChilliCream.Nitro.CommandLine.Services.Workspace; + +namespace ChilliCream.Nitro.CommandLine.Tests.Agents; + +/// +/// Covers the nitro agent hook venti commands end to end: Venti-shaped payloads on stdin, +/// the Claude-shaped response on stdout, and the Venti rows they write. +/// +public sealed class VentiHookCommandTests(NitroCommandFixture fixture) : AgentCommandTestBase(fixture) +{ + private const string SessionId = "venti-command-session"; + + [Fact] + public async Task SessionStart_Should_RegisterAVentiAgent_When_ThePayloadHasNullTranscriptPath() + { + // arrange + await InitWorkspaceAsync(); + SetupStandardInput( + $$""" + {"session_id":"{{SessionId}}","transcript_path":null,"cwd":{{JsonSerializer.Serialize(WorkingDirectory)}},"hook_event_name":"SessionStart","source":"startup","model":"fake-model"} + """); + + // act + var result = await ExecuteCommandAsync("agent", "hook", "venti", "session-start"); + + // assert + var actor = await QueryScalarAsync( + $"SELECT name FROM agents WHERE harness = 'venti' AND session_id = '{SessionId}'"); + Assert.Equal(0, result.ExitCode); + result.StdOut.Trim().Replace(actor!, "").MatchInlineSnapshot( + """{"hookSpecificOutput":{"hookEventName":"SessionStart","additionalContext":"Your Nitro actor name is \u0022\u0022. Pass this name to the \u0060--actor\u0060 option to act under this actor explicitly."}}"""); + } + + [Fact] + public async Task AgentNaming_Should_NameTheSessionAfterItsActor_When_TheAgentIsMain() + { + // arrange + await InitWorkspaceAsync(); + SetupStandardInput( + $$""" + {"session_id":"{{SessionId}}","transcript_path":null,"cwd":{{JsonSerializer.Serialize(WorkingDirectory)}},"permission_mode":"default","hook_event_name":"AgentNaming","agent_kind":"main"} + """); + + // act + var result = await ExecuteCommandAsync("agent", "hook", "venti", "agent-naming"); + + // assert + var actor = await QueryScalarAsync( + $"SELECT name FROM agents WHERE harness = 'venti' AND session_id = '{SessionId}'"); + Assert.Equal(0, result.ExitCode); + result.StdOut.Trim().Replace(actor!, "").MatchInlineSnapshot( + """{"hookSpecificOutput":{"hookEventName":"AgentNaming","agentName":""}}"""); + } + + [Theory] + [InlineData(",\"agent_kind\":\"subagent\",\"agent_type\":\"default\",\"parent_agent_name\":\"main\"")] + [InlineData("")] + [InlineData(",\"agent_kind\":\"Main\"")] + [InlineData(",\"agent_kind\":1")] + [InlineData("\"agent_kind\":\"main\"")] + public async Task AgentNaming_Should_WriteNeutralResponseWithoutWriting_When_ThePayloadNamesNoMainAgent( + string kindProperties) + { + // arrange + await InitWorkspaceAsync(); + SetupStandardInput( + $$""" + {"session_id":"{{SessionId}}","transcript_path":null,"cwd":{{JsonSerializer.Serialize(WorkingDirectory)}},"hook_event_name":"AgentNaming"{{kindProperties}}} + """); + + // act + var result = await ExecuteCommandAsync("agent", "hook", "venti", "agent-naming"); + + // assert + Assert.Equal(0, result.ExitCode); + Assert.Equal("0", await QueryScalarAsync("SELECT COUNT(*) FROM agents")); + result.StdOut.Trim().MatchInlineSnapshot("{}"); + } + + [Fact] + public async Task SessionStart_Should_AnnounceTheNamedActor_When_AgentNamingRegisteredTheSession() + { + // arrange + await InitWorkspaceAsync(); + var cwd = JsonSerializer.Serialize(WorkingDirectory); + SetupStandardInput( + $$""" + {"session_id":"{{SessionId}}","transcript_path":null,"cwd":{{cwd}},"hook_event_name":"AgentNaming","agent_kind":"main"} + """); + var naming = await ExecuteCommandAsync("agent", "hook", "venti", "agent-naming"); + var actor = JsonDocument.Parse(naming.StdOut).RootElement + .GetProperty("hookSpecificOutput").GetProperty("agentName").GetString()!; + SetupStandardInput( + $$""" + {"session_id":"{{SessionId}}","transcript_path":null,"cwd":{{cwd}},"hook_event_name":"SessionStart","source":"startup"} + """); + + // act + var result = await ExecuteCommandAsync("agent", "hook", "venti", "session-start"); + + // assert + Assert.Equal(actor, await QueryScalarAsync("SELECT group_concat(name) FROM agents")); + result.StdOut.Trim().Replace(actor, "").MatchInlineSnapshot( + """{"hookSpecificOutput":{"hookEventName":"SessionStart","additionalContext":"Your Nitro actor name is \u0022\u0022. Pass this name to the \u0060--actor\u0060 option to act under this actor explicitly."}}"""); + } + + [Fact] + public async Task Stop_Should_BlockTheTurn_When_UnreadMailIsUndelivered() + { + // arrange + await InitWorkspaceAsync(); + await BindAgentSessionAsync("maya", SessionId, AgentSessionHarness.Venti); + var message = await SeedMailAsync(); + SetupStandardInput( + $$""" + {"session_id":"{{SessionId}}","transcript_path":null,"cwd":{{JsonSerializer.Serialize(WorkingDirectory)}},"hook_event_name":"Stop","stop_hook_active":false} + """); + + // act + var result = await ExecuteCommandAsync("agent", "hook", "venti", "stop"); + + // assert + Assert.Equal(0, result.ExitCode); + result.StdOut.Trim().Replace(message.Id, "").MatchInlineSnapshot( + """ + {"decision":"block","reason":"Unread nitro mail is waiting; handle it before ending this turn, or ignore this once if it is not actionable right now.\nYou have 1 unread nitro message; 1 shown below as \u0060nitro agent mail read --thread --output json\u0060 prints them. Reply with \u0060nitro agent mail reply --message \u003Cid\u003E --actor maya --body \u0022...\u0022\u0060 or ack with \u0060nitro agent mail ack --message \u003Cid\u003E --actor maya\u0060; anything not shown is in \u0060nitro agent mail inbox --unread --actor maya\u0060.\n{\n \u0022items\u0022: [\n {\n \u0022id\u0022: \u0022\u0022,\n \u0022threadId\u0022: \u0022\u0022,\n \u0022inReplyTo\u0022: null,\n \u0022from\u0022: \u0022ada\u0022,\n \u0022to\u0022: [\n \u0022maya\u0022\n ],\n \u0022cc\u0022: [],\n \u0022subject\u0022: \u0022Status\u0022,\n \u0022body\u0022: \u0022All good.\u0022,\n \u0022createdAt\u0022: \u00222026-01-01T00:00:00\u002B00:00\u0022,\n \u0022read\u0022: false,\n \u0022archived\u0022: false,\n \u0022takeovers\u0022: []\n }\n ]\n}"} + """); + } + + [Fact] + public async Task SessionEnd_Should_EndOnlyTheVentiAgent_When_AClaudeSessionSharesTheSessionId() + { + // arrange + await InitWorkspaceAsync(); + await BindAgentSessionAsync("maya", SessionId, AgentSessionHarness.ClaudeCode); + await BindAgentSessionAsync("nova", SessionId, AgentSessionHarness.Venti); + SetupStandardInput( + $$""" + {"session_id":"{{SessionId}}","transcript_path":null,"cwd":{{JsonSerializer.Serialize(WorkingDirectory)}},"hook_event_name":"SessionEnd","reason":"prompt_input_exit"} + """); + + // act + var result = await ExecuteCommandAsync("agent", "hook", "venti", "session-end"); + + // assert + Assert.Equal(0, result.ExitCode); + Assert.Equal( + "maya", + await QueryScalarAsync( + "SELECT group_concat(name) FROM agents WHERE ended_at IS NULL AND session_id IS NOT NULL")); + Assert.Equal( + "nova", + await QueryScalarAsync("SELECT group_concat(name) FROM agents WHERE ended_at IS NOT NULL")); + } + + [Fact] + public async Task Notification_Should_WriteNeutralResponse_When_TheTypeIsAVentiPermissionPrompt() + { + // arrange + await InitWorkspaceAsync(); + SetupStandardInput( + $$""" + {"session_id":"{{SessionId}}","transcript_path":null,"cwd":{{JsonSerializer.Serialize(WorkingDirectory)}},"hook_event_name":"Notification","notification_type":"permission_prompt","message":"Approve Bash?"} + """); + + // act + var result = await ExecuteCommandAsync("agent", "hook", "venti", "notification"); + + // assert + Assert.Equal(0, result.ExitCode); + Assert.Equal("0", await QueryScalarAsync("SELECT COUNT(*) FROM agents WHERE harness = 'venti'")); + result.StdOut.Trim().MatchInlineSnapshot("{}"); + } + + [Fact] + public async Task UserPromptSubmit_Should_WriteNeutralResponseWithoutWriting_When_HooksAreSuppressed() + { + // arrange + await InitWorkspaceAsync(); + SetupRawEnvironmentVariable("NITRO_HOOK_SUPPRESS", "1"); + SetupStandardInput( + $$""" + {"session_id":"{{SessionId}}","transcript_path":null,"cwd":{{JsonSerializer.Serialize(WorkingDirectory)}},"hook_event_name":"UserPromptSubmit","prompt":"hi"} + """); + + // act + var result = await ExecuteCommandAsync("agent", "hook", "venti", "user-prompt-submit"); + + // assert + Assert.Equal(0, result.ExitCode); + Assert.Equal("0", await QueryScalarAsync("SELECT COUNT(*) FROM agents WHERE harness = 'venti'")); + result.StdOut.Trim().MatchInlineSnapshot("{}"); + } + + [Fact] + public async Task VentiHelp_Should_ListTheHookEvents_When_HelpIsRequested() + { + // arrange + // act + var result = await ExecuteCommandAsync("agent", "hook", "venti", "--help"); + + // assert + result.AssertHelpOutput( + """ + Description: + Adapt Venti turn-boundary hook events. + + Usage: + nitro agent hook venti [command] [options] + + Options: + -?, -h, --help Show help and usage information + + Commands: + agent-naming Adapt Venti's AgentNaming hook: name a new main session after its actor. + session-start Adapt Venti's SessionStart hook: upsert this session's presence row. + user-prompt-submit Adapt Venti's UserPromptSubmit hook: reset the block budget and inject the unread-mail digest. + stop Adapt Venti's Stop hook: block the turn while unread mail is undelivered. + notification Adapt Venti's Notification hook: touch last-seen on an idle-prompt notification. + session-end Adapt Venti's SessionEnd hook: mark this session's agent row as ended. + """); + } + + private async Task SeedMailAsync() + { + await SeedAgentAsync("ada"); + + var store = new MailStore( + new TestFileSystem(WorkingDirectory), + FakeTime, + new AgentDatabase(), + new AgentStore(new TestFileSystem(WorkingDirectory), FakeTime, new AgentDatabase())); + + return await store.SendMessageAsync( + new MailMessageCreation { Sender = "ada", Subject = "Status", Body = "All good.", To = ["maya"] }, + TestContext.Current.CancellationToken); + } +} diff --git a/src/Nitro/CommandLine/test/CommandLine.Tests/Commands/Agent/VentiHooksCommandTests.cs b/src/Nitro/CommandLine/test/CommandLine.Tests/Commands/Agent/VentiHooksCommandTests.cs new file mode 100644 index 00000000000..965e1f37aa5 --- /dev/null +++ b/src/Nitro/CommandLine/test/CommandLine.Tests/Commands/Agent/VentiHooksCommandTests.cs @@ -0,0 +1,729 @@ +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Text.RegularExpressions; + +namespace ChilliCream.Nitro.CommandLine.Tests.Agents; + +/// +/// Covers nitro agent hooks venti through the real command pipeline against a project-scoped +/// .venti/settings.json in a temporary workspace, with the user-scope +/// ~/.venti/settings.json redirected into the temporary tree. +/// +public sealed partial class VentiHooksCommandTests : AgentCommandTestBase +{ + private const string UserSettings = + """ + { + "model": "fake-model", + "hooks": { + "Stop": [ + { + "hooks": [ + { "type": "command", "command": "./scripts/notify.sh", "timeout": 5 } + ] + } + ], + "PreToolUse": [ + { + "matcher": "Bash", + "hooks": [ + { "type": "command", "command": "./scripts/check-command.sh" } + ] + } + ] + } + } + """; + + public VentiHooksCommandTests(NitroCommandFixture fixture) : base(fixture) + { + SetupVentiSettingsPathResolver(UserSettingsPath, SettingsPath); + } + + private string SettingsPath => Path.Combine(WorkingDirectory, ".venti", "settings.json"); + + private string UserSettingsPath + => Path.GetFullPath(Path.Combine(WorkingDirectory, "..", "home", ".venti", "settings.json")); + + [Fact] + public async Task Install_Should_AddNativeEntriesBesideUserHooks_When_ProjectScopeIsSelected() + { + // arrange + SetupGlobalConfigDirectory(Path.Combine(WorkingDirectory, "..", "app-data")); + await InitWorkspaceAsync(); + WriteSettings(UserSettings); + + // act + var result = await ExecuteCommandAsync("agent", "hooks", "venti", "install", "--scope", "project"); + + // assert + Assert.Equal(0, result.ExitCode); + var settings = await File.ReadAllTextAsync(SettingsPath, TestContext.Current.CancellationToken); + NormalizeCommands(settings).MatchInlineSnapshot( + """ + { + "model": "fake-model", + "hooks": { + "Stop": [ + { + "hooks": [ + { + "type": "command", + "command": "./scripts/notify.sh", + "timeout": 5 + } + ] + }, + { + "hooks": [ + { + "type": "command", + "command": " agent hook venti stop", + "timeout": 10 + } + ] + } + ], + "PreToolUse": [ + { + "matcher": "Bash", + "hooks": [ + { + "type": "command", + "command": "./scripts/check-command.sh" + } + ] + } + ], + "AgentNaming": [ + { + "matcher": "main", + "hooks": [ + { + "type": "command", + "command": " agent hook venti agent-naming", + "timeout": 10 + } + ] + } + ], + "SessionStart": [ + { + "hooks": [ + { + "type": "command", + "command": " agent hook venti session-start", + "timeout": 10 + } + ] + } + ], + "UserPromptSubmit": [ + { + "hooks": [ + { + "type": "command", + "command": " agent hook venti user-prompt-submit", + "timeout": 10 + } + ] + } + ], + "Notification": [ + { + "hooks": [ + { + "type": "command", + "command": " agent hook venti notification", + "timeout": 10 + } + ] + } + ], + "SessionEnd": [ + { + "hooks": [ + { + "type": "command", + "command": " agent hook venti session-end", + "timeout": 10 + } + ] + } + ] + } + } + """); + } + + [Fact] + public async Task Install_Should_ReportEveryEventUnchanged_When_RunTwice() + { + // arrange + SetupGlobalConfigDirectory(Path.Combine(WorkingDirectory, "..", "app-data")); + await InitWorkspaceAsync(); + WriteSettings(UserSettings); + await ExecuteCommandAsync("agent", "hooks", "venti", "install", "--scope", "project"); + var afterFirstInstall = await File.ReadAllTextAsync(SettingsPath, TestContext.Current.CancellationToken); + + // act + var result = await ExecuteCommandAsync("agent", "hooks", "venti", "install", "--scope", "project"); + + // assert + Assert.Equal( + afterFirstInstall, + await File.ReadAllTextAsync(SettingsPath, TestContext.Current.CancellationToken)); + result.StdOut.Replace(SettingsPath, "").MatchInlineSnapshot( + """ + ✓ Installed Venti hooks in ''. + AgentNaming unchanged + SessionStart unchanged + UserPromptSubmit unchanged + Stop unchanged + Notification unchanged + SessionEnd unchanged + """); + } + + [Fact] + public async Task Status_Should_ReportCurrent_When_NativeEntriesAreInstalled() + { + // arrange + SetupGlobalConfigDirectory(Path.Combine(WorkingDirectory, "..", "app-data")); + await InitWorkspaceAsync(); + await ExecuteCommandAsync("agent", "hooks", "venti", "install", "--scope", "project"); + + // act + var result = await ExecuteCommandAsync("agent", "hooks", "venti", "status", "--scope", "project"); + + // assert + Assert.Equal(0, result.ExitCode); + result.StdOut.Replace(SettingsPath, "").MatchInlineSnapshot( + """ + Venti hooks in '': + AgentNaming installed + SessionStart installed + UserPromptSubmit installed + Stop installed + Notification installed + SessionEnd installed + """); + } + + [Fact] + public async Task Status_Should_ReportAgentNamingMissingAndFail_When_TheEntriesPredateIt() + { + // arrange + SetupGlobalConfigDirectory(Path.Combine(WorkingDirectory, "..", "app-data")); + await InitWorkspaceAsync(); + await ExecuteCommandAsync("agent", "hooks", "venti", "install", "--scope", "project"); + var settings = JsonNode.Parse( + await File.ReadAllTextAsync(SettingsPath, TestContext.Current.CancellationToken))!; + settings["hooks"]!.AsObject().Remove("AgentNaming"); + WriteSettings(settings.ToJsonString()); + + // act + var status = await ExecuteCommandAsync("agent", "hooks", "venti", "status", "--scope", "project"); + var install = await ExecuteCommandAsync("agent", "hooks", "venti", "install", "--scope", "project"); + + // assert + Assert.Equal((1, 0), (status.ExitCode, install.ExitCode)); + new[] { status.StdOut, install.StdOut } + .Select(output => output.Replace(SettingsPath, "")) + .MatchInlineSnapshots( + [ + """ + Venti hooks in '': + AgentNaming missing + SessionStart installed + UserPromptSubmit installed + Stop installed + Notification installed + SessionEnd installed + """, + """ + ✓ Installed Venti hooks in ''. + AgentNaming installed + SessionStart unchanged + UserPromptSubmit unchanged + Stop unchanged + Notification unchanged + SessionEnd unchanged + """ + ]); + } + + [Fact] + public async Task Uninstall_Should_RestoreTheUserHooks_When_NativeEntriesWereInstalled() + { + // arrange + SetupGlobalConfigDirectory(Path.Combine(WorkingDirectory, "..", "app-data")); + await InitWorkspaceAsync(); + WriteSettings(UserSettings); + await ExecuteCommandAsync("agent", "hooks", "venti", "install", "--scope", "project"); + + // act + var result = await ExecuteCommandAsync("agent", "hooks", "venti", "uninstall", "--scope", "project"); + + // assert + Assert.Equal(0, result.ExitCode); + var settings = await File.ReadAllTextAsync(SettingsPath, TestContext.Current.CancellationToken); + Assert.True(JsonNode.DeepEquals(JsonNode.Parse(UserSettings), JsonNode.Parse(settings))); + } + + [Theory] + [InlineData("install")] + [InlineData("status")] + public async Task Command_Should_FailWithoutWriting_When_TheSettingsRunNitroClaudeHooks(string verb) + { + // arrange + SetupGlobalConfigDirectory(Path.Combine(WorkingDirectory, "..", "app-data")); + await InitWorkspaceAsync(); + const string settings = + """ + { + "hooks": { + "Stop": [ + { + "hooks": [ + { "type": "command", "command": "./scripts/notify.sh" }, + { "type": "command", "command": "nitro agent hook claude stop" } + ] + } + ], + "SessionEnd": [ + { "hooks": [{ "type": "command", "command": "nitro agent hook claude session-end", "timeout": 10 }] } + ] + } + } + """; + WriteSettings(settings); + + // act + var result = await ExecuteCommandAsync("agent", "hooks", "venti", verb, "--scope", "project"); + + // assert + Assert.Equal(1, result.ExitCode); + Assert.Equal(settings, await File.ReadAllTextAsync(SettingsPath, TestContext.Current.CancellationToken)); + result.StdErr.Replace(SettingsPath, "").MatchInlineSnapshot( + """ + '' already runs Nitro's `agent hook claude` hooks under Stop, SessionEnd. Next to the `agent hook venti` hooks in '' they would handle every event twice. Remove the `agent hook claude` entries from '', keeping any other hooks, then run the command again. Nothing was written. + """); + } + + [Theory] + [InlineData("install")] + [InlineData("status")] + public async Task Command_Should_FailWithoutWritingEitherFile_When_TheUserSettingsRunNitroClaudeHooks(string verb) + { + // arrange + SetupGlobalConfigDirectory(Path.Combine(WorkingDirectory, "..", "app-data")); + await InitWorkspaceAsync(); + const string userSettings = + """ + { + "hooks": { + "SessionStart": [ + { "hooks": [{ "type": "command", "command": "nitro agent hook claude session-start", "timeout": 10 }] } + ] + } + } + """; + WriteFile(UserSettingsPath, userSettings); + WriteSettings(UserSettings); + + // act + var result = await ExecuteCommandAsync("agent", "hooks", "venti", verb, "--scope", "project"); + + // assert + Assert.Equal(1, result.ExitCode); + Assert.Equal( + (userSettings, UserSettings), + (await File.ReadAllTextAsync(UserSettingsPath, TestContext.Current.CancellationToken), + await File.ReadAllTextAsync(SettingsPath, TestContext.Current.CancellationToken))); + ReplacePaths(result.StdErr).MatchInlineSnapshot( + """ + '' already runs Nitro's `agent hook claude` hooks under SessionStart. Next to the `agent hook venti` hooks in '' they would handle every event twice. Remove the `agent hook claude` entries from '', keeping any other hooks, then run the command again. Nothing was written. + """); + } + + [Fact] + public async Task Install_Should_FailWithoutWriting_When_TheLaunchDirectorySettingsRunNitroClaudeHooks() + { + // arrange + SetupGlobalConfigDirectory(Path.Combine(WorkingDirectory, "..", "app-data")); + const string projectSettings = + """ + { + "hooks": { + "Stop": [ + { + "hooks": [ + { "type": "command", "command": "./mine.sh" }, + { "type": "command", "command": "nitro agent hook claude stop" } + ] + } + ] + } + } + """; + WriteSettings(projectSettings); + + // act + var result = await ExecuteCommandAsync("agent", "hooks", "venti", "install", "--scope", "user"); + + // assert + Assert.Equal(1, result.ExitCode); + Assert.Equal( + (false, projectSettings), + (File.Exists(UserSettingsPath), + await File.ReadAllTextAsync(SettingsPath, TestContext.Current.CancellationToken))); + ReplacePaths(result.StdErr).MatchInlineSnapshot( + """ + '' already runs Nitro's `agent hook claude` hooks under Stop. Next to the `agent hook venti` hooks in '' they would handle every event twice. Remove the `agent hook claude` entries from '', keeping any other hooks, then run the command again. Nothing was written. + """); + } + + [Fact] + public async Task Uninstall_Should_RemoveEveryNativeHandlerAndKeepTheUserHook_When_TheyShareAGroup() + { + // arrange + SetupGlobalConfigDirectory(Path.Combine(WorkingDirectory, "..", "app-data")); + await InitWorkspaceAsync(); + await ExecuteCommandAsync("agent", "hooks", "venti", "install", "--scope", "project"); + // a user hook joins the native Stop group and an older install appended a second native group + var settings = JsonNode.Parse(await File.ReadAllTextAsync(SettingsPath, TestContext.Current.CancellationToken))!; + var stopGroups = settings["hooks"]!["Stop"]!.AsArray(); + var nativeStop = stopGroups[0]!["hooks"]![0]!; + stopGroups[0]!["hooks"]!.AsArray().Insert(0, new JsonObject { ["type"] = "command", ["command"] = "./mine.sh" }); + stopGroups[0]!["matcher"] = "*"; + stopGroups.Add(new JsonObject { ["hooks"] = new JsonArray(nativeStop.DeepClone()) }); + WriteSettings(settings.ToJsonString()); + + // act + var result = await ExecuteCommandAsync("agent", "hooks", "venti", "uninstall", "--scope", "project"); + + // assert + Assert.Equal(0, result.ExitCode); + (await File.ReadAllTextAsync(SettingsPath, TestContext.Current.CancellationToken)).MatchInlineSnapshot( + """ + { + "hooks": { + "Stop": [ + { + "hooks": [ + { + "type": "command", + "command": "./mine.sh" + } + ], + "matcher": "*" + } + ] + } + } + """); + } + + [Fact] + public async Task Install_Should_ReportStopUnchanged_When_TheNativeHandlerSharesAGroupWithAUserHook() + { + // arrange + SetupGlobalConfigDirectory(Path.Combine(WorkingDirectory, "..", "app-data")); + await InitWorkspaceAsync(); + await ExecuteCommandAsync("agent", "hooks", "venti", "install", "--scope", "project"); + var settings = JsonNode.Parse(await File.ReadAllTextAsync(SettingsPath, TestContext.Current.CancellationToken))!; + settings["hooks"]!["Stop"]![0]!["hooks"]!.AsArray() + .Insert(0, new JsonObject { ["type"] = "command", ["command"] = "./mine.sh" }); + WriteSettings(settings.ToJsonString(new JsonSerializerOptions { WriteIndented = true })); + var before = await File.ReadAllTextAsync(SettingsPath, TestContext.Current.CancellationToken); + + // act + var result = await ExecuteCommandAsync("agent", "hooks", "venti", "install", "--scope", "project"); + + // assert + Assert.Equal(before, await File.ReadAllTextAsync(SettingsPath, TestContext.Current.CancellationToken)); + ReplacePaths(result.StdOut).MatchInlineSnapshot( + """ + ✓ Installed Venti hooks in ''. + AgentNaming unchanged + SessionStart unchanged + UserPromptSubmit unchanged + Stop unchanged + Notification unchanged + SessionEnd unchanged + """); + } + + [Fact] + public async Task Status_Should_ReportTheSwitchedOffHandlerAndFail_When_ItsEnabledFlagIsFalse() + { + // arrange + SetupGlobalConfigDirectory(Path.Combine(WorkingDirectory, "..", "app-data")); + await InitWorkspaceAsync(); + await ExecuteCommandAsync("agent", "hooks", "venti", "install", "--scope", "project"); + await SwitchOffStopHandlerAsync(); + + // act + var result = await ExecuteCommandAsync("agent", "hooks", "venti", "status", "--scope", "project"); + + // assert + Assert.Equal(1, result.ExitCode); + ReplacePaths(result.StdOut).MatchInlineSnapshot( + """ + Venti hooks in '': + AgentNaming installed + SessionStart installed + UserPromptSubmit installed + Stop disabled + Notification installed + SessionEnd installed + Venti skips Nitro's Stop hooks because their entries in '' set "enabled": false. Remove that flag or set it to true to let them run. + """); + } + + [Fact] + public async Task Install_Should_KeepTheHandlerSwitchedOffAndReportIt_When_ItsEnabledFlagIsFalse() + { + // arrange + SetupGlobalConfigDirectory(Path.Combine(WorkingDirectory, "..", "app-data")); + await InitWorkspaceAsync(); + await ExecuteCommandAsync("agent", "hooks", "venti", "install", "--scope", "project"); + await SwitchOffStopHandlerAsync(); + var before = await File.ReadAllTextAsync(SettingsPath, TestContext.Current.CancellationToken); + + // act + var result = await ExecuteCommandAsync("agent", "hooks", "venti", "install", "--scope", "project"); + + // assert + Assert.Equal( + (0, before), + (result.ExitCode, await File.ReadAllTextAsync(SettingsPath, TestContext.Current.CancellationToken))); + ReplacePaths(result.StdOut).MatchInlineSnapshot( + """ + ✓ Installed Venti hooks in ''. + AgentNaming unchanged + SessionStart unchanged + UserPromptSubmit unchanged + Stop disabled + Notification unchanged + SessionEnd unchanged + Venti skips Nitro's Stop hooks because their entries in '' set "enabled": false. Remove that flag or set it to true to let them run. + """); + } + + [Theory] + [InlineData("install", 0)] + [InlineData("status", 1)] + public async Task Command_Should_ReportTheGlobalSwitchWithoutChangingIt_When_TheUserSettingsDisableAllHooks( + string verb, + int expectedExitCode) + { + // arrange + SetupGlobalConfigDirectory(Path.Combine(WorkingDirectory, "..", "app-data")); + await InitWorkspaceAsync(); + await ExecuteCommandAsync("agent", "hooks", "venti", "install", "--scope", "project"); + const string userSettings = """{ "disableAllHooks": true }"""; + WriteFile(UserSettingsPath, userSettings); + + // act + var result = await ExecuteCommandAsync("agent", "hooks", "venti", verb, "--scope", "project"); + + // assert + Assert.Equal( + (expectedExitCode, userSettings), + (result.ExitCode, await File.ReadAllTextAsync(UserSettingsPath, TestContext.Current.CancellationToken))); + ReplacePaths(result.StdOut).Split('\n').Last().MatchInlineSnapshot( + """ + Venti runs no hooks, Nitro's included, while '' sets "disableAllHooks": true. Set it to false or remove it to let them run. + """); + } + + [Fact] + public async Task Commands_Should_WriteJsonResults_When_JsonOutputIsRequested() + { + // arrange + SetupGlobalConfigDirectory(Path.Combine(WorkingDirectory, "..", "app-data")); + await InitWorkspaceAsync(); + + // act + var install = await ExecuteCommandAsync( + "agent", "hooks", "venti", "install", "--scope", "project", "--output", "json"); + var status = await ExecuteCommandAsync( + "agent", "hooks", "venti", "status", "--scope", "project", "--output", "json"); + var uninstall = await ExecuteCommandAsync( + "agent", "hooks", "venti", "uninstall", "--scope", "project", "--output", "json"); + + // assert + Assert.Equal((0, 0, 0), (install.ExitCode, status.ExitCode, uninstall.ExitCode)); + Assert.Equal("{}", NormalizeJson(await File.ReadAllTextAsync(SettingsPath, TestContext.Current.CancellationToken))); + new[] { install.StdOut, status.StdOut, uninstall.StdOut } + .Select(output => NormalizeCommands(ReplacePaths(NormalizeJson(output)))) + .MatchInlineSnapshots( + [ + """ + { + "settingsPath": "", + "events": [ + { + "event": "AgentNaming", + "outcome": "Installed" + }, + { + "event": "SessionStart", + "outcome": "Installed" + }, + { + "event": "UserPromptSubmit", + "outcome": "Installed" + }, + { + "event": "Stop", + "outcome": "Installed" + }, + { + "event": "Notification", + "outcome": "Installed" + }, + { + "event": "SessionEnd", + "outcome": "Installed" + } + ], + "allHooksDisabledIn": null + } + """, + """ + { + "settingsPath": "", + "current": true, + "events": [ + { + "event": "AgentNaming", + "outcome": "Installed", + "installedCommand": " agent hook venti agent-naming" + }, + { + "event": "SessionStart", + "outcome": "Installed", + "installedCommand": " agent hook venti session-start" + }, + { + "event": "UserPromptSubmit", + "outcome": "Installed", + "installedCommand": " agent hook venti user-prompt-submit" + }, + { + "event": "Stop", + "outcome": "Installed", + "installedCommand": " agent hook venti stop" + }, + { + "event": "Notification", + "outcome": "Installed", + "installedCommand": " agent hook venti notification" + }, + { + "event": "SessionEnd", + "outcome": "Installed", + "installedCommand": " agent hook venti session-end" + } + ], + "allHooksDisabledIn": null + } + """, + """ + { + "settingsPath": "", + "events": [ + { + "event": "AgentNaming", + "outcome": "Removed" + }, + { + "event": "SessionStart", + "outcome": "Removed" + }, + { + "event": "UserPromptSubmit", + "outcome": "Removed" + }, + { + "event": "Stop", + "outcome": "Removed" + }, + { + "event": "Notification", + "outcome": "Removed" + }, + { + "event": "SessionEnd", + "outcome": "Removed" + } + ] + } + """ + ]); + } + + [Fact] + public async Task InstallHelp_Should_DescribeTheVentiSettingsFiles_When_HelpIsRequested() + { + // arrange + // act + var result = await ExecuteCommandAsync("agent", "hooks", "venti", "install", "--help"); + + // assert + result.AssertHelpOutput( + """ + Description: + Add or update this CLI's Venti turn-boundary hook entries. + + Usage: + nitro agent hooks venti install [options] + + Options: + --scope Where the Venti settings file lives: 'user' (~/.venti/settings.json) or 'project' (./.venti/settings.json, read when Venti is launched from the current directory) [default: user] + --output The output format (enables non-interactive mode) [env: NITRO_OUTPUT_FORMAT] + -?, -h, --help Show help and usage information + + Example: + nitro agent hooks venti install + nitro agent hooks venti install --scope project + """); + } + + private void WriteSettings(string json) => WriteFile(SettingsPath, json); + + private static void WriteFile(string path, string json) + { + Directory.CreateDirectory(Path.GetDirectoryName(path)!); + File.WriteAllText(path, json); + } + + private async Task SwitchOffStopHandlerAsync() + { + var settings = JsonNode.Parse(await File.ReadAllTextAsync(SettingsPath, TestContext.Current.CancellationToken))!; + settings["hooks"]!["Stop"]![0]!["hooks"]![0]!["enabled"] = false; + WriteSettings(settings.ToJsonString(new JsonSerializerOptions { WriteIndented = true })); + } + + private string ReplacePaths(string text) + => text + .Replace(JsonEncodedText.Encode(SettingsPath).ToString(), "") + .Replace(JsonEncodedText.Encode(UserSettingsPath).ToString(), "") + .Replace(SettingsPath, "") + .Replace(UserSettingsPath, ""); + + private static string NormalizeJson(string json) + => JsonNode.Parse(json)!.ToJsonString(new JsonSerializerOptions { WriteIndented = true }); + + /// + /// Replaces the machine-specific launch prefix of each Nitro hook command. + /// + private static string NormalizeCommands(string settingsJson) + => NitroCommandPrefix().Replace(settingsJson, "\"$1\": \" agent hook venti "); + + [GeneratedRegex("\"(command|installedCommand)\": \"[^\"]* agent hook venti ")] + private static partial Regex NitroCommandPrefix(); +} diff --git a/src/Nitro/CommandLine/test/CommandLine.Tests/Commands/CommandTestBase.cs b/src/Nitro/CommandLine/test/CommandLine.Tests/Commands/CommandTestBase.cs index 9102be72397..5d931c35556 100644 --- a/src/Nitro/CommandLine/test/CommandLine.Tests/Commands/CommandTestBase.cs +++ b/src/Nitro/CommandLine/test/CommandLine.Tests/Commands/CommandTestBase.cs @@ -43,6 +43,7 @@ public abstract class CommandTestBase private IGlobalConfigDirectoryProvider? _globalConfigDirectoryProviderOverride; private Services.Preferences.IBoardPreferencesStore? _boardPreferencesStoreOverride; private Services.Hook.IClaudeSettingsPathResolver? _claudeSettingsPathResolverOverride; + private Services.Hook.IClaudeSettingsPathResolver? _ventiSettingsPathResolverOverride; private Services.Hook.ICodexPathResolver? _codexPathResolverOverride; private Services.Hook.ICodexQueueClient? _codexQueueClientOverride; private Services.Notify.IClaudePeerClient? _claudePeerClientOverride; @@ -125,6 +126,14 @@ private protected void SetupClaudeSettingsPathResolver(string userScopePath, str _claudeSettingsPathResolverOverride = new FixedClaudeSettingsPathResolver(userScopePath, projectScopePath); } + /// + /// Configures Venti settings lookup to use the supplied user and project paths. + /// + private protected void SetupVentiSettingsPathResolver(string userScopePath, string projectScopePath) + { + _ventiSettingsPathResolverOverride = new FixedClaudeSettingsPathResolver(userScopePath, projectScopePath); + } + /// /// Configures Codex CLI hooks and configuration lookup to use the supplied paths. /// @@ -394,6 +403,12 @@ private ServiceProvider BuildServices(INitroConsole console) services.Replace(ServiceDescriptor.Singleton(_claudeSettingsPathResolverOverride)); } + if (_ventiSettingsPathResolverOverride is not null) + { + services.RemoveAllKeyed(AgentSessionHarness.Venti); + services.AddKeyedSingleton(AgentSessionHarness.Venti, _ventiSettingsPathResolverOverride); + } + if (_codexPathResolverOverride is not null) { services.Replace(ServiceDescriptor.Singleton(_codexPathResolverOverride)); @@ -647,6 +662,8 @@ internal sealed class FixedClaudeSettingsPathResolver(string userScopePath, stri { public string Resolve(string scope) => scope == Services.Hook.HookInstallScopes.Project ? projectScopePath : userScopePath; + + public IReadOnlyList ResolveForCurrentDirectory() => [userScopePath, projectScopePath]; } internal sealed class FixedCodexPathResolver(string hooksJsonPath, string configTomlPath) diff --git a/src/Nitro/CommandLine/test/CommandLine.Tests/Services/Hook/ClaudeHookExecutorTests.cs b/src/Nitro/CommandLine/test/CommandLine.Tests/Services/Hook/ClaudeHookExecutorTests.cs index 626929c084b..8f820a90707 100644 --- a/src/Nitro/CommandLine/test/CommandLine.Tests/Services/Hook/ClaudeHookExecutorTests.cs +++ b/src/Nitro/CommandLine/test/CommandLine.Tests/Services/Hook/ClaudeHookExecutorTests.cs @@ -178,6 +178,7 @@ public async Task RunAsync_Should_WriteNeutral_When_TheDatabaseIsContended() agentStore, ledger, mail, + AgentSessionHarness.ClaudeCode, new FixedClaudeSessionFileReader()); await using (await database.InitializeAsync(workspaceDirectory, cancellationToken)) @@ -254,6 +255,7 @@ public async Task RunAsync_Should_WriteNeutral_When_SchemaVersionMismatches() agentStore, ledger, mail, + AgentSessionHarness.ClaudeCode, new FixedClaudeSessionFileReader()); await using (await database.InitializeAsync(workspaceDirectory, cancellationToken)) diff --git a/src/Nitro/CommandLine/test/CommandLine.Tests/Services/Hook/ClaudeHookHandlerTests.cs b/src/Nitro/CommandLine/test/CommandLine.Tests/Services/Hook/ClaudeHookHandlerTests.cs index 766f55febf8..57aec1ec416 100644 --- a/src/Nitro/CommandLine/test/CommandLine.Tests/Services/Hook/ClaudeHookHandlerTests.cs +++ b/src/Nitro/CommandLine/test/CommandLine.Tests/Services/Hook/ClaudeHookHandlerTests.cs @@ -52,6 +52,7 @@ public ClaudeHookHandlerTests() _agentStore, _ledger, _mail, + AgentSessionHarness.ClaudeCode, sessionFileReader ?? new FixedClaudeSessionFileReader()); // ---------- SessionStart ---------- @@ -670,6 +671,7 @@ public async Task HandleStopAsync_Should_ReturnNeutral_When_TheBudgetIncrementNo new IncrementNeverMatchesAgentStore(_agentStore), _ledger, _mail, + AgentSessionHarness.ClaudeCode, new FixedClaudeSessionFileReader()); // act @@ -699,6 +701,7 @@ public async Task HandleStopAsync_Should_ReserveAtMostMaxDigestMessages_When_Man _agentStore, spyLedger, _mail, + AgentSessionHarness.ClaudeCode, new FixedClaudeSessionFileReader()); // act diff --git a/src/Nitro/CommandLine/test/CommandLine.Tests/Services/Hook/ClaudeHooksEditorTests.cs b/src/Nitro/CommandLine/test/CommandLine.Tests/Services/Hook/ClaudeHooksEditorTests.cs index d34eddba7e6..363f3ef9c51 100644 --- a/src/Nitro/CommandLine/test/CommandLine.Tests/Services/Hook/ClaudeHooksEditorTests.cs +++ b/src/Nitro/CommandLine/test/CommandLine.Tests/Services/Hook/ClaudeHooksEditorTests.cs @@ -9,6 +9,8 @@ namespace ChilliCream.Nitro.CommandLine.Tests.Hook; /// public sealed class ClaudeHooksEditorTests { + private static readonly ClaudeHooksTemplate s_template = ClaudeHooksTemplate.Claude; + private static readonly LaunchDescriptor s_descriptor = new("/home/agent/.dotnet/tools/nitro", []); @@ -17,7 +19,7 @@ public void Install_Should_CreateAllFiveEventsAsInstalled_When_TheSettingsFileIs { // arrange // act - var result = ClaudeHooksEditor.Install(null, s_descriptor, DateTimeOffset.UnixEpoch); + var result = ClaudeHooksEditor.Install(null, s_template, s_descriptor, DateTimeOffset.UnixEpoch); // assert result.Outcomes.MatchInlineSnapshot( @@ -49,10 +51,10 @@ public void Install_Should_CreateAllFiveEventsAsInstalled_When_TheSettingsFileIs var root = Parse(result.SettingsJson); var hooks = (JsonObject)root["hooks"]!; - foreach (var claudeEvent in ClaudeHooksTemplate.Events) + foreach (var claudeEvent in s_template.Events) { var group = SingleGroup(hooks, claudeEvent); - AssertCommand(group, ClaudeHooksTemplate.BuildCommand(s_descriptor, claudeEvent), 10); + AssertCommand(group, s_template.BuildCommand(s_descriptor, claudeEvent), 10); } Assert.Equal(5, result.Sidecar.Count); @@ -66,7 +68,7 @@ public void Install_ForeignOnly_AddsAlongsideWithoutTouchingForeignEntryOrOtherT var beforeRoot = Parse(before); var herdrBefore = ((JsonObject)beforeRoot["hooks"]!)["SessionStart"]![0]; - var result = ClaudeHooksEditor.Install(before, s_descriptor, DateTimeOffset.UnixEpoch); + var result = ClaudeHooksEditor.Install(before, s_template, s_descriptor, DateTimeOffset.UnixEpoch); Assert.All(result.Outcomes, o => Assert.Equal(HookInstallOutcome.Installed, o.Outcome)); @@ -76,7 +78,7 @@ public void Install_ForeignOnly_AddsAlongsideWithoutTouchingForeignEntryOrOtherT Assert.Equal(2, sessionStart.Count); Assert.True(JsonNode.DeepEquals(herdrBefore, sessionStart[0])); AssertCommand( - (JsonObject)sessionStart[1]!, ClaudeHooksTemplate.BuildCommand(s_descriptor, "SessionStart"), 10); + (JsonObject)sessionStart[1]!, s_template.BuildCommand(s_descriptor, "SessionStart"), 10); // Untouched foreign top-level content round-trips unchanged. Assert.True(JsonNode.DeepEquals(beforeRoot["someOtherTopLevelKey"], afterRoot["someOtherTopLevelKey"])); @@ -91,7 +93,7 @@ public void Install_Mixed_UpdatesOutdatedAddsMissingLeavesForeignAndCurrentAlone var herdrBefore = ((JsonArray)beforeHooks["SessionStart"]!)[0]; var foreignSessionEndBefore = ((JsonArray)beforeHooks["SessionEnd"]!)[0]; - var result = ClaudeHooksEditor.Install(before, s_descriptor, DateTimeOffset.UnixEpoch); + var result = ClaudeHooksEditor.Install(before, s_template, s_descriptor, DateTimeOffset.UnixEpoch); var byEvent = result.Outcomes.ToDictionary(o => o.Event, o => o.Outcome); Assert.Equal(HookInstallOutcome.Updated, byEvent["SessionStart"]); @@ -105,19 +107,19 @@ public void Install_Mixed_UpdatesOutdatedAddsMissingLeavesForeignAndCurrentAlone Assert.Equal(2, sessionStart.Count); Assert.True(JsonNode.DeepEquals(herdrBefore, sessionStart[0])); AssertCommand( - (JsonObject)sessionStart[1]!, ClaudeHooksTemplate.BuildCommand(s_descriptor, "SessionStart"), 10); + (JsonObject)sessionStart[1]!, s_template.BuildCommand(s_descriptor, "SessionStart"), 10); var sessionEnd = (JsonArray)afterHooks["SessionEnd"]!; Assert.Equal(2, sessionEnd.Count); Assert.True(JsonNode.DeepEquals(foreignSessionEndBefore, sessionEnd[0])); AssertCommand( - (JsonObject)sessionEnd[1]!, ClaudeHooksTemplate.BuildCommand(s_descriptor, "SessionEnd"), 10); + (JsonObject)sessionEnd[1]!, s_template.BuildCommand(s_descriptor, "SessionEnd"), 10); AssertCommand( SingleGroup(afterHooks, "UserPromptSubmit"), - ClaudeHooksTemplate.BuildCommand(s_descriptor, "UserPromptSubmit"), + s_template.BuildCommand(s_descriptor, "UserPromptSubmit"), 10); - AssertCommand(SingleGroup(afterHooks, "Stop"), ClaudeHooksTemplate.BuildCommand(s_descriptor, "Stop"), 10); + AssertCommand(SingleGroup(afterHooks, "Stop"), s_template.BuildCommand(s_descriptor, "Stop"), 10); } [Fact] @@ -125,7 +127,7 @@ public void Install_AlreadyInstalled_AllUnchanged() { var before = ClaudeHooksInstallFixtures.Read("install", "already-installed.json"); - var result = ClaudeHooksEditor.Install(before, s_descriptor, DateTimeOffset.UnixEpoch); + var result = ClaudeHooksEditor.Install(before, s_template, s_descriptor, DateTimeOffset.UnixEpoch); Assert.All(result.Outcomes, o => Assert.Equal(HookInstallOutcome.Unchanged, o.Outcome)); Assert.True(JsonNode.DeepEquals(Parse(before), Parse(result.SettingsJson))); @@ -136,7 +138,7 @@ public void Install_Outdated_ReplacesStaleCommandOnlyForThatEvent() { var before = ClaudeHooksInstallFixtures.Read("install", "outdated.json"); - var result = ClaudeHooksEditor.Install(before, s_descriptor, DateTimeOffset.UnixEpoch); + var result = ClaudeHooksEditor.Install(before, s_template, s_descriptor, DateTimeOffset.UnixEpoch); var byEvent = result.Outcomes.ToDictionary(o => o.Event, o => o.Outcome); Assert.Equal(HookInstallOutcome.Updated, byEvent["SessionStart"]); @@ -146,7 +148,7 @@ public void Install_Outdated_ReplacesStaleCommandOnlyForThatEvent() var hooks = (JsonObject)Parse(result.SettingsJson)["hooks"]!; AssertCommand( - SingleGroup(hooks, "SessionStart"), ClaudeHooksTemplate.BuildCommand(s_descriptor, "SessionStart"), 10); + SingleGroup(hooks, "SessionStart"), s_template.BuildCommand(s_descriptor, "SessionStart"), 10); } [Fact] @@ -154,21 +156,23 @@ public void Install_ManuallyEdited_TreatedIdenticallyToOutdated() { var before = ClaudeHooksInstallFixtures.Read("install", "manually-edited.json"); - var result = ClaudeHooksEditor.Install(before, s_descriptor, DateTimeOffset.UnixEpoch); + var result = ClaudeHooksEditor.Install(before, s_template, s_descriptor, DateTimeOffset.UnixEpoch); var outcome = Assert.Single(result.Outcomes, o => o.Event == "SessionStart"); Assert.Equal(HookInstallOutcome.Updated, outcome.Outcome); var hooks = (JsonObject)Parse(result.SettingsJson)["hooks"]!; AssertCommand( - SingleGroup(hooks, "SessionStart"), ClaudeHooksTemplate.BuildCommand(s_descriptor, "SessionStart"), 10); + SingleGroup(hooks, "SessionStart"), s_template.BuildCommand(s_descriptor, "SessionStart"), 10); } [Fact] public void Install_Twice_IsIdempotent() { - var once = ClaudeHooksEditor.Install(null, s_descriptor, DateTimeOffset.UnixEpoch).SettingsJson; - var twice = ClaudeHooksEditor.Install(once, s_descriptor, DateTimeOffset.UnixEpoch).SettingsJson; + var once = ClaudeHooksEditor.Install( + null, s_template, s_descriptor, DateTimeOffset.UnixEpoch).SettingsJson; + var twice = ClaudeHooksEditor.Install( + once, s_template, s_descriptor, DateTimeOffset.UnixEpoch).SettingsJson; Assert.Equal(once, twice); } @@ -180,7 +184,7 @@ public void Status_Should_ReportNotificationMissing_When_TheInstallHasFourEntrie var before = ClaudeHooksInstallFixtures.Read("install", "four-events.json"); // act - var result = ClaudeHooksEditor.Status(before, s_descriptor); + var result = ClaudeHooksEditor.Status(before, s_template, s_descriptor); // assert result.MatchInlineSnapshot( @@ -222,7 +226,7 @@ public void Install_Should_AddNotificationAndLeaveTheOtherFourUnchanged_When_The var before = ClaudeHooksInstallFixtures.Read("install", "four-events.json"); // act - var result = ClaudeHooksEditor.Install(before, s_descriptor, DateTimeOffset.UnixEpoch); + var result = ClaudeHooksEditor.Install(before, s_template, s_descriptor, DateTimeOffset.UnixEpoch); // assert result.Outcomes.MatchInlineSnapshot( @@ -250,7 +254,7 @@ public void Install_Should_AddNotificationAndLeaveTheOtherFourUnchanged_When_The } ] """); - ClaudeHooksEditor.Status(result.SettingsJson, s_descriptor).MatchInlineSnapshot( + ClaudeHooksEditor.Status(result.SettingsJson, s_template, s_descriptor).MatchInlineSnapshot( """ [ { @@ -285,29 +289,30 @@ public void Install_Should_AddNotificationAndLeaveTheOtherFourUnchanged_When_The [Fact] public void Status_ReportsMissingInstalledAndOutdated() { - var missing = ClaudeHooksEditor.Status(null, s_descriptor); + var missing = ClaudeHooksEditor.Status(null, s_template, s_descriptor); Assert.All(missing, r => Assert.Equal(HookStatusOutcome.Missing, r.Outcome)); var installed = ClaudeHooksEditor.Status( - ClaudeHooksInstallFixtures.Read("install", "already-installed.json"), s_descriptor); + ClaudeHooksInstallFixtures.Read("install", "already-installed.json"), s_template, s_descriptor); Assert.All(installed, r => Assert.Equal(HookStatusOutcome.Installed, r.Outcome)); var outdated = ClaudeHooksEditor.Status( - ClaudeHooksInstallFixtures.Read("install", "outdated.json"), s_descriptor); + ClaudeHooksInstallFixtures.Read("install", "outdated.json"), s_template, s_descriptor); var sessionStart = Assert.Single(outdated, r => r.Event == "SessionStart"); Assert.Equal(HookStatusOutcome.Outdated, sessionStart.Outcome); Assert.Equal("/opt/old/nitro agent hook claude session-start", sessionStart.InstalledCommand); Assert.All(outdated.Where(r => r.Event != "SessionStart"), r => Assert.Equal(HookStatusOutcome.Missing, r.Outcome)); var manuallyEdited = ClaudeHooksEditor.Status( - ClaudeHooksInstallFixtures.Read("install", "manually-edited.json"), s_descriptor); + ClaudeHooksInstallFixtures.Read("install", "manually-edited.json"), s_template, s_descriptor); Assert.Equal(HookStatusOutcome.Outdated, Assert.Single(manuallyEdited, r => r.Event == "SessionStart").Outcome); } [Fact] public void Uninstall_MissingFile_AllNotPresent() { - var result = ClaudeHooksEditor.Uninstall(null, new Dictionary()); + var result = ClaudeHooksEditor.Uninstall( + null, s_template, new Dictionary()); Assert.All(result.Outcomes, o => Assert.Equal(HookUninstallOutcome.NotPresent, o.Outcome)); Assert.Empty(result.Sidecar); @@ -323,7 +328,7 @@ public void Uninstall_WithForeignEntry_RemovesOnlyOwnAndLeavesForeignEntryByteFo var sidecar = SidecarFor(before); - var result = ClaudeHooksEditor.Uninstall(before, sidecar); + var result = ClaudeHooksEditor.Uninstall(before, s_template, sidecar); Assert.All(result.Outcomes, o => Assert.Equal(HookUninstallOutcome.Removed, o.Outcome)); Assert.Empty(result.Sidecar); @@ -349,13 +354,13 @@ public void Uninstall_SidecarDrifted_FallsBackToMarkerMatch() var staleSidecar = new Dictionary { ["SessionStart"] = new( - ClaudeHooksTemplate.BuildCommand(s_descriptor, "SessionStart"), + s_template.BuildCommand(s_descriptor, "SessionStart"), 10, - ClaudeHooksSidecarEntry.ComputeHash(ClaudeHooksTemplate.BuildCommand(s_descriptor, "SessionStart"), 10), + ClaudeHooksSidecarEntry.ComputeHash(s_template.BuildCommand(s_descriptor, "SessionStart"), 10), DateTimeOffset.UnixEpoch) }; - var result = ClaudeHooksEditor.Uninstall(before, staleSidecar); + var result = ClaudeHooksEditor.Uninstall(before, s_template, staleSidecar); var sessionStart = Assert.Single(result.Outcomes, o => o.Event == "SessionStart"); Assert.Equal(HookUninstallOutcome.Removed, sessionStart.Outcome); @@ -369,19 +374,663 @@ public void Uninstall_ForeignSessionStartWithNoOwnedGroup_LeavesItInPlace() { var before = ClaudeHooksInstallFixtures.Read("install", "foreign-only.json"); - var result = ClaudeHooksEditor.Uninstall(before, new Dictionary()); + var result = ClaudeHooksEditor.Uninstall( + before, s_template, new Dictionary()); var sessionStart = Assert.Single(result.Outcomes, o => o.Event == "SessionStart"); Assert.Equal(HookUninstallOutcome.NotPresent, sessionStart.Outcome); Assert.True(JsonNode.DeepEquals(Parse(before), Parse(result.SettingsJson))); } + [Fact] + public void Install_Should_UpdateTheOwnedHandlerInPlaceAndDropDuplicates_When_ItSharesAGroupWithAUserHook() + { + // arrange + var settings = + $$""" + { + "hooks": { + "Stop": [ + { + "matcher": "*", + "hooks": [ + { "type": "command", "command": "./mine.sh" }, + { "type": "command", "command": "/opt/old/nitro agent hook venti stop", "timeout": 5 } + ] + }, + { "hooks": [{ "type": "command", "command": "{{VentiCommand("Stop")}}", "timeout": 10 }] } + ] + } + } + """; + + // act + var result = ClaudeHooksEditor.Install(settings, ClaudeHooksTemplate.Venti, s_descriptor, DateTimeOffset.UnixEpoch); + + // assert + Assert.Equal(HookInstallOutcome.Updated, Assert.Single(result.Outcomes, o => o.Event == "Stop").Outcome); + Format(Parse(result.SettingsJson)["hooks"]!["Stop"]!).MatchInlineSnapshot( + """ + [ + { + "matcher": "*", + "hooks": [ + { + "type": "command", + "command": "./mine.sh" + }, + { + "type": "command", + "command": "/home/agent/.dotnet/tools/nitro agent hook venti stop", + "timeout": 10 + } + ] + } + ] + """); + } + + [Fact] + public void Install_Should_MoveTheOwnedHandlerToItsOwnGroup_When_AMatcherNarrowsItsGroup() + { + // arrange + var settings = + $$""" + { + "hooks": { + "SessionStart": [ + { + "matcher": "startup", + "hooks": [ + { "type": "command", "command": "./mine.sh" }, + { "type": "command", "command": "{{VentiCommand("SessionStart")}}", "timeout": 10 } + ] + } + ] + } + } + """; + + // act + var result = ClaudeHooksEditor.Install(settings, ClaudeHooksTemplate.Venti, s_descriptor, DateTimeOffset.UnixEpoch); + + // assert + Format(Parse(result.SettingsJson)["hooks"]!["SessionStart"]!).MatchInlineSnapshot( + """ + [ + { + "matcher": "startup", + "hooks": [ + { + "type": "command", + "command": "./mine.sh" + } + ] + }, + { + "hooks": [ + { + "type": "command", + "command": "/home/agent/.dotnet/tools/nitro agent hook venti session-start", + "timeout": 10 + } + ] + } + ] + """); + } + + [Theory] + [InlineData("\"type\": \"command\", \"timeout\": 10, \"async\": true")] + [InlineData("\"type\": \"command\", \"timeout\": 10, \"enabled\": \"no\"")] + [InlineData("\"type\": \"command\", \"timeout\": 10, \"statusMessage\": 3")] + [InlineData("\"type\": \"command\", \"timeout\": \"10\"")] + [InlineData("\"type\": \"prompt\", \"timeout\": 10")] + public void Install_Should_LeaveARunnableHandler_When_TheOwnedHandlerIsNotRunnableAsWritten(string properties) + { + // arrange + var settings = + $$""" + { + "hooks": { + "Stop": [ + { "hooks": [{ "command": "{{VentiCommand("Stop")}}", {{properties}} }] } + ] + } + } + """; + var statusBefore = ClaudeHooksEditor.Status(settings, ClaudeHooksTemplate.Venti, s_descriptor); + + // act + var result = ClaudeHooksEditor.Install(settings, ClaudeHooksTemplate.Venti, s_descriptor, DateTimeOffset.UnixEpoch); + + // assert + var statusAfter = ClaudeHooksEditor.Status(result.SettingsJson, ClaudeHooksTemplate.Venti, s_descriptor); + Assert.Equal( + (HookStatusOutcome.Outdated, HookInstallOutcome.Updated, HookStatusOutcome.Installed), + (statusBefore.Single(r => r.Event == "Stop").Outcome, + result.Outcomes.Single(o => o.Event == "Stop").Outcome, + statusAfter.Single(r => r.Event == "Stop").Outcome)); + } + + [Fact] + public void Install_Should_MoveTheHandlerKeepingItSwitchedOff_When_ADisabledHandlerSharesANarrowedGroupWithUserHooks() + { + // arrange + const string settings = + """ + { + "hooks": { + "Notification": [ + { "hooks": [{ "type": "command", "command": "./always.sh" }] }, + { + "matcher": "idle_prompt", + "hooks": [ + { "type": "command", "command": "./mine.sh" }, + { + "type": "command", + "command": "/opt/old/nitro agent hook venti notification", + "timeout": 10, + "enabled": false, + "statusMessage": "Checking mail" + } + ] + } + ] + } + } + """; + + // act + var result = ClaudeHooksEditor.Install(settings, ClaudeHooksTemplate.Venti, s_descriptor, DateTimeOffset.UnixEpoch); + + // assert + Assert.Equal( + (HookInstallOutcome.Disabled, HookStatusOutcome.Disabled), + (result.Outcomes.Single(o => o.Event == "Notification").Outcome, + ClaudeHooksEditor.Status(result.SettingsJson, ClaudeHooksTemplate.Venti, s_descriptor) + .Single(r => r.Event == "Notification").Outcome)); + Format(Parse(result.SettingsJson)["hooks"]!["Notification"]!).MatchInlineSnapshot( + """ + [ + { + "hooks": [ + { + "type": "command", + "command": "./always.sh" + } + ] + }, + { + "matcher": "idle_prompt", + "hooks": [ + { + "type": "command", + "command": "./mine.sh" + } + ] + }, + { + "hooks": [ + { + "type": "command", + "command": "/home/agent/.dotnet/tools/nitro agent hook venti notification", + "timeout": 10, + "enabled": false, + "statusMessage": "Checking mail" + } + ] + } + ] + """); + } + + [Theory] + [InlineData("\"matcher\": null, ", "")] + [InlineData("", ", \"async\": null")] + [InlineData("", ", \"enabled\": null")] + [InlineData("", ", \"statusMessage\": null")] + public void Install_Should_RepairTheHandler_When_AnOptionalPropertyIsExplicitlyNull( + string groupProperties, + string handlerProperties) + { + // arrange + var settings = + $$""" + { + "hooks": { + "Stop": [ + { {{groupProperties}}"hooks": [{ "type": "command", "command": "{{VentiCommand("Stop")}}", "timeout": 10{{handlerProperties}} }] } + ] + } + } + """; + var statusBefore = ClaudeHooksEditor.Status(settings, ClaudeHooksTemplate.Venti, s_descriptor); + + // act + var result = ClaudeHooksEditor.Install(settings, ClaudeHooksTemplate.Venti, s_descriptor, DateTimeOffset.UnixEpoch); + + // assert + Assert.Equal( + (HookStatusOutcome.Outdated, HookInstallOutcome.Updated, HookStatusOutcome.Installed), + (statusBefore.Single(r => r.Event == "Stop").Outcome, + result.Outcomes.Single(o => o.Event == "Stop").Outcome, + ClaudeHooksEditor.Status(result.SettingsJson, ClaudeHooksTemplate.Venti, s_descriptor) + .Single(r => r.Event == "Stop").Outcome)); + Format(Parse(result.SettingsJson)["hooks"]!["Stop"]!).MatchInlineSnapshot( + """ + [ + { + "hooks": [ + { + "type": "command", + "command": "/home/agent/.dotnet/tools/nitro agent hook venti stop", + "timeout": 10 + } + ] + } + ] + """); + } + + [Fact] + public void Status_Should_ReportOutdated_When_TheEventHasTwoOwnedHandlers() + { + // arrange + var command = VentiCommand("Stop"); + var settings = + $$""" + { + "hooks": { + "Stop": [ + { "hooks": [{ "type": "command", "command": "{{command}}", "timeout": 10 }] }, + { "hooks": [{ "type": "command", "command": "{{command}}", "timeout": 10 }] } + ] + } + } + """; + + // act + var status = ClaudeHooksEditor.Status(settings, ClaudeHooksTemplate.Venti, s_descriptor); + + // assert + Assert.Equal(HookStatusOutcome.Outdated, status.Single(r => r.Event == "Stop").Outcome); + } + + [Fact] + public void Install_Should_KeepTheEnabledFlagAndReportDisabled_When_TheVentiHandlerIsSwitchedOff() + { + // arrange + const string settings = + """ + { + "hooks": { + "Stop": [ + { "hooks": [{ "type": "command", "command": "/opt/old/nitro agent hook venti stop", "timeout": 10, "enabled": false }] } + ] + } + } + """; + + // act + var result = ClaudeHooksEditor.Install(settings, ClaudeHooksTemplate.Venti, s_descriptor, DateTimeOffset.UnixEpoch); + + // assert + Assert.Equal( + (HookInstallOutcome.Disabled, HookStatusOutcome.Disabled), + (result.Outcomes.Single(o => o.Event == "Stop").Outcome, + ClaudeHooksEditor.Status(result.SettingsJson, ClaudeHooksTemplate.Venti, s_descriptor) + .Single(r => r.Event == "Stop").Outcome)); + Format(Parse(result.SettingsJson)["hooks"]!["Stop"]!).MatchInlineSnapshot( + """ + [ + { + "hooks": [ + { + "type": "command", + "command": "/home/agent/.dotnet/tools/nitro agent hook venti stop", + "timeout": 10, + "enabled": false + } + ] + } + ] + """); + } + + [Fact] + public void Status_Should_ReportInstalled_When_AClaudeHandlerCarriesAnEnabledFlag() + { + // arrange + var settings = + $$""" + { + "hooks": { + "Stop": [ + { "hooks": [{ "type": "command", "command": "{{s_template.BuildCommand(s_descriptor, "Stop")}}", "timeout": 10, "enabled": false }] } + ] + } + } + """; + + // act + var status = ClaudeHooksEditor.Status(settings, s_template, s_descriptor); + + // assert + Assert.Equal(HookStatusOutcome.Installed, status.Single(r => r.Event == "Stop").Outcome); + } + + [Fact] + public void Uninstall_Should_RemoveEveryOwnedHandlerAndKeepSiblingsWithTheirGroup_When_OwnedHandlersShareGroups() + { + // arrange + var command = VentiCommand("Stop"); + var settings = + $$""" + { + "hooks": { + "Stop": [ + { + "matcher": "*", + "hooks": [ + { "type": "command", "command": "{{command}}", "timeout": 10 }, + { "type": "command", "command": "./mine.sh" } + ] + }, + { "hooks": [{ "type": "command", "command": "{{command}}", "timeout": 10 }] }, + { "hooks": [{ "type": "command", "command": "/opt/old/nitro agent hook venti stop" }] } + ] + } + } + """; + + // act + var result = ClaudeHooksEditor.Uninstall( + settings, ClaudeHooksTemplate.Venti, new Dictionary()); + + // assert + Assert.Equal(HookUninstallOutcome.Removed, result.Outcomes.Single(o => o.Event == "Stop").Outcome); + Format(Parse(result.SettingsJson)).MatchInlineSnapshot( + """ + { + "hooks": { + "Stop": [ + { + "matcher": "*", + "hooks": [ + { + "type": "command", + "command": "./mine.sh" + } + ] + } + ] + } + } + """); + } + + [Fact] + public void Install_Should_AddAgentNamingInAMainGroup_When_VentiSettingsAreMissing() + { + // arrange + // act + var result = ClaudeHooksEditor.Install(null, ClaudeHooksTemplate.Venti, s_descriptor, DateTimeOffset.UnixEpoch); + + // assert + Assert.Equal( + ["AgentNaming", "SessionStart", "UserPromptSubmit", "Stop", "Notification", "SessionEnd"], + result.Outcomes.Select(o => o.Event)); + Assert.Equal(6, result.Sidecar.Count); + Format(Parse(result.SettingsJson)["hooks"]!["AgentNaming"]!).MatchInlineSnapshot( + """ + [ + { + "matcher": "main", + "hooks": [ + { + "type": "command", + "command": "/home/agent/.dotnet/tools/nitro agent hook venti agent-naming", + "timeout": 10 + } + ] + } + ] + """); + } + + [Fact] + public void Install_Should_AddOnlyAgentNaming_When_VentiSettingsHoldTheFiveLifecycleEntries() + { + // arrange + var installed = Parse( + ClaudeHooksEditor.Install(null, ClaudeHooksTemplate.Venti, s_descriptor, DateTimeOffset.UnixEpoch) + .SettingsJson); + ((JsonObject)installed["hooks"]!).Remove("AgentNaming"); + var settings = Format(installed); + var statusBefore = ClaudeHooksEditor.Status(settings, ClaudeHooksTemplate.Venti, s_descriptor); + + // act + var result = ClaudeHooksEditor.Install(settings, ClaudeHooksTemplate.Venti, s_descriptor, DateTimeOffset.UnixEpoch); + + // assert + var reinstalled = ClaudeHooksEditor.Install( + result.SettingsJson, ClaudeHooksTemplate.Venti, s_descriptor, DateTimeOffset.UnixEpoch); + Assert.Equal(result.SettingsJson, reinstalled.SettingsJson); + new + { + StatusBefore = statusBefore.Select(r => $"{r.Event}: {r.Outcome}"), + Install = result.Outcomes.Select(o => $"{o.Event}: {o.Outcome}"), + Reinstall = reinstalled.Outcomes.Select(o => $"{o.Event}: {o.Outcome}"), + StatusAfter = ClaudeHooksEditor.Status(result.SettingsJson, ClaudeHooksTemplate.Venti, s_descriptor) + .Select(r => $"{r.Event}: {r.Outcome}") + }.MatchInlineSnapshot( + """ + { + "StatusBefore": [ + "AgentNaming: Missing", + "SessionStart: Installed", + "UserPromptSubmit: Installed", + "Stop: Installed", + "Notification: Installed", + "SessionEnd: Installed" + ], + "Install": [ + "AgentNaming: Installed", + "SessionStart: Unchanged", + "UserPromptSubmit: Unchanged", + "Stop: Unchanged", + "Notification: Unchanged", + "SessionEnd: Unchanged" + ], + "Reinstall": [ + "AgentNaming: Unchanged", + "SessionStart: Unchanged", + "UserPromptSubmit: Unchanged", + "Stop: Unchanged", + "Notification: Unchanged", + "SessionEnd: Unchanged" + ], + "StatusAfter": [ + "AgentNaming: Installed", + "SessionStart: Installed", + "UserPromptSubmit: Installed", + "Stop: Installed", + "Notification: Installed", + "SessionEnd: Installed" + ] + } + """); + } + + [Theory] + [InlineData("")] + [InlineData("\"matcher\": \"*\", ")] + [InlineData("\"matcher\": \"\", ")] + [InlineData("\"matcher\": \"subagent\", ")] + [InlineData("\"matcher\": \"main|subagent\", ")] + [InlineData("\"matcher\": null, ")] + public void Install_Should_MoveTheNamingHandlerToAMainGroup_When_ItsGroupHasAnotherMatcher(string groupProperties) + { + // arrange + var settings = + $$""" + { + "hooks": { + "AgentNaming": [ + { + {{groupProperties}}"hooks": [ + { "type": "command", "command": "./mine.sh" }, + { "type": "command", "command": "{{VentiCommand("AgentNaming")}}", "timeout": 10 } + ] + } + ] + } + } + """; + var statusBefore = ClaudeHooksEditor.Status(settings, ClaudeHooksTemplate.Venti, s_descriptor); + + // act + var result = ClaudeHooksEditor.Install(settings, ClaudeHooksTemplate.Venti, s_descriptor, DateTimeOffset.UnixEpoch); + + // assert + var groups = (JsonArray)Parse(result.SettingsJson)["hooks"]!["AgentNaming"]!; + Assert.Equal( + (HookStatusOutcome.Outdated, HookInstallOutcome.Updated, HookStatusOutcome.Installed), + (statusBefore.Single(r => r.Event == "AgentNaming").Outcome, + result.Outcomes.Single(o => o.Event == "AgentNaming").Outcome, + ClaudeHooksEditor.Status(result.SettingsJson, ClaudeHooksTemplate.Venti, s_descriptor) + .Single(r => r.Event == "AgentNaming").Outcome)); + Assert.Equal( + ["./mine.sh", VentiCommand("AgentNaming")], + groups.Select(g => g!["hooks"]!.AsArray().Single()!["command"]!.GetValue())); + Assert.Equal("main", groups[1]!["matcher"]!.GetValue()); + } + + [Fact] + public void Install_Should_KeepTheSwitchedOffHandlerInTheMainGroup_When_ANamingDuplicateRunsOnEveryAgent() + { + // arrange + var settings = + $$""" + { + "hooks": { + "AgentNaming": [ + { + "matcher": "*", + "hooks": [ + { "type": "command", "command": "./always.sh" }, + { "type": "command", "command": "{{VentiCommand("AgentNaming")}}", "timeout": 10 } + ] + }, + { + "matcher": "main", + "hooks": [ + { "type": "command", "command": "./mine.sh" }, + { "type": "command", "command": "/opt/old/nitro agent hook venti agent-naming", "timeout": 5, "enabled": false } + ] + } + ] + } + } + """; + + // act + var result = ClaudeHooksEditor.Install(settings, ClaudeHooksTemplate.Venti, s_descriptor, DateTimeOffset.UnixEpoch); + + // assert + Assert.Equal( + (HookInstallOutcome.Disabled, HookStatusOutcome.Disabled), + (result.Outcomes.Single(o => o.Event == "AgentNaming").Outcome, + ClaudeHooksEditor.Status(result.SettingsJson, ClaudeHooksTemplate.Venti, s_descriptor) + .Single(r => r.Event == "AgentNaming").Outcome)); + Format(Parse(result.SettingsJson)["hooks"]!["AgentNaming"]!).MatchInlineSnapshot( + """ + [ + { + "matcher": "*", + "hooks": [ + { + "type": "command", + "command": "./always.sh" + } + ] + }, + { + "matcher": "main", + "hooks": [ + { + "type": "command", + "command": "./mine.sh" + }, + { + "type": "command", + "command": "/home/agent/.dotnet/tools/nitro agent hook venti agent-naming", + "timeout": 10, + "enabled": false + } + ] + } + ] + """); + } + + [Fact] + public void Uninstall_Should_RemoveTheNamingHandlerAndKeepTheMainGroupSibling_When_TheyShareAGroup() + { + // arrange + var settings = + $$""" + { + "hooks": { + "AgentNaming": [ + { + "matcher": "main", + "hooks": [ + { "type": "command", "command": "{{VentiCommand("AgentNaming")}}", "timeout": 10 }, + { "type": "command", "command": "./mine.sh" } + ] + } + ] + } + } + """; + + // act + var result = ClaudeHooksEditor.Uninstall( + settings, ClaudeHooksTemplate.Venti, new Dictionary()); + + // assert + Assert.Equal(HookUninstallOutcome.Removed, result.Outcomes.Single(o => o.Event == "AgentNaming").Outcome); + Format(Parse(result.SettingsJson)).MatchInlineSnapshot( + """ + { + "hooks": { + "AgentNaming": [ + { + "matcher": "main", + "hooks": [ + { + "type": "command", + "command": "./mine.sh" + } + ] + } + ] + } + } + """); + } + + private static string VentiCommand(string hookEvent) + => ClaudeHooksTemplate.Venti.BuildCommand(s_descriptor, hookEvent); + private static Dictionary SidecarFor(string installedJson) { var hooks = (JsonObject)Parse(installedJson)["hooks"]!; var sidecar = new Dictionary(); - foreach (var claudeEvent in ClaudeHooksTemplate.Events) + foreach (var claudeEvent in s_template.Events) { var group = SingleOwnedGroup(hooks, claudeEvent); var hook = (JsonObject)((JsonArray)group["hooks"]!)[0]!; @@ -399,7 +1048,7 @@ private static JsonObject SingleOwnedGroup(JsonObject hooks, string claudeEvent) => (JsonObject)((JsonArray)hooks[claudeEvent]!) .Single(g => ((JsonObject)g!)["hooks"]!.AsArray() .All(h => ((JsonObject)h!)["command"]!.GetValue() - .Contains(ClaudeHooksTemplate.CommandMarker, StringComparison.Ordinal)))!; + .Contains(s_template.CommandMarker, StringComparison.Ordinal)))!; private static JsonObject SingleGroup(JsonObject hooks, string claudeEvent) { @@ -418,4 +1067,6 @@ private static void AssertCommand(JsonObject group, string expectedCommand, int } private static JsonObject Parse(string json) => (JsonObject)JsonNode.Parse(json)!; + + private static string Format(JsonNode node) => node.ToJsonString(new() { WriteIndented = true }); } diff --git a/src/Nitro/CommandLine/test/CommandLine.Tests/Services/Hook/ClaudeHooksInstallerServiceTests.cs b/src/Nitro/CommandLine/test/CommandLine.Tests/Services/Hook/ClaudeHooksInstallerServiceTests.cs index 2beb29d561e..22aac0ca6aa 100644 --- a/src/Nitro/CommandLine/test/CommandLine.Tests/Services/Hook/ClaudeHooksInstallerServiceTests.cs +++ b/src/Nitro/CommandLine/test/CommandLine.Tests/Services/Hook/ClaudeHooksInstallerServiceTests.cs @@ -147,7 +147,8 @@ public async Task InstallAsync_TwoInterleavedInstalls_KeepsBothSidecarEntries() new FixedClaudeSettingsPathResolver(settingsPathB), new FixedLaunchDescriptorResolver(s_descriptor), new ClaudeHooksSidecarStore(_fileSystem, new FixedSidecarDirectoryProvider(_sidecarDirectory)), - _timeProvider); + _timeProvider, + ClaudeHooksTemplate.Claude); // Complete serviceB's install after serviceA reads the sidecar, but before it writes. var injectingFileSystem = new RunOnFirstReadFileSystem( @@ -158,7 +159,8 @@ public async Task InstallAsync_TwoInterleavedInstalls_KeepsBothSidecarEntries() new FixedClaudeSettingsPathResolver(settingsPathA), new FixedLaunchDescriptorResolver(s_descriptor), new ClaudeHooksSidecarStore(injectingFileSystem, new FixedSidecarDirectoryProvider(_sidecarDirectory)), - _timeProvider); + _timeProvider, + ClaudeHooksTemplate.Claude); await serviceA.InstallAsync(HookInstallScopes.User, ct); @@ -181,7 +183,8 @@ public async Task UninstallAsync_InterleavedInstall_KeepsConcurrentSidecarEntry( new FixedClaudeSettingsPathResolver(settingsPathA), new FixedLaunchDescriptorResolver(s_descriptor), new ClaudeHooksSidecarStore(_fileSystem, new FixedSidecarDirectoryProvider(_sidecarDirectory)), - _timeProvider); + _timeProvider, + ClaudeHooksTemplate.Claude); await preInstallService.InstallAsync(HookInstallScopes.User, ct); var serviceB = new ClaudeHooksInstallerService( @@ -189,7 +192,8 @@ public async Task UninstallAsync_InterleavedInstall_KeepsConcurrentSidecarEntry( new FixedClaudeSettingsPathResolver(settingsPathB), new FixedLaunchDescriptorResolver(s_descriptor), new ClaudeHooksSidecarStore(_fileSystem, new FixedSidecarDirectoryProvider(_sidecarDirectory)), - _timeProvider); + _timeProvider, + ClaudeHooksTemplate.Claude); // Complete serviceB's install after serviceA reads the sidecar, but before it writes. var injectingFileSystem = new RunOnFirstReadFileSystem( @@ -200,7 +204,8 @@ public async Task UninstallAsync_InterleavedInstall_KeepsConcurrentSidecarEntry( new FixedClaudeSettingsPathResolver(settingsPathA), new FixedLaunchDescriptorResolver(s_descriptor), new ClaudeHooksSidecarStore(injectingFileSystem, new FixedSidecarDirectoryProvider(_sidecarDirectory)), - _timeProvider); + _timeProvider, + ClaudeHooksTemplate.Claude); await serviceA.UninstallAsync(HookInstallScopes.User, ct); @@ -217,11 +222,14 @@ private static JsonObject SidecarFiles(string sidecarJson) new FixedClaudeSettingsPathResolver(_settingsPath), new FixedLaunchDescriptorResolver(s_descriptor), new ClaudeHooksSidecarStore(fileSystem, new FixedSidecarDirectoryProvider(_sidecarDirectory)), - _timeProvider); + _timeProvider, + ClaudeHooksTemplate.Claude); private sealed class FixedClaudeSettingsPathResolver(string path) : IClaudeSettingsPathResolver { public string Resolve(string scope) => path; + + public IReadOnlyList ResolveForCurrentDirectory() => [path]; } private sealed class FixedLaunchDescriptorResolver(LaunchDescriptor descriptor) : ILaunchDescriptorResolver diff --git a/src/Nitro/CommandLine/test/CommandLine.Tests/Services/Hook/LaunchDescriptorTests.cs b/src/Nitro/CommandLine/test/CommandLine.Tests/Services/Hook/LaunchDescriptorTests.cs new file mode 100644 index 00000000000..8e04dded05c --- /dev/null +++ b/src/Nitro/CommandLine/test/CommandLine.Tests/Services/Hook/LaunchDescriptorTests.cs @@ -0,0 +1,112 @@ +using System.Diagnostics; +using ChilliCream.Nitro.CommandLine.Services.Hook; + +namespace ChilliCream.Nitro.CommandLine.Tests.Hook; + +/// +/// Tests the PowerShell command lines builds for harnesses that run +/// Windows hooks through powershell.exe -Command. +/// +public sealed class LaunchDescriptorTests : IDisposable +{ + private readonly DirectoryInfo _tempRoot = Directory.CreateTempSubdirectory("nitro-launch-descriptor-tests"); + + public void Dispose() => _tempRoot.Delete(recursive: true); + + [Fact] + public void BuildPowerShellCommandLine_Should_CallTheQuotedExecutable_When_PathsContainSpacesAndApostrophes() + { + // arrange + var descriptor = new LaunchDescriptor( + @"C:\Program Files\dotnet\dotnet.exe", + [@"C:\Users\o'brien\nitro tools\nitro.dll"]); + + // act + var command = descriptor.BuildPowerShellCommandLine(["agent", "hook", "venti", "stop"]); + + // assert + command.MatchInlineSnapshot( + """ + & 'C:\Program Files\dotnet\dotnet.exe' 'C:\Users\o''brien\nitro tools\nitro.dll' agent hook venti stop + """); + } + + [Fact] + public void PowerShellQuote_Should_DoubleEverySingleQuoteCharacter_When_TheTokenContainsTypographicQuotes() + { + // arrange + // act + var quoted = LaunchDescriptor.PowerShellQuote("C:\\it\u2019s \u2018a\u2019 dir"); + + // assert + Assert.Equal("'C:\\it\u2019\u2019s \u2018\u2018a\u2019\u2019 dir'", quoted); + } + + [Fact] + public void BuildPowerShellCommandLine_Should_LeaveTheCommandUnquoted_When_NoTokenNeedsQuoting() + { + // arrange + var descriptor = new LaunchDescriptor(@"C:\tools\nitro.exe", []); + + // act + var command = descriptor.BuildPowerShellCommandLine(["agent", "hook", "venti", "session-start"]); + + // assert + command.MatchInlineSnapshot(@"C:\tools\nitro.exe agent hook venti session-start"); + } + + [Fact] + public async Task BuildPowerShellCommandLine_Should_PassEveryArgumentIntact_When_PowerShellRunsTheCommand() + { + // arrange + var powerShell = FindPowerShell(); + Assert.SkipWhen(powerShell is null, "PowerShell is not installed."); + var cancellationToken = TestContext.Current.CancellationToken; + var directory = Path.Combine(_tempRoot.FullName, "it's a dir"); + Directory.CreateDirectory(directory); + var executable = await WriteArgumentEchoAsync(directory, cancellationToken); + var assembly = Path.Combine(directory, "nitro tool's.dll"); + var command = new LaunchDescriptor(executable, [assembly]) + .BuildPowerShellCommandLine(["agent", "hook", "venti", "stop"]); + + // act + var start = new ProcessStartInfo(powerShell!) { ArgumentList = { "-NoProfile", "-Command", command } }; + start.RedirectStandardOutput = true; + using var process = Process.Start(start)!; + var output = await process.StandardOutput.ReadToEndAsync(cancellationToken); + await process.WaitForExitAsync(cancellationToken); + + // assert + Assert.Equal( + (0, $"{assembly}\nagent\nhook\nventi\nstop"), + (process.ExitCode, output.ReplaceLineEndings("\n").TrimEnd())); + } + + private static async Task WriteArgumentEchoAsync(string directory, CancellationToken cancellationToken) + { + if (OperatingSystem.IsWindows()) + { + var batch = Path.Combine(directory, "echo args.cmd"); + await File.WriteAllTextAsync( + batch, + "@echo off\r\n:next\r\nif \"%~1\"==\"\" goto :eof\r\necho %~1\r\nshift\r\ngoto next\r\n", + cancellationToken); + return batch; + } + + var script = Path.Combine(directory, "echo args.sh"); + await File.WriteAllTextAsync(script, "#!/bin/sh\nprintf '%s\\n' \"$@\"\n", cancellationToken); + File.SetUnixFileMode(script, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute); + return script; + } + + private static string? FindPowerShell() + { + string[] names = OperatingSystem.IsWindows() ? ["pwsh.exe", "powershell.exe"] : ["pwsh"]; + + return (Environment.GetEnvironmentVariable("PATH") ?? string.Empty) + .Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries) + .SelectMany(directory => names.Select(name => Path.Combine(directory, name))) + .FirstOrDefault(File.Exists); + } +} diff --git a/src/Nitro/CommandLine/test/CommandLine.Tests/Services/Hook/VentiHookHandlerTests.cs b/src/Nitro/CommandLine/test/CommandLine.Tests/Services/Hook/VentiHookHandlerTests.cs new file mode 100644 index 00000000000..a91a03546cb --- /dev/null +++ b/src/Nitro/CommandLine/test/CommandLine.Tests/Services/Hook/VentiHookHandlerTests.cs @@ -0,0 +1,603 @@ +using System.Text.Json; +using ChilliCream.Nitro.CommandLine.Services.Hook; +using ChilliCream.Nitro.CommandLine.Services.Mail; +using ChilliCream.Nitro.CommandLine.Services.Workspace; +using ChilliCream.Nitro.CommandLine.Tests.Agents; +using Microsoft.Extensions.Time.Testing; + +namespace ChilliCream.Nitro.CommandLine.Tests.Hook; + +/// +/// Tests bound to the Venti harness and Venti's peer registry, +/// against a real workspace database and a temporary registry directory. +/// +public sealed class VentiHookHandlerTests : IDisposable +{ + private const string SessionId = "0123456789abcdef0123456789abcdef"; + + private readonly DirectoryInfo _tempRoot; + private readonly string _workspaceRoot; + private readonly string _workspaceDirectory; + private readonly string _peerDirectory; + private readonly TestFileSystem _fileSystem; + private readonly FakeTimeProvider _timeProvider; + private readonly AgentDatabase _database; + private readonly AgentStore _agentStore; + private readonly AgentDeliveryLedger _ledger; + private readonly MailStore _mail; + private readonly ClaudeHookHandler _venti; + private readonly ClaudeHookHandler _claude; + + public VentiHookHandlerTests() + { + _tempRoot = Directory.CreateTempSubdirectory("nitro-venti-hook-handler-tests"); + _workspaceRoot = Path.Combine(_tempRoot.FullName, "repo"); + _workspaceDirectory = AgentWorkspace.GetDirectory(_workspaceRoot); + Directory.CreateDirectory(_workspaceDirectory); + _peerDirectory = Path.Combine(_tempRoot.FullName, "home", ".venti", "peers"); + Directory.CreateDirectory(_peerDirectory); + _fileSystem = new TestFileSystem(_workspaceRoot); + _timeProvider = new FakeTimeProvider(new DateTimeOffset(2026, 1, 10, 12, 0, 0, TimeSpan.Zero)); + _database = new AgentDatabase(); + _agentStore = new AgentStore(_fileSystem, _timeProvider, _database); + _ledger = new AgentDeliveryLedger(_fileSystem, _database); + _mail = new MailStore(_fileSystem, _timeProvider, _database, _agentStore); + _venti = CreateHandler(AgentSessionHarness.Venti, new VentiPeerRegistryReader(_peerDirectory)); + _claude = CreateHandler(AgentSessionHarness.ClaudeCode, new FixedClaudeSessionFileReader()); + } + + public void Dispose() => _tempRoot.Delete(recursive: true); + + [Fact] + public async Task HandleSessionStartAsync_Should_BindSeparateRows_When_ClaudeAndVentiReportTheSameSessionId() + { + // arrange + var cancellationToken = TestContext.Current.CancellationToken; + await InitializeWorkspaceAsync(cancellationToken); + await _claude.HandleSessionStartAsync(Payload(), skipSessionFileLookup: false, cancellationToken); + + // act + await _venti.HandleSessionStartAsync(Payload(), skipSessionFileLookup: false, cancellationToken); + + // assert + var claudeRow = await _agentStore.FindBySessionAsync( + AgentSessionHarness.ClaudeCode, SessionId, cancellationToken); + var ventiRow = await _agentStore.FindBySessionAsync(AgentSessionHarness.Venti, SessionId, cancellationToken); + Assert.NotEqual(claudeRow!.Name, ventiRow!.Name); + new[] { claudeRow, ventiRow } + .Select(row => new { row.Harness, Display = AgentHarnessDisplay.Name(row.Harness) }) + .MatchInlineSnapshot( + """ + [ + { + "Harness": "claude-code", + "Display": "Claude Code" + }, + { + "Harness": "venti", + "Display": "Venti" + } + ] + """); + } + + [Fact] + public async Task HandleSessionEndAsync_Should_EndOnlyTheVentiRow_When_AClaudeSessionSharesTheSessionId() + { + // arrange + var cancellationToken = TestContext.Current.CancellationToken; + await InitializeWorkspaceAsync(cancellationToken); + await _claude.HandleSessionStartAsync(Payload(), skipSessionFileLookup: false, cancellationToken); + await _venti.HandleSessionStartAsync(Payload(), skipSessionFileLookup: false, cancellationToken); + + // act + await _venti.HandleSessionEndAsync(Payload(), skipSessionFileLookup: false, cancellationToken); + + // assert + var claudeRow = await _agentStore.FindBySessionAsync( + AgentSessionHarness.ClaudeCode, SessionId, cancellationToken); + var ventiRow = await _agentStore.FindBySessionAsync(AgentSessionHarness.Venti, SessionId, cancellationToken); + Assert.Equal( + (AgentState.Unreachable, AgentState.Offline), + (AgentStateResolver.Resolve(claudeRow!, _timeProvider.GetUtcNow()), + AgentStateResolver.Resolve(ventiRow!, _timeProvider.GetUtcNow()))); + } + + [Fact] + public async Task HandleSessionStartAsync_Should_AdvertiseThePeerEndpoint_When_AnInteractiveSessionIsRegistered() + { + // arrange + var cancellationToken = TestContext.Current.CancellationToken; + await InitializeWorkspaceAsync(cancellationToken); + await WriteRegistryAsync(kind: "interactive", protocol: 1, cancellationToken); + + // act + await _venti.HandleSessionStartAsync(Payload(), skipSessionFileLookup: false, cancellationToken); + + // assert + var row = await _agentStore.FindBySessionAsync(AgentSessionHarness.Venti, SessionId, cancellationToken); + Assert.Equal( + (AgentSessionEndpointKind.ClaudePeer, SessionId, AgentState.Online), + (row!.EndpointKind, row.EndpointAddr, AgentStateResolver.Resolve(row, _timeProvider.GetUtcNow()))); + } + + [Fact] + public async Task HandleSessionStartAsync_Should_LeaveTheAgentUnreachable_When_TheSessionAdvertisesNoEndpoint() + { + // arrange + var cancellationToken = TestContext.Current.CancellationToken; + await InitializeWorkspaceAsync(cancellationToken); + + // act + await _venti.HandleSessionStartAsync(Payload(), skipSessionFileLookup: false, cancellationToken); + + // assert + var row = await _agentStore.FindBySessionAsync(AgentSessionHarness.Venti, SessionId, cancellationToken); + Assert.Equal( + (AgentSessionEndpointKind.None, AgentState.Unreachable), + (row!.EndpointKind, AgentStateResolver.Resolve(row, _timeProvider.GetUtcNow()))); + } + + [Fact] + public async Task HandleUserPromptSubmitAsync_Should_AdoptThePeerEndpoint_When_TheRegistryAppearsAfterSessionStart() + { + // arrange + // Venti starts SessionStart hooks before it opens the session's peer endpoint. + var cancellationToken = TestContext.Current.CancellationToken; + await InitializeWorkspaceAsync(cancellationToken); + await _venti.HandleSessionStartAsync(Payload(), skipSessionFileLookup: false, cancellationToken); + await WriteRegistryAsync(kind: "interactive", protocol: 1, cancellationToken); + + // act + await _venti.HandleUserPromptSubmitAsync(Payload(), skipSessionFileLookup: false, cancellationToken); + + // assert + var row = await _agentStore.FindBySessionAsync(AgentSessionHarness.Venti, SessionId, cancellationToken); + Assert.Equal((AgentSessionEndpointKind.ClaudePeer, SessionId), (row!.EndpointKind, row.EndpointAddr)); + } + + [Fact] + public async Task HandleUserPromptSubmitAsync_Should_InjectTheDigest_When_TheVentiPayloadHasNullTranscriptPath() + { + // arrange + var cancellationToken = TestContext.Current.CancellationToken; + await InitializeWorkspaceAsync(cancellationToken); + var actor = await StartAndGetActorAsync(cancellationToken); + var message = await SendMailAsync("bob", actor, cancellationToken); + var payload = JsonSerializer.Deserialize( + $$""" + { + "session_id": "{{SessionId}}", + "transcript_path": null, + "cwd": {{JsonSerializer.Serialize(_workspaceRoot)}}, + "hook_event_name": "UserPromptSubmit", + "permission_mode": "default", + "prompt": "continue" + } + """, + ClaudeHookJsonContext.Default.ClaudeHookPayload)!; + + // act + var outcome = await _venti.HandleUserPromptSubmitAsync( + payload, skipSessionFileLookup: false, cancellationToken); + + // assert + outcome.AdditionalContext!.Replace(actor, "").Replace(message.Id, "").MatchInlineSnapshot( + """ + You have 1 unread nitro message; 1 shown below as `nitro agent mail read --thread --output json` prints them. Reply with `nitro agent mail reply --message --actor --body "..."` or ack with `nitro agent mail ack --message --actor `; anything not shown is in `nitro agent mail inbox --unread --actor `. + { + "items": [ + { + "id": "", + "threadId": "", + "inReplyTo": null, + "from": "bob", + "to": [ + "" + ], + "cc": [], + "subject": "status", + "body": "please check", + "createdAt": "2026-01-10T12:00:00+00:00", + "read": false, + "archived": false, + "takeovers": [] + } + ] + } + """); + } + + [Fact] + public async Task HandleStopAsync_Should_BlockWithTheDigest_When_UnreadMailWaits() + { + // arrange + var cancellationToken = TestContext.Current.CancellationToken; + await InitializeWorkspaceAsync(cancellationToken); + var actor = await StartAndGetActorAsync(cancellationToken); + var message = await SendMailAsync("bob", actor, cancellationToken); + + // act + var outcome = await _venti.HandleStopAsync(Payload(), skipSessionFileLookup: false, cancellationToken); + + // assert + Assert.True(outcome.Block); + outcome.BlockReason!.Replace(actor, "").Replace(message.Id, "").MatchInlineSnapshot( + """ + Unread nitro mail is waiting; handle it before ending this turn, or ignore this once if it is not actionable right now. + You have 1 unread nitro message; 1 shown below as `nitro agent mail read --thread --output json` prints them. Reply with `nitro agent mail reply --message --actor --body "..."` or ack with `nitro agent mail ack --message --actor `; anything not shown is in `nitro agent mail inbox --unread --actor `. + { + "items": [ + { + "id": "", + "threadId": "", + "inReplyTo": null, + "from": "bob", + "to": [ + "" + ], + "cc": [], + "subject": "status", + "body": "please check", + "createdAt": "2026-01-10T12:00:00+00:00", + "read": false, + "archived": false, + "takeovers": [] + } + ] + } + """); + } + + [Fact] + public async Task HandleStopAsync_Should_ReturnNeutral_When_StopHookActiveIsTrue() + { + // arrange + var cancellationToken = TestContext.Current.CancellationToken; + await InitializeWorkspaceAsync(cancellationToken); + var actor = await StartAndGetActorAsync(cancellationToken); + await SendMailAsync("bob", actor, cancellationToken); + + // act + var outcome = await _venti.HandleStopAsync( + Payload() with { StopHookActive = true }, skipSessionFileLookup: false, cancellationToken); + + // assert + Assert.Equal(ClaudeHookOutcome.Neutral, outcome); + } + + [Fact] + public async Task HandleStopAsync_Should_StopBlocking_When_ThePerTurnBudgetIsExhausted() + { + // arrange + var cancellationToken = TestContext.Current.CancellationToken; + await InitializeWorkspaceAsync(cancellationToken); + var actor = await StartAndGetActorAsync(cancellationToken); + + for (var i = 0; i < ClaudeHookHandler.MaxBlocksPerTurn; i++) + { + await SendMailAsync($"bob-{i}", actor, cancellationToken); + await _venti.HandleStopAsync(Payload(), skipSessionFileLookup: false, cancellationToken); + } + + await SendMailAsync("carol", actor, cancellationToken); + + // act + var outcome = await _venti.HandleStopAsync(Payload(), skipSessionFileLookup: false, cancellationToken); + + // assert + Assert.Equal(ClaudeHookOutcome.Neutral, outcome); + } + + [Theory] + [InlineData("permission_prompt")] + [InlineData("agent_completed")] + [InlineData("agent_needs_input")] + public async Task HandleNotificationAsync_Should_LeaveTheStoreUntouched_When_TheTypeIsNotIdlePrompt( + string notificationType) + { + // arrange + var cancellationToken = TestContext.Current.CancellationToken; + await InitializeWorkspaceAsync(cancellationToken); + + // act + var outcome = await _venti.HandleNotificationAsync( + Payload() with { NotificationType = notificationType }, skipSessionFileLookup: false, cancellationToken); + + // assert + Assert.Equal(ClaudeHookOutcome.Neutral, outcome); + Assert.Null(await _agentStore.FindBySessionAsync(AgentSessionHarness.Venti, SessionId, cancellationToken)); + } + + [Fact] + public async Task HandleNotificationAsync_Should_MintAndAnnounceTheActor_When_TheTypeIsIdlePrompt() + { + // arrange + var cancellationToken = TestContext.Current.CancellationToken; + await InitializeWorkspaceAsync(cancellationToken); + + // act + var outcome = await _venti.HandleNotificationAsync( + Payload() with { NotificationType = "idle_prompt" }, skipSessionFileLookup: false, cancellationToken); + + // assert + var row = await _agentStore.FindBySessionAsync(AgentSessionHarness.Venti, SessionId, cancellationToken); + outcome.AdditionalContext!.Replace(row!.Name, "").MatchInlineSnapshot( + """ + Your Nitro actor name is "". Pass this name to the `--actor` option to act under this actor explicitly. + """); + } + + [Fact] + public async Task HandleAgentNamingAsync_Should_ReturnTheSameActor_When_TheSameSessionIdIsNamedAgain() + { + // arrange + var cancellationToken = TestContext.Current.CancellationToken; + await InitializeWorkspaceAsync(cancellationToken); + var first = await _venti.HandleAgentNamingAsync(MainNamingPayload(), skipSessionFileLookup: false, cancellationToken); + + // act + var second = await _venti.HandleAgentNamingAsync(MainNamingPayload(), skipSessionFileLookup: false, cancellationToken); + + // assert + var row = await _agentStore.FindBySessionAsync(AgentSessionHarness.Venti, SessionId, cancellationToken); + Assert.Equal((row!.Name, row.Name), (first.AgentName, second.AgentName)); + Assert.Single(await _agentStore.ListAsync(cancellationToken)); + } + + [Fact] + public async Task HandleAgentNamingAsync_Should_KeepTheActorAndRole_When_LifecycleEventsAndAResumeFollow() + { + // arrange + var cancellationToken = TestContext.Current.CancellationToken; + await InitializeWorkspaceAsync(cancellationToken); + var naming = await _venti.HandleAgentNamingAsync(MainNamingPayload(), skipSessionFileLookup: false, cancellationToken); + await _agentStore.SetRoleAsync(naming.AgentName!, "reviewer", cancellationToken); + + // act + var start = await _venti.HandleSessionStartAsync(Payload(), skipSessionFileLookup: false, cancellationToken); + await _venti.HandleUserPromptSubmitAsync(Payload(), skipSessionFileLookup: false, cancellationToken); + _timeProvider.Advance(TimeSpan.FromDays(2)); + await _venti.HandleNotificationAsync( + Payload() with { NotificationType = "idle_prompt" }, skipSessionFileLookup: false, cancellationToken); + await _venti.HandleSessionEndAsync(Payload(), skipSessionFileLookup: false, cancellationToken); + var resume = await _venti.HandleSessionStartAsync(Payload(), skipSessionFileLookup: false, cancellationToken); + + // assert + var rows = await _agentStore.ListAsync(cancellationToken); + new + { + Rows = rows.Select(row => new { Name = row.Name.Replace(naming.AgentName!, ""), row.Role, row.SessionId }), + Start = start.AdditionalContext!.Replace(naming.AgentName!, ""), + Resume = resume.AdditionalContext!.Replace(naming.AgentName!, "") + }.MatchInlineSnapshot( + """ + { + "Rows": [ + { + "Name": "", + "Role": "reviewer", + "SessionId": "0123456789abcdef0123456789abcdef" + } + ], + "Start": "Your Nitro actor name is \"\". Pass this name to the `--actor` option to act under this actor explicitly.\nYour Nitro role is \"reviewer\".", + "Resume": "Your Nitro actor name is \"\". Pass this name to the `--actor` option to act under this actor explicitly.\nYour Nitro role is \"reviewer\"." + } + """); + } + + [Fact] + public async Task HandleAgentNamingAsync_Should_GiveAFreshActor_When_AForkNamesANewSessionId() + { + // arrange + var cancellationToken = TestContext.Current.CancellationToken; + await InitializeWorkspaceAsync(cancellationToken); + var original = await _venti.HandleAgentNamingAsync(MainNamingPayload(), skipSessionFileLookup: false, cancellationToken); + await _agentStore.SetRoleAsync(original.AgentName!, "reviewer", cancellationToken); + + // act + var fork = await _venti.HandleAgentNamingAsync( + MainNamingPayload() with { SessionId = "fedcba9876543210fedcba9876543210" }, + skipSessionFileLookup: false, + cancellationToken); + + // assert + var rows = await _agentStore.ListAsync(cancellationToken); + Assert.NotEqual(original.AgentName, fork.AgentName); + rows.OrderBy(row => row.SessionId) + .Select(row => new + { + Name = row.Name == original.AgentName ? "" : row.Name == fork.AgentName ? "" : row.Name, + row.Role, + row.SessionId + }) + .MatchInlineSnapshot( + """ + [ + { + "Name": "", + "Role": "reviewer", + "SessionId": "0123456789abcdef0123456789abcdef" + }, + { + "Name": "", + "Role": "", + "SessionId": "fedcba9876543210fedcba9876543210" + } + ] + """); + } + + [Fact] + public async Task HandleAgentNamingAsync_Should_NameEachSessionDistinctly_When_IndependentHandlersShareTheWorkspace() + { + // arrange + var cancellationToken = TestContext.Current.CancellationToken; + await InitializeWorkspaceAsync(cancellationToken); + var handlers = Enumerable.Range(0, 8).Select(_ => CreateIndependentVentiHandler()).ToList(); + + // act + var outcomes = await Task.WhenAll(handlers.Select((handler, index) => handler.HandleAgentNamingAsync( + MainNamingPayload() with { SessionId = $"session-{index}" }, skipSessionFileLookup: false, cancellationToken))); + await _claude.HandleSessionStartAsync( + Payload() with { SessionId = "session-0" }, skipSessionFileLookup: false, cancellationToken); + + // assert + var rows = await _agentStore.ListAsync(cancellationToken); + Assert.Equal(9, rows.Select(row => row.Name).Distinct().Count()); + Assert.Equal( + outcomes.Select(outcome => outcome.AgentName).Order(), + rows.Where(row => row.Harness == AgentSessionHarness.Venti).Select(row => row.Name).Order()); + } + + [Fact] + public async Task HandleAgentNamingAsync_Should_LeaveTheStoreUntouched_When_ASubagentIsNamedInABoundSession() + { + // arrange + var cancellationToken = TestContext.Current.CancellationToken; + await InitializeWorkspaceAsync(cancellationToken); + var actor = await StartAndGetActorAsync(cancellationToken); + _timeProvider.Advance(TimeSpan.FromMinutes(5)); + + // act + var outcome = await _venti.HandleAgentNamingAsync( + Payload() with { AgentKind = "subagent" }, skipSessionFileLookup: false, cancellationToken); + + // assert + var rows = await _agentStore.ListAsync(cancellationToken); + Assert.Equal(ClaudeHookOutcome.Neutral, outcome); + Assert.Equal( + (actor, _timeProvider.GetUtcNow() - TimeSpan.FromMinutes(5)), + (Assert.Single(rows).Name, rows[0].LastSeenAt)); + } + + [Fact] + public async Task HandleAgentNamingAsync_Should_ReturnNeutral_When_TheSessionBelongsToADeletedAgent() + { + // arrange + var cancellationToken = TestContext.Current.CancellationToken; + await InitializeWorkspaceAsync(cancellationToken); + var actor = await StartAndGetActorAsync(cancellationToken); + await _agentStore.DeleteAsync(actor, cancellationToken); + + // act + var outcome = await _venti.HandleAgentNamingAsync(MainNamingPayload(), skipSessionFileLookup: false, cancellationToken); + + // assert + var row = await _agentStore.FindBySessionAsync(AgentSessionHarness.Venti, SessionId, cancellationToken); + Assert.Equal(ClaudeHookOutcome.Neutral, outcome); + Assert.Equal((actor, true), (row!.Name, row.IsDeleted)); + Assert.Empty(await _agentStore.ListAsync(cancellationToken)); + } + + [Fact] + public async Task HandleAgentNamingAsync_Should_ReturnNeutral_When_NoWorkspaceIsFound() + { + // arrange + var cancellationToken = TestContext.Current.CancellationToken; + + // act + var outcome = await _venti.HandleAgentNamingAsync(MainNamingPayload(), skipSessionFileLookup: false, cancellationToken); + + // assert + Assert.Equal(ClaudeHookOutcome.Neutral, outcome); + Assert.False(File.Exists(AgentWorkspace.GetDatabasePath(_workspaceDirectory))); + } + + [Theory] + [InlineData("headless", 1)] + [InlineData("interactive", 2)] + public async Task Find_Should_ReturnNull_When_TheRegistryDoesNotAdvertiseAProtocol1InteractiveEndpoint( + string kind, + int protocol) + { + // arrange + var cancellationToken = TestContext.Current.CancellationToken; + await WriteRegistryAsync(kind, protocol, cancellationToken); + var reader = new VentiPeerRegistryReader(_peerDirectory); + + // act + var session = reader.Find(SessionId); + + // assert + Assert.Null(session); + } + + private ClaudeHookHandler CreateHandler(string harness, IClaudeSessionFileReader sessionFileReader) => new( + _fileSystem, + _timeProvider, + _agentStore, + _ledger, + _mail, + harness, + sessionFileReader); + + private ClaudeHookPayload Payload() => new() { SessionId = SessionId, Cwd = _workspaceRoot }; + + private ClaudeHookPayload MainNamingPayload() => Payload() with { AgentKind = "main" }; + + private ClaudeHookHandler CreateIndependentVentiHandler() + { + var database = new AgentDatabase(); + var agentStore = new AgentStore(_fileSystem, _timeProvider, database); + + return new ClaudeHookHandler( + _fileSystem, + _timeProvider, + agentStore, + new AgentDeliveryLedger(_fileSystem, database), + new MailStore(_fileSystem, _timeProvider, database, agentStore), + AgentSessionHarness.Venti, + new VentiPeerRegistryReader(_peerDirectory)); + } + + private Task WriteRegistryAsync(string kind, int protocol, CancellationToken cancellationToken) + => File.WriteAllTextAsync( + Path.Combine(_peerDirectory, SessionId + ".json"), + JsonSerializer.Serialize( + new + { + sessionId = SessionId, + pid = 4242, + kind, + messagingSocketPath = Path.Combine(_peerDirectory, "0011223344556677.sock"), + procStart = 1767960000000, + peerProtocol = protocol + }), + cancellationToken); + + private async Task InitializeWorkspaceAsync(CancellationToken cancellationToken) + { + await using (await _database.InitializeAsync(_workspaceDirectory, cancellationToken)) + { + } + } + + private async Task StartAndGetActorAsync(CancellationToken cancellationToken) + { + await _venti.HandleSessionStartAsync(Payload(), skipSessionFileLookup: false, cancellationToken); + + return (await _agentStore.FindBySessionAsync(AgentSessionHarness.Venti, SessionId, cancellationToken))!.Name; + } + + private async Task SendMailAsync(string sender, string recipient, CancellationToken cancellationToken) + { + await using (var connection = await _database.ConnectAsync(_workspaceDirectory, cancellationToken)) + { + await using var command = connection.CreateCommand(); + command.CommandText = + """ + INSERT INTO agents (name, registered_at, started_at, last_seen_at) + VALUES (@name, @now, @now, @now) + ON CONFLICT (name) DO NOTHING; + """; + command.Parameters.AddWithValue("@name", sender); + command.Parameters.AddWithValue("@now", _timeProvider.GetUtcNow()); + await command.ExecuteNonQueryAsync(cancellationToken); + } + + return await _mail.SendMessageAsync( + new MailMessageCreation { Sender = sender, Subject = "status", Body = "please check", To = [recipient] }, + cancellationToken); + } +} diff --git a/src/Nitro/CommandLine/test/CommandLine.Tests/Services/Hook/VentiSettingsPathResolverTests.cs b/src/Nitro/CommandLine/test/CommandLine.Tests/Services/Hook/VentiSettingsPathResolverTests.cs new file mode 100644 index 00000000000..de31b54e288 --- /dev/null +++ b/src/Nitro/CommandLine/test/CommandLine.Tests/Services/Hook/VentiSettingsPathResolverTests.cs @@ -0,0 +1,69 @@ +using ChilliCream.Nitro.CommandLine.Services.Hook; +using ChilliCream.Nitro.CommandLine.Services.Workspace; + +namespace ChilliCream.Nitro.CommandLine.Tests.Hook; + +/// +/// Tests against a real workspace in a temporary directory. +/// +public sealed class VentiSettingsPathResolverTests : IDisposable +{ + private readonly DirectoryInfo _tempRoot = Directory.CreateTempSubdirectory("nitro-venti-settings-path-tests"); + + public void Dispose() => _tempRoot.Delete(recursive: true); + + [Fact] + public async Task Resolve_Should_TargetTheCurrentDirectory_When_ProjectScopeRunsInAWorkspaceSubdirectory() + { + // arrange + var cancellationToken = TestContext.Current.CancellationToken; + var workspaceDirectory = AgentWorkspace.GetDirectory(_tempRoot.FullName); + Directory.CreateDirectory(workspaceDirectory); + await using (await new AgentDatabase().InitializeAsync(workspaceDirectory, cancellationToken)) + { + } + + var launchDirectory = Path.Combine(_tempRoot.FullName, "src", "app"); + Directory.CreateDirectory(launchDirectory); + var fileSystem = new TestFileSystem(launchDirectory); + + // act + var ventiPath = new VentiSettingsPathResolver(fileSystem).Resolve(HookInstallScopes.Project); + var claudePath = new ClaudeSettingsPathResolver(fileSystem).Resolve(HookInstallScopes.Project); + + // assert + Assert.Equal( + (Path.Combine(launchDirectory, ".venti", "settings.json"), + Path.Combine(_tempRoot.FullName, ".claude", "settings.json")), + (ventiPath, claudePath)); + } + + [Fact] + public void Resolve_Should_ThrowWithInitGuidance_When_ProjectScopeRunsOutsideAWorkspace() + { + // arrange + var resolver = new VentiSettingsPathResolver(new TestFileSystem(_tempRoot.FullName)); + + // act + var exception = Assert.Throws(() => resolver.Resolve(HookInstallScopes.Project)); + + // assert + exception.Message.MatchInlineSnapshot("No agent workspace found. Run `nitro agent init` first."); + } + + [Fact] + public void Resolve_Should_TargetTheGlobalVentiSettings_When_UserScopeIsSelected() + { + // arrange + var resolver = new VentiSettingsPathResolver(new TestFileSystem(_tempRoot.FullName)); + + // act + var path = resolver.Resolve(HookInstallScopes.User); + + // assert + Assert.Equal( + Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".venti", "settings.json"), + path); + } +} diff --git a/src/Nitro/CommandLine/test/CommandLine.Tests/Services/Notify/ClaudePeerClientTests.cs b/src/Nitro/CommandLine/test/CommandLine.Tests/Services/Notify/ClaudePeerClientTests.cs index e32a116f5cb..90cd148f3b5 100644 --- a/src/Nitro/CommandLine/test/CommandLine.Tests/Services/Notify/ClaudePeerClientTests.cs +++ b/src/Nitro/CommandLine/test/CommandLine.Tests/Services/Notify/ClaudePeerClientTests.cs @@ -2,11 +2,13 @@ using System.Text; using System.Text.Json; using ChilliCream.Nitro.CommandLine.Services.Notify; +using ChilliCream.Nitro.CommandLine.Services.Workspace; namespace ChilliCream.Nitro.CommandLine.Tests.Agents; public sealed class ClaudePeerClientTests : IDisposable { + private const string Harness = AgentSessionHarness.ClaudeCode; private const int Pid = 4242; private const string SessionId = "session-1"; private const string ProcStart = "44276735"; @@ -23,7 +25,7 @@ public ClaudePeerClientTests() _sessionDirectory = Path.Combine(_tempRoot.FullName, "sessions"); Directory.CreateDirectory(_sessionDirectory); _socketPath = Path.Combine(_tempRoot.FullName, "peer.sock"); - _client = new ClaudePeerClient(_sessionDirectory); + _client = new ClaudePeerClient(_sessionDirectory, Path.Combine(_tempRoot.FullName, "venti-peers")); } public void Dispose() => _tempRoot.Delete(recursive: true); @@ -83,7 +85,7 @@ public async Task SendAsync_Should_ReturnUnsupportedWithoutConnecting_When_Proto await WriteRegistryAsync(protocol: 2, cancellationToken); // act - var outcome = await _client.SendAsync(SessionId, "hello", cancellationToken); + var outcome = await _client.SendAsync(Harness, SessionId, "hello", cancellationToken); // assert Assert.Equal(ClaudePeerSendOutcome.Unsupported, outcome); @@ -98,7 +100,7 @@ public async Task SendAsync_Should_ReturnInvalidAuthWithoutDowngrading_When_KeyG await WriteKeyAsync("different-generation", cancellationToken); // act - var outcome = await _client.SendAsync(SessionId, "hello", cancellationToken); + var outcome = await _client.SendAsync(Harness, SessionId, "hello", cancellationToken); // assert Assert.Equal(ClaudePeerSendOutcome.InvalidAuth, outcome); @@ -116,7 +118,7 @@ public async Task SendAsync_Should_ReturnEndpointGone_When_SocketIsStale() } // act - var outcome = await _client.SendAsync(SessionId, "hello", cancellationToken); + var outcome = await _client.SendAsync(Harness, SessionId, "hello", cancellationToken); // assert Assert.Equal(ClaudePeerSendOutcome.EndpointGone, outcome); @@ -130,7 +132,7 @@ public async Task SendAsync_Should_ReturnEndpointGone_When_RegistrySessionDoesNo await WriteRegistryAsync(protocol: 1, cancellationToken); // act - var outcome = await _client.SendAsync("different-session", "hello", cancellationToken); + var outcome = await _client.SendAsync(Harness, "different-session", "hello", cancellationToken); // assert Assert.Equal(ClaudePeerSendOutcome.EndpointGone, outcome); @@ -159,7 +161,7 @@ public async Task SendAsync_Should_ReturnAccessDenied_When_ConnectFailsWithSocke try { // act - var outcome = await _client.SendAsync(SessionId, "hello", cancellationToken); + var outcome = await _client.SendAsync(Harness, SessionId, "hello", cancellationToken); // assert Assert.Equal(ClaudePeerSendOutcome.AccessDenied, outcome); @@ -179,7 +181,7 @@ await File.WriteAllTextAsync( Path.Combine(_sessionDirectory, $"{Pid}.json"), "{ not json", cancellationToken); // act - var outcome = await _client.SendAsync(SessionId, "hello", cancellationToken); + var outcome = await _client.SendAsync(Harness, SessionId, "hello", cancellationToken); // assert Assert.Equal(ClaudePeerSendOutcome.TransportError("JsonReaderException"), outcome); @@ -195,7 +197,7 @@ public async Task SendAsync_Should_ReturnEndpointGone_When_RegistryRootIsNotAnOb await WriteRegistryJsonAsync(json, cancellationToken); // act - var outcome = await _client.SendAsync(SessionId, "hello", cancellationToken); + var outcome = await _client.SendAsync(Harness, SessionId, "hello", cancellationToken); // assert Assert.Equal(ClaudePeerSendOutcome.EndpointGone, outcome); @@ -218,7 +220,7 @@ public async Task SendAsync_Should_ReturnEndpointGone_When_RegistryPidIsNotANumb await WriteRegistryJsonAsync(json, cancellationToken); // act - var outcome = await _client.SendAsync(SessionId, "hello", cancellationToken); + var outcome = await _client.SendAsync(Harness, SessionId, "hello", cancellationToken); // assert Assert.Equal(ClaudePeerSendOutcome.EndpointGone, outcome); @@ -241,7 +243,7 @@ public async Task SendAsync_Should_ReturnUnsupported_When_RegistryProtocolIsNotA await WriteRegistryJsonAsync(json, cancellationToken); // act - var outcome = await _client.SendAsync(SessionId, "hello", cancellationToken); + var outcome = await _client.SendAsync(Harness, SessionId, "hello", cancellationToken); // assert Assert.Equal(ClaudePeerSendOutcome.Unsupported, outcome); @@ -258,7 +260,7 @@ public async Task SendAsync_Should_ReturnInvalidAuth_When_KeyRootIsNotAnObject(s await WriteKeyJsonAsync(json, cancellationToken); // act - var outcome = await _client.SendAsync(SessionId, "hello", cancellationToken); + var outcome = await _client.SendAsync(Harness, SessionId, "hello", cancellationToken); // assert Assert.Equal(ClaudePeerSendOutcome.InvalidAuth, outcome); @@ -269,7 +271,7 @@ public async Task SendAsync_Should_ReturnInvalidAuth_When_KeyRootIsNotAnObject(s { using var listener = CreateListener(); var acceptTask = listener.AcceptAsync(cancellationToken).AsTask(); - var sendTask = _client.SendAsync(SessionId, message, cancellationToken); + var sendTask = _client.SendAsync(Harness, SessionId, message, cancellationToken); using var peer = await acceptTask; await using var buffer = new MemoryStream(); var chunk = new byte[1024]; diff --git a/src/Nitro/CommandLine/test/CommandLine.Tests/Services/Notify/FakeClaudePeerCall.cs b/src/Nitro/CommandLine/test/CommandLine.Tests/Services/Notify/FakeClaudePeerCall.cs index 2d1d5313363..7aa995b2851 100644 --- a/src/Nitro/CommandLine/test/CommandLine.Tests/Services/Notify/FakeClaudePeerCall.cs +++ b/src/Nitro/CommandLine/test/CommandLine.Tests/Services/Notify/FakeClaudePeerCall.cs @@ -1,3 +1,3 @@ namespace ChilliCream.Nitro.CommandLine.Tests.Agents; -internal sealed record FakeClaudePeerCall(string SessionId, string Message); +internal sealed record FakeClaudePeerCall(string Harness, string SessionId, string Message); diff --git a/src/Nitro/CommandLine/test/CommandLine.Tests/Services/Notify/FakeClaudePeerClient.cs b/src/Nitro/CommandLine/test/CommandLine.Tests/Services/Notify/FakeClaudePeerClient.cs index 82fad4e52bd..619c76966be 100644 --- a/src/Nitro/CommandLine/test/CommandLine.Tests/Services/Notify/FakeClaudePeerClient.cs +++ b/src/Nitro/CommandLine/test/CommandLine.Tests/Services/Notify/FakeClaudePeerClient.cs @@ -9,11 +9,12 @@ internal sealed class FakeClaudePeerClient : IClaudePeerClient public ClaudePeerSendOutcome NextOutcome { get; set; } = ClaudePeerSendOutcome.Ok; public Task SendAsync( + string harness, string sessionId, string message, CancellationToken cancellationToken) { - Calls.Add(new FakeClaudePeerCall(sessionId, message)); + Calls.Add(new FakeClaudePeerCall(harness, sessionId, message)); return Task.FromResult(NextOutcome); } } diff --git a/src/Nitro/CommandLine/test/CommandLine.Tests/Services/Notify/FakePingSessionExecutor.cs b/src/Nitro/CommandLine/test/CommandLine.Tests/Services/Notify/FakePingSessionExecutor.cs index acefd65ac70..01d03678557 100644 --- a/src/Nitro/CommandLine/test/CommandLine.Tests/Services/Notify/FakePingSessionExecutor.cs +++ b/src/Nitro/CommandLine/test/CommandLine.Tests/Services/Notify/FakePingSessionExecutor.cs @@ -48,6 +48,7 @@ public Task ExecuteCodexThreadAsync( public Task ExecuteClaudePeerAsync( string actorName, + string harness, string sessionId, string attemptId, int slot, diff --git a/src/Nitro/CommandLine/test/CommandLine.Tests/Services/Notify/NoopClaudePeerClient.cs b/src/Nitro/CommandLine/test/CommandLine.Tests/Services/Notify/NoopClaudePeerClient.cs index 04b83231d52..2d408078bfc 100644 --- a/src/Nitro/CommandLine/test/CommandLine.Tests/Services/Notify/NoopClaudePeerClient.cs +++ b/src/Nitro/CommandLine/test/CommandLine.Tests/Services/Notify/NoopClaudePeerClient.cs @@ -8,6 +8,6 @@ namespace ChilliCream.Nitro.CommandLine.Tests.Agents; internal sealed class NoopClaudePeerClient : IClaudePeerClient { public Task SendAsync( - string sessionId, string message, CancellationToken cancellationToken) + string harness, string sessionId, string message, CancellationToken cancellationToken) => Task.FromResult(ClaudePeerSendOutcome.Ok); } diff --git a/src/Nitro/CommandLine/test/CommandLine.Tests/Services/Notify/PingSessionExecutorTests.cs b/src/Nitro/CommandLine/test/CommandLine.Tests/Services/Notify/PingSessionExecutorTests.cs index 2511f0d062a..57daa666816 100644 --- a/src/Nitro/CommandLine/test/CommandLine.Tests/Services/Notify/PingSessionExecutorTests.cs +++ b/src/Nitro/CommandLine/test/CommandLine.Tests/Services/Notify/PingSessionExecutorTests.cs @@ -164,7 +164,8 @@ public async Task ExecuteClaudePeerAsync_Should_SendTheDigestAndRecordOk_When_Un // act var outcome = await executor.ExecuteClaudePeerAsync( - actor, "peer-session", attemptId, slot!.Value, FarFutureDeadline(), cancellationToken); + actor, AgentSessionHarness.ClaudeCode, "peer-session", + attemptId, slot!.Value, FarFutureDeadline(), cancellationToken); // assert var call = Assert.Single(_claudePeerClient.Calls); @@ -189,7 +190,8 @@ await _mail.SendMessageAsync( // act var outcome = await executor.ExecuteClaudePeerAsync( - actor, "peer-session", attemptId, slot!.Value, FarFutureDeadline(), cancellationToken); + actor, AgentSessionHarness.ClaudeCode, "peer-session", + attemptId, slot!.Value, FarFutureDeadline(), cancellationToken); // assert // the typed outcome exactly matches what was durably written. @@ -217,7 +219,8 @@ await _mail.SendMessageAsync( // act var outcome = await executor.ExecuteClaudePeerAsync( - actor, "peer-session", attemptId, slot!.Value, FarFutureDeadline(), cancellationToken); + actor, AgentSessionHarness.ClaudeCode, "peer-session", + attemptId, slot!.Value, FarFutureDeadline(), cancellationToken); // assert // The coarse CHECK-compatible result stays "error", but the reason and detail stay specific. @@ -246,7 +249,8 @@ await _mail.SendMessageAsync( // act var outcome = await executor.ExecuteClaudePeerAsync( - actor, "peer-session", attemptId, slot!.Value, FarFutureDeadline(), cancellationToken); + actor, AgentSessionHarness.ClaudeCode, "peer-session", + attemptId, slot!.Value, FarFutureDeadline(), cancellationToken); // assert Assert.Equal(AgentPingResult.Error, outcome.Result); @@ -254,6 +258,32 @@ await _mail.SendMessageAsync( Assert.False(outcome.Retryable); } + [Fact] + public async Task ExecuteClaudePeerAsync_Should_ReturnRetryableTimeout_When_ThePeerClientReportsTimeout() + { + // arrange + var cancellationToken = TestContext.Current.CancellationToken; + var actor = await InitializeAgentAsync(cancellationToken); + await _mail.SendMessageAsync( + new MailMessageCreation { Sender = "pascal", Subject = "status", Body = "check", To = [actor] }, + cancellationToken); + var attemptId = await ClaimAttemptAsync(actor, cancellationToken); + var slot = await _leases.TryAcquireAsync( + attemptId, _timeProvider.GetUtcNow(), TimeSpan.FromSeconds(30), cancellationToken); + _claudePeerClient.NextOutcome = ClaudePeerSendOutcome.Timeout; + var executor = CreateExecutor(); + + // act + var outcome = await executor.ExecuteClaudePeerAsync( + actor, AgentSessionHarness.Venti, "peer-session", + attemptId, slot!.Value, FarFutureDeadline(), cancellationToken); + + // assert + Assert.Equal( + (AgentPingResult.Timeout, PingAttemptReason.Timeout, true, ClaudePeerSendOutcome.Timeout.Detail), + (outcome.Result, outcome.Reason, outcome.Retryable, outcome.Detail)); + } + [Fact] public async Task ExecuteCodexThreadAsync_Should_NotExposeTransportDetail_When_TheQueueClientReportsError() { @@ -302,7 +332,8 @@ await _agentStore.TryClaimPingCooldownAsync( // act var outcome = await executor.ExecuteClaudePeerAsync( - actor, "peer-session", staleAttemptId, slot!.Value, FarFutureDeadline(), cancellationToken); + actor, AgentSessionHarness.ClaudeCode, "peer-session", + staleAttemptId, slot!.Value, FarFutureDeadline(), cancellationToken); // assert Assert.Equal(AgentPingResult.Ok, outcome.Result); diff --git a/src/Nitro/CommandLine/test/CommandLine.Tests/Services/Notify/UnixFileOwnerTests.cs b/src/Nitro/CommandLine/test/CommandLine.Tests/Services/Notify/UnixFileOwnerTests.cs new file mode 100644 index 00000000000..8357850b074 --- /dev/null +++ b/src/Nitro/CommandLine/test/CommandLine.Tests/Services/Notify/UnixFileOwnerTests.cs @@ -0,0 +1,77 @@ +using System.Runtime.Versioning; +using ChilliCream.Nitro.CommandLine.Services.Notify; + +namespace ChilliCream.Nitro.CommandLine.Tests.Agents; + +[UnsupportedOSPlatform("windows")] +public sealed class UnixFileOwnerTests : IDisposable +{ + private readonly DirectoryInfo _tempRoot = Directory.CreateTempSubdirectory("nitro-owner-tests"); + + public void Dispose() => _tempRoot.Delete(recursive: true); + + [Fact] + public async Task IsCurrentUser_Should_ReturnTrue_When_TheCurrentUserCreatedTheFile() + { + Assert.SkipWhen(OperatingSystem.IsWindows(), "Windows files have no Unix owner."); + + // arrange + var path = Path.Combine(_tempRoot.FullName, "4242.0a1b2c3d.key"); + await File.WriteAllTextAsync(path, "{}", TestContext.Current.CancellationToken); + + // act + var ownedByCurrentUser = UnixFileOwner.IsCurrentUser(path); + + // assert + Assert.True(ownedByCurrentUser); + } + + [Fact] + public void IsCurrentUser_Should_ReturnFalse_When_AnotherAccountOwnsTheEntry() + { + Assert.SkipWhen(OperatingSystem.IsWindows(), "Windows files have no Unix owner."); + Assert.SkipWhen(Environment.IsPrivilegedProcess, "The root directory is owned by root."); + + // arrange + // The file system root is owned by root on every Unix system. + var path = Path.GetPathRoot(_tempRoot.FullName)!; + + // act + var ownedByCurrentUser = UnixFileOwner.IsCurrentUser(path); + + // assert + Assert.False(ownedByCurrentUser); + } + + [Fact] + public void IsCurrentUser_Should_ReturnFalse_When_TheFileDoesNotExist() + { + Assert.SkipWhen(OperatingSystem.IsWindows(), "Windows files have no Unix owner."); + + // arrange + var path = Path.Combine(_tempRoot.FullName, "missing.key"); + + // act + var ownedByCurrentUser = UnixFileOwner.IsCurrentUser(path); + + // assert + Assert.False(ownedByCurrentUser); + } + + [Fact] + public void IsCurrentUser_Should_JudgeTheLinkItself_When_ThePathIsASymbolicLinkToAnotherAccountsEntry() + { + Assert.SkipWhen(OperatingSystem.IsWindows(), "Windows files have no Unix owner."); + Assert.SkipWhen(Environment.IsPrivilegedProcess, "The root directory is owned by root."); + + // arrange + var path = Path.Combine(_tempRoot.FullName, "4242.0a1b2c3d.key"); + File.CreateSymbolicLink(path, Path.GetPathRoot(_tempRoot.FullName)!); + + // act + var ownedByCurrentUser = UnixFileOwner.IsCurrentUser(path); + + // assert + Assert.True(ownedByCurrentUser); + } +} diff --git a/src/Nitro/CommandLine/test/CommandLine.Tests/Services/Notify/VentiPeerClientTests.cs b/src/Nitro/CommandLine/test/CommandLine.Tests/Services/Notify/VentiPeerClientTests.cs new file mode 100644 index 00000000000..2616841de55 --- /dev/null +++ b/src/Nitro/CommandLine/test/CommandLine.Tests/Services/Notify/VentiPeerClientTests.cs @@ -0,0 +1,497 @@ +using System.Runtime.Versioning; +using System.Security.AccessControl; +using System.Security.Principal; +using System.Text; +using System.Text.Json; +using ChilliCream.Nitro.CommandLine.Services.Notify; +using ChilliCream.Nitro.CommandLine.Services.Workspace; + +namespace ChilliCream.Nitro.CommandLine.Tests.Agents; + +/// +/// Tests routing to Venti's peer registry, with registry and key +/// files shaped as Venti writes them and a real Unix domain socket, or a named pipe on Windows, +/// standing in for the endpoint. +/// +public sealed class VentiPeerClientTests : IDisposable +{ + private const int Pid = 4242; + private const string SessionId = "0123456789abcdef0123456789abcdef"; + private const long ProcStart = 1767960000000; + private const string PeerToken = "fedcba9876543210fedcba9876543210"; + private const UnixFileMode OwnerOnly = UnixFileMode.UserRead | UnixFileMode.UserWrite; + + // Longer than the client's transport budget, so only a send without one runs past it. + private static readonly TimeSpan s_completionBound = TimeSpan.FromSeconds(10); + + // Far larger than a socket or pipe buffer, so writing it stalls until the peer reads. + private static readonly string s_unbufferedMessage = new('x', 8 * 1024 * 1024); + + private readonly DirectoryInfo _tempRoot; + private readonly string _claudeDirectory; + private readonly string _peerDirectory; + private readonly ClaudePeerClient _client; + + public VentiPeerClientTests() + { + // A short root keeps the socket path within the platform's socket address limit. + _tempRoot = Directory.CreateTempSubdirectory("vp"); + _claudeDirectory = Path.Combine(_tempRoot.FullName, "claude"); + _peerDirectory = Path.Combine(_tempRoot.FullName, "peers"); + Directory.CreateDirectory(_claudeDirectory); + Directory.CreateDirectory(_peerDirectory); + _client = new ClaudePeerClient(_claudeDirectory, _peerDirectory); + } + + public void Dispose() => _tempRoot.Delete(recursive: true); + + [Fact] + public async Task SendAsync_Should_AuthenticateThenDeliverTheMessage_When_TheVentiKeyMatchesTheProcess() + { + // arrange + var cancellationToken = TestContext.Current.CancellationToken; + using var endpoint = VentiPeerTestEndpoint.Listen(_peerDirectory); + await WriteRegistryAsync(_peerDirectory, endpoint.Address, cancellationToken); + await WriteKeyAsync("0a1b2c3d", ProcStart, cancellationToken); + + // act + var (outcome, frames) = await SendAndReadAsync(endpoint, "hello venti", cancellationToken); + + // assert + Assert.Equal(ClaudePeerSendOutcome.Unconfirmed, outcome); + frames.MatchInlineSnapshot( + """ + {"type":"auth","token":"fedcba9876543210fedcba9876543210"} + {"type":"user","session_id":"0123456789abcdef0123456789abcdef","message":{"role":"user","content":"hello venti"}} + + """); + } + + [Theory] + [InlineData("missing")] + [InlineData("stale")] + [InlineData("insecure")] + [InlineData("ambiguous")] + [InlineData("malformed-token")] + public async Task SendAsync_Should_ReturnInvalidAuthWithoutConnecting_When_TheVentiKeyIsNotUsable(string keyState) + { + // arrange + var cancellationToken = TestContext.Current.CancellationToken; + using var endpoint = VentiPeerTestEndpoint.Listen(_peerDirectory); + await WriteRegistryAsync(_peerDirectory, endpoint.Address, cancellationToken); + await WriteKeyStateAsync(keyState, cancellationToken); + + // act + var outcome = await _client.SendAsync(AgentSessionHarness.Venti, SessionId, "hello", cancellationToken); + + // assert + Assert.Equal(ClaudePeerSendOutcome.InvalidAuth, outcome); + Assert.False(await endpoint.HasConnectionAsync()); + } + + [Fact] + public async Task SendAsync_Should_ReturnUnconfirmed_When_AVentiEndpointAcceptsTheToken() + { + // arrange + var cancellationToken = TestContext.Current.CancellationToken; + using var endpoint = VentiPeerTestEndpoint.Listen(_peerDirectory); + await WriteRegistryAsync(_peerDirectory, endpoint.Address, cancellationToken); + await WriteKeyAsync("0a1b2c3d", ProcStart, cancellationToken); + var serving = ServeLikeVentiAsync(endpoint, PeerToken, TimeSpan.Zero, cancellationToken); + + // act + var outcome = await _client.SendAsync(AgentSessionHarness.Venti, SessionId, "hello venti", cancellationToken); + + // assert + Assert.Equal( + (ClaudePeerSendOutcome.Unconfirmed, "hello venti"), + (outcome, string.Join(", ", await serving))); + } + + [Fact] + public async Task SendAsync_Should_ReturnInvalidAuth_When_AVentiEndpointRejectsTheToken() + { + // arrange + var cancellationToken = TestContext.Current.CancellationToken; + using var endpoint = VentiPeerTestEndpoint.Listen(_peerDirectory); + await WriteRegistryAsync(_peerDirectory, endpoint.Address, cancellationToken); + await WriteKeyAsync("0a1b2c3d", ProcStart, cancellationToken); + var serving = ServeLikeVentiAsync( + endpoint, "0123456789abcdef0123456789abcdef", TimeSpan.Zero, cancellationToken); + + // act + var outcome = await _client.SendAsync(AgentSessionHarness.Venti, SessionId, "hello venti", cancellationToken); + + // assert + Assert.Equal( + (ClaudePeerSendOutcome.InvalidAuth, 0), + (outcome, (await serving).Count)); + } + + [Fact] + public async Task SendAsync_Should_ReturnUnconfirmed_When_AVentiEndpointRejectsTheTokenAfterTheRejectionWindow() + { + // arrange + var cancellationToken = TestContext.Current.CancellationToken; + using var endpoint = VentiPeerTestEndpoint.Listen(_peerDirectory); + await WriteRegistryAsync(_peerDirectory, endpoint.Address, cancellationToken); + await WriteKeyAsync("0a1b2c3d", ProcStart, cancellationToken); + var serving = ServeLikeVentiAsync( + endpoint, "0123456789abcdef0123456789abcdef", TimeSpan.FromMilliseconds(750), cancellationToken); + + // act + var outcome = await _client.SendAsync(AgentSessionHarness.Venti, SessionId, "hello venti", cancellationToken); + + // assert + Assert.Equal( + (ClaudePeerSendOutcome.Unconfirmed, 0), + (outcome, (await serving).Count)); + } + + [Fact] + [SupportedOSPlatform("macos")] + public async Task SendAsync_Should_ReturnInvalidAuthWithoutConnecting_When_AMacOSKeyAclGrantsEveryoneAccess() + { + Assert.SkipUnless(OperatingSystem.IsMacOS(), "Extended file access control lists are checked on macOS."); + + // arrange + var cancellationToken = TestContext.Current.CancellationToken; + using var endpoint = VentiPeerTestEndpoint.Listen(_peerDirectory); + await WriteRegistryAsync(_peerDirectory, endpoint.Address, cancellationToken); + await WriteKeyAsync("0a1b2c3d", ProcStart, cancellationToken); + await AddMacOSAclEntryAsync("everyone allow read", cancellationToken); + + // act + var outcome = await _client.SendAsync(AgentSessionHarness.Venti, SessionId, "hello", cancellationToken); + + // assert + Assert.Equal( + (ClaudePeerSendOutcome.InvalidAuth, UnixFileMode.UserRead | UnixFileMode.UserWrite, false), + (outcome, File.GetUnixFileMode(KeyPath("0a1b2c3d")), await endpoint.HasConnectionAsync())); + } + + [Fact] + [SupportedOSPlatform("macos")] + public async Task SendAsync_Should_DeliverTheMessage_When_AMacOSKeyAclGrantsOnlyTheCurrentUser() + { + Assert.SkipUnless(OperatingSystem.IsMacOS(), "Extended file access control lists are checked on macOS."); + + // arrange + var cancellationToken = TestContext.Current.CancellationToken; + using var endpoint = VentiPeerTestEndpoint.Listen(_peerDirectory); + await WriteRegistryAsync(_peerDirectory, endpoint.Address, cancellationToken); + await WriteKeyAsync("0a1b2c3d", ProcStart, cancellationToken); + await AddMacOSAclEntryAsync(Environment.UserName + " allow read,write", cancellationToken); + var serving = ServeLikeVentiAsync(endpoint, PeerToken, TimeSpan.Zero, cancellationToken); + + // act + var outcome = await _client.SendAsync(AgentSessionHarness.Venti, SessionId, "hello venti", cancellationToken); + + // assert + Assert.Equal( + (ClaudePeerSendOutcome.Unconfirmed, "hello venti"), + (outcome, string.Join(", ", await serving))); + } + + [Fact] + [SupportedOSPlatform("windows")] + public async Task SendAsync_Should_ReturnInvalidAuthWithoutConnecting_When_TheWindowsKeyGrantsEveryoneAccess() + { + Assert.SkipUnless(OperatingSystem.IsWindows(), "Named pipes and file access control lists are Windows only."); + + // arrange + var cancellationToken = TestContext.Current.CancellationToken; + using var endpoint = VentiPeerTestEndpoint.Listen(_peerDirectory); + await WriteRegistryAsync(_peerDirectory, endpoint.Address, cancellationToken); + await WriteKeyAsync("0a1b2c3d", ProcStart, cancellationToken); + var keyFile = new FileInfo(KeyPath("0a1b2c3d")); + var security = keyFile.GetAccessControl(); + security.AddAccessRule( + new FileSystemAccessRule( + new SecurityIdentifier(WellKnownSidType.WorldSid, null), + FileSystemRights.Read, + AccessControlType.Allow)); + keyFile.SetAccessControl(security); + + // act + var outcome = await _client.SendAsync(AgentSessionHarness.Venti, SessionId, "hello", cancellationToken); + + // assert + Assert.Equal((ClaudePeerSendOutcome.InvalidAuth, false), (outcome, await endpoint.HasConnectionAsync())); + } + + [Fact] + public async Task SendAsync_Should_CompleteWithoutDelivery_When_TheVentiEndpointIsStale() + { + // arrange + var cancellationToken = TestContext.Current.CancellationToken; + string staleAddress; + + using (var endpoint = VentiPeerTestEndpoint.Listen(_peerDirectory)) + { + staleAddress = endpoint.Address; + } + + await WriteRegistryAsync(_peerDirectory, staleAddress, cancellationToken); + await WriteKeyAsync("0a1b2c3d", ProcStart, cancellationToken); + + // act + var outcome = await _client.SendAsync(AgentSessionHarness.Venti, SessionId, "hello", cancellationToken) + .WaitAsync(s_completionBound, cancellationToken); + + // assert + // A Unix socket refuses the connection, while a Windows client waits for the missing pipe. + Assert.Equal( + OperatingSystem.IsWindows() ? ClaudePeerSendOutcome.Timeout : ClaudePeerSendOutcome.EndpointGone, + outcome); + } + + [Fact] + public async Task SendAsync_Should_ReturnTimeout_When_AVentiEndpointStopsReadingTheMessage() + { + // arrange + var cancellationToken = TestContext.Current.CancellationToken; + using var endpoint = VentiPeerTestEndpoint.Listen(_peerDirectory); + await WriteRegistryAsync(_peerDirectory, endpoint.Address, cancellationToken); + await WriteKeyAsync("0a1b2c3d", ProcStart, cancellationToken); + + // act + var outcome = await _client + .SendAsync(AgentSessionHarness.Venti, SessionId, s_unbufferedMessage, cancellationToken) + .WaitAsync(s_completionBound, cancellationToken); + + // assert + Assert.Equal(ClaudePeerSendOutcome.Timeout, outcome); + } + + [Fact] + public async Task SendAsync_Should_PropagateCancellation_When_TheCallerCancelsWhileAVentiEndpointStopsReading() + { + // arrange + var cancellationToken = TestContext.Current.CancellationToken; + using var caller = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + using var endpoint = VentiPeerTestEndpoint.Listen(_peerDirectory); + await WriteRegistryAsync(_peerDirectory, endpoint.Address, cancellationToken); + await WriteKeyAsync("0a1b2c3d", ProcStart, cancellationToken); + var sending = _client.SendAsync(AgentSessionHarness.Venti, SessionId, s_unbufferedMessage, caller.Token); + await using var connection = await endpoint.AcceptAsync(cancellationToken); + + // act + await caller.CancelAsync(); + + // assert + await Assert.ThrowsAnyAsync( + () => sending.WaitAsync(s_completionBound, cancellationToken)); + } + + [Fact] + public async Task StaysOpenAsync_Should_ReturnTrue_When_TheTransportBudgetExpiresAfterTheMessageWasWritten() + { + // arrange + var cancellationToken = TestContext.Current.CancellationToken; + using var endpoint = VentiPeerTestEndpoint.Listen(_peerDirectory); + await using var client = await endpoint.ConnectAsync(cancellationToken); + await using var connection = await endpoint.AcceptAsync(cancellationToken); + using var budget = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + await budget.CancelAsync(); + + // act + var staysOpen = await ClaudePeerClient.StaysOpenAsync(client, budget.Token, cancellationToken); + + // assert + Assert.True(staysOpen); + } + + [Fact] + public async Task StaysOpenAsync_Should_PropagateCancellation_When_TheCallerCancelsWhileTheEndpointStaysOpen() + { + // arrange + var cancellationToken = TestContext.Current.CancellationToken; + using var endpoint = VentiPeerTestEndpoint.Listen(_peerDirectory); + await using var client = await endpoint.ConnectAsync(cancellationToken); + await using var connection = await endpoint.AcceptAsync(cancellationToken); + using var caller = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + using var budget = CancellationTokenSource.CreateLinkedTokenSource(caller.Token); + await caller.CancelAsync(); + + // act + var observing = ClaudePeerClient.StaysOpenAsync(client, budget.Token, caller.Token); + + // assert + await Assert.ThrowsAnyAsync(() => observing); + } + + [Fact] + public async Task SendAsync_Should_ReturnEndpointGone_When_OnlyTheClaudeRegistryListsTheSession() + { + // arrange + var cancellationToken = TestContext.Current.CancellationToken; + using var endpoint = VentiPeerTestEndpoint.Listen(_peerDirectory); + await WriteRegistryAsync(_claudeDirectory, endpoint.Address, cancellationToken); + + // act + var outcome = await _client.SendAsync(AgentSessionHarness.Venti, SessionId, "hello", cancellationToken); + + // assert + Assert.Equal(ClaudePeerSendOutcome.EndpointGone, outcome); + Assert.False(await endpoint.HasConnectionAsync()); + } + + [Fact] + public async Task SendAsync_Should_ReturnEndpointGone_When_OnlyTheVentiRegistryListsAClaudeSession() + { + // arrange + var cancellationToken = TestContext.Current.CancellationToken; + using var endpoint = VentiPeerTestEndpoint.Listen(_peerDirectory); + await WriteRegistryAsync(_peerDirectory, endpoint.Address, cancellationToken); + await WriteKeyAsync("0a1b2c3d", ProcStart, cancellationToken); + + // act + var outcome = await _client.SendAsync(AgentSessionHarness.ClaudeCode, SessionId, "hello", cancellationToken); + + // assert + Assert.Equal(ClaudePeerSendOutcome.EndpointGone, outcome); + Assert.False(await endpoint.HasConnectionAsync()); + } + + [Fact] + public async Task SendAsync_Should_ReturnUnsupported_When_TheHarnessHasNoPeerEndpoints() + { + // arrange + var cancellationToken = TestContext.Current.CancellationToken; + using var endpoint = VentiPeerTestEndpoint.Listen(_peerDirectory); + await WriteRegistryAsync(_peerDirectory, endpoint.Address, cancellationToken); + await WriteKeyAsync("0a1b2c3d", ProcStart, cancellationToken); + + // act + var outcome = await _client.SendAsync(AgentSessionHarness.Codex, SessionId, "hello", cancellationToken); + + // assert + Assert.Equal(ClaudePeerSendOutcome.Unsupported, outcome); + } + + private async Task WriteKeyStateAsync(string keyState, CancellationToken cancellationToken) + { + switch (keyState) + { + case "missing": + break; + + case "stale": + await WriteKeyAsync("0a1b2c3d", ProcStart - 1, cancellationToken); + break; + + case "insecure": + if (OperatingSystem.IsWindows()) + { + Assert.Skip("Windows key files carry no Unix file mode."); + } + + await WriteKeyJsonAsync( + "0a1b2c3d", + JsonSerializer.Serialize(new { procStart = ProcStart, peerToken = PeerToken }), + OwnerOnly | UnixFileMode.GroupRead, + cancellationToken); + break; + + case "ambiguous": + await WriteKeyAsync("0a1b2c3d", ProcStart, cancellationToken); + await WriteKeyAsync("4e5f6a7b", ProcStart, cancellationToken); + break; + + case "malformed-token": + await WriteKeyJsonAsync( + "0a1b2c3d", + JsonSerializer.Serialize(new { procStart = ProcStart, peerToken = "not-a-token" }), + OwnerOnly, + cancellationToken); + break; + } + } + + private static Task WriteRegistryAsync(string directory, string endpoint, CancellationToken cancellationToken) + => File.WriteAllTextAsync( + Path.Combine(directory, SessionId + ".json"), + JsonSerializer.Serialize( + new + { + sessionId = SessionId, + pid = Pid, + kind = "interactive", + messagingSocketPath = endpoint, + procStart = ProcStart, + peerProtocol = 1 + }), + cancellationToken); + + private Task WriteKeyAsync(string suffix, long procStart, CancellationToken cancellationToken) + => WriteKeyJsonAsync( + suffix, + JsonSerializer.Serialize(new { procStart, peerToken = PeerToken }), + OwnerOnly, + cancellationToken); + + private string KeyPath(string suffix) => Path.Combine(_peerDirectory, $"{Pid}.{suffix}.key"); + + /// + /// Adds to the access control list of the key file, leaving its mode bits as they are. + /// + private async Task AddMacOSAclEntryAsync(string entry, CancellationToken cancellationToken) + { + using var chmod = System.Diagnostics.Process.Start("/bin/chmod", ["+a", entry, KeyPath("0a1b2c3d")]); + await chmod.WaitForExitAsync(cancellationToken); + Assert.Equal(0, chmod.ExitCode); + } + + private Task WriteKeyJsonAsync( + string suffix, + string json, + UnixFileMode mode, + CancellationToken cancellationToken) + => VentiPeerTestEndpoint.WriteKeyFileAsync(KeyPath(suffix), json, mode, cancellationToken); + + /// + /// Serves one connection as a Venti peer endpoint does: closes it + /// after a first line that is not an auth line with + /// , otherwise returns the content of every user message line until + /// the peer hangs up. + /// + private static async Task> ServeLikeVentiAsync( + VentiPeerTestEndpoint endpoint, + string token, + TimeSpan rejectionDelay, + CancellationToken cancellationToken) + { + await using var stream = await endpoint.AcceptAsync(cancellationToken); + using var reader = new StreamReader(stream, Encoding.UTF8); + var messages = new List(); + + if (await reader.ReadLineAsync(cancellationToken) is not { } authLine + || JsonDocument.Parse(authLine).RootElement.GetProperty("token").GetString() != token) + { + await Task.Delay(rejectionDelay, cancellationToken); + return messages; + } + + while (await reader.ReadLineAsync(cancellationToken) is { } line) + { + messages.Add( + JsonDocument.Parse(line).RootElement.GetProperty("message").GetProperty("content").GetString()!); + } + + return messages; + } + + private async Task<(ClaudePeerSendOutcome Outcome, string Frames)> SendAndReadAsync( + VentiPeerTestEndpoint endpoint, + string message, + CancellationToken cancellationToken) + { + var sendTask = _client.SendAsync(AgentSessionHarness.Venti, SessionId, message, cancellationToken); + await using var stream = await endpoint.AcceptAsync(cancellationToken); + using var reader = new StreamReader(stream, Encoding.UTF8); + var frames = await reader.ReadToEndAsync(cancellationToken); + + return (await sendTask, frames); + } +} diff --git a/src/Nitro/CommandLine/test/CommandLine.Tests/Services/Notify/VentiPeerTestEndpoint.cs b/src/Nitro/CommandLine/test/CommandLine.Tests/Services/Notify/VentiPeerTestEndpoint.cs new file mode 100644 index 00000000000..e08972ff891 --- /dev/null +++ b/src/Nitro/CommandLine/test/CommandLine.Tests/Services/Notify/VentiPeerTestEndpoint.cs @@ -0,0 +1,150 @@ +using System.IO.Pipes; +using System.Net.Sockets; +using System.Security.AccessControl; +using System.Security.Principal; + +namespace ChilliCream.Nitro.CommandLine.Tests.Agents; + +/// +/// A peer endpoint listening the way a Venti session's does: a Unix domain socket in the peer +/// registry directory, or a named pipe on Windows. +/// +internal sealed class VentiPeerTestEndpoint : IDisposable +{ + private static readonly TimeSpan s_acceptTimeout = TimeSpan.FromSeconds(10); + private static readonly TimeSpan s_connectionGrace = TimeSpan.FromMilliseconds(100); + + private readonly Socket? _socket; + private readonly NamedPipeServerStream? _pipe; + private readonly Task? _pipeConnection; + + private VentiPeerTestEndpoint(string address, Socket socket) + { + Address = address; + _socket = socket; + } + + private VentiPeerTestEndpoint(string address, NamedPipeServerStream pipe) + { + Address = address; + _pipe = pipe; + _pipeConnection = pipe.WaitForConnectionAsync(); + } + + /// + /// The endpoint address a Venti registry file lists as messagingSocketPath. + /// + public string Address { get; } + + /// + /// Starts listening for one connection. The Unix domain socket is created in + /// . + /// + public static VentiPeerTestEndpoint Listen(string peerDirectory) + { + if (OperatingSystem.IsWindows()) + { + var pipeName = "nitro-venti-test-" + Guid.NewGuid().ToString("N"); + var pipe = new NamedPipeServerStream( + pipeName, + PipeDirection.InOut, + 1, + PipeTransmissionMode.Byte, + PipeOptions.Asynchronous | PipeOptions.CurrentUserOnly); + + return new VentiPeerTestEndpoint(@"\\.\pipe\" + pipeName, pipe); + } + + var socketPath = Path.Combine(peerDirectory, "0011223344556677.sock"); + var socket = new Socket(AddressFamily.Unix, SocketType.Stream, ProtocolType.Unspecified); + socket.Bind(new UnixDomainSocketEndPoint(socketPath)); + socket.Listen(1); + + return new VentiPeerTestEndpoint(socketPath, socket); + } + + /// + /// Returns the stream of the first connection, or throws when no + /// peer connects within ten seconds. + /// + public async Task AcceptAsync(CancellationToken cancellationToken) + { + if (_socket is not null) + { + var peer = await _socket.AcceptAsync(cancellationToken).AsTask() + .WaitAsync(s_acceptTimeout, cancellationToken); + + return new NetworkStream(peer, ownsSocket: true); + } + + await _pipeConnection!.WaitAsync(s_acceptTimeout, cancellationToken); + return _pipe!; + } + + /// + /// Connects to the endpoint as a peer client does and returns the client's stream. + /// + public async Task ConnectAsync(CancellationToken cancellationToken) + { + if (_socket is not null) + { + var client = new Socket(AddressFamily.Unix, SocketType.Stream, ProtocolType.Unspecified); + await client.ConnectAsync(new UnixDomainSocketEndPoint(Address), cancellationToken); + return new NetworkStream(client, ownsSocket: true); + } + + var pipe = new NamedPipeClientStream( + ".", + Address[@"\\.\pipe\".Length..], + PipeDirection.InOut, + PipeOptions.Asynchronous); + await pipe.ConnectAsync(cancellationToken); + return pipe; + } + + /// + /// Whether a peer has connected, waiting up to a tenth of a second for one. + /// + public async Task HasConnectionAsync() + { + if (_socket is not null) + { + return _socket.Poll(s_connectionGrace, SelectMode.SelectRead); + } + + return await Task.WhenAny(_pipeConnection!, Task.Delay(s_connectionGrace)) == _pipeConnection; + } + + /// + /// Writes a key file the way Venti does: with on Unix, or on + /// Windows with an access control list that grants only the current user access. + /// + public static async Task WriteKeyFileAsync( + string path, + string json, + UnixFileMode unixMode, + CancellationToken cancellationToken) + { + await File.WriteAllTextAsync(path, json, cancellationToken); + + if (OperatingSystem.IsWindows()) + { + using var identity = WindowsIdentity.GetCurrent(); + var security = new FileSecurity(); + security.SetAccessRuleProtection(isProtected: true, preserveInheritance: false); + security.AddAccessRule( + new FileSystemAccessRule(identity.User!, FileSystemRights.FullControl, AccessControlType.Allow)); + new FileInfo(path).SetAccessControl(security); + } + else + { + File.SetUnixFileMode(path, unixMode); + } + } + + public void Dispose() + { + _socket?.Dispose(); + _pipe?.Dispose(); + } +} diff --git a/src/Nitro/CommandLine/test/CommandLine.Tests/Services/Notify/VentiPeerWakeTests.cs b/src/Nitro/CommandLine/test/CommandLine.Tests/Services/Notify/VentiPeerWakeTests.cs new file mode 100644 index 00000000000..0e4b61ce3e0 --- /dev/null +++ b/src/Nitro/CommandLine/test/CommandLine.Tests/Services/Notify/VentiPeerWakeTests.cs @@ -0,0 +1,273 @@ +using System.Text; +using System.Text.Json; +using ChilliCream.Nitro.CommandLine.Services.Hook; +using ChilliCream.Nitro.CommandLine.Services.Mail; +using ChilliCream.Nitro.CommandLine.Services.Notify; +using ChilliCream.Nitro.CommandLine.Services.Workspace; +using Microsoft.Extensions.Time.Testing; + +namespace ChilliCream.Nitro.CommandLine.Tests.Agents; + +/// +/// Drives a Venti session from its hooks to a mail wake: the Venti hook handler registers the +/// session against a temporary peer registry, and the real wake dispatcher, ping executor, mail +/// nudge, and peer client deliver to a real Unix domain socket, or a named pipe on Windows, +/// listed in that registry. +/// +public sealed class VentiPeerWakeTests : IDisposable +{ + private const string LeaderToken = "leader-token"; + private const string SessionId = "0123456789abcdef0123456789abcdef"; + private const int Pid = 4242; + private const long ProcStart = 1767960000000; + private const string PeerToken = "fedcba9876543210fedcba9876543210"; + + private readonly DirectoryInfo _tempRoot; + private readonly string _workspaceRoot; + private readonly string _workspaceDirectory; + private readonly string _peerDirectory; + private readonly FakeTimeProvider _timeProvider; + private readonly AgentDatabase _database; + private readonly AgentStore _agentStore; + private readonly AgentDeliveryLedger _ledger; + private readonly MailStore _mail; + private readonly MailWakeBatchStore _batches; + private readonly MailWakeDaemonLeaderStore _leaderStore; + private readonly ClaudePeerClient _peerClient; + private readonly ActorWakeDispatcher _dispatcher; + private readonly ClaudeHookHandler _hooks; + + public VentiPeerWakeTests() + { + // A short root keeps the socket path within the platform's socket address limit. + _tempRoot = Directory.CreateTempSubdirectory("vw"); + _workspaceRoot = Path.Combine(_tempRoot.FullName, "repo"); + _workspaceDirectory = AgentWorkspace.GetDirectory(_workspaceRoot); + Directory.CreateDirectory(_workspaceDirectory); + _peerDirectory = Path.Combine(_tempRoot.FullName, "peers"); + Directory.CreateDirectory(_peerDirectory); + + var fileSystem = new TestFileSystem(_workspaceRoot); + _timeProvider = new FakeTimeProvider(new DateTimeOffset(2026, 1, 10, 12, 0, 0, TimeSpan.Zero)); + _database = new AgentDatabase(); + _agentStore = new AgentStore(fileSystem, _timeProvider, _database); + _ledger = new AgentDeliveryLedger(fileSystem, _database); + _mail = new MailStore(fileSystem, _timeProvider, _database, _agentStore); + _batches = new MailWakeBatchStore(fileSystem, _database); + _leaderStore = new MailWakeDaemonLeaderStore(fileSystem, _database); + _peerClient = new ClaudePeerClient(Path.Combine(_tempRoot.FullName, "claude"), _peerDirectory); + + var leases = new PingLeaseStore(fileSystem, _database); + var executor = new PingSessionExecutor( + _mail, + _ledger, + new NeverCompletingCodexQueueClient(), + _peerClient, + _agentStore, + leases, + _timeProvider, + new FakeOpencodeServerClient()); + _dispatcher = new ActorWakeDispatcher( + _batches, + _agentStore, + new SessionGateCoordinator(new AgentPingGateStore(fileSystem, _database), leases), + executor, + _mail, + _timeProvider); + _hooks = new ClaudeHookHandler( + fileSystem, + _timeProvider, + _agentStore, + _ledger, + _mail, + AgentSessionHarness.Venti, + new VentiPeerRegistryReader(_peerDirectory)); + } + + public void Dispose() => _tempRoot.Delete(recursive: true); + + [Fact] + public async Task DispatchAsync_Should_SendTheDigestWithoutRecordingDelivery_When_AnInteractiveVentiSessionHasUnreadMail() + { + // arrange + var cancellationToken = TestContext.Current.CancellationToken; + await InitializeWorkspaceAsync(cancellationToken); + using var endpoint = VentiPeerTestEndpoint.Listen(_peerDirectory); + await WriteRegistryAsync(endpoint.Address, cancellationToken); + await WriteKeyAsync(cancellationToken); + var actor = await StartVentiSessionAsync(cancellationToken); + await SendMailAsync(actor, MailWakePolicy.Enqueue, cancellationToken); + var frames = ReadFramesAsync(endpoint, cancellationToken); + + // act + var receipt = await _dispatcher.DispatchAsync(actor, LeaderToken, Deadline(), cancellationToken); + + // assert + var target = Assert.Single(receipt!.Targets); + Assert.Equal((MailWakeTargetStatus.Failed, "Unconfirmed"), (target.Status, target.LastError)); + (await frames).MatchInlineSnapshot( + """ + [ + "auth token=fedcba9876543210fedcba9876543210", + "user session_id=0123456789abcdef0123456789abcdef role=user" + ] + """); + } + + [Fact] + public async Task DispatchAsync_Should_FailWithInvalidAuthWithoutConnecting_When_TheVentiKeyIsMissing() + { + // arrange + var cancellationToken = TestContext.Current.CancellationToken; + await InitializeWorkspaceAsync(cancellationToken); + using var endpoint = VentiPeerTestEndpoint.Listen(_peerDirectory); + await WriteRegistryAsync(endpoint.Address, cancellationToken); + var actor = await StartVentiSessionAsync(cancellationToken); + await SendMailAsync(actor, MailWakePolicy.Enqueue, cancellationToken); + + // act + var receipt = await _dispatcher.DispatchAsync(actor, LeaderToken, Deadline(), cancellationToken); + + // assert + var target = Assert.Single(receipt!.Targets); + Assert.Equal((MailWakeTargetStatus.Failed, "InvalidAuth"), (target.Status, target.LastError)); + Assert.False(await endpoint.HasConnectionAsync()); + } + + [Fact] + public async Task DispatchAsync_Should_SkipAsUnreachable_When_TheVentiSessionAdvertisesNoEndpoint() + { + // arrange + var cancellationToken = TestContext.Current.CancellationToken; + await InitializeWorkspaceAsync(cancellationToken); + var actor = await StartVentiSessionAsync(cancellationToken); + await SendMailAsync(actor, MailWakePolicy.Enqueue, cancellationToken); + + // act + var receipt = await _dispatcher.DispatchAsync(actor, LeaderToken, Deadline(), cancellationToken); + + // assert + var target = Assert.Single(receipt!.Targets); + Assert.Equal((MailWakeTargetStatus.Skipped, "unreachable"), (target.Status, target.LastError)); + } + + [Fact] + public async Task NudgeAsync_Should_DeliverTheDigestToTheVentiSocket_When_AnInteractiveSessionHasUnreadMail() + { + // arrange + var cancellationToken = TestContext.Current.CancellationToken; + await InitializeWorkspaceAsync(cancellationToken); + using var endpoint = VentiPeerTestEndpoint.Listen(_peerDirectory); + await WriteRegistryAsync(endpoint.Address, cancellationToken); + await WriteKeyAsync(cancellationToken); + var actor = await StartVentiSessionAsync(cancellationToken); + await SendMailAsync(actor, MailWakePolicy.Skip, cancellationToken); + var nudge = new MailNudge( + _agentStore, _mail, _ledger, _peerClient, new NeverCompletingCodexQueueClient(), _timeProvider); + var frames = ReadFramesAsync(endpoint, cancellationToken); + + // act + await nudge.NudgeAsync([actor], cancellationToken); + + // assert + (await frames).MatchInlineSnapshot( + """ + [ + "auth token=fedcba9876543210fedcba9876543210", + "user session_id=0123456789abcdef0123456789abcdef role=user" + ] + """); + } + + private DateTimeOffset Deadline() => _timeProvider.GetUtcNow() + WakeDispatchPolicy.BatchDeadline; + + private async Task InitializeWorkspaceAsync(CancellationToken cancellationToken) + { + await using (await _database.InitializeAsync(_workspaceDirectory, cancellationToken)) + { + } + + await _leaderStore.TryAcquireAsync( + LeaderToken, _timeProvider.GetUtcNow(), TimeSpan.FromDays(1), cancellationToken); + } + + private async Task StartVentiSessionAsync(CancellationToken cancellationToken) + { + await _hooks.HandleSessionStartAsync( + new ClaudeHookPayload { SessionId = SessionId, Cwd = _workspaceRoot }, + skipSessionFileLookup: false, + cancellationToken); + + return (await _agentStore.FindBySessionAsync(AgentSessionHarness.Venti, SessionId, cancellationToken))!.Name; + } + + private async Task SendMailAsync(string actor, MailWakePolicy wakePolicy, CancellationToken cancellationToken) + { + await using (var connection = await _database.ConnectAsync(_workspaceDirectory, cancellationToken)) + { + await using var command = connection.CreateCommand(); + command.CommandText = + """ + INSERT INTO agents (name, registered_at, started_at, last_seen_at) + VALUES ('pascal', @now, @now, @now); + """; + command.Parameters.AddWithValue("@now", _timeProvider.GetUtcNow()); + await command.ExecuteNonQueryAsync(cancellationToken); + } + + await _mail.SendMessageAsync( + new MailMessageCreation + { + Sender = "pascal", + Subject = "status", + Body = "check", + To = [actor], + WakePolicy = wakePolicy + }, + cancellationToken); + } + + private Task WriteRegistryAsync(string endpoint, CancellationToken cancellationToken) + => File.WriteAllTextAsync( + Path.Combine(_peerDirectory, SessionId + ".json"), + JsonSerializer.Serialize( + new + { + sessionId = SessionId, + pid = Pid, + kind = "interactive", + messagingSocketPath = endpoint, + procStart = ProcStart, + peerProtocol = 1 + }), + cancellationToken); + + private Task WriteKeyAsync(CancellationToken cancellationToken) + => VentiPeerTestEndpoint.WriteKeyFileAsync( + Path.Combine(_peerDirectory, $"{Pid}.0a1b2c3d.key"), + JsonSerializer.Serialize(new { procStart = ProcStart, peerToken = PeerToken }), + UnixFileMode.UserRead | UnixFileMode.UserWrite, + cancellationToken); + + /// + /// Accepts one peer connection and summarizes each JSON line it sends, leaving the digest + /// text out. + /// + private static async Task> ReadFramesAsync( + VentiPeerTestEndpoint endpoint, + CancellationToken cancellationToken) + { + await using var stream = await endpoint.AcceptAsync(cancellationToken); + using var reader = new StreamReader(stream, Encoding.UTF8); + var lines = (await reader.ReadToEndAsync(cancellationToken)) + .Split('\n', StringSplitOptions.RemoveEmptyEntries); + + return lines + .Select(line => JsonDocument.Parse(line).RootElement) + .Select(frame => frame.GetProperty("type").GetString() == "auth" + ? $"auth token={frame.GetProperty("token").GetString()}" + : $"user session_id={frame.GetProperty("session_id").GetString()} " + + $"role={frame.GetProperty("message").GetProperty("role").GetString()}") + .ToList(); + } +} diff --git a/src/Nitro/CommandLine/test/CommandLine.Tests/Services/Workspace/AgentDatabaseTests.cs b/src/Nitro/CommandLine/test/CommandLine.Tests/Services/Workspace/AgentDatabaseTests.cs index 0b57f5b5acc..6025c103d78 100644 --- a/src/Nitro/CommandLine/test/CommandLine.Tests/Services/Workspace/AgentDatabaseTests.cs +++ b/src/Nitro/CommandLine/test/CommandLine.Tests/Services/Workspace/AgentDatabaseTests.cs @@ -1,4 +1,5 @@ using ChilliCream.Nitro.CommandLine.Services.Mail; +using ChilliCream.Nitro.CommandLine.Services.Memory; using ChilliCream.Nitro.CommandLine.Services.Tasks; using ChilliCream.Nitro.CommandLine.Services.Workspace; using Microsoft.Data.Sqlite; @@ -12,6 +13,39 @@ namespace ChilliCream.Nitro.CommandLine.Tests.Agents; /// public sealed class AgentDatabaseTests : IDisposable { + /// + /// The agents table and index exactly as schema v18 created them. + /// + private const string V18AgentRegistrySchema = + """ + CREATE TABLE IF NOT EXISTS agents ( + name TEXT PRIMARY KEY, + role TEXT NOT NULL DEFAULT '', + harness TEXT NULL CHECK (harness IN ('claude-code', 'codex', 'copilot', 'opencode')), + harness_version TEXT NOT NULL DEFAULT '', + session_id TEXT NULL, + cwd TEXT NOT NULL DEFAULT '', + workspace_path TEXT NOT NULL DEFAULT '', + registered_at TEXT NOT NULL, + started_at TEXT NOT NULL, + last_seen_at TEXT NOT NULL, + ended_at TEXT NULL, + deleted_at TEXT NULL, + endpoint_kind TEXT NOT NULL DEFAULT 'none' CHECK (endpoint_kind IN ('claude-peer', 'codex-thread', 'copilot-extension', 'opencode-server', 'db-watch', 'none')), + endpoint_addr TEXT NOT NULL DEFAULT '', + endpoint_secret TEXT NULL, + block_budget_used INTEGER NOT NULL DEFAULT 0, + last_ping_at TEXT NULL, + last_ping_attempt TEXT NULL, + last_ping_result TEXT NULL CHECK (last_ping_result IN ('ok', 'spawn-failed', 'endpoint-gone', 'timeout', 'capacity-dropped', 'error', 'unsupported') OR last_ping_result IS NULL), + last_ping_detail TEXT NULL CHECK (last_ping_detail IS NULL OR length(last_ping_detail) <= 200), + announcement_pending INTEGER NOT NULL DEFAULT 0, + idle_push_armed INTEGER NOT NULL DEFAULT 0 + ); + + CREATE UNIQUE INDEX IF NOT EXISTS idx_agents_session ON agents (harness, session_id) WHERE harness IS NOT NULL; + """; + private readonly DirectoryInfo _tempRoot; private readonly string _workspaceDirectory; private readonly AgentDatabase _database; @@ -136,7 +170,7 @@ await QueryScalarLongAsync(upgraded, "SELECT COUNT(*) FROM message_recipients", state.MatchInlineSnapshot( """ { - "Version": 18, + "Version": 19, "Tasks": 1, "Messages": 1, "MessageRecipients": 1, @@ -246,7 +280,7 @@ INSERT INTO message_recipients (message_id, recipient, ordinal, read_at) state.MatchInlineSnapshot( """ { - "Version": 18, + "Version": 19, "Sender": "ghost", "Subject": "Old mail", "ReadAt": "2026-01-11T09:00:00+00:00" @@ -344,7 +378,7 @@ last_error TEXT NULL CHECK (last_error IS NULL OR length(last_error) <= 200) state.MatchInlineSnapshot( """ { - "Version": 18, + "Version": 19, "OutboxColumns": [ "actor", "requested_generation", @@ -489,7 +523,7 @@ await QueryScalarLongAsync(upgraded, "SELECT COUNT(*) FROM message_recipients", state.MatchInlineSnapshot( """ { - "Version": 18, + "Version": 19, "Messages": 1, "MessageRecipients": 1, "Agents": 0, @@ -522,6 +556,141 @@ await QueryScalarLongAsync(upgraded, "SELECT COUNT(*) FROM message_recipients", """); } + /// + /// Seeds a v18 database exactly as v18 laid it down, with every agent-domain table + /// populated. The upgrade must keep every row and reference while the rebuilt + /// agents table accepts the venti harness. + /// + [Fact] + public async Task InitializeAsync_Should_PreserveEveryAgentDomainRow_When_ExistingVersionIsV18() + { + // arrange + var cancellationToken = TestContext.Current.CancellationToken; + await using (var connection = new SqliteConnection( + $"Data Source={AgentWorkspace.GetDatabasePath(_workspaceDirectory)};Pooling=False")) + { + await connection.OpenAsync(cancellationToken); + await ExecuteAsync(connection, "PRAGMA journal_mode = WAL; PRAGMA foreign_keys = ON;", cancellationToken); + await ExecuteAsync(connection, TaskStoreSchema.Create, cancellationToken); + await ExecuteAsync(connection, V18AgentRegistrySchema, cancellationToken); + await ExecuteAsync(connection, MailStoreSchema.Create, cancellationToken); + await ExecuteAsync(connection, PingLeaseSchema.Create, cancellationToken); + await ExecuteAsync(connection, MailWakeSchema.Create, cancellationToken); + await ExecuteAsync(connection, AgentDeliverySchema.Create, cancellationToken); + await ExecuteAsync(connection, AgentPingGateSchema.Create, cancellationToken); + await ExecuteAsync(connection, MemoryStoreSchema.Create, cancellationToken); + await ExecuteAsync(connection, TakeoverLedgerSchema.Create, cancellationToken); + await ExecuteAsync( + connection, + """ + INSERT INTO agents ( + name, role, harness, harness_version, session_id, cwd, workspace_path, + registered_at, started_at, last_seen_at, ended_at, deleted_at, + endpoint_kind, endpoint_addr, endpoint_secret, block_budget_used, + last_ping_at, last_ping_attempt, last_ping_result, last_ping_detail, + announcement_pending, idle_push_armed) + VALUES + ('maya', 'backend', 'claude-code', '2.1.241', 'session-1', '/repo', '/repo/.git/nitro', + '2026-01-10T12:00:00+00:00', '2026-01-10T12:01:00+00:00', '2026-01-10T12:02:00+00:00', + NULL, NULL, 'claude-peer', 'peer-maya', NULL, 2, + '2026-01-10T12:03:00+00:00', 'attempt-1', 'endpoint-gone', 'socket gone', 1, 1), + ('nova', 'reviewer', 'opencode', '1.0.0', 'session-2', '/repo', '/repo/.git/nitro', + '2026-01-10T12:00:00+00:00', '2026-01-10T12:00:00+00:00', '2026-01-10T12:00:00+00:00', + '2026-01-10T12:05:00+00:00', '2026-01-10T12:06:00+00:00', 'opencode-server', + 'http://127.0.0.1:4096', 'secret', 0, NULL, NULL, NULL, NULL, 0, 0), + ('lena', '', NULL, '', NULL, '', '', + '2026-01-10T12:00:00+00:00', '2026-01-10T12:00:00+00:00', '2026-01-10T12:00:00+00:00', + NULL, NULL, 'none', '', NULL, 0, NULL, NULL, NULL, NULL, 0, 0); + + INSERT INTO tasks (id, title, assignee, created_at, updated_at) + VALUES ('task-1', 'Keep me', 'maya', '2026-01-10T12:00:00+00:00', '2026-01-10T12:00:00+00:00'); + + INSERT INTO messages (id, thread_id, sender, subject, body, created_at) + VALUES ('mail-1', 'mail-1', 'nova', 'Old mail', 'Keep me too', '2026-01-10T12:00:00+00:00'); + INSERT INTO message_recipients (message_id, recipient, ordinal) + VALUES ('mail-1', 'maya', 0); + + INSERT INTO memory_journal (id, body, created_at, created_by) + VALUES ('memory-1', 'Remember me', '2026-01-10T12:00:00+00:00', 'maya'); + + INSERT INTO agent_deliveries (agent, message_id, channel, delivered_at) + VALUES ('maya', 'mail-1', 'digest', '2026-01-10T12:04:00+00:00'); + + INSERT INTO agent_ping_gates (agent, attempt_id, acquired_at, expires_at) + VALUES ('maya', 'attempt-1', '2026-01-10T12:03:00+00:00', '2026-01-10T12:04:00+00:00'); + + INSERT INTO ping_leases (slot, attempt_id, acquired_at, expires_at) + VALUES (1, 'attempt-1', '2026-01-10T12:03:00+00:00', '2026-01-10T12:04:00+00:00'); + + INSERT INTO mail_wake_outbox (actor, requested_generation, settled_generation, due_at, updated_at) + VALUES ('maya', 2, 1, '2026-01-10T12:03:00+00:00', '2026-01-10T12:03:00+00:00'); + + INSERT INTO mail_wake_batches ( + batch_id, actor, claimed_generation, owner_id, attempt_id, claimed_at, expires_at) + VALUES ('batch-1', 'maya', 2, 'leader', 'batch-attempt-1', + '2026-01-10T12:03:00+00:00', '2026-01-10T12:04:00+00:00'); + + INSERT INTO mail_wake_targets (batch_id, agent, status, updated_at) + VALUES ('batch-1', 'maya', 'pending', '2026-01-10T12:03:00+00:00'); + + PRAGMA user_version = 18; + """, + cancellationToken); + } + + // act + await using var upgraded = await _database.InitializeAsync(_workspaceDirectory, cancellationToken); + + // assert + var agents = await QueryRowsAsync(upgraded, "SELECT * FROM agents ORDER BY name", cancellationToken); + var rowCounts = new Dictionary(); + + foreach (var table in new[] + { + "tasks", "messages", "message_recipients", "memory_journal", "agent_deliveries", + "agent_ping_gates", "ping_leases", "mail_wake_outbox", "mail_wake_batches", "mail_wake_targets" + }) + { + rowCounts[table] = await QueryScalarLongAsync(upgraded, $"SELECT COUNT(*) FROM {table}", cancellationToken); + } + + var agentIndexes = await QueryRowsAsync( + upgraded, + "SELECT name, sql FROM sqlite_master WHERE type = 'index' AND tbl_name = 'agents' AND sql IS NOT NULL", + cancellationToken); + var foreignKeyViolations = await QueryRowsAsync(upgraded, "PRAGMA foreign_key_check", cancellationToken); + var integrity = await QueryScalarStringAsync(upgraded, "PRAGMA integrity_check", cancellationToken); + await ExecuteAsync( + upgraded, + """ + INSERT INTO agents (name, harness, session_id, registered_at, started_at, last_seen_at) + VALUES ('vera', 'venti', 'session-1', '2026-01-10T13:00:00+00:00', '2026-01-10T13:00:00+00:00', + '2026-01-10T13:00:00+00:00') + """, + cancellationToken); + var ventiAgents = await QueryRowsAsync( + upgraded, "SELECT name, harness, session_id FROM agents WHERE harness = 'venti'", cancellationToken); + var orphanDelivery = await Record.ExceptionAsync( + () => ExecuteAsync( + upgraded, + """ + INSERT INTO agent_deliveries (agent, message_id, channel, delivered_at) + VALUES ('ghost', 'mail-1', 'gate', '2026-01-10T13:00:00+00:00') + """, + cancellationToken)); + + Snapshot.Create() + .Add(await QueryScalarLongAsync(upgraded, "PRAGMA user_version", cancellationToken), "Version") + .Add(agents, "Agents") + .Add(rowCounts, "RowCounts") + .Add(agentIndexes, "AgentIndexes") + .Add(foreignKeyViolations, "ForeignKeyViolations") + .Add(integrity, "IntegrityCheck") + .Add(ventiAgents, "VentiAgentsAfterInsert") + .Add((orphanDelivery as SqliteException)?.Message, "OrphanDeliveryError") + .MatchMarkdownSnapshot(); + } + /// /// Opening an already-current database, whether for the first time after /// creation or a second time in a row, never wipes the agents already in it. @@ -796,6 +965,7 @@ await _database.InitializeAsync(_workspaceDirectory, cancellationToken)) [InlineData(6)] [InlineData(7)] [InlineData(17)] + [InlineData(18)] public async Task ConnectAsync_Should_Throw_When_VersionIsUpgradable(int upgradableVersion) { // arrange @@ -1399,6 +1569,36 @@ private static async Task> QueryColumnNamesAsync( return names; } + /// + /// Returns every row of as column-name-to-value pairs in column order. + /// + private static async Task>> QueryRowsAsync( + SqliteConnection connection, + string sql, + CancellationToken cancellationToken) + { + await using var command = connection.CreateCommand(); + command.CommandText = sql; + + var rows = new List>(); + + await using var reader = await command.ExecuteReaderAsync(cancellationToken); + + while (await reader.ReadAsync(cancellationToken)) + { + var row = new Dictionary(); + + for (var i = 0; i < reader.FieldCount; i++) + { + row[reader.GetName(i)] = reader.IsDBNull(i) ? null : reader.GetValue(i); + } + + rows.Add(row); + } + + return rows; + } + private static async Task ExecuteAsync( SqliteConnection connection, string sql, diff --git a/src/Nitro/CommandLine/test/CommandLine.Tests/Services/Workspace/__snapshots__/AgentDatabaseTests.InitializeAsync_Should_AddMailWakeAndAgentPingGateTables_When_ExistingVersionIsV6.md b/src/Nitro/CommandLine/test/CommandLine.Tests/Services/Workspace/__snapshots__/AgentDatabaseTests.InitializeAsync_Should_AddMailWakeAndAgentPingGateTables_When_ExistingVersionIsV6.md index a6d915cf288..58bb55313b5 100644 --- a/src/Nitro/CommandLine/test/CommandLine.Tests/Services/Workspace/__snapshots__/AgentDatabaseTests.InitializeAsync_Should_AddMailWakeAndAgentPingGateTables_When_ExistingVersionIsV6.md +++ b/src/Nitro/CommandLine/test/CommandLine.Tests/Services/Workspace/__snapshots__/AgentDatabaseTests.InitializeAsync_Should_AddMailWakeAndAgentPingGateTables_When_ExistingVersionIsV6.md @@ -3,7 +3,7 @@ ## Version ```json -18 +19 ``` ## Tables diff --git a/src/Nitro/CommandLine/test/CommandLine.Tests/Services/Workspace/__snapshots__/AgentDatabaseTests.InitializeAsync_Should_CreateAllSchemasAndStampCurrentVersion_When_DatabaseIsNew.md b/src/Nitro/CommandLine/test/CommandLine.Tests/Services/Workspace/__snapshots__/AgentDatabaseTests.InitializeAsync_Should_CreateAllSchemasAndStampCurrentVersion_When_DatabaseIsNew.md index 247e9d5e004..1ac3f0152f1 100644 --- a/src/Nitro/CommandLine/test/CommandLine.Tests/Services/Workspace/__snapshots__/AgentDatabaseTests.InitializeAsync_Should_CreateAllSchemasAndStampCurrentVersion_When_DatabaseIsNew.md +++ b/src/Nitro/CommandLine/test/CommandLine.Tests/Services/Workspace/__snapshots__/AgentDatabaseTests.InitializeAsync_Should_CreateAllSchemasAndStampCurrentVersion_When_DatabaseIsNew.md @@ -3,7 +3,7 @@ ## Version ```json -18 +19 ``` ## Tables diff --git a/src/Nitro/CommandLine/test/CommandLine.Tests/Services/Workspace/__snapshots__/AgentDatabaseTests.InitializeAsync_Should_PreserveEveryAgentDomainRow_When_ExistingVersionIsV18.md b/src/Nitro/CommandLine/test/CommandLine.Tests/Services/Workspace/__snapshots__/AgentDatabaseTests.InitializeAsync_Should_PreserveEveryAgentDomainRow_When_ExistingVersionIsV18.md new file mode 100644 index 00000000000..f6db0b507d0 --- /dev/null +++ b/src/Nitro/CommandLine/test/CommandLine.Tests/Services/Workspace/__snapshots__/AgentDatabaseTests.InitializeAsync_Should_PreserveEveryAgentDomainRow_When_ExistingVersionIsV18.md @@ -0,0 +1,144 @@ +# InitializeAsync_Should_PreserveEveryAgentDomainRow_When_ExistingVersionIsV18 + +## Version + +```json +19 +``` + +## Agents + +```json +[ + { + "name": "lena", + "role": "", + "harness": null, + "harness_version": "", + "session_id": null, + "cwd": "", + "workspace_path": "", + "registered_at": "2026-01-10T12:00:00+00:00", + "started_at": "2026-01-10T12:00:00+00:00", + "last_seen_at": "2026-01-10T12:00:00+00:00", + "ended_at": null, + "deleted_at": null, + "endpoint_kind": "none", + "endpoint_addr": "", + "endpoint_secret": null, + "block_budget_used": 0, + "last_ping_at": null, + "last_ping_attempt": null, + "last_ping_result": null, + "last_ping_detail": null, + "announcement_pending": 0, + "idle_push_armed": 0 + }, + { + "name": "maya", + "role": "backend", + "harness": "claude-code", + "harness_version": "2.1.241", + "session_id": "session-1", + "cwd": "/repo", + "workspace_path": "/repo/.git/nitro", + "registered_at": "2026-01-10T12:00:00+00:00", + "started_at": "2026-01-10T12:01:00+00:00", + "last_seen_at": "2026-01-10T12:02:00+00:00", + "ended_at": null, + "deleted_at": null, + "endpoint_kind": "claude-peer", + "endpoint_addr": "peer-maya", + "endpoint_secret": null, + "block_budget_used": 2, + "last_ping_at": "2026-01-10T12:03:00+00:00", + "last_ping_attempt": "attempt-1", + "last_ping_result": "endpoint-gone", + "last_ping_detail": "socket gone", + "announcement_pending": 1, + "idle_push_armed": 1 + }, + { + "name": "nova", + "role": "reviewer", + "harness": "opencode", + "harness_version": "1.0.0", + "session_id": "session-2", + "cwd": "/repo", + "workspace_path": "/repo/.git/nitro", + "registered_at": "2026-01-10T12:00:00+00:00", + "started_at": "2026-01-10T12:00:00+00:00", + "last_seen_at": "2026-01-10T12:00:00+00:00", + "ended_at": "2026-01-10T12:05:00+00:00", + "deleted_at": "2026-01-10T12:06:00+00:00", + "endpoint_kind": "opencode-server", + "endpoint_addr": "http://127.0.0.1:4096", + "endpoint_secret": "secret", + "block_budget_used": 0, + "last_ping_at": null, + "last_ping_attempt": null, + "last_ping_result": null, + "last_ping_detail": null, + "announcement_pending": 0, + "idle_push_armed": 0 + } +] +``` + +## RowCounts + +```json +{ + "tasks": 1, + "messages": 1, + "message_recipients": 1, + "memory_journal": 1, + "agent_deliveries": 1, + "agent_ping_gates": 1, + "ping_leases": 1, + "mail_wake_outbox": 1, + "mail_wake_batches": 1, + "mail_wake_targets": 1 +} +``` + +## AgentIndexes + +```json +[ + { + "name": "idx_agents_session", + "sql": "CREATE UNIQUE INDEX idx_agents_session ON agents (harness, session_id) WHERE harness IS NOT NULL" + } +] +``` + +## ForeignKeyViolations + +```json +[] +``` + +## IntegrityCheck + +```text +ok +``` + +## VentiAgentsAfterInsert + +```json +[ + { + "name": "vera", + "harness": "venti", + "session_id": "session-1" + } +] +``` + +## OrphanDeliveryError + +```text +SQLite Error 19: 'FOREIGN KEY constraint failed'. +``` diff --git a/website/content/docs/nitro/cli/agent-hooks.md b/website/content/docs/nitro/cli/agent-hooks.md index 34ae62c3295..45d05c042bb 100644 --- a/website/content/docs/nitro/cli/agent-hooks.md +++ b/website/content/docs/nitro/cli/agent-hooks.md @@ -1,11 +1,11 @@ --- title: agent hooks Command -description: "Wire a coding harness (Claude Code, Codex CLI, or Opencode) into Nitro's turn-boundary hooks with the `nitro agent hooks` commands: install, status, and uninstall, per harness." +description: "Wire a coding harness (Claude Code, Codex CLI, Opencode, or Venti) into Nitro's turn-boundary hooks with the `nitro agent hooks` commands: install, status, and uninstall, per harness." --- The `nitro agent hooks` commands install, inspect, and remove the turn-boundary hook entries that connect a coding harness to Nitro. Once installed, three things happen automatically inside a coding session: -- **Actor identity**: Claude Code and Codex CLI announce the session's actor name and role at `SessionStart`; Opencode announces it on the session's first chat message. +- **Actor identity**: Claude Code, Codex CLI, and Venti announce the session's actor name and role at `SessionStart`; Opencode announces it on the session's first chat message. - **Unread-mail digests**: a prompt sent while `nitro agent mail` has unread messages for the session's actor is prefixed with a nudge naming how many are unread. - **Idle wake and delivery**: a session that goes idle with mail still unread is pushed a delivery. @@ -15,6 +15,7 @@ Each harness wires these in differently. `nitro agent hooks ` is a sibl nitro agent hooks claude install nitro agent hooks codex install nitro agent hooks opencode install +nitro agent hooks venti install ``` # Claude Code @@ -193,6 +194,76 @@ nitro agent hooks opencode uninstall nitro agent hooks opencode uninstall --scope project ``` +# Venti + +Venti hook entries live in a `settings.json` file and cover six events: `AgentNaming`, `SessionStart`, `UserPromptSubmit`, `Stop`, `Notification`, and `SessionEnd`. Each entry runs `nitro agent hook venti `. The `AgentNaming` entry runs only for main sessions (matcher `main`) and names each new session, including the one `/clear` starts, after its Nitro actor, so Venti and Nitro use the same name. A resumed session keeps its name and actor, and subagents keep the names Venti gives them. The `Stop` entry is a hard gate: it can block the turn from ending, up to three times per turn, until unread mail is read, resetting on the next `UserPromptSubmit`. The `Notification` entry only refreshes the agent's last-seen time, and only when Venti's `notification_type` is `idle_prompt`. + +`install` and `uninstall` only ever touch the entries Nitro itself owns; hook entries another tool added for the same event are left in place. + +Restart Venti after installing or uninstalling hooks. For project-scoped hooks, start Venti from the directory where you ran `install`. Only sessions started after the restart are named after their actor; a session started earlier keeps the name Venti gave it, also when resumed. + +Entries installed by an earlier Nitro version have no `AgentNaming` entry: `status` reports it as missing, and running `install` again adds it. + +## `nitro agent hooks venti install` + +Add or update this CLI's Venti turn-boundary hook entries. + +```shell +nitro agent hooks venti install +``` + +### Options + +| Option | Description | +| ------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `--scope ` | Where the settings file lives: `user` (`~/.venti/settings.json`) or `project` (`.venti/settings.json` in the current directory, inside a workspace). Default: `user`. | +| `--output ` | The output format. Setting `json` also enables non-interactive mode (see [Global Options](./global-options.md)). | + +### Examples + +```shell +nitro agent hooks venti install +nitro agent hooks venti install --scope project +``` + +## `nitro agent hooks venti status` + +Show whether this CLI's Venti hook entries are missing, current, or outdated. + +```shell +nitro agent hooks venti status +``` + +### Options + +Same `--scope` and `--output` options as `install`. + +### Examples + +```shell +nitro agent hooks venti status +nitro agent hooks venti status --scope project +``` + +## `nitro agent hooks venti uninstall` + +Remove this CLI's Venti turn-boundary hook entries. + +```shell +nitro agent hooks venti uninstall +``` + +### Options + +Same `--scope` and `--output` options as `install`. + +### Examples + +```shell +nitro agent hooks venti uninstall +nitro agent hooks venti uninstall --scope project +``` + # Caveats - **Pushes only flow while the board is open.** The wake loop that delivers idle pushes runs inside `nitro agent`'s board TUI; there is no separate daemon or autostart. Closing the board stops pushes until it is reopened.