ewoowe/dsh-plugin-dependency-graph-plugin

dsh-plugin-dependency-graph-plugin

Plugin dependency graph for DeepSeek Harness: which plugin provides the services every other plugin injects, read from the live Cordis runtime.

未分類dshdsh-plugins

English | 中文

Plugin graph

Which plugin provides the services every other plugin injects — read from the live Cordis runtime, not from a manifest.

A settings section draws the graph, and a standalone page shows the same graph at full width. Both render the same panel: the two hosts differ only in what they can supply (a locale service, a theme, a window instead of a settings column).

Screenshots

The section, as it opens: the counts, the status row (a dot per state the graph has, its count, and a click that filters to it), the drawing, and the four-line key in the corner. The controls above the drawing are ← → (selection history), zoom, reset and fullscreen.

The plugin graph section

Fullscreen. The canvas IS the fullscreen element, so everything the panel put beside it is off screen — which is why the toolbar moves to the top line inside the canvas and the search box takes the second.

Fullscreen

A selected node. Its own description appears under the name, Listens to says which events it subscribes to, and the wires split by direction — the key in the corner names which colour is which — dashed where the dependency is only acquired at runtime.

A node selected

Searching. The query is matched against package names, Loader entry ids and the services a plugin PROVIDES, so ctx.locale finds whoever offers it. The matches are listed as a card, each row carrying the package and its state — plus the service that matched, under the name, when that is how the row was found. The drawing dims everything else. The × in the field clears the query and hands the caret back.

Search results

The standalone viewer, opened in a new tab: the same graph at the width of a whole window. Here it has the (harness) node selected — the runtime's own row, which carries everything no plugin claims: the services the root fiber provides, the services it injects, and the events it listens to. It depends on nothing, so it has incoming wires only.

The standalone viewer

The two trees

There are two Cordis runtimes, and they get two graphs:

HostBrowser
Where it runsthe Node processthe page
Collected bycollectGraph(ctx) on the hostcollectGraph(ctx.root) in the page
Shown bythis section, fetched over HTTPreported by the page, read back by the viewer

They are never merged. They are different runtimes with different plugins and different service names, so a merged graph would not be a bigger one — it would be a wrong one. The Browser tab is a second source, not a second set of nodes.

The browser tree cannot be collected by the Node half, so the page is the only thing that can describe it: the section POSTs what it collected to /dsh-plugin-dependency-graph/client, and the viewer GETs it back (one path, two methods). The report carries the instant it was taken, and the viewer prints it — a graph a reader believes is current but is not is worse than one that admits its age.

The browser tree's descriptions come from a route of their own (/dsh-plugin-dependency-graph/descriptions), because the page cannot read node_modules while the packages it is describing are the same ones: this half reads them and serves a name → description table. That read is the one place the two halves differ in capability, and it is why the merge lives in src/describe.ts — pure, so both halves run one implementation, while only the Node half touches node:fs. Resolution tries the plugin's own tree first, then bases learned from the host's profileContext.dir: measured, the obvious bases all land on the checkout, where none of the third-party plugins are installed.

How a dependency is acquired

A plugin can take a service two ways, and the difference is not cosmetic:

  • Declared on the plugin (export const inject = [...], an inject: option, or the @Inject decorator) — the fiber stays pending until every one of them resolves. The plugin does not load without them.
  • At runtime, through the ctx.inject(deps, callback) helper — the callback runs once they appear. The plugin loads either way; only that contribution waits.

Both are real dependencies, both get edges, and the second is what the optional mark means. A missing required service is a broken composition; a missing optional one is the ordinary case the callback form exists for.

