dsh-skill-invocation-monitor

English | 简体中文

npm version license GitHub stars

> A **read-only** [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) (DSH) plugin that puts > every skill invocation on one screen: which Agents are running, which Skill each one is calling, > whether it is running / finished / failed, and how long it has been going — live, in the Web GUI sidebar. ![The Skill Monitor panel: two sessions, the first one running a skill the user invoked](docs/screenshot.png) *Captured from the running plugin: the sidebar entry opens a panel listing every Agent, and each row is one skill call with its status, elapsed time and start clock.* ## What changes after you install it Skill invocation in DSH is invisible. The model loads a skill, and you see the answer — not the call, not the timing, not the one that never came back. After installing this plugin: - the sidebar gains a **技能监控 / Skill Monitor** entry, with a live badge showing how many calls are running; - the panel lists every live Agent, and under each one every skill call with its **status** and **elapsed time**; - **both** ways a skill can be reached are covered and told apart — the model calling the `skill` tool, and **you** triggering a skill yourself, which carries a `用户触发` badge on its row; - nothing is written anywhere: no tool registered, no session state touched, no call intercepted or delayed. ## Install ```sh # from npm (recommended — prebuilt, so no build-script permission is needed) dsh plugin --profile web add @yyc-jnu/dsh-skill-invocation-monitor # or straight from GitHub (plain ESM JavaScript: no build step, so pnpm never asks for allowBuilds) dsh plugin --profile web add github:YYC-JNU/dsh-skill-invocation-monitor ``` Then **restart DSH and refresh the page**. Both halves — the host observers plus the read-only route, and the browser bundle — are scanned during host start-up, so no sidebar entry appears before that restart. Verify the layer before booting: ```sh dsh --profile web --dump-config # look for: # == @yyc-jnu/dsh-skill-invocation-monitor ``` If that layer is missing, the package installed as a plain dependency and the plugin never activated. Uninstall: ```sh dsh plugin --profile web remove @yyc-jnu/dsh-skill-invocation-monitor ``` ## Features | | | |---|---| | Live Agent list | Every live Agent, its session title, and whether it is running or idle | | Per-call rows | Skill name, status (`运行中` / `已完成` / `失败` / `未观测到结果`), elapsed time, start clock | | Both invocation paths | The model's `skill` tool call, and a human triggering a skill — marked `用户触发` | | Ticking durations | The elapsed time updates every second while a call is running | | Sidebar badge | The running-call count sits on the sidebar entry, so you can see activity without opening the panel | | Honest failures | A failed call shows a truncated reason, e.g. `skill "…" is unknown or no longer available` | | Self-check diagnostics | Every response carries counters describing what the plugin observed, so a wrong assumption is visible rather than silent | | Zero dependencies | Neither half imports a third-party package (the browser half uses only `react` from the host module table) | ## Permissions and data This plugin exists to observe, and its whole design is built around not participating. | | | |---|---| | Observes (read-only) | Five Cordis events: `tools/pre-execute`, `tools/result`, `session/event`, `agent/status`, `agent/disposed` | | Reads | The live Agent list, session titles, and the durable session-event feed as it is appended | | Does **not** write | No session state, no session log, nothing on disk, no config, no tool, no command | | Does **not** interfere | The `tools/pre-execute` listener returns `next()` unchanged and never denies, cancels, rewrites or delays a call; every observer is wrapped so a bug in this plugin cannot break a dispatch | | HTTP surface | Exactly one route, `GET /api/plugins/skill-monitor/state` — no body, no mutation, `cache-control: no-store` | | Network exposure | Loopback only: a non-loopback peer, a cross-site fetch marker or a foreign `Origin` gets `403`; any non-GET method gets `405` | | Where the data lives | One `Map` in host memory. Records are capped (200 by default) and expire (10 minutes after they settle); restarting DSH clears everything | | Not collected | Prompt text, model output, file contents, credentials, or anything else the plugin never reads | ## Compatibility | | | |---|---| | DSH | Verified against **0.2.0-rc.2** (the build this plugin was developed and tested on). The seams it uses are documented core surfaces, so other 0.2.x builds are expected to work | | Node.js | Declared `>=20`. The plugin runs inside the DSH host, so the host's own Node requirement is what matters | | Surface | Web GUI (`dsh.client.platform: "web"`). Uses only official Slots — `sidebar.panellist` for the entry and `main` for the panel — never DOM injection | | Skill paths | Uses the durable contracts for both paths: tool calls on `tools/pre-execute` / `tools/result`, and human invocations through the `skill-invocation` message source declared by the skills seam | ## How it works
The two invocation paths — and why the human one is read from a message source A skill can be reached two structurally different ways in DSH, and the plugin observes both. | | Model path | User path | |---|---|---| | Trigger | The model calls the `skill` tool | You invoke a skill (e.g. `/grill-me`) | | What DSH does | Runs the tool pipeline and produces a tool result | Appends the skill's instructions to the conversation as a `user/message` — **no tool call and no command event** | | Pairing key | `callId` | the message `id` | | Duration measured | `tools/result` − `pre-execute` | `turn/end` − injection (see the note below) | | Row marker | none | `用户触发` | The human path is read from the structured source the skills seam itself attaches (`SkillInvocationSource`, in the persistence catalog): ```json { "type": "user/message", "data": { "source": { "kind": "skill-invocation", "name": "grill-me", "form": "instructions" }, "role": "user", "id": "7c2e0f5a-3b9d-4e18-9a6c-1d2f3a4b5c6d" } } ``` The name comes straight from `source.name`, so no skill-catalog lookup and no text parsing is involved. That is a measured result, not a guess: an earlier implementation assumed the human path went through `command/run` / `command/done`, and a real session log disproved it — a human-invoked skill produces no command event at all, which removed an entire catalog cache and its 60-second TTL. **One deliberate choice:** a human skill has no result event of its own, so its duration is the *turn window* (injection until `turn/end`), not the skill's own execution time. That is a stated convention, not a measurement — if you would rather see an instantaneous event with a duration of 0, only the settling point changes.
Status semantics | Status | Meaning | |---|---| | `运行中` | A start was observed (a pre-execute or a skill message) and no ending event has arrived; the duration grows live | | `已完成` | The tool reported `isError === false`, or the turn that carried the skill ended normally | | `失败` | The tool reported `isError === true`; the row shows a truncated reason | | `未观测到结果` | No ending event arrived within 15 minutes (the call was denied before dispatch, its parent was cancelled, the host crashed). This is a *derived* status and is never written back into the record |
The read-only contract This is the plugin's core constraint, and there are tests asserting each row. | Constraint | How it is implemented | |---|---| | Registers no writable tool | Never touches `ctx.tools` — no `register`, `restrict` or `guard`; `apply()` adds no member to `ctx` (asserted by a test) | | Modifies no session state | Never appends a session event, never writes a session log, never calls a service method; only reads `agents.list()` / `sessions.get()` / `sessionTitle.get()` | | Intercepts nothing | The `tools/pre-execute` listener returns `next()` untouched and never denies, cancels, rewrites or measures; a hostile arguments Proxy cannot make it block a call (asserted by a test) | | Writes nothing to disk | All state is one in-memory `Map`, capped and expiring; no history, no persistence, no alerting | | One read-only route | `GET /api/plugins/skill-monitor/state` only — no POST, no request body, no write path | | Zero runtime dependencies | Neither half imports a third-party package |
Diagnostics — how a wrong assumption stays visible instead of silent Every state response carries a `diagnostics` object describing what the plugin actually saw: | Field | What it tells you | |---|---| | `sessionEvents` | The durable event feed did reach the plugin | | `skillInjections` / `trackedInjections` | Human skill messages were seen and recorded | | `unmatchedInjections` | A `skill-invocation` message arrived whose name could not be read | | `turnEnds` / `settledByTurnEnd` | Turn endings arrived, and how many human records they closed | This is not decoration: it is what caught the disproved command-path assumption in the first place.
Configuration (optional) ```yaml - id: skill-invocation-monitor name: '@yyc-jnu/dsh-skill-invocation-monitor' config: maxRecords: 200 # in-memory record cap retentionMs: 600000 # keep settled records for 10 minutes staleMs: 900000 # no outcome within 15 minutes → 未观测到结果 maxCallsPerAgent: 20 # rows shown per Agent in the panel ```
## Development
Tests, scripts and repository layout — for contributors ```sh cd dsh-skill-invocation-monitor npm run verify # node --check on all three entry files npm test # 72 offline tests: node --test ``` 72 offline tests cover three layers: the pure aggregation model (`test/tracker.test.mjs`, 36 — duration settling for both paths, pairing keys, staleness, eviction and caps, Agent merging, field-name parsing of the durable payload), the read-only contract itself (`test/host-plugin.test.mjs`, 25 — exactly five events, exactly one GET route, a pure passthrough pre-execute listener, loopback fencing, disposal), and the browser bundle (`test/client-bundle.test.mjs`, 11 — loaded the way the shell loads it, asserting the slot registrations and the rendered output). The plugin is plain ESM JavaScript: `lib/` **is** the source, there is no build step and no `src/`. ``` dsh-skill-invocation-monitor/ ├── package.json # name / exports / dsh.bundle.patch / dsh.client ├── cordis.patch.yml # the one profile row both halves hang off ├── lib/ │ ├── host/ │ │ ├── index.js # host half: 5 read-only observers + 1 GET route │ │ └── tracker.js # pure aggregation model (no Cordis, no I/O, explicit timestamps) │ └── client.js # browser half: ModuleLoader bundle, 2 slot registrations ├── docs/ │ ├── screenshot.png # the screenshot above │ └── skill-monitor-flow.json # archify sequence-diagram source └── test/ # 72 offline tests ```
## License [MIT](LICENSE) ## Acknowledgements The code in this plugin was written with **AI assistance**: the author defined the requirements, reviewed the results and accepted them, while the AI produced the implementation and the documentation. Every behaviour was verified against a running DSH instance; session titles, message ids and timestamps in tests and examples are synthetic and contain no real user data.