@dsh-blue/herdr-agent-state 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 dsh-blue
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,99 @@
1
+ # @dsh-blue/herdr-agent-state
2
+
3
+ A DeepSeek Harness (`dsh`) plugin that reports a pane's agent state — `working`,
4
+ `blocked`, `idle` — and its session reference to [Herdr](https://herdr.dev/)
5
+ through Herdr's pane socket integration. It lets Herdr's sidebar show where the
6
+ agent actually is, surface waiting agents, and expose the session for restore,
7
+ **without any change to Herdr** (Herdr's [custom integration](https://herdr.dev/docs/integrations/#integrate-your-own-agent)
8
+ path).
9
+
10
+ It works in **any dsh frontend** — TUIs, the web app, and headless — because it
11
+ subscribes only to documented dsh extension points (agent lifecycle events, the
12
+ approval and user-question waterfalls) and carries no UI or renderer dependency.
13
+
14
+ ## Install
15
+
16
+ The plugin is a **dsh bundle** (`dsh.bundle.patch` → `cordis.patch.yml`), so
17
+ `dsh plugin add` activates it automatically — no manual `cordis.patch.yml` edit:
18
+
19
+ ```sh
20
+ dsh plugin --profile <profile> add @dsh-blue/herdr-agent-state
21
+ ```
22
+
23
+ It inserts a row labelled `herdr-agent-state`. To change the Herdr agent label
24
+ that a frontend reports, override the same row id in the profile's
25
+ `cordis.patch.yml` (last write wins per row):
26
+
27
+ ```yaml
28
+ plugins:
29
+ herdr-agent-state:
30
+ config:
31
+ agent: blue # default dsh; this frontend's own label
32
+ ```
33
+
34
+ The plugin is a strict no-op outside a Herdr pane (`HERDR_ENV=1` plus
35
+ `HERDR_SOCKET_PATH` and `HERDR_PANE_ID` absent), so it never adds side effects
36
+ to a normal terminal session.
37
+
38
+ ### Install straight from GitHub (before publishing to npm)
39
+
40
+ `dsh plugin` is a thin [pnpm](https://pnpm.io/) forwarder, so it accepts any
41
+ pnpm dependency spec — including a GitHub repo. The plugin ships plain ESM
42
+ JavaScript with no build step, so `dsh plugin add` installs and auto-activates
43
+ the bundle in one command with no `prepare`/`lib` and no `allowBuilds` entry:
44
+
45
+ ```sh
46
+ # master branch; pin the exact commit when you want reproducibility
47
+ dsh plugin --profile <profile> add github:dsh-blue/herdr-agent-state
48
+ # or pinned to a commit (the pattern Blue marketplace installs use):
49
+ dsh plugin --profile <profile> add dsh-blue/herdr-agent-state#<40-char-sha>
50
+ ```
51
+
52
+ ## How it reports state
53
+
54
+ | Herdr state | dsh signal |
55
+ |---|---|
56
+ | `working` | any agent reports `agent/status = running` |
57
+ | `blocked` | an `approval/request` or `user-questions/request` waterfall is awaiting an answer |
58
+ | `idle` | no agent running and nothing pending |
59
+
60
+ Blocked observations are **passive**: the plugin calls `await next()` and returns
61
+ the downstream decision unchanged, so approval and question flows are never
62
+ altered. Reports are coalesced (latest value wins) and tagged with a strictly
63
+ increasing `seq`, mirroring Herdr's own Pi integration wire contract.
64
+
65
+ The plugin releases the pane's lifecycle authority on unload and process exit,
66
+ and re-reports on `agent/session-start` so a reload does not leave Herdr with a
67
+ stale authority.
68
+
69
+ ## Configuration
70
+
71
+ | Field | Default | Meaning |
72
+ |---|---|---|
73
+ | `agent` | `'dsh'` | Herdr agent label reported for the pane. A frontend sets its own name here. |
74
+ | `source` | `'herdr:dsh-agent-state'` | Stable unique integration source. Keep it constant. |
75
+ | `transport` | `'socket'` | `socket` (implemented) or `cli` (declared, not yet implemented — rejected at load). |
76
+ | `reportSession` | `true` | Report the pane's session reference to Herdr. |
77
+ | `message` | `'tool'` | `tool` attaches a human label to blocked reports; `none` omits it. |
78
+ | `enabled` | `true` | Kill-switch to coexist with another reporter in the same tree. |
79
+
80
+ ## Version compatibility
81
+
82
+ Built against the dsh `0.1.2-alpha` line. It shares the host's
83
+ `@deepseek-ai/schemastery` instance (declared as a peer, so the runtime
84
+ `Config` schema uses the same copy the host validates against).
85
+
86
+ ## Development
87
+
88
+ ```sh
89
+ pnpm install
90
+ pnpm test # vitest run (unit + fake-socket integration)
91
+ ```
92
+
93
+ The plugin ships as plain ESM JavaScript, so there is no build step. Its
94
+ `state` and `transport` modules depend only on Node builtins, so their tests
95
+ run without a dsh host.
96
+
97
+ ## License
98
+
99
+ MIT.
@@ -0,0 +1,13 @@
1
+ # The herdr-agent-state bundle patch: insert the pane-state reporter plugin
2
+ # into the profile's layer stack. Because package.json declares
3
+ # `dsh.bundle.patch`, `dsh plugin add` joins this bundle into the profile's
4
+ # `dsh.profile.bundles` and activates the row automatically — no manual
5
+ # `cordis.patch.yml` edit needed. Override a value by patching the same row id
6
+ # from the profile layer (last write wins per row).
7
+ - insert:
8
+ - id: herdr-agent-state
9
+ name: '@dsh-blue/herdr-agent-state'
10
+ config:
11
+ # Herdr agent label for the pane. Set a frontend's own name (e.g.
12
+ # `blue`) by overriding this row's `config` in the profile patch.
13
+ agent: dsh
package/package.json ADDED
@@ -0,0 +1,45 @@
1
+ {
2
+ "name": "@dsh-blue/herdr-agent-state",
3
+ "version": "0.1.0",
4
+ "description": "dsh plugin: report agent state (working/blocked/idle) and session reference to Herdr via its pane socket integration. Works in any dsh profile — TUIs, web, headless.",
5
+ "type": "module",
6
+ "main": "src/index.js",
7
+ "exports": {
8
+ ".": "./src/index.js"
9
+ },
10
+ "files": [
11
+ "src",
12
+ "cordis.patch.yml",
13
+ "README.md",
14
+ "LICENSE"
15
+ ],
16
+ "dsh": {
17
+ "bundle": {
18
+ "patch": "./cordis.patch.yml"
19
+ }
20
+ },
21
+ "engines": {
22
+ "node": "^22.19 || >=24"
23
+ },
24
+ "keywords": [
25
+ "dsh",
26
+ "deepseek-harness",
27
+ "cordis",
28
+ "plugin",
29
+ "herdr",
30
+ "agent-state",
31
+ "pane",
32
+ "integration"
33
+ ],
34
+ "license": "MIT",
35
+ "peerDependencies": {
36
+ "@deepseek-ai/schemastery": "^3.18.2"
37
+ },
38
+ "devDependencies": {
39
+ "vitest": "^3.0.0"
40
+ },
41
+ "scripts": {
42
+ "test": "vitest run",
43
+ "test:watch": "vitest"
44
+ }
45
+ }
package/src/index.js ADDED
@@ -0,0 +1,137 @@
1
+ /**
2
+ * Herdr agent-state reporter for any dsh frontend.
3
+ *
4
+ * A Cordis function plugin that, when loaded inside a Herdr pane, reports the
5
+ * pane's semantic state (working / blocked / idle) and session reference to
6
+ * Herdr's pane socket. It depends only on documented dsh extension points —
7
+ * agent lifecycle events, the approval and user-question waterfalls — so it
8
+ * works in TUI, web, and headless profiles alike. Outside a Herdr pane it is a
9
+ * strict no-op.
10
+ *
11
+ * Ships as plain ESM JavaScript (no build step) so `dsh plugin add` from a git
12
+ * repo loads it directly without `prepare`/`lib`.
13
+ *
14
+ * @module @dsh-blue/herdr-agent-state
15
+ */
16
+
17
+ import z from '@deepseek-ai/schemastery'
18
+
19
+ import { AgentStateModel } from './state.js'
20
+ import { HerdrReporter, herdrEnabled } from './transport.js'
21
+
22
+ export const name = 'herdr-agent-state'
23
+
24
+ /** The plugin consumes no injected services; it reads the environment and events only. */
25
+ export const inject = []
26
+
27
+ /** Schemastery configuration for the plugin. */
28
+ export const Config = z.object({
29
+ /**
30
+ * Herdr agent label reported for the pane. Defaults to `dsh`; a host frontend
31
+ * sets its own name (e.g. `blue`) by overriding this field in a patch overlay.
32
+ */
33
+ agent: z.string().default('dsh'),
34
+ /**
35
+ * Stable, unique integration source. Keep it constant so Herdr attributes the
36
+ * pane's lifecycle authority to this reporter and so a future dsh-built
37
+ * reporter can coexist under a different source.
38
+ */
39
+ source: z.string().default('herdr:dsh-agent-state'),
40
+ /**
41
+ * Transport to Herdr. `socket` speaks the pane socket directly; `cli` is a
42
+ * declared-but-unimplemented fallback and is rejected at load.
43
+ */
44
+ transport: z.union([z.const('socket'), z.const('cli')]).default('socket'),
45
+ /** Report the pane's session reference so Herdr can expose it for restore. */
46
+ reportSession: z.boolean().default(true),
47
+ /** Whether to attach a human label to blocked reports. */
48
+ message: z.union([z.const('tool'), z.const('none')]).default('tool'),
49
+ /** Kill-switch for coexisting with another reporter in the same tree. */
50
+ enabled: z.boolean().default(true),
51
+ })
52
+
53
+ /** A concise human label for the leading question in a pending request. */
54
+ function questionLabel(questions) {
55
+ const item = questions[0]
56
+ const text = item?.title ?? item?.label ?? item?.placeholder ?? 'question'
57
+ return questions.length > 1 ? `${text} (+${questions.length - 1} more)` : text
58
+ }
59
+
60
+ /**
61
+ * Drive one pane's reporter from the live dsh event stream.
62
+ * @param {import('@deepseek-ai/cordis').Context} ctx
63
+ * @param {{ agent: string, source: string, transport: 'socket' | 'cli', reportSession: boolean, message: 'tool' | 'none', enabled: boolean }} config
64
+ */
65
+ export function apply(ctx, config) {
66
+ if (!config.enabled) return
67
+ if (config.transport !== 'socket') {
68
+ throw new Error(`herdr-agent-state: transport "${String(config.transport)}" is not implemented; use 'socket'`)
69
+ }
70
+
71
+ const env = process.env
72
+ if (!herdrEnabled(env)) return
73
+
74
+ const reporter = new HerdrReporter({
75
+ source: config.source,
76
+ agent: config.agent,
77
+ reportSession: config.reportSession,
78
+ env,
79
+ })
80
+ const model = new AgentStateModel()
81
+
82
+ const publish = (force = false) => {
83
+ const report = model.desired()
84
+ if (config.message === 'none' && report.state === 'blocked') {
85
+ reporter.publishState({ state: report.state }, force)
86
+ } else {
87
+ reporter.publishState(report, force)
88
+ }
89
+ }
90
+
91
+ ctx.on('agent/disposed', (payload) => {
92
+ model.setRunning(payload.agent, false)
93
+ publish()
94
+ })
95
+
96
+ ctx.on('agent/status', (payload) => {
97
+ model.setRunning(payload.agent, payload.status === 'running')
98
+ publish()
99
+ })
100
+
101
+ // Observers only: they delegate with `await next()` and never alter the
102
+ // downstream decision, so the approval / question flow is untouched.
103
+ ctx.on('approval/request', async (req, next) => {
104
+ model.setBlocked(true, req.reason ?? req.toolName)
105
+ publish()
106
+ try {
107
+ return await next()
108
+ } finally {
109
+ model.setBlocked(false)
110
+ publish()
111
+ }
112
+ })
113
+
114
+ ctx.on('user-questions/request', async (request, next) => {
115
+ model.setBlocked(true, questionLabel(request.questions))
116
+ publish()
117
+ try {
118
+ return await next()
119
+ } finally {
120
+ model.setBlocked(false)
121
+ publish()
122
+ }
123
+ })
124
+
125
+ ctx.on('agent/session-start', (payload) => {
126
+ reporter.setSessionId(payload.agent?.session?.header?.id ?? undefined)
127
+ reporter.reportSession(payload.source)
128
+ model.setRunning(payload.agent, false)
129
+ publish(true)
130
+ })
131
+
132
+ ctx.effect(
133
+ () => () => reporter.release(),
134
+ 'herdr-agent-state: release pane lifecycle authority on unload',
135
+ )
136
+ process.once('beforeExit', () => reporter.release())
137
+ }
package/src/state.js ADDED
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Pure semantic state model for the pane's Herdr state.
3
+ *
4
+ * @module @dsh-blue/herdr-agent-state/state
5
+ */
6
+
7
+ export class AgentStateModel {
8
+ constructor() {
9
+ /** Agent objects reported as `running`, by identity (stable per session). */
10
+ this.runningAgents = new Set()
11
+ /** Number of open approval/question interactions. */
12
+ this.blockedCount = 0
13
+ /** Label of the most recently opened blocked interaction. */
14
+ this.blockedMessage = undefined
15
+ }
16
+
17
+ /**
18
+ * Record one agent's running state; pass the same agent object on later events.
19
+ * @param {unknown} agent
20
+ * @param {boolean} running
21
+ */
22
+ setRunning(agent, running) {
23
+ if (running) this.runningAgents.add(agent)
24
+ else this.runningAgents.delete(agent)
25
+ }
26
+
27
+ /**
28
+ * Open or close one blocked interaction. `active: true` opens a pending
29
+ * approval/question and records `message`; `active: false` closes the most
30
+ * recent one (never below zero).
31
+ * @param {boolean} active
32
+ * @param {string} [message]
33
+ */
34
+ setBlocked(active, message) {
35
+ if (active) {
36
+ this.blockedCount += 1
37
+ if (message !== undefined) this.blockedMessage = message
38
+ return
39
+ }
40
+ this.blockedCount = Math.max(0, this.blockedCount - 1)
41
+ if (this.blockedCount === 0) this.blockedMessage = undefined
42
+ }
43
+
44
+ /**
45
+ * The report derived from the current inputs.
46
+ * @returns {{ state: 'working' | 'blocked' | 'idle', message?: string }}
47
+ */
48
+ desired() {
49
+ if (this.blockedCount > 0) {
50
+ return this.blockedMessage !== undefined
51
+ ? { state: 'blocked', message: this.blockedMessage }
52
+ : { state: 'blocked' }
53
+ }
54
+ if (this.runningAgents.size > 0) return { state: 'working' }
55
+ return { state: 'idle' }
56
+ }
57
+ }
@@ -0,0 +1,178 @@
1
+ /**
2
+ * Herdr pane socket transport and reporter.
3
+ *
4
+ * Mirrors Herdr's own bundled Pi integration wire contract: one newline-delimited
5
+ * JSON request per `pane.report_agent` / `pane.report_agent_session` /
6
+ * `pane.release_agent` call, a fresh single connection per request, a short
7
+ * timeout plus one longer retry, and a failure that never rejects into the host
8
+ * process (Herdr being absent must not disturb the dsh frontend).
9
+ *
10
+ * Only `node:net` is used.
11
+ * @module @dsh-blue/herdr-agent-state/transport
12
+ */
13
+
14
+ import net from 'node:net'
15
+
16
+ /** True when the process runs inside a Herdr pane that can receive reports. */
17
+ export function herdrEnabled(env) {
18
+ return env.HERDR_ENV === '1' && Boolean(env.HERDR_SOCKET_PATH) && Boolean(env.HERDR_PANE_ID)
19
+ }
20
+
21
+ /** Resolve the socket endpoint, mapping Windows named pipes as Herdr does. */
22
+ export function socketEndpoint(env) {
23
+ const raw = env.HERDR_SOCKET_PATH ?? ''
24
+ return process.platform === 'win32' && raw !== '' ? `\\\\.\\pipe\\${raw}` : raw
25
+ }
26
+
27
+ /**
28
+ * Deliver one request over a fresh single connection. A pane report is
29
+ * fire-and-forget, so delivery means the request was written to Herdr's socket
30
+ * (the write flushed), not that Herdr replied — Herdr's pane socket does not
31
+ * necessarily acknowledge. Resolves `true` once the write flushes, `false` on
32
+ * any failure. Never throws.
33
+ *
34
+ * @param {{ id: string, method: string, params: Record<string, unknown> }} request
35
+ * @param {string} endpoint
36
+ * @param {number} [timeoutMs]
37
+ * @param {number} [retryMs]
38
+ * @returns {Promise<boolean>}
39
+ */
40
+ export function sendRequest(request, endpoint, timeoutMs = 500, retryMs = 1500) {
41
+ return new Promise((resolve) => {
42
+ const attempt = (delay, onSettled) => {
43
+ let finished = false
44
+ let timer
45
+ const finish = (delivered) => {
46
+ if (finished) return
47
+ finished = true
48
+ if (timer !== undefined) clearTimeout(timer)
49
+ client.destroy()
50
+ onSettled(delivered)
51
+ }
52
+
53
+ const client = net.createConnection(endpoint)
54
+ client.on('error', () => finish(false))
55
+ client.on('end', () => finish(false))
56
+ client.on('connect', () => {
57
+ client.write(`${JSON.stringify(request)}\n`, () => finish(true))
58
+ })
59
+ timer = setTimeout(() => finish(false), delay)
60
+ timer.unref?.()
61
+ }
62
+
63
+ attempt(timeoutMs, (delivered) => {
64
+ if (delivered) resolve(true)
65
+ else attempt(retryMs, (retried) => resolve(retried))
66
+ })
67
+ })
68
+ }
69
+
70
+ /**
71
+ * Owns the seq counter, the single-flight latest-wins state queue, the session
72
+ * reference, and the transport. Calling `publishState` coalesces bursts: only
73
+ * the newest state is sent, and only when it differs from the last sent one.
74
+ */
75
+ export class HerdrReporter {
76
+ /**
77
+ * @param {{ source: string, agent: string, reportSession: boolean, env: Record<string, string | undefined> }} options
78
+ */
79
+ constructor(options) {
80
+ this.source = options.source
81
+ this.agent = options.agent
82
+ this.reportSessionRef = options.reportSession
83
+ this.endpoint = socketEndpoint(options.env)
84
+ this.paneId = options.env.HERDR_PANE_ID ?? ''
85
+ // Wall-clock base keeps seq strictly increasing across process restarts.
86
+ this.seq = Date.now() * 1000
87
+ this.sessionId = undefined
88
+ this.sendInFlight = false
89
+ this.queued = undefined
90
+ this.lastSent = undefined
91
+ }
92
+
93
+ /** Record the session reference to attach to subsequent reports. */
94
+ setSessionId(id) {
95
+ this.sessionId = id
96
+ }
97
+
98
+ nextSeq() {
99
+ this.seq += 1
100
+ return this.seq
101
+ }
102
+
103
+ sessionParams() {
104
+ if (this.reportSessionRef && this.sessionId !== undefined && this.sessionId !== '') {
105
+ return { agent_session_id: this.sessionId }
106
+ }
107
+ return {}
108
+ }
109
+
110
+ send(request) {
111
+ return sendRequest(request, this.endpoint)
112
+ }
113
+
114
+ /** Report the current pane state, coalescing bursts to a single latest value. */
115
+ publishState(report, force = false) {
116
+ if (!force && report.state === this.lastSent?.state && report.message === this.lastSent.message) {
117
+ return
118
+ }
119
+ if (!force) this.lastSent = report
120
+ this.queued = { ...report, seq: this.nextSeq() }
121
+ void this.drain()
122
+ }
123
+
124
+ /** Report the pane's session reference; Herdr exposes it for restore. */
125
+ reportSession(sessionStartSource) {
126
+ const params = this.sessionParams()
127
+ if (Object.keys(params).length === 0) return
128
+ void this.send({
129
+ id: `${this.source}:session:${Date.now()}:${Math.random().toString(36).slice(2)}`,
130
+ method: 'pane.report_agent_session',
131
+ params: {
132
+ pane_id: this.paneId,
133
+ source: this.source,
134
+ agent: this.agent,
135
+ seq: this.nextSeq(),
136
+ ...(sessionStartSource !== undefined ? { session_start_source: sessionStartSource } : {}),
137
+ ...params,
138
+ },
139
+ })
140
+ }
141
+
142
+ /** Release this pane's lifecycle authority (on unload or process exit). */
143
+ release() {
144
+ void this.send({
145
+ id: `${this.source}:release:${Date.now()}:${Math.random().toString(36).slice(2)}`,
146
+ method: 'pane.release_agent',
147
+ params: { pane_id: this.paneId, source: this.source, agent: this.agent },
148
+ })
149
+ }
150
+
151
+ async drain() {
152
+ if (this.sendInFlight) return
153
+ this.sendInFlight = true
154
+ try {
155
+ while (this.queued !== undefined) {
156
+ const next = this.queued
157
+ this.queued = undefined
158
+ const params = this.sessionParams()
159
+ await this.send({
160
+ id: `${this.source}:${Date.now()}:${Math.random().toString(36).slice(2)}`,
161
+ method: 'pane.report_agent',
162
+ params: {
163
+ pane_id: this.paneId,
164
+ source: this.source,
165
+ agent: this.agent,
166
+ state: next.state,
167
+ ...(next.message !== undefined ? { message: next.message } : {}),
168
+ seq: next.seq,
169
+ ...params,
170
+ },
171
+ })
172
+ }
173
+ } finally {
174
+ this.sendInFlight = false
175
+ if (this.queued !== undefined) this.drain()
176
+ }
177
+ }
178
+ }