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.