The collection reads three things, and the distinction matters: the Loader entries give the ids and package names, the reflection store is the authoritative provider table (keyed by isolation symbol — the fiber's own store is the wrong source and would name consumers as providers), and the registry supplies every live fiber, including ones started at runtime.

Reading the graph

  • The layout is data, not physics. A node sits closer to the middle the more plugins depend on it, and the angle is a golden-angle walk over the ids — so the same composition draws the same picture on every visit, and a refresh never makes the reader find their bearings again. Hubs are the centre; the rim is what nothing depends on.
  • Plugins with no edges at all are COLLAPSED by default, behind one control that says how many (61 with no dependencies). In the composition this was built against that is 31% of the picture — nodes with no relation to draw, holding the rim and therefore the zoom — so they are hidden rather than dropped: pressing the control brings them back, because "this plugin takes part in nothing" is itself an answer a reader comes here for. A node the search or the status filter singles out is drawn even when it has no edges, so following a result row always lands somewhere.
  • Refresh re-reads the graph. It keeps whatever is on screen while it does, rather than blanking to a spinner: the canvas can be the fullscreen element, and an element that leaves the document takes fullscreen with it.
  • Search filters as you type over the package name, the Loader entry id, and SERVICE names — and a service hit brings back both ends of that service: whoever provides it (marked Provides) and everyone who injects it (marked Injects). That is what makes "who uses ctx.locale" one query instead of a walk from node to node, and it is also why the drawing lights the service's wires: the canvas keeps an edge only when both of its ends are in the result. A required service that NOTHING provides is still not findable — it lives in the problems list below — and subscribed event names are not searched either; the event blocks in the detail panel are what answer those. Each row carries the package's state, and the list is also the way to reach a node the filter has dimmed off to the edge. The × in the field clears the query and returns the caret to it; the field keeps the room for that button whether or not it is showing, so nothing shifts as you type.
  • The status row is a legend, a census and a filter at once: one pill per state the graph actually has, its count, and a click that narrows the drawing to that state. It lists only the states present — a row of mostly-zero pills would read as legend for colours nobody can see. The key is always shown; the swatches keep the selected colours whether or not anything is selected, because a reader who has not clicked yet is the one who needs to know what the colours mean.
  • Scroll zooms, drag pans, a click selects. A scroll inside the match card or the detail card scrolls THAT card: they are panels over the drawing, and reaching for them means scrolling a list, not zooming what is behind it.
  • Back and forward walk the trail of selected nodes. Choosing a new node after going back drops the forward entries — the same rule a browser applies, for the same reason — and "nothing selected" is a step like any other, so a node can be reached again from either side.
  • Fullscreen fills the window. The toolbar and the search box move INSIDE the canvas, on the first two lines, and the detail follows onto it as an overlay: the column beside the drawing is not on screen in that mode.
  • Open in a new tab hands the graph to the standalone viewer — a whole page, served by the Node half, which is the point: an iframe or a route inside the app would inherit the same width the reader is trying to get away from.
  • View config draws THIS entry's own configuration in the panel: every layer that touched that row, already composed in order, and nothing about any other plugin. Not the file, and not a native editor — the reader is in a browser, which need not be the machine the host runs on, and a reader looking at one plugin wants that plugin's row rather than the file all of them live in. Two things it is NOT: not evaluated, so a !!js scalar is drawn as its source (the same choice the host's own --dump-config makes — expression nodes are interpolated against the row's fiber and never written back into the entry's options), and not schema-resolved, so a field the row never wrote is absent rather than defaulted. The qualified path above the text is where a reader follows it back to the file, and the view closes when another plugin is selected.

What a node reports

Selecting a node fills the detail card with everything the runtime knows about that plugin:

  • The package's own description, read from its package.json. Absent rather than empty when the package does not say, or when nothing could read it — drawing no line is the truthful rendering of "this package does not describe itself".
  • Declared injections — the services this entry's own patch row asks for, when it asks for any. Read from the ENTRY rather than from the running fiber, which is why it sits beside Injects instead of inside it: the two differ exactly when a declaration went unmet, and an entry that never loaded has no runtime injections at all. Absent rather than empty: 3 entries of a 194-entry composition declare anything, so a permanent "declares nothing" row would be a line about the other 191. The rest of the entry's registration — mount path, group, isolate, intercept, config — is still collected and served by the JSON route; it simply earned no rows.
  • Disabled — a badge beside the state, on the entries that are switched off, and the state's missing half: a disabled entry reads not loaded, exactly like one that is merely waiting, and in that same composition all 29 of the former were the former.
  • Provides — the services its fibers registered.
  • Listens to — the event names its fibers subscribed to, read from the dispatcher's own table. Listeners produce no edges: a listener waits for a name, not for a provider, so it is not a dependency, and drawing it as an edge would be a false one. The other direction is Emits, which the runtime cannot answer and the next bullet explains.
  • Emits — the event names this plugin's own code sends, marked seen when the runtime was also observed dispatching them. The only field on the card that is not read from the running runtime, because the runtime cannot answer it: the dispatcher's table maps a name to its LISTENERS and never records a publisher, and the one channel that does carry an emitter (internal/dispatch's thisArg) is filled only for the explicit emit(thisArg, name, …) form — measured at 0 of the host's 132 emit calls. So attribution is textual (emittedIn over the module's own files) and the runtime's contribution is confirmation. Two consequences worth knowing: a name built at runtime is not a literal and is missing, and any .emit( counts — including Node's EventEmitter, which is where close, open and ready come from. Absent rather than empty in the browser tree and the standalone viewer: those are collected where no filesystem exists, and an empty list would claim a plugin emits nothing. Picking a name lists the plugins that listen to it — computed from the same listens data the listeners' own cards show, out of the same collection, so the two cannot disagree about who is listening — and each row selects that plugin. The listened names work the same way in reverse: picking one lists the plugins whose code emits it, which is the scan's answer and is titled as such. Where the graph was collected without a filesystem (the browser tree, the standalone viewer) there is no scan to answer with, and the listened names stay plain tags rather than opening an answer that would read as "nobody emits this".
  • Injects and Injected at runtime — the two ways a dependency is acquired, listed apart because they answer different questions; a plugin asking "why did this not load" wants the first.
  • Depends on and Used by — every edge in both directions, each row naming the service that makes it and whether it is optional.

(harness) is the one node that is not a plugin: it is the runtime's own row. Services provided by a fiber with no Loader entry behind it — the root fiber, and anything started outside the entry tree — are credited here rather than dropped, and their injections and listeners come with them. It is what keeps loader and the environment rows from reading as unresolved dependencies of everything that injects them.

What the graph reports

Two blocks sit under the drawing, and both are about work rather than about drawing:

  • Unresolved dependencies — required services with no provider anywhere in this composition. Only required ones: an optional injection with no provider has simply not been offered yet, and the plugin is working as designed.
  • Isolated services — one service name with more than one live implementation, i.e. provided under more than one isolation label.

The standalone viewer

A page the Node half serves as a whole document. It has no Cordis of its own, so it can only show the host's tree plus the browser report the app sent — and it reads two things from the URL it was opened with, because it cannot read them any other way:

ParameterWhy
?scheme=dark|lightthe page styles itself with the app's --dsw-* tokens; the attribute that switches them is the app's, not this page's
?lang=zh|en|ja|ko|es|fr|deso the new tab opens in the language the app is in, instead of guessing from the browser

The locale is whitelisted against the dictionaries before it reaches the document — a query string is not a place to trust, and that value ends up in the page's lang attribute. A page opened directly (no parameter) falls back to the browser's own preferences, then to English.

The browser report lives in memory only, never on disk: it describes a runtime that exists while the page is open, and a report that outlived a host restart would be describing something that is not there.

Language

Seven dictionaries in src/client/locales.ts: zh and en (the two the shell carries) plus ja, ko, es, fr and de, contributed one namespace at a time through the single-locale overload — the language pack a profile carries owns the definition that makes a language selectable, and this plugin adds only its own strings to it. It deliberately does not call addLanguage.

Every dictionary is typed Record<MessagesKey, string>, so a key added to MessagesKey fails to compile until all seven carry it. Wording follows session-messages, which shipped these five first: the plugins sit in one interface, so a reader must meet the same terms in both.

Layout

dsh-plugin-dependency-graph-plugin/
  package.json        dsh.bundle + dsh.client declarations, exports map (private, not published)
  cordis.patch.yml    layer patch: the Loader row
  build.mjs           build script: bundles both halves, and copies the theme at build time
  tsconfig.json       IDE type resolution only, pointing at the checkout source (read-only)
  src/
    index.ts                 Node half: collectGraph, and the routes below
    graph-types.ts           the wire shape both halves share (types + path constants)
    collect.ts               the collector, one function for both runtimes
    describe.ts              the description merge, pure — both halves run it, only the
                             Node half reads package.json
    emits.ts                 the emitted-events scan: reads a package's own code, and
                             merges the runtime's confirmation (Node half only)
    viewer-page.ts           the standalone page's document, as a string
    viewer/main.tsx          the standalone page's body (React bundled in — that page has no
                             module table to answer a bare `react` import)
    client/
      index.ts               registers the settings section
      GraphPanel.tsx         the panel both hosts render
      graph-canvas.tsx       the drawing: layout, hit testing, zoom/pan
      search.ts              the query → the nodes it names, and which field matched
      locales.ts             the seven dictionaries
  docs/                      the screenshots above (chinese variants: `-zh.png`)

Routes, all under /dsh-plugin-dependency-graph: the graph itself, /view (the page), /viewer.js, and /theme.css — plus /client, which takes a POST from the app and answers a GET for the viewer, /descriptions, which serves the name → description table for the browser tree, and /config, which takes an entry id and serves THAT entry's composed configuration.

Development

npm run build     # bundles lib/index.js, lib/client.js and lib/viewer.js
npx tsc -p tsconfig.json   # type check

Install as a plugin in a DSH checkout by adding this directory's cordis.patch.yml to the bundle: it inserts the single Loader row. Nothing here needs the host's source to be modified — that is a constraint this plugin is built to, not a coincidence.