Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/Capacitor.Cli.Daemon/Services/AgentOrchestrator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1062,7 +1062,7 @@ void HandleInputWait(string agentId, bool waiting) {
/// the vendor's own terminal. Keyed on the broker alone, so a request outliving its agent's
/// table entry is still retired.
void HandleToolSettled(string agentId, ToolSettledNotice notice) {
if (notice.ToolUseId is { } toolUseId) _permissionBroker.TryWithdrawTool(agentId, notice.SessionId, toolUseId);
if (notice.NamesATool) _permissionBroker.TryWithdrawTool(agentId, notice.SessionId, notice.ToolUseId, notice.ToolName, notice.ToolInput, notice.SubagentId);
else _permissionBroker.WithdrawTurn(agentId, notice.SessionId, notice.SubagentId);
}

Expand Down
11 changes: 8 additions & 3 deletions src/Capacitor.Cli.Daemon/Services/LocalPermissionBridge.cs
Original file line number Diff line number Diff line change
Expand Up @@ -687,7 +687,10 @@ _ when rawUrl.Equals($"/{submitToken}/flow-message", StringComparison.Ordinal)
toolName ?? "", behavior, PermissionSettlements.SourcePolicy));
_ = server.AppendAgentRunEventAsync(governed.AgentId, decided.Event);
// No Register: a call the policy answered raises no card, and the
// decision log plus the run event are its audit trail.
// decision log plus the run event are its audit trail. An allowed one
// still runs and reports, and that report must not retire a prompt.
if (decided.Outcome == PolicyOutcome.Allow)
_broker.ExpectCompletion(governed.AgentId, canonicalSessionId!, SubagentIdOf(node), toolName ?? "", toolInput);
await WriteResponseAsync(context, BuildHookResponseJson(new PermissionDecision(behavior, null, null), vendor), time);

