@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 +21 -0
- package/README.md +99 -0
- package/cordis.patch.yml +13 -0
- package/package.json +45 -0
- package/src/index.js +137 -0
- package/src/state.js +57 -0
- package/src/transport.js +178 -0
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.
|
package/cordis.patch.yml
ADDED
|
@@ -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
|
+
}
|
package/src/transport.js
ADDED
|
@@ -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
|
+
}
|