Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
63383bb
Fix wrapped JSON command output
michaelstaib Sep 1, 2026
347b5a9
Add session delivery lookup
michaelstaib Sep 1, 2026
1ea12b7
Transfer mail participation
michaelstaib Sep 2, 2026
95aad78
Fix mail transfer compatibility coverage
michaelstaib Sep 2, 2026
f454d73
Add bounded mail digest renderer
michaelstaib Sep 2, 2026
e8d786c
Fix mail digest projection
michaelstaib Sep 2, 2026
3bf9459
Add atomic task reassignment store operation
michaelstaib Sep 2, 2026
f57fe1c
Fix nullable assignee database parameter
michaelstaib Sep 2, 2026
7fc61f5
Fix mail JSON source generation imports
michaelstaib Sep 2, 2026
916caa2
Fix mail store test collection inference
michaelstaib Sep 2, 2026
796a2a5
Render unread mail in hook notifications
michaelstaib Sep 2, 2026
02173a4
test: correct hook delivery assertions
michaelstaib Sep 2, 2026
243151d
Add takeover audit ledger
michaelstaib Sep 2, 2026
ab3e2ed
Fix takeover ledger database handling
michaelstaib Sep 2, 2026
932463f
Remove unnecessary block reason null forgiveness
michaelstaib Sep 2, 2026
dd52742
Fix shadowed connection in agent database test
michaelstaib Sep 2, 2026
b771918
Update Claude hook JSON snapshots
michaelstaib Sep 2, 2026
34e785c
Fix Claude prompt hook snapshot
michaelstaib Sep 2, 2026
fe1b1dc
Fix takeover header parameter binding
michaelstaib Sep 2, 2026
f8df131
Fix takeover query materialization
michaelstaib Sep 2, 2026
cdec970
Test pre-cancelled takeover recording
michaelstaib Sep 2, 2026
27763e0
Include mail bodies in session nudges
michaelstaib Sep 2, 2026
6910400
Keep mail nudges fail open during discovery
michaelstaib Sep 2, 2026
19f68af
Fix mail nudge test imports
michaelstaib Sep 2, 2026
65383b1
Add agent takeover command
michaelstaib Sep 2, 2026
f059010
Add task type filters to ready and list
michaelstaib Sep 2, 2026
a697344
Stabilize ready type filter ordering tests
michaelstaib Sep 2, 2026
a18e3f3
Resolve effective session-start roles
michaelstaib Sep 2, 2026
11db2e6
Preserve actor context when role lookup fails
michaelstaib Sep 2, 2026
ca52508
Add takeover history to agent details
michaelstaib Sep 2, 2026
94a6a6f
Fix takeover history message counts
michaelstaib Sep 2, 2026
ce6de89
Remove duplicate mail test query helper
michaelstaib Sep 2, 2026
6a27c77
feat(nitro-cli): add well-known researcher role
michaelstaib Sep 2, 2026
c2407bd
test(nitro-cli): pin researcher role styling
michaelstaib Sep 2, 2026
d16660d
Merge branch 'main' into mst/nitro-mail-payload-takeover
michaelstaib Sep 8, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ public AgentCommand() : base("agent")
Subcommands.Add(new LoginAgentCommand());
Subcommands.Add(new RegisterAgentCommand());
Subcommands.Add(new ListAgentCommand());
Subcommands.Add(new TakeoverAgentCommand());
Subcommands.Add(new HookCommand());
Subcommands.Add(new HooksCommand());

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ private static async Task<int> ExecuteAsync(
var console = services.GetRequiredService<INitroConsole>();
var store = services.GetRequiredService<IMailStore>();
var registry = services.GetRequiredService<IAgentRegistry>();
var ledger = services.GetRequiredService<ITakeoverLedger>();
var actorResolver = services.GetRequiredService<IActingActorResolver>();
var resultHolder = services.GetRequiredService<IResultHolder>();

Expand All @@ -48,10 +49,13 @@ private static async Task<int> ExecuteAsync(
var messages = thread
? await MarkThreadReadAsync(store, message.ThreadId, actor, cancellationToken)
: [await MarkMessageReadAsync(store, message, actor, cancellationToken)];
var takeovers = await GetTakeoversAsync(ledger, messages, cancellationToken);

if (!console.IsHumanReadable)
{
var results = messages.Select(m => MailMessageDetailResult.Create(m, actor)).ToArray();
var results = messages
.Select((message, index) => MailMessageDetailResult.Create(message, actor, takeovers[index]))
.ToArray();

resultHolder.SetResult(
thread
Expand All @@ -71,7 +75,7 @@ private static async Task<int> ExecuteAsync(
}

var sender = await registry.GetAsync(messages[i].Sender, cancellationToken);
WriteMessage(console, messages[i], sender?.Role ?? "");
WriteMessage(console, messages[i], sender?.Role ?? "", takeovers[i]);
}

return ExitCodes.Success;
Expand Down Expand Up @@ -131,7 +135,29 @@ private static async Task<IReadOnlyList<MailMessage>> MarkThreadReadAsync(
return await store.GetThreadMessagesAsync(threadId, cancellationToken);
}

private static void WriteMessage(INitroConsole console, MailMessage message, string senderRole)
private static async Task<IReadOnlyList<TakeoverReferenceResult>[]> GetTakeoversAsync(
ITakeoverLedger ledger,
IReadOnlyList<MailMessage> messages,
CancellationToken cancellationToken)
{
var takeovers = new IReadOnlyList<TakeoverReferenceResult>[messages.Count];

for (var index = 0; index < messages.Count; index++)
{
var records = await ledger.QueryAsync(
new TakeoverFilter { MessageId = messages[index].Id },
cancellationToken);
takeovers[index] = records.Select(TakeoverReferenceResult.FromRecord).ToArray();
}
Comment on lines +138 to +151

return takeovers;
}

private static void WriteMessage(
INitroConsole console,
MailMessage message,
string senderRole,
IReadOnlyList<TakeoverReferenceResult> takeovers)
{
var to = message.Recipients
.Where(r => r.Kind == MailRecipientKinds.To)
Expand Down Expand Up @@ -159,6 +185,14 @@ private static void WriteMessage(INitroConsole console, MailMessage message, str
console.WriteLine($"Date: {TaskDates.Format(message.CreatedAt)}");
console.WriteLine($"Subject: {message.Subject}");
console.WriteLine($"Thread: {message.ThreadId}");

foreach (var takeover in takeovers)
{
console.WriteLine(
$"Takeover: {takeover.From} -> {takeover.To} "
+ $"({takeover.Id}, {takeover.CreatedAt.ToUniversalTime():yyyy-MM-dd})");
}

console.WriteLine();
console.WriteLine(message.Body);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,7 @@ private static async Task<int> ExecuteAsync(
{
resultHolder.SetResult(
new ListResult<MailMessageDetailResult>(
arrived.Select(m => MailMessageDetailResult.Create(m, actor)).ToArray()));
arrived.Select(m => MailMessageDetailResult.Create(m, actor, [])).ToArray()));

return ExitCodes.Success;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,6 @@ internal sealed class ForceActorTakeoverOption : Option<bool>
{
public ForceActorTakeoverOption() : base("--force")
{
Description = "Take the actor from another session and remove that session";
Description = "Take over even when the source actor still has a live session";
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@ internal sealed class RoleAgentOption : Option<string>
{
public RoleAgentOption() : base("--role")
{
Description = "The actor role, normalized lowercase";
Description =
"The actor role, normalized lowercase. Known roles: orchestrator, planner, implementer, "
+ "reviewer, researcher; any other value is accepted.";
Required = false;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
namespace ChilliCream.Nitro.CommandLine.Commands.Agent.Options;

internal sealed class TakeoverActorOption : Option<string>
{
public TakeoverActorOption() : base("--actor")
{
Description = "The actor taking over the mail and tasks";
Required = true;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
namespace ChilliCream.Nitro.CommandLine.Commands.Agent.Options;

internal sealed class TakeoverFromActorOption : Option<string>
{
public TakeoverFromActorOption() : base("--from")
{
Description = "The actor whose mail and tasks to take over";
Required = true;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
namespace ChilliCream.Nitro.CommandLine.Commands.Agent.Options;

internal sealed class TakeoverHistoryActorOption : Option<string?>
{
public TakeoverHistoryActorOption() : base("--actor")
{
Description = "Filter to takeovers involving this actor";
Required = false;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
namespace ChilliCream.Nitro.CommandLine.Commands.Agent.Options;

internal sealed class TakeoverHistoryLimitOption : Option<int?>
{
public TakeoverHistoryLimitOption() : base("--limit")
{
Description = "The maximum number of takeovers to show";
Required = false;
Validators.Add(result =>
{
var limit = result.GetValue(this);

if (limit is <= 0)
{
result.AddError("Option '--limit' must be a positive number.");
}
});
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
namespace ChilliCream.Nitro.CommandLine.Commands.Agent.Options;

internal sealed class TakeoverReasonOption : Option<string>
{
public TakeoverReasonOption() : base("--reason")
{
Description = "The reason recorded for the takeover";
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ public RegisterAgentCommand() : base("register")
Options.Add(Opt<RoleAgentOption>.Instance);
Options.Add(Opt<OptionalOutputFormatOption>.Instance);

this.AddExamples("agent register --actor \"maya\"", "agent register --actor \"maya\" --role \"backend\"");
this.AddExamples("agent register --actor \"maya\"", "agent register --actor \"maya\" --role \"researcher\"");

this.SetActionWithExceptionHandling(ExecuteAsync);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
using ChilliCream.Nitro.CommandLine.Commands.Agent.Options;
using ChilliCream.Nitro.CommandLine.Helpers;
using ChilliCream.Nitro.CommandLine.Results;
using ChilliCream.Nitro.CommandLine.Services.Mail;
using ChilliCream.Nitro.CommandLine.Services.Tasks;
using ChilliCream.Nitro.CommandLine.Services.Workspace;

namespace ChilliCream.Nitro.CommandLine.Commands.Agent;

internal sealed class TakeoverAgentCommand : Command
{
public TakeoverAgentCommand() : base("takeover")
{
Description = "Take over another actor's mail and tasks.";

Subcommands.Add(new TakeoverHistoryAgentCommand());

Options.Add(Opt<TakeoverFromActorOption>.Instance);
Options.Add(Opt<TakeoverActorOption>.Instance);
Options.Add(Opt<ForceActorTakeoverOption>.Instance);
Options.Add(Opt<TakeoverReasonOption>.Instance);
Options.Add(Opt<OptionalOutputFormatOption>.Instance);

this.AddExamples(
"agent takeover --from \"maya\" --actor \"nora\"",
"agent takeover --from \"maya\" --actor \"nora\" --force --reason \"session ended\"");

this.SetActionWithExceptionHandling(ExecuteAsync);
}

private static async Task<int> ExecuteAsync(
ICommandServices services,
ParseResult parseResult,
CancellationToken cancellationToken)
{
var console = services.GetRequiredService<INitroConsole>();
var resultHolder = services.GetRequiredService<IResultHolder>();
var agents = services.GetRequiredService<IAgentRegistry>();
var sessions = services.GetRequiredService<IAgentSessionRegistry>();
var mail = services.GetRequiredService<IMailStore>();
var tasks = services.GetRequiredService<ITaskStore>();
var ledger = services.GetRequiredService<ITakeoverLedger>();

var from = MailAgentName.Normalize(
parseResult.GetValue(Opt<TakeoverFromActorOption>.Instance) ?? string.Empty);
var to = MailAgentName.Normalize(
parseResult.GetValue(Opt<TakeoverActorOption>.Instance) ?? string.Empty);
var force = parseResult.GetValue(Opt<ForceActorTakeoverOption>.Instance);
var reason = parseResult.GetValue(Opt<TakeoverReasonOption>.Instance);

var source = await agents.GetAsync(from, cancellationToken)
?? throw UnknownActor(from);
var target = await agents.GetAsync(to, cancellationToken)
?? throw UnknownActor(to);

if (from == to)
{
throw new ExitException("The source and target actors must be different.");
}

if (!force
&& (await sessions.FindLiveClaimedByAgentNameAsync(from, cancellationToken)).Count > 0)
{
throw new ExitException(
$"Actor '{from}' still has a live session; pass --force to take over anyway.");
}

var role = target.Role;
if (role.Length == 0 && source.Role.Length > 0)
{
target = await agents.RegisterAsync(to, source.Role, target.Client, cancellationToken);
role = target.Role;
}

var mailTransfer = await mail.TransferParticipationAsync(from, to, cancellationToken);
var taskIds = await tasks.ReassignAsync(
from,
to,
to,
$"Taken over from '{from}' by '{to}'.",
cancellationToken);
var items = CreateItems(mailTransfer, taskIds);
var takeover = await ledger.RecordAsync(
new TakeoverRecordCreation
{
FromActor = from,
ToActor = to,
Actor = to,
Forced = force,
Role = role.Length > 0 ? role : null,
Reason = reason
},
items,
cancellationToken);
Comment on lines +75 to +94

if (!console.IsHumanReadable)
{
resultHolder.SetResult(new ObjectResult(
new AgentTakeoverResult(
takeover.Id,
from,
to,
role,
mailTransfer.RecipientsMoved,
mailTransfer.SendersMoved,
taskIds)));

return ExitCodes.Success;
}

var taskSummary = taskIds.Count == 0
? "no tasks"
: $"{taskIds.Count} tasks ({string.Join(", ", taskIds.Select(id => id.EscapeMarkup()))})";
console.OkLine(
$"'{to.EscapeMarkup()}' took over from '{from.EscapeMarkup()}': "
+ $"role '{role.EscapeMarkup()}', "
+ $"{mailTransfer.RecipientsMoved + mailTransfer.SendersMoved} messages, {taskSummary}.");

return ExitCodes.Success;
}

private static ExitException UnknownActor(string actor)
=> new($"Unknown actor '{actor}'. Run `nitro agent list` to see the actors this workspace knows.");

private static IReadOnlyList<TakeoverItem> CreateItems(
MailTransferResult mailTransfer,
IReadOnlyList<string> taskIds)
{
var items = new List<TakeoverItem>(
mailTransfer.SenderMessageIds.Count
+ mailTransfer.RecipientMessageIds.Count
+ taskIds.Count);
items.AddRange(mailTransfer.SenderMessageIds.Select(
id => new TakeoverItem { Kind = TakeoverItemKinds.MessageSender, ItemId = id }));
items.AddRange(mailTransfer.RecipientMessageIds.Select(
id => new TakeoverItem { Kind = TakeoverItemKinds.MessageRecipient, ItemId = id }));
items.AddRange(taskIds.Select(
id => new TakeoverItem { Kind = TakeoverItemKinds.Task, ItemId = id }));

return items;
}

public sealed record AgentTakeoverResult(
string Id,
string From,
string To,
string Role,
int RecipientsMoved,
int SendersMoved,
IReadOnlyList<string> Tasks);
}
Loading
Loading