return;
Expand Down Expand Up @@ -982,14 +985,16 @@ async Task HandleToolSettledAsync(HttpListenerContext context, string path) {
if (await ReadRelayAsync(context, path, ToolSettledSuffix, reviewerAllowed: false) is not (var node, var sessionId, _)) return;

if (!TryScope(node, "tool_use_id", PermissionWire.MaxToolUseIdBytes, out var toolUseId)
|| !TryScope(node, "subagent_id", PermissionWire.MaxAgentIdBytes, out var subagentId)) {
|| !TryScope(node, "subagent_id", PermissionWire.MaxAgentIdBytes, out var subagentId)
|| !TryScope(node, "tool_name", PermissionWire.MaxToolNameBytes, out var toolName)) {
Close(context, 400);

return;
}

var attributed = AttributeHandler?.Invoke(new PermissionAttribution(Str(node, "agent_id"), sessionId, Str(node, "cwd")));
if (attributed is { } agent) ToolSettledHandler?.Invoke(agent.AgentId, new ToolSettledNotice(sessionId, toolUseId, subagentId));
if (attributed is { } agent)
ToolSettledHandler?.Invoke(agent.AgentId, new ToolSettledNotice(sessionId, toolUseId, subagentId, toolName, ExtractElement(node, "tool_input")));

Close(context, 204);
}
Expand Down
125 changes: 115 additions & 10 deletions src/Capacitor.Cli.Daemon/Services/PermissionPromptBroker.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
using System.Collections.Concurrent;
using System.Globalization;
using System.Text.Json;
using System.Threading.Channels;
using Capacitor.Cli.Core;
using Capacitor.Cli.Core.LocalIpc;
Expand All @@ -25,16 +27,40 @@ internal static class PermissionSettlements {
/// TrySettle under one gate that replay/registration also takes, so a subscriber observes a
/// request as nothing, or Pending then Resolved, never Pending alone. The withdrawn set is
/// service-lifetime: agent ids are never reused, so it can never suppress a future agent.
internal sealed class PermissionPromptBroker {
internal sealed class PermissionPromptBroker(TimeProvider? time = null) {
sealed record Entry(PermissionPendingDto Dto, TaskCompletionSource<PermissionSettlement> Tcs, string? SubagentId);

/// The hook-reporting scope a prompt or a completion belongs to.
readonly record struct Scope(string AgentId, string SessionId, string? SubagentId);

/// A completion report a scope is still owed: the call ran, or will, and its hook reports it
/// once. Lapses a grace period after its turn ends, since no report can be in flight by then.
sealed class Owed(Scope scope, string toolName, JsonElement? toolInput) {
public Scope Scope { get; } = scope;
public string ToolName { get; } = toolName;
public JsonElement? ToolInput { get; } = toolInput;
public DateTimeOffset? LapsesAt { get; set; }
}

/// The hook that reports a completion has a 5s timeout and the relay a 1s cap, so a report
/// still in flight when the turn ends lands well inside this.
internal static readonly TimeSpan OwedGraceAfterTurn = TimeSpan.FromSeconds(30);
internal const int MaxOwedPerScope = 256;

readonly TimeProvider _time = time ?? TimeProvider.System;
readonly ConcurrentDictionary<string, Entry> _pending = new(StringComparer.Ordinal);
readonly List<Owed> _owed = [];
/// Scopes whose ledger overflowed, so their fallback is off; the value is the lapse a turn's
/// end set, null until a turn ends with nothing discarded since.
readonly Dictionary<Scope, DateTimeOffset?> _suspended = new();
readonly ConcurrentDictionary<Guid, Channel<PermissionStreamItem>> _subscribers = new();
readonly HashSet<string> _withdrawnAgents = new(StringComparer.Ordinal);
readonly object _gate = new();

public bool HasSubscriber => !_subscribers.IsEmpty;

internal int OwedCountForTest { get { lock (_gate) return _owed.Count; } }

/// <param name="subagentId">The vendor's id for the subagent whose tool call this is, null for
/// the main agent's own. Kept off the wire: only <see cref="WithdrawTurn"/> reads it.</param>
public Task<PermissionSettlement> Register(PermissionPendingDto dto, string? subagentId = null) {
Expand Down Expand Up @@ -91,33 +117,72 @@ public void Unsubscribe(Guid id) {
public void WithdrawForAgent(string agentId) {
lock (_gate) {
_withdrawnAgents.Add(agentId);
_owed.RemoveAll(o => o.Scope.AgentId == agentId);
foreach (var scope in _suspended.Keys.Where(s => s.AgentId == agentId).ToList()) _suspended.Remove(scope);
foreach (var e in _pending.Values.Where(e => e.Dto.AgentId == agentId).ToList())
SettleLocked(e.Dto.RequestId, PermissionSettlements.DenyDecision, PermissionSettlements.Withdrawn, PermissionSettlements.SourceAgentGone);
}
}

/// A tool that has run was answered where the daemon cannot see, so its prompt is moot. The
/// agent and the session are matched as well as the id: every hosted agent posts on the same
/// shared token, and a notice retires only what its own session registered.
public bool TryWithdrawTool(string agentId, string sessionId, string toolUseId) {
/// agent and the session are matched as well as the tool: every hosted agent posts on the same
/// shared token, and a notice retires only what its own session registered. A prompt that
/// carries a tool-use id is matched by it alone. One raised without an id (Claude's permission
/// hook never sends one) is matched by tool name and input within the subagent scope the hook
/// ran in, and only after the completions this scope is owed: a call allowed here runs and
/// reports too, and hooks run asynchronously, so its report can land after an identical prompt
/// nobody has answered was raised. A report nothing accounts for and no prompt matches is
/// dropped; a false match would deny a live hook, so the fallback runs only while the ledger
/// is whole. Among identical pending prompts the oldest goes first.
public bool TryWithdrawTool(
string agentId, string sessionId, string? toolUseId, string? toolName = null, JsonElement? toolInput = null, string? subagentId = null) {
lock (_gate) {
var entry = _pending.Values.FirstOrDefault(e =>
e.Dto.AgentId == agentId && e.Dto.SessionId == sessionId && e.Dto.ToolUseId == toolUseId);
PurgeLapsedLocked();
var own = _pending.Values.Where(e => e.Dto.AgentId == agentId && e.Dto.SessionId == sessionId);
var entry = toolUseId is null ? null : own.FirstOrDefault(e => e.Dto.ToolUseId == toolUseId);
if (entry is null && toolName is not null && toolInput is { } input) {
var scope = new Scope(agentId, sessionId, subagentId);
var owed = _owed.FindIndex(o => o.Scope == scope && o.ToolName == toolName
&& o.ToolInput is { } owedInput && JsonElement.DeepEquals(owedInput, input));
if (owed >= 0) {
_owed.RemoveAt(owed);
return false;
}
if (_suspended.ContainsKey(scope)) return false;
entry = own
.Where(e => e.Dto.ToolUseId is null && e.SubagentId == subagentId && e.Dto.ToolName == toolName
&& e.Dto.ToolInput is { } pendingInput && JsonElement.DeepEquals(pendingInput, input))
.OrderBy(e => RequestedAt(e.Dto))
.FirstOrDefault();
}
return entry is not null
&& SettleLocked(entry.Dto.RequestId, PermissionSettlements.DenyDecision, PermissionSettlements.Withdrawn, PermissionSettlements.SourceToolSettled);
&& SettleLocked(entry.Dto.RequestId, PermissionSettlements.DenyDecision, PermissionSettlements.Withdrawn, PermissionSettlements.SourceToolSettled, isCompletionReport: true);
}
}

/// A call allowed without a prompt (the launch policy's decision) still runs and reports, so
/// the scope is owed its completion like an allowed prompt's.
public void ExpectCompletion(string agentId, string sessionId, string? subagentId, string toolName, JsonElement? toolInput) {
lock (_gate) OweLocked(new Owed(new Scope(agentId, sessionId, subagentId), toolName, toolInput));
}

/// Withdraws what a finished turn left pending in the session: the main agent's own requests
/// for a null subagentId, otherwise that subagent's. A background subagent's prompt outlives
/// the parent's turn, which is why the scope matters. Unlike an exit, a turn's end says nothing
/// about the agent's next request.
/// about the agent's next request. A prompt still pending here may have been allowed in the
/// terminal with its report overtaken by Stop, so it is owed that report like any settlement;
/// then the scope's owed completions, and its suspension, start their grace period.
public int WithdrawTurn(string agentId, string sessionId, string? subagentId) {
lock (_gate) {
PurgeLapsedLocked();
var scope = new Scope(agentId, sessionId, subagentId);
var settled = 0;
foreach (var e in _pending.Values.Where(e =>
e.Dto.AgentId == agentId && e.Dto.SessionId == sessionId && e.SubagentId == subagentId).ToList())
if (SettleLocked(e.Dto.RequestId, PermissionSettlements.DenyDecision, PermissionSettlements.Withdrawn, PermissionSettlements.SourceToolSettled)) settled++;
var lapses = _time.GetUtcNow() + OwedGraceAfterTurn;
foreach (var o in _owed) if (o.Scope == scope) o.LapsesAt ??= lapses;
if (_suspended.TryGetValue(scope, out var suspendedUntil) && suspendedUntil is null) _suspended[scope] = lapses;
return settled;
}
}
Expand All @@ -126,10 +191,50 @@ public IReadOnlyList<PermissionPendingDto> PendingSnapshot() {
lock (_gate) return _pending.Values.Select(e => e.Dto).ToList();
}

// Caller holds _gate. Instance-scoped removal, the consent broker's discipline.
bool SettleLocked(string requestId, PermissionDecision decision, string outcome, string source) {
/// Only Claude's hooks report completions; another vendor's allow would never be collected.
static bool ReportsCompletions(string vendor) => vendor == "claude";

static DateTimeOffset RequestedAt(PermissionPendingDto dto) =>
DateTimeOffset.TryParse(dto.RequestedAt, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind, out var at) ? at : DateTimeOffset.MaxValue;

// Caller holds _gate. A gone agent reports nothing more and ends no more turns, so an entry
// owed on its behalf could neither be collected nor lapse. A scope past the cap is one whose
// reports are not arriving; forgetting any of them could let a late one pass for a terminal
// answer, so the scope's fallback goes instead, and its ledger with it. Every completion
// discarded while suspended re-arms the suspension: it lapses only a grace period after a
// turn's end with nothing discarded since, because a call allowed under suspension reports on
// its own clock, not the earlier turn's.
void OweLocked(Owed owed) {
if (_withdrawnAgents.Contains(owed.Scope.AgentId)) return;
PurgeLapsedLocked();
if (_suspended.ContainsKey(owed.Scope)) {
_suspended[owed.Scope] = null;
return;
}
if (_owed.Count(o => o.Scope == owed.Scope) >= MaxOwedPerScope) {
_owed.RemoveAll(o => o.Scope == owed.Scope);
_suspended[owed.Scope] = null;
return;
}
_owed.Add(owed);
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

// Caller holds _gate.
void PurgeLapsedLocked() {
var now = _time.GetUtcNow();
_owed.RemoveAll(o => o.LapsesAt is { } at && at <= now);
foreach (var scope in _suspended.Where(kv => kv.Value is { } at && at <= now).Select(kv => kv.Key).ToList()) _suspended.Remove(scope);
}

// Caller holds _gate. Instance-scoped removal, the consent broker's discipline. Every
// settlement but the completion report's own owes that report: the terminal can answer a
// prompt the daemon still shows, so a deny or a withdrawal here does not prove the tool never
// ran. An allowed call runs with the input the decision settled on, which is what it reports.
bool SettleLocked(string requestId, PermissionDecision decision, string outcome, string source, bool isCompletionReport = false) {
if (!_pending.TryGetValue(requestId, out var entry)) return false;
if (!_pending.TryRemove(new KeyValuePair<string, Entry>(requestId, entry))) return false;
if (!isCompletionReport && ReportsCompletions(entry.Dto.Vendor))
OweLocked(new Owed(new Scope(entry.Dto.AgentId, entry.Dto.SessionId, entry.SubagentId), entry.Dto.ToolName, decision.UpdatedInput ?? entry.Dto.ToolInput));
Broadcast(new PermissionStreamItem.Resolved(new PermissionResolvedDto(requestId, outcome, source)));
entry.Tcs.TrySetResult(new PermissionSettlement(decision, outcome, source));
return true;
Expand Down
13 changes: 10 additions & 3 deletions src/Capacitor.Cli.Daemon/Services/ToolSettledNotice.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,13 @@
using System.Text.Json;

namespace Capacitor.Cli.Daemon.Services;

/// What a hosted vendor's hook reports as finished, and so which of its pending prompts are moot:
/// one tool by its id, a subagent's whole turn, or, with neither, the main agent's own turn. The
/// session is the one the hook ran in; a prompt registered under another is not its to retire.
internal readonly record struct ToolSettledNotice(string SessionId, string? ToolUseId, string? SubagentId);
/// one tool by its id or by its name and input, a subagent's whole turn, or, with none of those,
/// the main agent's own turn. The session is the one the hook ran in; a prompt registered under
/// another is not its to retire. Claude's permission hook raises a prompt without a tool-use id,
/// which is why the name and input travel too.
internal readonly record struct ToolSettledNotice(
string SessionId, string? ToolUseId, string? SubagentId, string? ToolName = null, JsonElement? ToolInput = null) {
public bool NamesATool => ToolUseId is not null || ToolName is not null;
}
11 changes: 9 additions & 2 deletions src/Capacitor.Cli/Commands/DaemonBridgeRelay.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
using System.Text;
using System.Text.Json.Nodes;
using Capacitor.Cli.Core;
using Capacitor.Cli.Core.LocalIpc;

namespace Capacitor.Cli.Commands;

Expand Down Expand Up @@ -41,13 +42,19 @@ public static Task NotifySubagentAsync(
/// <summary>
/// A tool ran, a subagent stopped (<paramref name="subagentId"/>) or, with neither id, the
/// turn ended, so a prompt the daemon still holds for it can be retired: the answer was given
/// in the vendor's own terminal, which the daemon cannot see.
/// in the vendor's own terminal, which the daemon cannot see. The tool's name and input go
/// with its id because the permission hook that raised the prompt had no id to record; an
/// input past the wire's element cap is left out, as the prompt's own was.
/// </summary>
public static Task NotifyToolSettledAsync(
HostedAgent hosted, string vendor, string? sessionId, string? cwd, string? toolUseId, string? subagentId, TimeSpan budget) {
HostedAgent hosted, string vendor, string? sessionId, string? cwd, string? toolUseId, string? subagentId, TimeSpan budget,
string? toolName = null, JsonNode? toolInput = null) {
var payload = new JsonObject { ["session_id"] = sessionId, ["cwd"] = cwd };
if (toolUseId is not null) payload["tool_use_id"] = toolUseId;
if (subagentId is not null) payload["subagent_id"] = subagentId;
if (toolName is not null) payload["tool_name"] = toolName;
if (toolInput is not null && Encoding.UTF8.GetByteCount(toolInput.ToJsonString()) <= PermissionWire.MaxElementBytes)
payload["tool_input"] = toolInput.DeepClone();

return PostAsync(hosted, vendor, "tool-settled", payload, budget);
}
Expand Down
Loading
Loading