# Agent Team Console: design notes
Public design notes for the `dsh-agent-team-console` plugin, version 1.4.0 (snapshot contract version
4, unchanged by this release). This document describes the architecture, the HTTP snapshot contract,
the card-grid and graph-first UI model, the polling lifecycle, the theme and accessibility model, and
the current limitations. It is written for people who want to understand or extend the plugin;
user-facing instructions live in [the README](../README.md).
## 1. Overview and goals
Agent Team Console is a read-only dashboard for Agent Teams in the DeepSeek Harness (DSH) Web UI. It
adds a sidebar entry and a first-class main panel that shows, for every live team:
- a responsive grid of workspace block cards with one expanded card at a time;
- a project-scoped expanded header with a working-vs-idle donut and team statistics;
- a top-down SVG flow graph (Lead at the top centre, teammates in balanced rows below, hand-off and
message edges) whose members are deterministic pixel-art characters;
- a compact task summary with in-progress rows on demand;
- team and member statistics (rounds, steps, token usage, working / idle / wall time) derived from the
optional session projections.
The goals that shaped the design:
1. **Zero friction.** A plain DSH Web bundle with no build step, no npm dependencies, and no external
services. The host half imports nothing; the client half borrows only React from the host.
2. **Read-only by construction.** There is no mutation path to audit away: one read-only HTTP route,
no request bodies, no write calls, no host timers, no filesystem access.
3. **Graph-first and visual.** The panel shows state as pictures and numbers instead of walls of text:
a top-down pixel-art flow graph, a workspace card grid, a working-vs-idle donut, and compact stat
lines lead the page.
4. **Stats over transcripts.** Rounds, token usage, and working time come from stock session
projections, not from rendering tool calls or event text.
5. **In-progress first.** The panel leads with what is happening now: active teams expand, in-progress
tasks come first, and idle work stays collapsed or as counts.
6. **Project-scoped.** A one-glance project filter keeps a multi-workspace setup scannable without
adding configuration or persistence.
7. **Native look and feel.** The panel uses DSH theme tokens and typography so it follows the host's
dark and light themes, plus the panel's own accessibility conventions.
8. **Defensive data handling.** The Agent Teams and projection services are experimental; their views
may change. Unknown or malformed payloads must degrade to a readable fallback instead of throwing.
9. **Bilingual.** Every visible string goes through the DSH client locale service, with Chinese and
English dictionaries kept key-for-key in sync.
## 2. Non-goals
- Replacing the official experimental Agent Teams bundle. This plugin does not provide the Agent
Teams service, the team tools, or the model-facing behavior.
- Creating, spawning, renaming, interrupting, claiming, completing, deleting, or reassigning anything.
- A message-content or mailbox timeline. Peer message bodies are not shown; the graph aggregates
message counts only.
- Tool-call or task-dependency graph edges; only structural lead links and mailbox message traffic are
represented.
- Push-based updates. The panel polls; there is no stream, WebSocket, or projection subscription.
- A build pipeline, a bundler, a test framework, or runtime dependencies.
- Anything on the CLI or headless surfaces; this is a Web UI plugin.
## 3. Why a snapshot endpoint instead of the client projection
The official Agent Teams Web UI reads the `agentTeam` client projection through host Client packages.
A self-contained plain-JS bundle cannot import those packages, so it cannot read the projection
directly.
This plugin takes a different route that is fully within the public plugin surface:
1. The **host half** aggregates the live view from the ordinary Host services `agentTeams`, `agents`,
and `sessions`, optionally resolves project/workspace data through the `workspaceRegistry` service,
optionally reads session statistics through the `sessionProjections` service, and exposes the
result as one JSON document over a same-origin HTTP route.
2. The **client half** fetches that document from the browser and renders it as an interactive member
graph.
The pattern is common for small DSH Web plugins: register a route on the Web server, then `fetch` it
from the same origin. It keeps the client bundle dependency-free, and it makes the read-only
guarantee easy to verify: the browser can only read the snapshot route, and that route only calls
read methods on the services.
## 4. Architecture
```mermaid
flowchart LR
subgraph Browser["DSH Web UI (browser)"]
P["client.js
sidebar entry + main panel"]
end
subgraph Host["DSH host process"]
R["GET /api/team-console/snapshot"]
A["agentTeams"]
G["agents"]
S["sessions"]
W["workspaceRegistry (optional)"]
J["sessionProjections (optional)"]
R --> A
R --> G
R --> S
R --> W
R --> J
end
P -- "same-origin fetch, every 2s" --> R
```
The two halves are independent files with independent lifecycles:
| Half | File | Loaded by | Responsibility |
| --- | --- | --- | --- |
| Host | `index.js` | the DSH host plugin loader (Cordis bundle) | Register the HTTP route; build the version 4 JSON snapshot per request from live services and optional projections. |
| Client | `client.js` | the DSH Web module loader | Register locale dictionaries, the sidebar entry, and the main panel; poll, filter by project, and render the graph-first workspace UI. |
The package manifest wires both halves: `dsh.bundle.patch` points at `cordis.patch.yml`, which
inserts the bundle row into the profile loader, and `exports["./client"]` points at the browser
bundle that the Web client loads. The browser half also declares its platform (`dsh.client.platform:
"web"`), its immediate mount flag (`dsh.client.immediately: true`), and its client-module dependency
on the locale package.
## 5. HTTP contract
### 5.1 Route and response basics
- Route: `GET /api/team-console/snapshot` only, registered under the `/api/team-console` prefix.
- Content type: `application/json; charset=utf-8`.
- Cache headers: `cache-control: no-store`. The client also sends `cache: 'no-store'`.
- Admission: when the host mounts the DSH `connection` service, the handler calls its request
admission check (`connection.requestRejection(req)`) before any routing, so the route enforces the
same Host/Origin fence and browser-session admission as the rest of the Web UI. A rejected request
receives a JSON `401` (`unauthorized`) or `403` (`forbidden`) response with the usual JSON content
type and no-store cache header, before any snapshot work. When the service is not mounted, the route
leaves admission to the deployment's own access control. The handler adds no other authentication
mechanism and no cross-origin access.
- The handler never reads the request body and never rejects. Request method and path come from
`req.url`, parsed against a local base URL.
### 5.2 Snapshot document
Contract version 4 (`VERSION = 4`). A healthy response is HTTP 200 with:
```jsonc
{
"version": 4,
"ok": true,
"generatedAt": 1730000000000, // Date.now() at the host when the snapshot was built
"unsupported": null, // string when Agent Teams services are absent
"totals": {
"teams": 2,
"activeTeams": 1,
"members": 5,
"running": 2,
"tasks": 7,
"pending": 3,
"inProgress": 2,
"blocked": 1,
"completed": 2
},
"projects": [
{
"id": "workspace-id", // workspace id, or "cwd:" for the fallback project
"title": "team", // workspace title, else the directory basename
"path": "/path/to/team", // workspace path, else Lead session cwd, else null
"teams": 2,
"activeTeams": 1,
"running": 2,
"inProgress": 2
}
],
"teams": [
{
"id": "session-abc", // String(team root session id); also the Lead id
"error": null, // string if roster or task reads failed for this team
"project": { "id": "workspace-id", "title": "team", "path": "/path/to/team" },
"active": true, // running/provisioning member or in-progress task
"counts": {
"members": 3,
"running": 1,
"provisioning": 0,
"failed": 0,
"idle": 2,
"tasks": 4,
"pending": 1,
"inProgress": 2,
"completed": 1,
"blocked": 1,
"ready": 1
},
"stats": {
"turns": 12, // sum over members that have the value
"steps": 34,
"tokens": { // null when no member reported token usage
"total": 123456,
"uncachedInput": 4567,
"cacheRead": 100000,
"cacheWrite": 2000,
"output": 16889
},
"llmMs": 120000,
"toolMs": 30000,
"workingMs": 150000,
"idleMs": 900000,
"wallMs": 1050000,
"workingRatio": 0.1429 // workingMs / (workingMs + idleMs), 0..1, null without a denominator
},
"members": [
{
"id": "session-abc",
"name": "lead",
"role": "lead", // "lead" | "teammate"
"status": "running", // "running" | "inactive" | "provisioning" | "failed"
"description": "Coordinates the team.",
"provider": "deepseek", // string | null (best effort)
"model": "deepseek-flash", // string | null (best effort)
"context": "fresh", // "fresh" | "fork" | null
"diagnostics": [],
"lastActivityAt": 1730000000000, // number | null (epoch milliseconds)
"lastEventType": "tool/result", // string | null (raw session event type)
"turns": 7, // number | null
"steps": 21, // number | null
"active": true, // running or provisioning
"stats": {
"turns": 8, // sessionStats.turns, or null
"steps": 21, // sessionStats.steps, or null
"tokens": { // null when no token usage is available
"total": 111111,
"uncachedInput": 4567,
"cacheRead": 90000,
"cacheWrite": 1000,
"output": 15544
},
"llmMs": 90000,
"toolMs": 20000,
"workingMs": 110000, // llmMs + toolMs when both are known; null otherwise
"idleMs": 700000, // max(0, wallMs - workingMs); null without wallMs
"wallMs": 810000, // now - session.header.createdAt; null when unknown
"workingRatio": 0.1358, // workingMs / (workingMs + idleMs), 0..1, null without a denominator
"live": true // member.active && wallMs !== null
}
}
],
"tasks": [
{
"id": "task-1",
"revision": 2,
"subject": "Write the client panel",
"description": "...", // capped at 2000 characters
"status": "in_progress", // "pending" | "in_progress" | "completed"
"ownerName": "reviewer", // string | null
"blockedBy": ["task-2"], // retained dependency ids; presence alone is not a blocked state
"ready": false, // ready = pending && all blockers completed; false for non-pending
"writeScopes": ["client.js"],
"writeScopeWarnings": [],
"active": true // status === "in_progress"
}
],
"activity": [
{
"seq": 42,
"time": 1730000000000,
"kind": "task", // "member" | "task"
"action": "claim", // short machine label
"memberName": "reviewer", // string | null where derivable
"taskId": "task-3", // string | null where derivable
"subject": "Review the diff", // string | null where derivable
"status": "in_progress", // string | null where derivable
"text": "task-3 · claim · Review the diff" // required human-readable fallback
}
],
"edges": [
// Structural: the Lead owns each teammate.
{ "from": "session-abc", "to": "session-def", "kind": "lead", "weight": 1, "lastAt": null },
// Interaction: peer messages (live agent-message deliveries + legacy queued records).
{ "from": "session-def", "to": "session-ghi", "kind": "message", "weight": 3, "lastAt": 1730000000000 }
]
}
]
}
```
`project` is `null` when the workspace could not be resolved; such teams still appear in `teams` and
under the client's all-projects filter, but they are never grouped into `projects[]`.
Readiness is derived by the host runtime: `ready` is true only when the task is pending and all of its
blockers are completed. A pending task can therefore be ready while its retained `blockedBy` list
still names the completed blockers. In-progress and completed tasks always have `ready: false`, but
they are claimed or finished work, not blocked work; only pending tasks can be shown as blocked, and
the blocked marker carries the blocker ids when they are known.
Contract version 3 adds `stats` to every listed member and every listed team. Member stats are read
from the optional `sessionProjections` service and are entirely best effort; team stats are aggregates
of the member rows. Contract version 4 adds `edges[]` to every listed team: the structural and message
interaction graph. `activity[]` stays in the wire contract for API consumers, but the client no longer
renders it.
### 5.3 Field rules
- **IDs are strings.** Branded ids are converted with `String(...)`; missing ids become `""`, never
`undefined`.
- **No `undefined` in JSON.** `undefined`, functions, symbols, non-finite numbers, and circular
references become `null`. The snapshot is assembled through one JSON-safety pass (`toJsonSafe`), so
it round-trips through `JSON.stringify` / `JSON.parse` unchanged.
- **Caps.** Task descriptions are capped at 2000 characters; activity labels at 200 characters.
- **Active flags.** A team is active when `counts.provisioning > 0`, `counts.running > 0`, or
`counts.inProgress > 0`. A member is active when its status is `running` or `provisioning`. A task
is active when its status is `in_progress`. The flags are computed on the host and echoed as
component-level `active` booleans.
- **Readiness.** The runtime derives `ready` as `status === "pending" && all blockers completed`.
Consequently every in-progress and completed task has `ready: false` and is never described as
blocked. `blockedBy` is retained dependency metadata, so a pending task may be ready while the list
still names its completed blockers. Only pending tasks can be shown as blocked, and a blocked
pending task names its blocker ids when they are known.
- **Team visibility.** A team is listed only when it has more than one member or at least one shared
task. Every ordinary runtime root is an implicit one-person Lead; without this filter, unrelated
single-agent sessions would appear as teams.
- **Ordering.** `teams` is ordered active first (descending), then by most recent observed activity
descending. Within a team, the Lead is first and teammates follow in roster order. Tasks keep the
service's creation order with deleted tasks omitted. Activity is newest first. `projects` is ordered
by `activeTeams` descending, then `teams` descending, then `title` ascending.
- **Counts.** Per-team counts keep the version 1 names and add `idle` (inactive members) and `ready`
(pending tasks that are ready to start, i.e. `ready === true`). `blocked` counts pending tasks that
are not ready (`ready === false`), which means at least one blocker is still open; in-progress and
completed tasks are never counted as blocked. `inProgress` and `completed` count tasks by status;
member status counts are exclusive.
- **Totals.** `totals.teams` counts the listed teams and `totals.activeTeams` the active ones; member
and task counters are sums over the listed teams. Version 2 adds `activeTeams`, `pending`,
`inProgress`, and `blocked`; `teams`, `members`, `running`, `tasks`, and `completed` already existed
in version 1.
- **Session stats.** Every listed member carries `stats`; every listed team carries an aggregate
`stats`. The values come only from the optional synchronous `sessionProjections` service
(`stateOf(session, 'sessionStats')` and `stateOf(session, 'tokenUsage')`), with two bounded
projection reads per member and no session-log scan for statistics. Missing projections, missing
sessions, and throwing reads all degrade to nulls (and `live: false`).
- Member `turns` and `steps` come from `sessionStats`. `tokens.total` is
`uncachedInput + cacheRead + cacheWrite + output` once any bucket is available; `tokens` itself is
null when no usage is available. `workingMs = llmMs + toolMs` when both are known, else null;
`wallMs = max(0, now - session.header.createdAt)` when the creation header carries a finite
`createdAt`, else null; `idleMs = max(0, wallMs - workingMs)` when both are known;
`workingRatio = workingMs / (workingMs + idleMs)` clamped to `[0, 1]` when the denominator is
positive, else null. `live = member.active && wallMs !== null`.
- Team stats sum each numeric field over the members that know it (null when nobody does), sum the
token buckets with the same total rule (null when no member has tokens), and recompute
`workingRatio` from the summed working and idle times. Team stats never derive member counts;
`counts` remains authoritative for the roster.
- **Projects.** `projects[]` contains only projects that own at least one listed team; teams with no
resolvable project are not grouped into a virtual project. Each project entry aggregates the listed
teams with the same project id. Resolution is best effort and never throws:
1. take the Lead session (`sessions.get(teamId)`) and read its immutable creation-header `cwd` when
that is a non-empty string;
2. ask the optional `workspaceRegistry` service for `list()`, whose entities expose
`{ id, path, title, sessionIds }`; an entity whose `sessionIds` includes the Lead session id wins,
otherwise an entity whose path matches the Lead cwd (case-insensitive across `/` and `\`)
is used;
3. when there is no workspace entity but there is a cwd, fall back to a synthetic
`id: "cwd:"` project whose `title` is the directory basename and whose `path` is the cwd;
4. with neither a workspace nor a cwd, set `project: null` and keep the team listed.
- **Interaction edges.** Every listed team carries `edges[]`, the member interaction graph. Two kinds
exist: `lead` and `message`, and no others this release.
- `lead` edges are structural: the Lead member id to every other listed member, `weight: 1`,
`lastAt: null`. A team with fewer than two members has no lead edges.
- `message` edges aggregate observed peer traffic from two bounded session-log sources:
- **Live deliveries.** Each listed member session (Lead included) is read once for its newest
1000 events. A `user/message` event whose `data.source = { kind: "agent-message", form: "relay",
senderSessionId }` is a peer delivery into that member's log, so `senderSessionId -> member id`
is one observed edge. This covers lead-to-member, member-to-lead, and member-to-member traffic.
- **Legacy records.** The Lead session's newest 2000 events are scanned for
`team/message/queued` mailbox events whose `data.teamId` matches the team; their
`data.message.senderId -> targetId` pairs count as well.
Both sources aggregate into one pair set: `weight` is the total count capped at 99, `lastAt` is the
newest event time (or null), and self-edges, malformed payloads, non-string ids, and endpoints that
are not current listed members are dropped, so no edge ever dangles. Bounds: at most 2000 Lead
events plus 1000 events per listed member per request.
- Edges are deduplicated by `(from, to, kind)`. Ordering: `lead` edges first in member order, then
`message` edges by weight descending, `lastAt` descending, and `from`/`to` string order. A missing
or throwing session degrades only that session's contribution; structural edges always survive.
- **Unsupported runtime.** When the Agent Teams service is missing, or exists but does not expose
the expected methods, the response is still `200`, still `version: 4`, still `ok: true`, with
`unsupported` set to a short reason string (for example, `"Agent Teams service is not mounted"`)
and empty `teams` and `projects` arrays.
- **Per-team errors.** If reading one team's roster or task board throws, that team is still returned
with `error` set and whatever data was readable; other teams are unaffected.
- **Contract evolution.** Version 2 added fields to the version 1 shape; version 3 added `member.stats`
and `team.stats`; version 4 adds `team.edges`. No version removes or renames existing fields. The
bundled client reads every field defensively and keeps local fallbacks (including for `active`, the
counters, the stats, and the edges), so a partial or older payload still renders.
### 5.4 Error responses
- Admission rejection: HTTP 401 with
`{ "version": 4, "ok": false, "error": "unauthorized", "teams": [] }`, or HTTP 403 with
`"error": "forbidden"`, written before routing when the DSH `connection` service rejects the
request. Admission runs first, so an unauthenticated request never reaches the 404/500 paths below.
- Unknown method or path: HTTP 404 with
`{ "version": 4, "ok": false, "error": "unknown ...", "teams": [] }`.
- Unexpected aggregation failure: HTTP 500 with
`{ "version": 4, "ok": false, "error": "...", "teams": [] }`.
- A response object that cannot be written to is handled defensively; the handler never throws or
rejects into the Web server.
## 6. Host half
`index.js` is deliberately plain: **zero `import` statements**, so a dependency-free Node test can
import and exercise its exported helpers directly.
### 6.1 Registration and lifecycle
```js
export const inject = ['webServer']
export function apply(ctx) {
// ... guard the webServer service and ctx.effect ...
ctx.effect(() => ctx.webServer.register({
kind: 'prefix',
path: '/api/team-console',
handler: createRouteHandler(ctx),
}), 'agent-team-console: /api/team-console route')
}
```
- `webServer` is the only required service. If it is missing, `apply` logs once and registers
nothing; it never throws.
- The optional `connection` service is resolved per request and used for admission (see 5.1). It is
not required; on a host without it the route leaves admission to the deployment's own access
control.
- The optional `workspaceRegistry` service is resolved per request and listed once per snapshot for
project resolution. It is not required: a missing, throwing, or malformed registry degrades to the
Lead session cwd fallback (see 5.3).
- The optional `sessionProjections` service is resolved per request and used for the version 3
member/team statistics. It is not required: when it is missing, or `stateOf(...)` throws for a unit,
the stats degrade to nulls (see 5.3). The aggregation stays synchronous.
- Interaction edges require no additional service: `lead` edges are structural, and `message` edges
come from one bounded read of the Lead session log (see 6.5).
- Registration happens inside `ctx.effect(...)`, so unloading the plugin disposes the route.
- The Agent Teams services are optional and are resolved per request with `ctx.get(...)`, guarded at
every use. They are never captured at load time, so a service that mounts later is picked up on the
next request.
### 6.2 Aggregation pipeline
Per request, the route handler first applies admission (see 5.1); only admitted requests reach the
snapshot builder. `createSnapshot(ctx)` then:
1. Resolves `agentTeams`, `agents`, `sessions`, and the optional `sessionProjections` service;
computes an `unsupported` reason if the required Agent Teams API is not present.
2. Lists the optional `workspaceRegistry` entities once for the whole snapshot (guarded; an absent or
failing registry simply yields no workspaces).
3. Enumerates live agents through `agents.list()` and asks `agentTeams.tryMembership(agent)` for each
one. Agents whose membership role is not `lead` are skipped; team ids are deduplicated.
4. For each team, reads the roster (`listMembers`) and the task board (`listTasks`) inside
per-call try/catch, normalizes rows, and makes sure a Lead row exists (synthesizing one from the
root agent status when the roster does not carry it).
5. Decides whether the team is listable (more than one member or at least one task). Cheap counts are
computed first, so the more expensive session reads are skipped for implicit one-person Leads.
6. Enriches members from the `sessions` service, builds each member's version 3 `stats` from the
optional projections, normalizes team activity from the Lead session's event log, builds the
version 4 interaction `edges` (structural lead edges plus the bounded mailbox-message scan), and
resolves the team's `project` (workspace entity first, cwd fallback, else `null`).
7. Aggregates the member stats into `team.stats`, computes the per-team counts and `active` flag,
plus the `activityAt` sort key (the latest member or activity timestamp). Sorts teams active first
and then by `activityAt` descending, aggregates `totals` and `projects`, and returns the JSON-safe
document.
### 6.3 Member enrichment from sessions
For each member, the host reads a bounded tail of that member's session events (at most 60 trailing
events) and fills in the version 1 activity fields:
- `lastActivityAt` and `lastEventType` from the newest event;
- `turns` from the highest `turn/start` event seen;
- `steps` from the highest `step/start` event seen;
- `provider` and `model` from the session request header's `config` when the roster view did not
carry them.
All of these reads are best effort. A missing session, a throwing accessor, or an unexpected event
shape leaves the fields `null`. These legacy fields are separate from the version 3 `stats` row, which
comes from the session projections instead (see 6.4).
### 6.4 Session stats (contract version 3)
Member and team statistics come only from the optional synchronous `sessionProjections` service, so
the request path performs exactly two bounded projection reads per listed member and never scans a
session log for stats:
- `stateOf(session, 'sessionStats')` supplies `turns`, `steps`, `llmMs`, and `toolMs`.
- `stateOf(session, 'tokenUsage')` supplies the token buckets; `tokens.total` is their sum once any
bucket is available.
- `wallMs` comes from the immutable creation header (`max(0, now - header.createdAt)`).
- `workingMs`, `idleMs`, `workingRatio`, and `live` are derived as described in 5.3.
- `computeTeamStats` sums the member rows (null-safe), sums the token buckets, and recomputes the
team working ratio.
Every read is wrapped in try/catch: a missing service, a session without projections, an unregistered
unit key, or a throwing `stateOf` degrades that member to an all-null stats row and never fails the
snapshot. The service is resolved once per snapshot and never becomes required.
### 6.5 Interaction edges (contract version 4)
`collectTeamEdges` builds each listed team's `edges[]` in two parts, never throwing:
1. **Structural lead edges.** For a team with at least two members, one edge from the Lead member id to
every other listed member id, in member order. `weight` is 1 and `lastAt` is null.
2. **Message edges from two bounded sources.**
- *Live `agent-message` deliveries.* `collectLiveMessageEdges` performs one bounded read per listed
member session (Lead included) over its newest 1000 events (`MEMBER_MESSAGE_SCAN_EVENTS`). A
`user/message` event counts when `data.source.kind === 'agent-message'` and
`data.source.senderSessionId` is a non-empty id of a different current member; it yields one
observed `senderSessionId -> target member id` edge. Because a delivery lands in the target's
log, lead-to-member, member-to-lead, and member-to-member traffic are all covered.
- *Legacy `team/message/queued` records.* `collectLegacyMessageEdges` performs one bounded read of
the newest 2000 Lead-session events (`MESSAGE_SCAN_EVENTS`) and counts events whose
`data.teamId` equals the team, using `data.message.senderId -> data.message.targetId`.
`addMessageEdge` merges both sources into one pair map: weights add (cap 99) and `lastAt` keeps the
newest observed time. Malformed payloads, self-edges, and unknown endpoints are dropped.
3. **Dedupe and order.** Pairs are deduped by `(from, to, kind)`. Lead edges come first in member order,
followed by message edges sorted by weight descending, `lastAt` descending, then `from`/`to` string
order.
A missing, throwing, or snapshot-less member (or Lead) session contributes nothing for that session
only; structural lead edges always survive, and the whole step is wrapped so an unexpected payload
yields an empty list instead of failing the snapshot. The graph adds exactly two bounded read kinds:
the newest 1000 events per listed member session and the newest 2000 Lead-session events, never a
full-log scan.
### 6.6 Activity normalization
The activity list is built only from the Lead session log, and only from the two event types owned by
the Agent Teams projection: `team/member` and `team/task`.
- The parser accepts both the projection's wrapped payload (`{ member: {...} }`, `{ task: {...} }`)
and a flat payload, so minor shape changes degrade instead of failing.
- `team/member` phases map to actions and statuses: `provisioning` to `spawn`/`provisioning`,
`active` to `start`/`running`, `failed` to `fail`/`failed`; anything else becomes a generic update.
- `team/task` events derive an action from the revision and status: a first revision is a creation, a
`completed` status completes, `deleted` deletes, `in_progress` claims, a return to `pending`
releases or reopens depending on the previous status, and everything else is an update. Owner ids
are resolved to member names when possible.
- Every entry carries a `text` fallback built from the id, action, and subject, capped at 200
characters. The client can always render something even for a future event shape.
- Events are filtered by team id when the payload carries one, capped to the newest 40 entries, and
returned newest first. The client does not render this list; it remains in the wire contract for API
consumers.
- To keep long-running Lead sessions cheap, the scan starts with the newest 400 events and expands by
a factor of 4 until it has at least 12 activity entries, reaches the start of the log, or hits the
20000-event ceiling.
### 6.7 Project resolution
Every listed team gets a `project` object or `null`, resolved best effort from two optional sources:
1. **Workspace registry.** `workspaceRegistry.list()` is called once per snapshot. Entities are
expected to expose `{ id, path, title, sessionIds }` synchronously. A `sessionIds` match on the Lead
session id wins; otherwise a workspace `path` equal to the Lead session cwd (case-insensitive with
`/` and `\` unified) is used.
2. **Cwd fallback.** When no workspace entity matches but the Lead session has a non-empty creation
header `cwd`, the host synthesizes `{ id: "cwd:", title: , path: }`. Windows
separator spellings, trailing separators, and drive roots are handled; a drive root falls back to
the path itself as the title.
3. **No project.** When neither a workspace nor a cwd is available, `project` is `null`. The team is
still listed and the client shows it under the all-projects filter only.
Resolution is wrapped in try/catch and every registry read is guarded separately, so a single
malformed entity or a throwing registry accessor degrades to the fallback instead of failing the
snapshot.
### 6.8 Defensive parsing and JSON safety
The host treats every service view and event payload as untrusted:
- Row normalization uses copy-and-coerce helpers; wrong types become `null`, empty strings, or empty
lists; deleted task tombstones are dropped; unknown statuses fall back to `pending`.
- Project resolution never throws: missing services, throwing accessors, malformed entities, and odd
path spellings all degrade to the cwd fallback or `null`.
- Event normalization is wrapped so even an unexpected shape produces a generic fallback entry
rather than an exception.
- The completed snapshot goes through `toJsonSafe`, which deep-copies only JSON-representable values,
converts bigints to strings, and breaks cycles with `null`.
- The route wraps aggregation in try/catch and turns an unexpected failure into a valid JSON 500.
### 6.9 Bounded work and no side effects
- The host keeps no mutable module state, starts no timers, monitors nothing, and touches no files.
- Every request rebuilds the snapshot from read-only service reads; concurrent requests cannot
corrupt each other.
- All reads are bounded: 60 trailing events per member for enrichment, a 400-to-20000 event window for
activity, 40 activity entries per team, and capped text fields in the output.
- Project resolution adds one synchronous workspace-registry `list()` per snapshot and at most a
handful of synchronous entity reads; the request path never awaits or calls async registry APIs.
- Session stats add exactly two synchronous projection reads per listed member plus one creation-header
read; they never scan a session log, start timers, or add async work.
- Interaction edges add bounded session-log reads only: the newest 1000 events of every listed member
session (Lead included) for live `agent-message` deliveries, plus the newest 2000 Lead-session events
for legacy `team/message/queued` records (fixed windows, never the full log), with no services,
timers, or async work.
## 7. Client half
`client.js` is a self-registering classic browser bundle. Nothing compiles it, and it has no module
imports of its own.
### 7.1 Registration
```js
window.__ModuleLoader__.load({
id: 'dsh-agent-team-console', // must equal the package name
factory: (require) => {
const React = require('react') // the only allowed dependency
// ... components ...
return {
inject: ['slots', 'locale'],
apply(ctx) {
// locale dictionaries, then slot registrations
},
}
},
})
```
`apply(ctx)`:
- Reads the optional `locale` service. If present, it registers the `zh` and `en` dictionaries under
the `agent-team-console` namespace inside `ctx.effect(...)` and binds `t` to that namespace. If the
service is absent, the bundle falls back to its built-in Chinese dictionary so the panel still
renders.
- Resolves the `slots` service and registers:
- a `sidebar.panellist` entry with id `team-console`, order 20, a label resolved through `t(...)`,
and the panel icon component;
- a `main` panel with key `team-console`.
- Registers nothing outside the plugin's own slot tree, writes nothing to `document.body`, and
attaches no global listeners outside React effects. Unloading the plugin removes the slot entries
and disposes the locale registrations.
### 7.2 Locale
All visible copy comes from a `t(key, params)` function. The bundle carries complete `zh` and `en`
dictionaries with identical key sets; placeholder interpolation is handled by the locale service when
present, or by a tiny local fallback. A locale subscription re-renders the panel when the host
language changes, and times and dates use the active language tag (`zh-CN` or `en-US`).
### 7.3 Polling and state machine
- Cadence: `POLL_MS = 2000` while the panel is mounted and `document.visibilityState` is `visible`.
- Request: relative URL `/api/team-console/snapshot`, `credentials: 'same-origin'`,
`cache: 'no-store'`, `accept: application/json`.
- An in-flight guard prevents overlapping requests; a manual refresh is skipped while a request is
running.
- An `AbortController` per request is aborted on unmount; the interval and the `visibilitychange`
listener are removed on unmount.
- When the tab becomes visible again, the panel loads immediately instead of waiting for the next
tick.
The hook keeps one state object:
| State | Trigger | What the panel shows |
| --- | --- | --- |
| `loading` with no data | first mount | header + skeleton blocks, no layout jump |
| `ok` | successful response | status strip, workspace cards |
| `ok` + `stale` | a later poll failed | last data stays visible with a reconnect hint |
| `error` with no data | first load failed | error card with the message and a retry button |
| `refreshing` | manual refresh in flight | refresh icon spins; data stays visible |
An `ok: false` body is treated as a failure. A successful response also updates the last-updated
clock. The panel treats every field defensively: missing or wrongly typed values fall back to empty
lists, `null`, or a dash.
### 7.4 Rendering
The panel renders, in order:
1. A `