dsh-team-progress
A DeepSeek Harness GUI plugin showing four views of a running agent team, counted from
durable state: who is on what, which defects are tracked, how far the goal has moved,
and what needs a human now. A source it cannot read renders unavailable with the
reason instead of a 0.
The host half (lib/index.js) registers one loopback JSON route
POST /api/team-progress/rpc on the webServer service; the client half
(lib/client.js) registers the panel with the slot service and re-reads that route
every 5 seconds while the panel is open.
The panel
STALLED task task-5 owner beta is inactive ← 4. act now, FIRST
BLOCKED task task-6 waiting on task-4
3/6 tasks · 2/3 members ← headline
▓▓▓▓▓▓▓░░░▓
MEMBERS 4 ← 1. who is on what
member status claimed task task status
lead running task task-6 pending
alpha running task task-4 in progress
beta inactive task task-5 in progress STALLED
gamma running no claimed task
DEFECTS 10 fixed · 5 in flight · 4 open (19) ← 2. tracked defects
#2 Repo→app mapping is dead code.
#17 False CRITICAL.
+2 more open
GOAL round 7/256 · active · armed ← 3. goal progress
Extend dsh-team-progress with the four views
conversation.view id "team-progress", order 30 a "Team progress" session tab
POST /api/team-progress/rpc { method: "state" } the payload
The panel is one conversation.view tab, bound to the session it was opened in. The
registration at mount() in lib/client.js is the authority on what is rendered: it
registers that tab and nothing else.
What it adds
- MEMBERS — one row per member: live status, the shared task it claims, that
task's status. A member holding nothing renders
no claimed task. - DEFECTS — counts by status across the configured defect logs, plus the rows
still open. Recognised:
open/in flight/fixed/closed as superseded; anything else is counted asotherand displayed asunclassified. - GOAL — rounds begun against the cap, the phase, and
armed/disarmed. - STALLED / BLOCKED — the action list, rendered first because a stall is the only state that asks for a decision.
- One RPC route —
POST /api/team-progress/rpc, methodsstateandact. - One optional action surface — complete / reopen / claim / release / reassign, wake, interrupt, spawn, auto-assign, broadcast, member cap, all through the same domain methods the model's own tools call. Off by default; see Security.
Requirements
The read path was traced against DeepSeek Harness 0.2.1-rc.3 (2026-10-24), and the
plugin was last exercised end to end on 0.2.1-rc.5 (2026-11-03): a
POST /api/team-progress/rpc with {"method":"state"} answered 200 with a live
payload. No minimum version is declared.
- The client half is declared for the
webplatform (dsh.client.platform). It is browser-plane code, so importingdsh-team-progress/clientin Node fails withwindow is not defined— that is the plane, not a defect. - Only
webServeris a hard injection.agents,agentTeams,sessionProjections,goals,sessionQueryanduiWorkspaceare resolved lazily, so a profile with one of those bundles disabled still loads the panel and reports the source.
Install
A local, unpublished plugin package ("private": true): package.json declares
dsh.bundle.patch (./cordis.patch.yml) and the client bundle (./lib/client.js,
platform web).
cordis.patch.yml ships exactly one row and no config:
- insert:
- id: team-progress
name: 'dsh-team-progress'
That file does not enable the plugin. It is the bundle's own patch, applied only once the bundle is enabled somewhere; enabling is a profile change that affects every session, so it is left to the profile owner.
To enable it, three steps.
1. Clone, then make the package resolvable to the profile. A profile resolves
plugin packages from its own package.json, so add the checkout as a link:
dependency and run the profile's install (the profile directory ships a
pnpm-workspace.yaml):
git clone https://github.com/ucfjoe/dsh-team-progress.git ~/dsh-team-progress
PROFILE=~/.dsh/profiles/desktop # the profile you run
# in "$PROFILE/package.json":
# "dependencies": { …, "dsh-team-progress": "link:$HOME/dsh-team-progress" }
cd "$PROFILE" && pnpm install
ls -l node_modules/dsh-team-progress # expect a symlink to the checkout
This step was run from a fresh clone on pnpm 11.22.0 / Node v26.7.0: the symlink
appears, import('dsh-team-progress') resolves the host half (apply, inject,
name), and npm test passes inside the clone.
2. Add the row to the profile's cordis.patch.yml.
- id: team-progress
name: 'dsh-team-progress'
config:
defectLogs:
- ~/repos/your-project/docs/defects.md
allowMutations: false
The - insert: wrapper shown above is also accepted. ~ is expanded in defectLogs
entries and empty entries are ignored.
3. Reload. After editing the host half, a file edit is not enough — the module is ESM inside the app process, so reload the plugin (disable, then enable) to re-import it and rebuild the client bundle. Recorded in the development log.
Configuration
Every key is optional; the defaults below are the ones lib/index.js applies.
| key | default | what it does |
|---|---|---|
defectLogs |
[] |
Paths to markdown defect logs, absolute or ~/…. Empty is the shipped default and makes the defects view report unavailable: no defect-log paths are configured. Read from config only, never from the request body. |
allowMutations |
false |
Enables the act method. Off means act answers 403 TEAM_PROGRESS_ACTIONS_DISABLED; every view keeps working. |
actionPolicy |
'all' |
Narrows the actions reachable once allowMutations is on. 'read-write' withholds the two that outlive the click — spawn (creates an agent) and member-limit (rewrites a config file on disk) — answering 403 TEAM_PROGRESS_ACTION_WITHHELD and reaching no domain method. Every board action still runs. An absent or unrecognised value resolves to 'all', so an existing profile is unchanged; the payload's actions.policy always names the value in force. |
spawnFreshProvider |
'spawn' |
Provider name passed to spawnTeammate for a fresh-context spawn. |
spawnForkProvider |
'fork' |
Provider name passed to spawnTeammate for a fork-context spawn. |
aliasFile |
~/.dsh/team-progress/aliases.json |
JSON store for the panel-owned display alias, one-line purpose and hidden flag per member, keyed leadId|memberId. |
homePatchFile |
~/.dsh/cordis.patch.yml |
The patch layer the member-limit action edits, with a timestamped backup written beside it. |
The defect-log format
The parser reads markdown tables. A table counts when its header row has a Status
column and a column matching Defect / Issue / Problem / Bug, immediately
followed by the separator row:
| # | Defect | Evidence | Status |
|---|---|---|---|
| 2 | Repo→app mapping is dead code. | investigate.py:319-321 | **open** |
| 17 | False CRITICAL. | run.log | **fixed** #222 — merged |
- A status cell resolves to
open,in flight(orin-flight),fixedorclosed as superseded. The cell is cut at the first,;:—–(#or-, so a trailing reference (**fixed #222** — merged) does not change the state; a**bold**phrase is read from inside the emphasis. - A state outside those four is counted as
other, not dropped and not guessed at, so a new phrasing shows up asunclassifiedinstead of disappearing. - An escaped
\|does not split a cell. A blank line does not end a table while the next non-blank line is still a data row; a heading, a separator, a new header or prose does. Section headings are carried on each row. - Counts are
fixed/inFlight/open/superseded/other/total. Open rows are capped at 50 withopenTruncatedcarrying the remainder; a file over 4 MiB is refused and a table stops at 5000 rows.
Security: the action surface
POST /api/team-progress/rpc is registered directly on the web server, outside the
app's own auth middleware. With allowMutations: true, any local process — any
script, any tool, any agent on the machine — can drive the team board through this
route: complete, reopen, claim or reassign a shared task, wake an inactive member,
interrupt a running agent, spawn a teammate, broadcast to the whole team.
- The shipped default is off (
allowMutations: false). - With it off,
actanswers403 TEAM_PROGRESS_ACTIONS_DISABLEDand reaches no domain method. The read views, the counts and the read path keep working unchanged. - Turn it on deliberately, on a single-user machine, and treat the route as a local control socket rather than an authenticated API.
actionPolicy: 'read-write'narrows it further without turning it off:spawnandmember-limitanswer403 TEAM_PROGRESS_ACTION_WITHHELDwhile every board action keeps working. Those two are the ones that outlive the click — one creates a live agent with model budget attached, the other rewrites a config file on disk. Withholding is opt-in, so the value an existing profile already means ('all') stays the default rather than being re-interpreted under it.
Paths in defectLogs come from config only, never from the request, so the route
cannot be turned into an arbitrary-file reader. The body is capped at 64 KiB.
Payload / RPC contract
Exact path, no-store.
Two methods: state takes { sessionId? } and answers the snapshot below; act takes
{ leadId, action, … } and answers a receipt, or a refusal with a domain code.
Input handling: non-POST → 405; body over 64 KiB → 413; invalid JSON, JSON
that is not an object, or a method that is missing / empty / not a string → 400;
a method that is not registered (including constructor, __proto__, toString) →
404; act while mutations are off → 403 TEAM_PROGRESS_ACTIONS_DISABLED.
{
"ok": true,
"generatedAt": "2026-10-30T11:02:50.000Z", // ISO, host clock at read time
"summary": { "total": 6, "completed": 3, "inProgress": 2, "pendingReady": 0,
"blocked": 1, "stalled": 1, "members": { "live": 2, "total": 3 } },
"teams": [ /* one card per lead: label, summary, roster, lanes, goal, lineage */ ],
"roster": { "state": "ok", "leads": 1, "stalled": 1, "members": [ /* rows */ ] },
"defects": {
"state": "ok", // ok | partial | unavailable
"files": [{ "path": "…", "state": "ok", "rows": 19 }],
"counts": { "fixed": 10, "inFlight": 5, "open": 4, "superseded": 0,
"other": 0, "total": 19 },
"open": [ /* rows */ ], "openShown": 1, "openTruncated": 3
},
"goal": { "state": "ok", "goals": [ /* roundsStarted, maxGoalRounds, phase */ ] },
"waitingOnYou": { "state": "ok", "total": 3, "stalled": 1, "unassigned": 2, "idle": 0 },
"attention": [ { "kind": "stalled", "label": "task-5", "detail": "owner beta is inactive" } ],
"actions": { "enabled": false, "policy": "all" },
"degraded": { "any": false, "reasons": [] }
}
The honesty rules
nullis never0. A count that could not be read isnull, and the view says why.completed + inProgress + pendingReady + blocked === totalholds on every readable snapshot.- An absent source is not a zero. Each view carries its own
state(ok/partial/none/unavailable) and, when it is notok, the reason. A missing defect log isunavailablewithcounts: null; one log readable and one missing ispartialwith the real counts and the missing file named.none("there is no goal") andunavailable("I could not look") are different answers, and render differently. - Three-valued on purpose.
stalledistrue,false, ornullwhen the live status could not be read; anulldraws no badge rather than claiming "not stalled". degradedis narrow. It covers the board and team read only, so a missing defect file states itself in place without making a readable task board look degraded.- Nothing is estimated. No ETA, no percentage, no elapsed time.
Tests
Run npm test. Both suites pass; they were run here on Node v26.7.0.
test/verify.mjs— renders the real client bundle through a React shim and asserts each view, including every absence: attention first in document order, aSTALLEDbadge only where the status is known, and no invented0anywhere.test/verify-host.mjs— drives the host half against a fixture board and asserts the payload, the read path (onetryMembershipper lead, zeroagents.get()calls), the RPC status codes, and that a request cannot redirect a defect-log path.
Design notes
- A member's claimed task beats the description it was spawned with. The description is fixed at spawn and drifts as work moves; the task the member holds is the durable answer to "what is it doing".
provisioningis not a stall. Building a teammate is a normal startup state. Onlyinactiveorfailedwhile holding in-progress work counts, and an unknown status yieldsnullrather thanfalse.- A reader that throws must render
unavailable, never "no goal". The goal service'sget()throws for an agent that is not the registry's live instance, so every call is wrapped: conflating "could not look" with "there is none" would report success on a broken read. - A source that cannot be read is not a zero. A missing file, an unregistered
service and an unreadable board each say so in their own field, because a fabricated
0reads as a fact. - The flag that lets the panel act is off by default. A read-only panel is safe on any profile; a route that can wake agents and move a shared board is an explicit owner decision.
License
MIT — see LICENSE and the license field in package.json.
No comments yet. Be the first to write one.