diff --git a/src/ClawdDotNet.App/AppHost.cs b/src/ClawdDotNet.App/AppHost.cs
index c9430b2..1cd4d9a 100644
--- a/src/ClawdDotNet.App/AppHost.cs
+++ b/src/ClawdDotNet.App/AppHost.cs
@@ -113,6 +113,9 @@ public sealed class AppHost : IAsyncDisposable
Board, AuditRepository, Scanner, Usage, Pricing, () => Instance.Agents,
LoggerFactory.CreateLogger("ClawdDotNet.App.Tasks"));
+ /// #711: The team overview. Stateless, like .
+ public TeamOverview Team => new(Engine, Staging, AuditRepository, Usage, () => Instance.Agents);
+
public TelegramClientManager? TelegramClient { get; private init; }
public OpenRouterStatusService? Status { get; private init; }
diff --git a/src/ClawdDotNet.App/Services/TeamOverview.cs b/src/ClawdDotNet.App/Services/TeamOverview.cs
new file mode 100644
index 0000000..929ba6d
--- /dev/null
+++ b/src/ClawdDotNet.App/Services/TeamOverview.cs
@@ -0,0 +1,170 @@
+using ClawdDotNet.Core.Accounting;
+using ClawdDotNet.Core.Activity;
+using ClawdDotNet.Core.Audit;
+using ClawdDotNet.Core.Config;
+using ClawdDotNet.Core.Engine;
+using ClawdDotNet.Core.Staging;
+
+namespace ClawdDotNet.App.Services;
+
+/// What an agent is doing, as the team overview shows it (#711).
+public enum TeamState
+{
+ /// Nothing running, nothing open, the last run went fine (or there was none).
+ Idle,
+
+ /// At least one run in progress.
+ Working,
+
+ /// A run has taken much longer than this agent's runs usually take.
+ Stuck,
+
+ /// Tool calls of this agent wait for a human decision.
+ WaitingForApproval,
+
+ /// The last run failed, hit a limit or found the target busy.
+ Problem,
+
+ /// The last run was refused because the budget is used up.
+ BudgetExhausted
+}
+
+/// One agent in the team overview.
+public sealed record TeamMember(
+ string AgentId,
+ string DisplayName,
+ string Model,
+ TeamState State,
+ IReadOnlyList Runs,
+ int PendingApprovals,
+ string? LastRunStatus,
+ DateTime? LastRunAtUtc,
+ string? LastRunResult,
+ long TypicalRunMs);
+
+/// The whole picture at one moment: every agent, and who reached whom recently.
+public sealed record TeamSnapshot(IReadOnlyList Members, IReadOnlyList RecentLinks, DateTime AtUtc);
+
+///
+/// #711: The team overview — who is working, who reached whom, where something is stuck.
+///
+/// Detecting is code, interpreting is model (Organisation-Konzept, 10a). Every
+/// state here comes from a fact the instance recorded: a run in progress, an open staged
+/// call, the status of the last receipt. Nothing is judged by a model, so the picture is
+/// the same for everyone who looks and costs nothing to draw.
+///
+/// Order of states. When several apply, the one that needs someone first wins:
+/// stuck, then waiting for approval, then working; a problem or an exhausted budget only
+/// shows while nothing new is running — a new run is the attempt to get past it.
+///
+public sealed class TeamOverview(
+ AgentEngine? engine,
+ StagingService? staging,
+ IAuditRepository? audit,
+ IUsageRepository? usage,
+ Func> agents,
+ TimeProvider? clock = null)
+{
+ /// How far back the last receipt of each agent is looked for.
+ public static readonly TimeSpan ReceiptWindow = TimeSpan.FromDays(3);
+
+ /// How long recent links stay in the snapshot — the overview fades them out.
+ public static readonly TimeSpan LinkWindow = TimeSpan.FromMinutes(10);
+
+ /// A run counts as stuck after this many times the agent's typical duration…
+ public const int StuckFactor = 3;
+
+ /// …but never before this — short runs vary a lot in relative terms.
+ public static readonly TimeSpan StuckFloor = TimeSpan.FromMinutes(5);
+
+ /// Without history, a run counts as stuck after this long.
+ public static readonly TimeSpan StuckWithoutHistory = TimeSpan.FromMinutes(15);
+
+ private readonly TimeProvider _clock = clock ?? TimeProvider.System;
+
+ public async Task SnapshotAsync(CancellationToken ct)
+ {
+ var now = _clock.GetUtcNow().UtcDateTime;
+ var configured = agents();
+
+ var active = engine?.Activity.ActiveRuns() ?? [];
+ var links = engine?.Activity.RecentLinks(LinkWindow) ?? [];
+
+ var pending = staging is null ? [] : await staging.ListPendingAsync(ct);
+ var pendingByAgent = pending
+ .GroupBy(c => c.AgentId, StringComparer.OrdinalIgnoreCase)
+ .ToDictionary(g => g.Key, g => g.Count(), StringComparer.OrdinalIgnoreCase);
+
+ var lastReceipt = new Dictionary(StringComparer.OrdinalIgnoreCase);
+ if (audit is not null)
+ {
+ var receipts = await audit.ListReceiptsAsync(now - ReceiptWindow, now, 5_000, ct);
+ foreach (var receipt in receipts)
+ {
+ if (!lastReceipt.TryGetValue(receipt.AgentId, out var known) || receipt.OccurredAt > known.OccurredAt)
+ lastReceipt[receipt.AgentId] = receipt;
+ }
+ }
+
+ var members = new List();
+ foreach (var agent in configured)
+ {
+ var runs = active
+ .Where(r => string.Equals(r.AgentId, agent.AgentId, StringComparison.OrdinalIgnoreCase))
+ .OrderBy(r => r.StartedAtUtc)
+ .ToList();
+ var approvals = pendingByAgent.GetValueOrDefault(agent.AgentId);
+ lastReceipt.TryGetValue(agent.AgentId, out var last);
+ var typical = await TypicalRunAsync(agent.AgentId, ct);
+
+ var state = Classify(runs, approvals, last?.Status, typical, now);
+ members.Add(new TeamMember(
+ agent.AgentId, agent.DisplayName, agent.Model, state, runs, approvals,
+ last?.Status, last?.OccurredAt, last?.ResultRef, (long)typical.TotalMilliseconds));
+ }
+
+ return new TeamSnapshot(members, links, now);
+ }
+
+ ///
+ /// The state from the facts. Pure, so the rules can be tested without an instance.
+ ///
+ public static TeamState Classify(
+ IReadOnlyList runs, int pendingApprovals, string? lastRunStatus, TimeSpan typicalRun, DateTime nowUtc)
+ {
+ if (runs.Count > 0)
+ {
+ var limit = typicalRun > TimeSpan.Zero
+ ? TimeSpan.FromTicks(Math.Max(typicalRun.Ticks * StuckFactor, StuckFloor.Ticks))
+ : StuckWithoutHistory;
+ if (runs.Any(r => nowUtc - r.StartedAtUtc > limit))
+ return TeamState.Stuck;
+ }
+
+ if (pendingApprovals > 0)
+ return TeamState.WaitingForApproval;
+
+ if (runs.Count > 0)
+ return TeamState.Working;
+
+ return lastRunStatus switch
+ {
+ nameof(AgentRunStatus.BudgetExceeded) => TeamState.BudgetExhausted,
+ nameof(AgentRunStatus.Failed)
+ or nameof(AgentRunStatus.LoopLimitExceeded)
+ or nameof(AgentRunStatus.TargetBusy) => TeamState.Problem,
+ _ => TeamState.Idle
+ };
+ }
+
+ /// The agent's typical run duration — the median of its recent runs.
+ private async Task TypicalRunAsync(string agentId, CancellationToken ct)
+ {
+ if (usage is null)
+ return TimeSpan.Zero;
+
+ var recent = await usage.GetRecentForAgentAsync(agentId, 20, ct);
+ var durations = recent.Where(r => r.DurationMs > 0).Select(r => r.DurationMs).Order().ToList();
+ return durations.Count == 0 ? TimeSpan.Zero : TimeSpan.FromMilliseconds(durations[durations.Count / 2]);
+ }
+}
diff --git a/src/ClawdDotNet.Core/Activity/AgentActivity.cs b/src/ClawdDotNet.Core/Activity/AgentActivity.cs
new file mode 100644
index 0000000..cb27828
--- /dev/null
+++ b/src/ClawdDotNet.Core/Activity/AgentActivity.cs
@@ -0,0 +1,132 @@
+using ClawdDotNet.Core.Tools;
+
+namespace ClawdDotNet.Core.Activity;
+
+/// A run that is in progress right now.
+public sealed record ActiveRun(
+ string RunId,
+ string AgentId,
+ string Source,
+ string? TaskId,
+ string? CalledBy,
+ DateTime StartedAtUtc);
+
+///
+/// One agent reaching another — for now a delegated run ( =
+/// delegation). Meetings and escalations will add their own kinds.
+///
+public sealed record AgentLink(string From, string To, string Kind, DateTime AtUtc);
+
+///
+/// #711: Who is working right now, and who reached whom — the live side of the team overview.
+///
+/// Why a component of its own. The engine already tracked running chats, but
+/// only those that went through ChatAsync; a fresh run (@new) was invisible to
+/// IsRunning. And nothing recorded that agent A had called agent B — the delegation
+/// chain knew it for the length of the call and then forgot. Here both are kept and
+/// announced, independent of which engine path a run takes.
+///
+/// Memory only, and bounded. This is the view of "now". What happened
+/// yesterday is in the audit log; the time line (#711, later) replays it from there.
+///
+public sealed class AgentActivity
+{
+ /// How many recent links are kept for a page that connects late.
+ public const int RecentLinkCapacity = 500;
+
+ private readonly Lock _lock = new();
+ private readonly Dictionary _active = new(StringComparer.Ordinal);
+ private readonly Queue _recentLinks = new();
+ private readonly TimeProvider _clock;
+
+ public AgentActivity(TimeProvider? clock = null) => _clock = clock ?? TimeProvider.System;
+
+ public event Action? RunStarted;
+
+ /// A run ended — with the AgentRunStatus name, or Aborted.
+ public event Action? RunFinished;
+
+ public event Action? LinkObserved;
+
+ ///
+ /// Announces a run. Dispose the handle when it ends; call
+ /// first to report how. A delegated run ( deeper than its root)
+ /// also records the link from the caller.
+ ///
+ public RunHandle Begin(string runId, string agentId, string source, string? taskId, DelegationChain? chain)
+ {
+ var now = _clock.GetUtcNow().UtcDateTime;
+ var calledBy = chain is { Depth: > 0 } ? chain.Agents[^2] : null;
+ var run = new ActiveRun(runId, agentId, source, taskId, calledBy, now);
+
+ AgentLink? link = calledBy is null ? null : new AgentLink(calledBy, agentId, "delegation", now);
+
+ lock (_lock)
+ {
+ _active[runId] = run;
+ if (link is not null)
+ {
+ _recentLinks.Enqueue(link);
+ while (_recentLinks.Count > RecentLinkCapacity)
+ _recentLinks.Dequeue();
+ }
+ }
+
+ Raise(() => RunStarted?.Invoke(run));
+ if (link is not null)
+ Raise(() => LinkObserved?.Invoke(link));
+
+ return new RunHandle(this, run);
+ }
+
+ /// The runs in progress right now.
+ public IReadOnlyList ActiveRuns()
+ {
+ lock (_lock)
+ return [.. _active.Values];
+ }
+
+ /// Links observed within , oldest first.
+ public IReadOnlyList RecentLinks(TimeSpan window)
+ {
+ var since = _clock.GetUtcNow().UtcDateTime - window;
+ lock (_lock)
+ return [.. _recentLinks.Where(l => l.AtUtc >= since)];
+ }
+
+ private void End(ActiveRun run, string status)
+ {
+ lock (_lock)
+ {
+ if (!_active.Remove(run.RunId))
+ return;
+ }
+
+ Raise(() => RunFinished?.Invoke(run, status));
+ }
+
+ /// A subscriber that throws must not break the run it is watching.
+ private static void Raise(Action raise)
+ {
+ try { raise(); }
+ catch { /* observers are best effort */ }
+ }
+
+ public sealed class RunHandle : IDisposable
+ {
+ private readonly AgentActivity _owner;
+ private readonly ActiveRun _run;
+ private string _status = "Aborted";
+
+ internal RunHandle(AgentActivity owner, ActiveRun run)
+ {
+ _owner = owner;
+ _run = run;
+ }
+
+ /// How the run ended. Without it, disposal reports Aborted.
+ public void Complete(string status) => _status = status;
+
+ public void Dispose() => _owner.End(_run, _status);
+ }
+}
diff --git a/src/ClawdDotNet.Core/Engine/AgentEngine.cs b/src/ClawdDotNet.Core/Engine/AgentEngine.cs
index b7c44a6..496f81a 100644
--- a/src/ClawdDotNet.Core/Engine/AgentEngine.cs
+++ b/src/ClawdDotNet.Core/Engine/AgentEngine.cs
@@ -41,6 +41,9 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut
///
public BudgetGuard? BudgetGuard => _budgetGuard;
+ /// #711: Who is working right now and who reached whom — for the team overview.
+ public Activity.AgentActivity Activity { get; } = new();
+
///
/// Wie viele Weitergaben eine Delegationskette höchstens haben darf (B8).
/// A→B ist die Tiefe 1, A→B→C die Tiefe 2.
@@ -179,8 +182,10 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut
var runId = Guid.NewGuid().ToString("N");
var chain = delegation ?? DelegationChain.Root(agentConfig.AgentId);
+ using var activity = Activity.Begin(runId, agentConfig.AgentId, source ?? AuditSource.Direct, taskId, chain);
var recorder = CreateRecorder(agentConfig);
var result = await RunCoreAsync(agentConfig, userMessage, instanceId, externalCt, runId, source, chain, recorder);
+ activity.Complete(result.Status.ToString());
await RecordUsageAsync(agentConfig, result);
await RecordReceiptAsync(runId, agentConfig, result, source ?? AuditSource.Direct, taskId);
await SaveTranscriptAsync(recorder, runId, agentConfig, result, source ?? AuditSource.Direct, taskId);
@@ -498,8 +503,11 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut
try
{
+ // #711: "working" starts past the gate — waiting for it is not working.
+ using var activity = Activity.Begin(runId, agentConfig.AgentId, source ?? AuditSource.Direct, taskId, chain);
var recorder = CreateRecorder(agentConfig);
var result = await ChatCoreAsync(agentConfig, userMessage, instanceId, runCts.Token, source, runId, chain, recorder);
+ activity.Complete(result.Status.ToString());
await RecordUsageAsync(agentConfig, result);
await RecordReceiptAsync(runId, agentConfig, result, source, taskId);
await SaveTranscriptAsync(recorder, runId, agentConfig, result, source ?? AuditSource.Direct, taskId);
diff --git a/src/ClawdDotNet.Manager.Web/wwwroot/app.css b/src/ClawdDotNet.Manager.Web/wwwroot/app.css
index c781a6d..10c146d 100644
--- a/src/ClawdDotNet.Manager.Web/wwwroot/app.css
+++ b/src/ClawdDotNet.Manager.Web/wwwroot/app.css
@@ -1721,3 +1721,77 @@ div.klein,
td.klein {
font-size: 12px;
}
+
+/* ─── Team overview (#711) ─── */
+
+.team-ansicht {
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+ height: 100%;
+}
+
+.team-zusammenfassung {
+ flex-wrap: wrap;
+ min-height: 24px;
+}
+
+.team-flaeche {
+ display: flex;
+ gap: 12px;
+ flex: 1;
+ min-height: 420px;
+}
+
+.team-graph {
+ flex: 1;
+ min-width: 0;
+ min-height: 420px;
+ background: var(--surface-subtil);
+ border: 1px solid var(--divider);
+}
+
+.team-seitenleiste {
+ width: 320px;
+ flex-shrink: 0;
+ padding: 14px;
+ background: var(--surface);
+ border: 1px solid var(--divider);
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ overflow-y: auto;
+}
+
+.team-seitenleiste-kopf {
+ justify-content: space-between;
+}
+
+.team-seitenleiste h3 {
+ margin: 8px 0 0;
+}
+
+.team-ergebnis {
+ white-space: pre-wrap;
+ word-break: break-word;
+ max-height: 160px;
+ overflow-y: auto;
+ font-family: var(--schrift-mono);
+}
+
+.team-aktionen {
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+ margin-top: 8px;
+}
+
+.team-legende {
+ flex-wrap: wrap;
+ gap: 16px;
+}
+
+@media (max-width: 800px) {
+ .team-flaeche { flex-direction: column; }
+ .team-seitenleiste { width: auto; }
+}
diff --git a/src/ClawdDotNet.Manager.Web/wwwroot/app.js b/src/ClawdDotNet.Manager.Web/wwwroot/app.js
index 5f0f203..efe8187 100644
--- a/src/ClawdDotNet.Manager.Web/wwwroot/app.js
+++ b/src/ClawdDotNet.Manager.Web/wwwroot/app.js
@@ -35,10 +35,12 @@ import { renderUsageRuns } from "./views/usage-runs.js";
import { renderUsageTimeline } from "./views/usage-timeline.js";
import { renderRunDetail } from "./views/run-detail.js";
import { renderPromptHistory } from "./views/prompt-history.js";
+import { renderTeam, leaveTeam } from "./views/team.js";
// ─── 1. Routen registrieren ───
function verlasseAnsicht() {
+ leaveTeam();
trenneLogStream();
trenneAgentenChatsStream();
stoppeInstanzenPolling();
@@ -50,6 +52,7 @@ registriereRoute("/chat/:agentId", (p) => { verlasseAnsicht(); return rendereCha
registriereRoute("/agenten", () => { verlasseAnsicht(); return rendereAgenten(); });
registriereRoute("/agenten/:agentId", (p) => { verlasseAnsicht(); return rendereAgenten(p); });
registriereRoute("/aufgaben", () => { verlasseAnsicht(); return rendereAufgaben(); });
+registriereRoute("/team", () => { verlasseAnsicht(); return renderTeam(); });
registriereRoute("/dienste", () => { verlasseAnsicht(); return rendereDienste(); });
registriereRoute("/staging", () => { verlasseAnsicht(); return rendereStaging(); });
registriereRoute("/verbrauch", () => { verlasseAnsicht(); return rendereVerbrauch(); });
diff --git a/src/ClawdDotNet.Manager.Web/wwwroot/i18n/de.json b/src/ClawdDotNet.Manager.Web/wwwroot/i18n/de.json
index 1755eb9..30a58ba 100644
--- a/src/ClawdDotNet.Manager.Web/wwwroot/i18n/de.json
+++ b/src/ClawdDotNet.Manager.Web/wwwroot/i18n/de.json
@@ -67,6 +67,28 @@
"nav.chat": "Chat",
"nav.agents": "Agenten",
"nav.tasks": "Aufgaben",
+ "nav.team": "Teamübersicht",
+ "team.title": "Teamübersicht",
+ "team.subtitle": "Wer arbeitet, wer hat in den letzten Sekunden wen erreicht, und wo klemmt es. Ein Klick auf einen Agenten zeigt Einzelheiten.",
+ "team.noInstance": "Keine Instanz ausgewählt. Oben eine aktive Instanz wählen.",
+ "team.empty": "Diese Instanz hat keine Agenten.",
+ "team.legendLink": "hat erreicht (verblasst nach 30 s)",
+ "team.state.Idle": "bereit",
+ "team.state.Working": "arbeitet",
+ "team.state.Stuck": "hängt",
+ "team.state.WaitingForApproval": "wartet auf Freigabe",
+ "team.state.Problem": "Problem",
+ "team.state.BudgetExhausted": "Budget erschöpft",
+ "team.running": "Läuft gerade",
+ "team.since": "seit {time}",
+ "team.task": "Aufgabe {id}",
+ "team.calledBy": "gerufen von {agent}",
+ "team.stuckHint": "Läufe dieses Agenten dauern sonst etwa {minutes} Min.",
+ "team.approvals": "{n} Werkzeugaufruf/-aufrufe warten auf Freigabe",
+ "team.lastRun": "Letzter Lauf",
+ "team.openApprovals": "Freigaben öffnen",
+ "team.openChat": "Chat öffnen",
+ "team.openRuns": "Läufe anzeigen",
"nav.services": "Dienste",
"nav.group.analytics": "Analyse",
"nav.usage": "Tokennutzung",
diff --git a/src/ClawdDotNet.Manager.Web/wwwroot/i18n/en.json b/src/ClawdDotNet.Manager.Web/wwwroot/i18n/en.json
index 220b99a..ee7f842 100644
--- a/src/ClawdDotNet.Manager.Web/wwwroot/i18n/en.json
+++ b/src/ClawdDotNet.Manager.Web/wwwroot/i18n/en.json
@@ -67,6 +67,28 @@
"nav.chat": "Chat",
"nav.agents": "Agents",
"nav.tasks": "Tasks",
+ "nav.team": "Team overview",
+ "team.title": "Team overview",
+ "team.subtitle": "Who is working, who reached whom in the last seconds, and where something is stuck. Click an agent for details.",
+ "team.noInstance": "No instance selected. Choose an active instance in the header.",
+ "team.empty": "No agents in this instance.",
+ "team.legendLink": "reached (fades after 30 s)",
+ "team.state.Idle": "idle",
+ "team.state.Working": "working",
+ "team.state.Stuck": "stuck",
+ "team.state.WaitingForApproval": "waiting for approval",
+ "team.state.Problem": "problem",
+ "team.state.BudgetExhausted": "budget exhausted",
+ "team.running": "Running now",
+ "team.since": "since {time}",
+ "team.task": "task {id}",
+ "team.calledBy": "called by {agent}",
+ "team.stuckHint": "Runs of this agent usually take about {minutes} min.",
+ "team.approvals": "{n} tool call(s) waiting for approval",
+ "team.lastRun": "Last run",
+ "team.openApprovals": "Open approvals",
+ "team.openChat": "Open chat",
+ "team.openRuns": "Show runs",
"nav.services": "Services",
"nav.group.analytics": "Analytics",
"nav.usage": "Token Usage",
diff --git a/src/ClawdDotNet.Manager.Web/wwwroot/index.html b/src/ClawdDotNet.Manager.Web/wwwroot/index.html
index 0f99b2b..34b1ee7 100644
--- a/src/ClawdDotNet.Manager.Web/wwwroot/index.html
+++ b/src/ClawdDotNet.Manager.Web/wwwroot/index.html
@@ -55,6 +55,12 @@
Agents
+
+
+ Team overview
+
public sealed record TaskBudgetMissDto(int Count, DateTime? LastAt, DateTime? DeferredUntil, bool CatchUpPending, bool CarriedOver);
+/// #711: The team overview — every agent and who reached whom recently.
+public sealed record TeamSnapshotDto(IReadOnlyList Members, IReadOnlyList Links, DateTime AtUtc);
+
+///
+/// #711: One agent in the team overview. is one of Idle, Working, Stuck,
+/// WaitingForApproval, Problem, BudgetExhausted — decided by the instance, not the page.
+///
+public sealed record TeamMemberDto(
+ string AgentId,
+ string DisplayName,
+ string Model,
+ string State,
+ IReadOnlyList Runs,
+ int PendingApprovals,
+ string? LastRunStatus,
+ DateTime? LastRunAtUtc,
+ string? LastRunResult,
+ long TypicalRunMs);
+
+/// #711: A run in progress.
+public sealed record ActiveRunDto(string RunId, string AgentId, string Source, string? TaskId, string? CalledBy, DateTime StartedAtUtc);
+
+/// #711: One agent reaching another.
+public sealed record AgentLinkDto(string From, string To, string Kind, DateTime AtUtc);
+
/// Der letzte Lauf einer Aufgabe (#594) - aus dem Beleg, samt Kosten.
public sealed record TaskLastRunDto(DateTime OccurredAt, string Status, decimal CostUsd, bool CostIsKnown, string ResultRef);
diff --git a/src/ClawdDotNet.Web/Contracts/Mapping.cs b/src/ClawdDotNet.Web/Contracts/Mapping.cs
index 5d837ae..cf179e0 100644
--- a/src/ClawdDotNet.Web/Contracts/Mapping.cs
+++ b/src/ClawdDotNet.Web/Contracts/Mapping.cs
@@ -69,6 +69,21 @@ internal static class Mapping
public static ChatEntryDto ToDto(this ChatEntry entry)
=> new(entry.Role, entry.Content, entry.Timestamp, entry.Source);
+ public static TeamSnapshotDto ToDto(this App.Services.TeamSnapshot snapshot)
+ => new(
+ snapshot.Members.Select(m => new TeamMemberDto(
+ m.AgentId, m.DisplayName, m.Model, m.State.ToString(),
+ m.Runs.Select(ToDto).ToList(),
+ m.PendingApprovals, m.LastRunStatus, m.LastRunAtUtc, m.LastRunResult, m.TypicalRunMs)).ToList(),
+ snapshot.RecentLinks.Select(ToDto).ToList(),
+ snapshot.AtUtc);
+
+ public static ActiveRunDto ToDto(this Core.Activity.ActiveRun run)
+ => new(run.RunId, run.AgentId, run.Source, run.TaskId, run.CalledBy, run.StartedAtUtc);
+
+ public static AgentLinkDto ToDto(this Core.Activity.AgentLink link)
+ => new(link.From, link.To, link.Kind, link.AtUtc);
+
public static TaskDto ToDto(this TaskOverview overview)
=> overview.Task.ToDto() with
{
diff --git a/src/ClawdDotNet.Web/Endpoints/ApiEndpoints.cs b/src/ClawdDotNet.Web/Endpoints/ApiEndpoints.cs
index 92b2fe4..bc01936 100644
--- a/src/ClawdDotNet.Web/Endpoints/ApiEndpoints.cs
+++ b/src/ClawdDotNet.Web/Endpoints/ApiEndpoints.cs
@@ -31,6 +31,7 @@ internal static class ApiEndpoints
api.MapUsage();
api.MapAnalytics();
api.MapLogs();
+ api.MapGet("/team", async (AppHost host, CancellationToken ct) => (await host.Team.SnapshotAsync(ct)).ToDto());
app.MapEvents();
app.MapControl();
diff --git a/src/ClawdDotNet.Web/Endpoints/EventEndpoints.cs b/src/ClawdDotNet.Web/Endpoints/EventEndpoints.cs
index 5aef9ea..500a8a1 100644
--- a/src/ClawdDotNet.Web/Endpoints/EventEndpoints.cs
+++ b/src/ClawdDotNet.Web/Endpoints/EventEndpoints.cs
@@ -71,12 +71,23 @@ internal static class EventEndpoints
void OnRunCompleted(string model, Core.Engine.AgentRunResult result)
=> Publish("run", new
{
+ agentId = result.AgentId,
model,
status = result.Status.ToString(),
steps = result.StepCount,
tokens = result.TokensUsed
});
+ // #711: the team overview — who starts, who stops, who reached whom.
+ void OnRunStarted(Core.Activity.ActiveRun run)
+ => Publish("activity", new { kind = "started", run = run.ToDto() });
+
+ void OnRunFinished(Core.Activity.ActiveRun run, string status)
+ => Publish("activity", new { kind = "finished", run = run.ToDto(), status });
+
+ void OnLink(Core.Activity.AgentLink link)
+ => Publish("activity", new { kind = "link", link = link.ToDto() });
+
void OnInstanceChanged(InstanceChange change)
=> Publish("instance", new { kind = change.Kind.ToString(), id = change.Id });
@@ -88,6 +99,9 @@ internal static class EventEndpoints
engine.OnChatEntryAdded += OnChat;
engine.OnStepCompleted += OnStep;
engine.OnRunCompleted += OnRunCompleted;
+ engine.Activity.RunStarted += OnRunStarted;
+ engine.Activity.RunFinished += OnRunFinished;
+ engine.Activity.LinkObserved += OnLink;
}
host.InstanceChanged += OnInstanceChanged;
@@ -106,6 +120,9 @@ internal static class EventEndpoints
e.OnChatEntryAdded -= OnChat;
e.OnStepCompleted -= OnStep;
e.OnRunCompleted -= OnRunCompleted;
+ e.Activity.RunStarted -= OnRunStarted;
+ e.Activity.RunFinished -= OnRunFinished;
+ e.Activity.LinkObserved -= OnLink;
}
host.InstanceChanged -= OnInstanceChanged;
diff --git a/tests/ClawdDotNet.App.Tests/Services/TeamOverviewTests.cs b/tests/ClawdDotNet.App.Tests/Services/TeamOverviewTests.cs
new file mode 100644
index 0000000..a84e280
--- /dev/null
+++ b/tests/ClawdDotNet.App.Tests/Services/TeamOverviewTests.cs
@@ -0,0 +1,82 @@
+using ClawdDotNet.App.Services;
+using ClawdDotNet.Core.Activity;
+using Shouldly;
+
+namespace ClawdDotNet.App.Tests.Services;
+
+///
+/// #711: The rules that decide what the team overview shows for an agent. Every state comes
+/// from a recorded fact, never from a model's judgement.
+///
+public sealed class TeamOverviewTests
+{
+ private static readonly DateTime Now = new(2027, 3, 10, 12, 0, 0, DateTimeKind.Utc);
+
+ private static ActiveRun RunningFor(TimeSpan duration)
+ => new("r", "worker", "task", null, null, Now - duration);
+
+ [Fact]
+ public void Nothing_running_and_a_good_last_run_is_idle()
+ => TeamOverview.Classify([], 0, "Completed", TimeSpan.FromMinutes(1), Now).ShouldBe(TeamState.Idle);
+
+ [Fact]
+ public void An_agent_without_any_run_yet_is_idle()
+ => TeamOverview.Classify([], 0, null, TimeSpan.Zero, Now).ShouldBe(TeamState.Idle);
+
+ [Fact]
+ public void A_run_in_progress_is_working()
+ => TeamOverview.Classify([RunningFor(TimeSpan.FromMinutes(1))], 0, null, TimeSpan.FromMinutes(1), Now)
+ .ShouldBe(TeamState.Working);
+
+ [Fact]
+ public void A_run_far_beyond_the_usual_duration_is_stuck()
+ // Usually 2 minutes: three times that is 6, running for 7.
+ => TeamOverview.Classify([RunningFor(TimeSpan.FromMinutes(7))], 0, null, TimeSpan.FromMinutes(2), Now)
+ .ShouldBe(TeamState.Stuck);
+
+ [Fact]
+ public void Short_runs_get_a_floor_before_they_count_as_stuck()
+ // Usually 20 seconds: three times is one minute, but the floor is five.
+ => TeamOverview.Classify([RunningFor(TimeSpan.FromMinutes(3))], 0, null, TimeSpan.FromSeconds(20), Now)
+ .ShouldBe(TeamState.Working);
+
+ [Fact]
+ public void Without_history_a_run_is_stuck_only_after_a_long_while()
+ {
+ TeamOverview.Classify([RunningFor(TimeSpan.FromMinutes(10))], 0, null, TimeSpan.Zero, Now).ShouldBe(TeamState.Working);
+ TeamOverview.Classify([RunningFor(TimeSpan.FromMinutes(16))], 0, null, TimeSpan.Zero, Now).ShouldBe(TeamState.Stuck);
+ }
+
+ [Fact]
+ public void An_open_approval_outranks_working()
+ // What the human can do something about comes first.
+ => TeamOverview.Classify([RunningFor(TimeSpan.FromMinutes(1))], 2, null, TimeSpan.FromMinutes(1), Now)
+ .ShouldBe(TeamState.WaitingForApproval);
+
+ [Fact]
+ public void Stuck_outranks_an_open_approval()
+ => TeamOverview.Classify([RunningFor(TimeSpan.FromHours(1))], 2, null, TimeSpan.FromMinutes(1), Now)
+ .ShouldBe(TeamState.Stuck);
+
+ [Theory]
+ [InlineData("Failed")]
+ [InlineData("LoopLimitExceeded")]
+ [InlineData("TargetBusy")]
+ public void A_failed_last_run_is_a_problem(string status)
+ => TeamOverview.Classify([], 0, status, TimeSpan.Zero, Now).ShouldBe(TeamState.Problem);
+
+ [Fact]
+ public void A_refused_last_run_shows_the_exhausted_budget()
+ => TeamOverview.Classify([], 0, "BudgetExceeded", TimeSpan.Zero, Now).ShouldBe(TeamState.BudgetExhausted);
+
+ [Fact]
+ public void A_new_run_hides_the_old_problem()
+ // A new run is the attempt to get past it; the problem shows again if it fails.
+ => TeamOverview.Classify([RunningFor(TimeSpan.FromMinutes(1))], 0, "Failed", TimeSpan.FromMinutes(1), Now)
+ .ShouldBe(TeamState.Working);
+
+ [Fact]
+ public void A_cancelled_run_is_not_a_problem()
+ // Cancelled means a human stopped it — that was a decision, not a fault.
+ => TeamOverview.Classify([], 0, "Cancelled", TimeSpan.Zero, Now).ShouldBe(TeamState.Idle);
+}
diff --git a/tests/ClawdDotNet.Core.Tests/Activity/AgentActivityTests.cs b/tests/ClawdDotNet.Core.Tests/Activity/AgentActivityTests.cs
new file mode 100644
index 0000000..fced1ab
--- /dev/null
+++ b/tests/ClawdDotNet.Core.Tests/Activity/AgentActivityTests.cs
@@ -0,0 +1,143 @@
+using ClawdDotNet.Core.Activity;
+using ClawdDotNet.Core.Tests.Infrastructure;
+using ClawdDotNet.Core.Tools;
+using Shouldly;
+
+namespace ClawdDotNet.Core.Tests.Activity;
+
+///
+/// #711: Who is working right now and who reached whom — the live side of the team overview.
+///
+public sealed class AgentActivityTests
+{
+ private readonly FakeTimeProvider _clock = new(new DateTimeOffset(2027, 3, 10, 12, 0, 0, TimeSpan.Zero));
+
+ [Fact]
+ public void A_run_is_active_until_its_handle_is_disposed()
+ {
+ var activity = new AgentActivity(_clock);
+
+ using (var run = activity.Begin("r1", "worker", "task", "t-1", null))
+ {
+ var active = activity.ActiveRuns().ShouldHaveSingleItem();
+ active.AgentId.ShouldBe("worker");
+ active.TaskId.ShouldBe("t-1");
+ active.CalledBy.ShouldBeNull();
+ run.Complete("Completed");
+ }
+
+ activity.ActiveRuns().ShouldBeEmpty();
+ }
+
+ [Fact]
+ public void Start_and_end_are_announced_with_the_status()
+ {
+ var activity = new AgentActivity(_clock);
+ var log = new List();
+ activity.RunStarted += r => log.Add($"start {r.AgentId}");
+ activity.RunFinished += (r, status) => log.Add($"end {r.AgentId} {status}");
+
+ using (var run = activity.Begin("r1", "worker", "chat", null, null))
+ run.Complete("LoopLimitExceeded");
+ using (activity.Begin("r2", "worker", "chat", null, null)) { }
+
+ log.ShouldBe(["start worker", "end worker LoopLimitExceeded", "start worker", "end worker Aborted"]);
+ }
+
+ [Fact]
+ public void A_delegated_run_records_who_called_whom()
+ {
+ var activity = new AgentActivity(_clock);
+ AgentLink? seen = null;
+ activity.LinkObserved += l => seen = l;
+ var chain = DelegationChain.Root("lead").Append("buyer");
+
+ using (activity.Begin("r1", "buyer", "agent_comm", null, chain)) { }
+
+ seen.ShouldNotBeNull();
+ seen.From.ShouldBe("lead");
+ seen.To.ShouldBe("buyer");
+ seen.Kind.ShouldBe("delegation");
+ activity.RecentLinks(TimeSpan.FromMinutes(1)).ShouldHaveSingleItem().From.ShouldBe("lead");
+ }
+
+ [Fact]
+ public void Old_links_fall_out_of_the_recent_window()
+ {
+ var activity = new AgentActivity(_clock);
+ using (activity.Begin("r1", "buyer", "agent_comm", null, DelegationChain.Root("lead").Append("buyer"))) { }
+
+ _clock.Advance(TimeSpan.FromMinutes(11));
+
+ activity.RecentLinks(TimeSpan.FromMinutes(10)).ShouldBeEmpty();
+ }
+
+ [Fact]
+ public void The_memory_of_links_is_bounded()
+ {
+ var activity = new AgentActivity(_clock);
+ var chain = DelegationChain.Root("lead").Append("buyer");
+
+ for (var i = 0; i < AgentActivity.RecentLinkCapacity + 50; i++)
+ using (activity.Begin($"r{i}", "buyer", "agent_comm", null, chain)) { }
+
+ activity.RecentLinks(TimeSpan.FromDays(1)).Count.ShouldBe(AgentActivity.RecentLinkCapacity);
+ }
+
+ [Fact]
+ public void A_subscriber_that_throws_does_not_break_the_run()
+ {
+ var activity = new AgentActivity(_clock);
+ activity.RunStarted += _ => throw new InvalidOperationException("broken page");
+ activity.RunFinished += (_, _) => throw new InvalidOperationException("broken page");
+
+ Should.NotThrow(() =>
+ {
+ using var run = activity.Begin("r1", "worker", "chat", null, null);
+ run.Complete("Completed");
+ });
+ activity.ActiveRuns().ShouldBeEmpty();
+ }
+
+ // ─── through the engine ───
+
+ [Fact]
+ public async Task The_engine_announces_a_delegated_run_with_its_caller()
+ {
+ var fixture = new EngineFixture();
+ fixture.AddAgent("lead");
+ fixture.AddAgent("buyer");
+ fixture.Client.RespondsWithText("Erledigt.");
+
+ var started = new List();
+ var finished = new List();
+ fixture.Engine.Activity.RunStarted += started.Add;
+ fixture.Engine.Activity.RunFinished += (_, status) => finished.Add(status);
+
+ var result = await fixture.Engine.SendMessageAsync("lead", "buyer", "Bitte pruefen", "inst", null, default);
+
+ result.Delivered.ShouldBeTrue(result.Error);
+ var run = started.ShouldHaveSingleItem();
+ run.AgentId.ShouldBe("buyer");
+ run.CalledBy.ShouldBe("lead");
+ finished.ShouldBe(["Completed"]);
+ fixture.Engine.Activity.ActiveRuns().ShouldBeEmpty();
+ fixture.Engine.Activity.RecentLinks(TimeSpan.FromMinutes(1)).ShouldHaveSingleItem().To.ShouldBe("buyer");
+ }
+
+ [Fact]
+ public async Task A_fresh_run_is_visible_too()
+ {
+ // Before #711 only runs through ChatAsync counted as "running"; RunAsync (@new) did not.
+ var fixture = new EngineFixture();
+ var agent = fixture.AddAgent("worker");
+ fixture.Client.RespondsWithText("Fertig.");
+
+ var started = new List();
+ fixture.Engine.Activity.RunStarted += started.Add;
+
+ await fixture.Engine.RunAsync(agent, "Los", "inst", CancellationToken.None);
+
+ started.ShouldHaveSingleItem().AgentId.ShouldBe("worker");
+ }
+}