@dsh-blue/herdr-agent-state 0.1.0 → 0.3.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/README.md +174 -18
- package/package.json +2 -2
- package/src/index.js +130 -10
- package/src/state.js +241 -3
- package/src/transport.js +124 -9
package/README.md
CHANGED
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
# @dsh-blue/herdr-agent-state
|
|
2
2
|
|
|
3
3
|
A DeepSeek Harness (`dsh`) plugin that reports a pane's agent state — `working`,
|
|
4
|
-
`blocked`, `idle
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
4
|
+
`blocked`, `idle`, labeled with the currently-executing tool while working — its
|
|
5
|
+
session reference and log path, and its session display facts (title, model,
|
|
6
|
+
and context usage as pane metadata) to [Herdr](https://herdr.dev/) through
|
|
7
|
+
Herdr's pane socket integration. It lets Herdr's sidebar show where the agent
|
|
8
|
+
actually is, surface waiting agents, name panes after the conversation, and
|
|
9
|
+
expose the session for restore, **without any change to Herdr** (Herdr's
|
|
10
|
+
[custom integration](https://herdr.dev/docs/integrations/#integrate-your-own-agent)
|
|
8
11
|
path).
|
|
9
12
|
|
|
10
13
|
It works in **any dsh frontend** — TUIs, the web app, and headless — because it
|
|
@@ -21,14 +24,15 @@ dsh plugin --profile <profile> add @dsh-blue/herdr-agent-state
|
|
|
21
24
|
```
|
|
22
25
|
|
|
23
26
|
It inserts a row labelled `herdr-agent-state`. To change the Herdr agent label
|
|
24
|
-
|
|
25
|
-
|
|
27
|
+
a frontend reports, patch the same row id in the profile's `cordis.patch.yml`
|
|
28
|
+
(an id-targeted patch replaces that row's whole `config`; the schema defaults
|
|
29
|
+
fill any field you omit):
|
|
26
30
|
|
|
27
31
|
```yaml
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
+
# ~/.dsh/profiles/<profile>/cordis.patch.yml
|
|
33
|
+
- id: herdr-agent-state
|
|
34
|
+
config:
|
|
35
|
+
agent: blue # default dsh; this frontend's own label
|
|
32
36
|
```
|
|
33
37
|
|
|
34
38
|
The plugin is a strict no-op outside a Herdr pane (`HERDR_ENV=1` plus
|
|
@@ -57,6 +61,13 @@ dsh plugin --profile <profile> add dsh-blue/herdr-agent-state#<40-char-sha>
|
|
|
57
61
|
| `blocked` | an `approval/request` or `user-questions/request` waterfall is awaiting an answer |
|
|
58
62
|
| `idle` | no agent running and nothing pending |
|
|
59
63
|
|
|
64
|
+
While working, the pane report carries a `message` naming the
|
|
65
|
+
currently-executing tool (observed on the `tools/execute` waterfall, which
|
|
66
|
+
fires only for calls that survived approval — denied calls never label). The
|
|
67
|
+
session reference carries both `agent_session_id` and, when the profile
|
|
68
|
+
persists sessions as jsonl, `agent_session_path` (the absolute log path);
|
|
69
|
+
sqlite or no-persistence backends omit the path.
|
|
70
|
+
|
|
60
71
|
Blocked observations are **passive**: the plugin calls `await next()` and returns
|
|
61
72
|
the downstream decision unchanged, so approval and question flows are never
|
|
62
73
|
altered. Reports are coalesced (latest value wins) and tagged with a strictly
|
|
@@ -66,16 +77,161 @@ The plugin releases the pane's lifecycle authority on unload and process exit,
|
|
|
66
77
|
and re-reports on `agent/session-start` so a reload does not leave Herdr with a
|
|
67
78
|
stale authority.
|
|
68
79
|
|
|
80
|
+
## How it reports metadata
|
|
81
|
+
|
|
82
|
+
All display-only extras ride Herdr's `pane.report_metadata` channel. Title and
|
|
83
|
+
state labels are presentation fields guarded by the same `source`/`agent` as
|
|
84
|
+
the state reports plus an `applies_to_source` guard, so they apply exactly
|
|
85
|
+
while this reporter holds the pane's lifecycle authority; tokens always apply
|
|
86
|
+
and are this reporter's to clear. Herdr checks the guards when a report
|
|
87
|
+
arrives (not continuously), so the reporter clears everything it sent when it
|
|
88
|
+
releases the pane's authority. Metadata never affects waits, notifications, or
|
|
89
|
+
rollups, and is not restored across a Herdr server restart.
|
|
90
|
+
|
|
91
|
+
- **Title** (`title: session`) mirrors the dsh session title — first-prompt
|
|
92
|
+
fallback, LLM-generated refinement, or pinned by `/rename`. Titles are
|
|
93
|
+
observed on the same `session/title` session-log feed the dsh TUI renders,
|
|
94
|
+
filtered to the session the pane's agent is running (subagent sessions in
|
|
95
|
+
the same process are skipped); a resumed session's existing title is read
|
|
96
|
+
directly at `agent/session-start`, since past title events are replay seeds
|
|
97
|
+
that never re-enter the live feed. After a `/clear` the previous title stays
|
|
98
|
+
until the new session produces its first title (usually seconds).
|
|
99
|
+
- **Tokens** (`tokens: auto`) report `model` (the raw model id) and `ctx`
|
|
100
|
+
(context occupancy, `used/window` mirroring the dsh TUI status bar, e.g.
|
|
101
|
+
`34k/1.0M`; bare `used` when the route's context window is unknown). Model
|
|
102
|
+
and window come from the `request/header` / `request/context` log events and
|
|
103
|
+
update on every assistant step's usage; a resumed session seeds all three
|
|
104
|
+
from the replayed log. Herdr's Agent sidebar can render them as `$model`
|
|
105
|
+
and `$ctx`. Tokens are cleared on release.
|
|
106
|
+
- **State labels** (`stateLabels`) override the visible text per Herdr state —
|
|
107
|
+
for example `{ working: 工作中, blocked: 等待确认 }`. Non-blank entries are
|
|
108
|
+
sent once per session start and cleared on release.
|
|
109
|
+
- **Working message** (`workingMessage: tool`) attaches the
|
|
110
|
+
currently-executing tool name to `working` state reports (see above);
|
|
111
|
+
blocked labels are unchanged and still governed by `message`.
|
|
112
|
+
|
|
69
113
|
## Configuration
|
|
70
114
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
|
74
|
-
|
|
75
|
-
| `
|
|
76
|
-
| `
|
|
77
|
-
| `
|
|
78
|
-
| `
|
|
115
|
+
### Configuration reference
|
|
116
|
+
|
|
117
|
+
| Field | Type | Default | Meaning |
|
|
118
|
+
|---|---|---|---|
|
|
119
|
+
| `agent` | string | `'dsh'` | The Herdr agent label reported for the pane. Set a frontend's own name (e.g. `blue`) so Herdr's sidebar groups it under that label. |
|
|
120
|
+
| `source` | string | `'herdr:dsh-agent-state'` | Stable, unique integration source. Herdr attributes the pane's lifecycle authority to this source. **Keep it constant.** Changing it makes Herdr treat the pane as a *different* authority mid-session. |
|
|
121
|
+
| `transport` | `'socket'` \| `'cli'` | `'socket'` | How to report to Herdr. Only `socket` is implemented (speaks the pane socket directly). `cli` is declared but not yet implemented — it throws **at load**, so don't set it. |
|
|
122
|
+
| `reportSession` | boolean | `true` | Report the pane's session reference (`agent_session_id`) so Herdr can expose it for restore. Set `false` to suppress session reporting. |
|
|
123
|
+
| `title` | `'session'` \| `'none'` | `'session'` | Which title to publish as the Herdr pane title (display-only metadata). `session` mirrors the dsh session title — first-prompt fallback, LLM-generated, or pinned by `/rename`; `none` disables title reporting. |
|
|
124
|
+
| `message` | `'tool'` \| `'none'` | `'tool'` | Whether to attach a human label to `blocked` reports. `tool` sends the tool name / question summary; `none` sends `blocked` with no message. |
|
|
125
|
+
| `workingMessage` | `'tool'` \| `'none'` | `'tool'` | Whether to attach the currently-executing tool name to `working` reports. |
|
|
126
|
+
| `tokens` | `'auto'` \| `'none'` | `'auto'` | Report the `model` and `ctx` (context usage `used/window`) tokens as Herdr Agent-sidebar metadata. `none` disables. |
|
|
127
|
+
| `stateLabels` | `{ idle?, working?, blocked?, done?, unknown? }` | `{}` | Display text per Herdr state; non-blank entries are sent as pane state labels. |
|
|
128
|
+
| `enabled` | boolean | `true` | Kill-switch. Set `false` to disable the reporter in this tree — useful to coexist with another reporter. |
|
|
129
|
+
|
|
130
|
+
### How to configure it
|
|
131
|
+
|
|
132
|
+
The plugin is a bundle row labelled `herdr-agent-state`, so it is inserted
|
|
133
|
+
automatically when you `dsh plugin add`. Because its `Config` schema gives every
|
|
134
|
+
field a default, you only set what you want to change; the schema fills the
|
|
135
|
+
rest. A profile's `cordis.patch.yml` is a **top-level YAML array of loader patch
|
|
136
|
+
entries**, so you target the row by `id` and replace its `config`:
|
|
137
|
+
|
|
138
|
+
```yaml
|
|
139
|
+
# ~/.dsh/profiles/<profile>/cordis.patch.yml
|
|
140
|
+
- id: herdr-agent-state
|
|
141
|
+
config:
|
|
142
|
+
agent: blue
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
The patch replaces the row's whole `config`, so the schema defaults supply any
|
|
146
|
+
field you don't set. Include `name` as a guard — if it ever mismatches the row,
|
|
147
|
+
the patch is skipped with a warning instead of silently applying:
|
|
148
|
+
|
|
149
|
+
```yaml
|
|
150
|
+
- id: herdr-agent-state
|
|
151
|
+
name: '@dsh-blue/herdr-agent-state'
|
|
152
|
+
config:
|
|
153
|
+
agent: blue
|
|
154
|
+
source: herdr:dsh-agent-state
|
|
155
|
+
transport: socket
|
|
156
|
+
reportSession: true
|
|
157
|
+
title: session
|
|
158
|
+
message: tool
|
|
159
|
+
workingMessage: tool
|
|
160
|
+
tokens: auto
|
|
161
|
+
stateLabels:
|
|
162
|
+
idle: ''
|
|
163
|
+
working: ''
|
|
164
|
+
blocked: ''
|
|
165
|
+
done: ''
|
|
166
|
+
unknown: ''
|
|
167
|
+
enabled: true
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
### Examples
|
|
171
|
+
|
|
172
|
+
Set the Herdr label to `blue` when Blue hosts the pane (all other fields
|
|
173
|
+
default):
|
|
174
|
+
|
|
175
|
+
```yaml
|
|
176
|
+
- id: herdr-agent-state
|
|
177
|
+
config:
|
|
178
|
+
agent: blue
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
Disable session reporting and verbose blocked messages:
|
|
182
|
+
|
|
183
|
+
```yaml
|
|
184
|
+
- id: herdr-agent-state
|
|
185
|
+
config:
|
|
186
|
+
reportSession: false
|
|
187
|
+
message: none
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
Disable title reporting (keep state and session reports):
|
|
191
|
+
|
|
192
|
+
```yaml
|
|
193
|
+
- id: herdr-agent-state
|
|
194
|
+
config:
|
|
195
|
+
title: none
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
Localize the Herdr state display (state labels):
|
|
199
|
+
|
|
200
|
+
```yaml
|
|
201
|
+
- id: herdr-agent-state
|
|
202
|
+
config:
|
|
203
|
+
stateLabels:
|
|
204
|
+
working: 工作中
|
|
205
|
+
blocked: 等待确认
|
|
206
|
+
idle: 空闲
|
|
207
|
+
done: 已完成
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
Turn off the sidebar tokens while keeping the title:
|
|
211
|
+
|
|
212
|
+
```yaml
|
|
213
|
+
- id: herdr-agent-state
|
|
214
|
+
config:
|
|
215
|
+
tokens: none
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
### Notes
|
|
219
|
+
|
|
220
|
+
- **Changing `source`** re-attributes the pane's authority in Herdr. Keep it at
|
|
221
|
+
the default unless you are deliberately running two reporters in the same
|
|
222
|
+
tree — then give each a distinct `source`, and use `enabled: false` on the one
|
|
223
|
+
you want silent.
|
|
224
|
+
- **`transport: 'cli'` is not implemented.** Setting it throws during plugin
|
|
225
|
+
load (fail-fast), so leave it as `socket`.
|
|
226
|
+
- **`config` is validated** against the schemastery schema at load; an invalid
|
|
227
|
+
value (for example a `transport` that isn't `socket`/`cli`) is rejected, and
|
|
228
|
+
the plugin fails to load rather than running half-configured.
|
|
229
|
+
- The pane title and state labels ride the same `source` and `agent` guards as
|
|
230
|
+
the state reports; changing `source` mid-session affects them the same way
|
|
231
|
+
it affects lifecycle authority. Tokens are not guarded — they always apply —
|
|
232
|
+
so this reporter clears them on release.
|
|
233
|
+
- Because a patch replaces the row's whole `config`, any field you don't set
|
|
234
|
+
comes from the schema default — you do not need to copy every field.
|
|
79
235
|
|
|
80
236
|
## Version compatibility
|
|
81
237
|
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dsh-blue/herdr-agent-state",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "dsh plugin: report agent state (working/blocked/idle) and session
|
|
3
|
+
"version": "0.3.0",
|
|
4
|
+
"description": "dsh plugin: report agent state (working/blocked/idle, labeled with the current tool), session reference and log path, and session metadata (title, model, context-usage tokens, state labels) to Herdr via its pane socket integration. Works in any dsh profile — TUIs, web, headless.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.js",
|
|
7
7
|
"exports": {
|
package/src/index.js
CHANGED
|
@@ -2,11 +2,13 @@
|
|
|
2
2
|
* Herdr agent-state reporter for any dsh frontend.
|
|
3
3
|
*
|
|
4
4
|
* A Cordis function plugin that, when loaded inside a Herdr pane, reports the
|
|
5
|
-
* pane's semantic state (working / blocked / idle
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
5
|
+
* pane's semantic state (working / blocked / idle, labeled with the current
|
|
6
|
+
* tool while working), session reference and log path, and session display
|
|
7
|
+
* facts — title, model, and context usage as pane metadata — to Herdr's pane
|
|
8
|
+
* socket. It depends only on documented dsh extension points — agent lifecycle
|
|
9
|
+
* events, the approval / user-question / tool-dispatch waterfalls, and the
|
|
10
|
+
* session-log event feed — so it works in TUI, web, and headless profiles
|
|
11
|
+
* alike. Outside a Herdr pane it is a strict no-op.
|
|
10
12
|
*
|
|
11
13
|
* Ships as plain ESM JavaScript (no build step) so `dsh plugin add` from a git
|
|
12
14
|
* repo loads it directly without `prepare`/`lib`.
|
|
@@ -16,7 +18,7 @@
|
|
|
16
18
|
|
|
17
19
|
import z from '@deepseek-ai/schemastery'
|
|
18
20
|
|
|
19
|
-
import { AgentStateModel } from './state.js'
|
|
21
|
+
import { AgentStateModel, SessionFactsModel, stateLabelsPayload, sumUsageTokens } from './state.js'
|
|
20
22
|
import { HerdrReporter, herdrEnabled } from './transport.js'
|
|
21
23
|
|
|
22
24
|
export const name = 'herdr-agent-state'
|
|
@@ -44,8 +46,32 @@ export const Config = z.object({
|
|
|
44
46
|
transport: z.union([z.const('socket'), z.const('cli')]).default('socket'),
|
|
45
47
|
/** Report the pane's session reference so Herdr can expose it for restore. */
|
|
46
48
|
reportSession: z.boolean().default(true),
|
|
49
|
+
/**
|
|
50
|
+
* Which title to publish as the Herdr pane title (display-only metadata).
|
|
51
|
+
* `session` mirrors the dsh session title — first-prompt fallback,
|
|
52
|
+
* LLM-generated, or pinned by `/rename`; `none` disables title reporting.
|
|
53
|
+
*/
|
|
54
|
+
title: z.union([z.const('session'), z.const('none')]).default('session'),
|
|
47
55
|
/** Whether to attach a human label to blocked reports. */
|
|
48
56
|
message: z.union([z.const('tool'), z.const('none')]).default('tool'),
|
|
57
|
+
/** Whether to attach the currently-executing tool name to working reports. */
|
|
58
|
+
workingMessage: z.union([z.const('tool'), z.const('none')]).default('tool'),
|
|
59
|
+
/**
|
|
60
|
+
* Report the model id (`model`) and context usage (`ctx`, `used/window`
|
|
61
|
+
* mirroring the dsh TUI status bar) as Herdr Agent-sidebar tokens.
|
|
62
|
+
*/
|
|
63
|
+
tokens: z.union([z.const('auto'), z.const('none')]).default('auto'),
|
|
64
|
+
/**
|
|
65
|
+
* Display text per Herdr state; non-blank entries become pane state labels
|
|
66
|
+
* (e.g. `{ working: 工作中, blocked: 等待确认 }`). All-blank disables them.
|
|
67
|
+
*/
|
|
68
|
+
stateLabels: z.object({
|
|
69
|
+
idle: z.string().default(''),
|
|
70
|
+
working: z.string().default(''),
|
|
71
|
+
blocked: z.string().default(''),
|
|
72
|
+
done: z.string().default(''),
|
|
73
|
+
unknown: z.string().default(''),
|
|
74
|
+
}).default({}),
|
|
49
75
|
/** Kill-switch for coexisting with another reporter in the same tree. */
|
|
50
76
|
enabled: z.boolean().default(true),
|
|
51
77
|
})
|
|
@@ -57,10 +83,34 @@ function questionLabel(questions) {
|
|
|
57
83
|
return questions.length > 1 ? `${text} (+${questions.length - 1} more)` : text
|
|
58
84
|
}
|
|
59
85
|
|
|
86
|
+
/**
|
|
87
|
+
* Seed model / context window / context occupancy from the replayed session
|
|
88
|
+
* log. A resumed session's past events are constructor seeds that never re-fire
|
|
89
|
+
* on the live session/event feed, so the initial facts come from the log.
|
|
90
|
+
* @param {unknown} session
|
|
91
|
+
* @returns {{ model?: string, contextWindow?: number, usedTokens?: number }}
|
|
92
|
+
*/
|
|
93
|
+
function seedSessionFacts(session) {
|
|
94
|
+
try {
|
|
95
|
+
const events = session?.events
|
|
96
|
+
if (!Array.isArray(events)) return {}
|
|
97
|
+
const model = events.findLast((e) => e.type === 'request/header')?.data?.header?.config?.model
|
|
98
|
+
const context = events.findLast((e) => e.type === 'request/context')?.data
|
|
99
|
+
const used = sumUsageTokens(events.findLast((e) => e.type === 'assistant/message')?.data?.usage)
|
|
100
|
+
return {
|
|
101
|
+
...(typeof model === 'string' && model !== '' ? { model } : {}),
|
|
102
|
+
...(Number.isFinite(context?.contextWindow) ? { contextWindow: context.contextWindow } : {}),
|
|
103
|
+
...(used !== undefined ? { usedTokens: used } : {}),
|
|
104
|
+
}
|
|
105
|
+
} catch {
|
|
106
|
+
return {}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
60
110
|
/**
|
|
61
111
|
* Drive one pane's reporter from the live dsh event stream.
|
|
62
112
|
* @param {import('@deepseek-ai/cordis').Context} ctx
|
|
63
|
-
* @param {{ agent: string, source: string, transport: 'socket' | 'cli', reportSession: boolean, message: 'tool' | 'none', enabled: boolean }} config
|
|
113
|
+
* @param {{ agent: string, source: string, transport: 'socket' | 'cli', reportSession: boolean, title: 'session' | 'none', message: 'tool' | 'none', workingMessage: 'tool' | 'none', tokens: 'auto' | 'none', stateLabels: Record<string, string>, enabled: boolean }} config
|
|
64
114
|
*/
|
|
65
115
|
export function apply(ctx, config) {
|
|
66
116
|
if (!config.enabled) return
|
|
@@ -75,13 +125,19 @@ export function apply(ctx, config) {
|
|
|
75
125
|
source: config.source,
|
|
76
126
|
agent: config.agent,
|
|
77
127
|
reportSession: config.reportSession,
|
|
128
|
+
reportTitle: config.title !== 'none',
|
|
129
|
+
reportTokens: config.tokens !== 'none',
|
|
78
130
|
env,
|
|
79
131
|
})
|
|
80
132
|
const model = new AgentStateModel()
|
|
133
|
+
const factsModel = new SessionFactsModel()
|
|
134
|
+
const stateLabels = stateLabelsPayload(config.stateLabels)
|
|
81
135
|
|
|
82
136
|
const publish = (force = false) => {
|
|
83
137
|
const report = model.desired()
|
|
84
|
-
if (
|
|
138
|
+
if (report.state === 'blocked' && config.message === 'none') {
|
|
139
|
+
reporter.publishState({ state: report.state }, force)
|
|
140
|
+
} else if (report.state === 'working' && config.workingMessage === 'none') {
|
|
85
141
|
reporter.publishState({ state: report.state }, force)
|
|
86
142
|
} else {
|
|
87
143
|
reporter.publishState(report, force)
|
|
@@ -99,7 +155,7 @@ export function apply(ctx, config) {
|
|
|
99
155
|
})
|
|
100
156
|
|
|
101
157
|
// Observers only: they delegate with `await next()` and never alter the
|
|
102
|
-
// downstream decision, so the approval / question
|
|
158
|
+
// downstream decision, so the approval / question / tool flows are untouched.
|
|
103
159
|
ctx.on('approval/request', async (req, next) => {
|
|
104
160
|
model.setBlocked(true, req.reason ?? req.toolName)
|
|
105
161
|
publish()
|
|
@@ -122,11 +178,75 @@ export function apply(ctx, config) {
|
|
|
122
178
|
}
|
|
123
179
|
})
|
|
124
180
|
|
|
181
|
+
// The dispatch waterfall fires only for calls that survived approval, so the
|
|
182
|
+
// label names a tool that is really about to run; tools/result (and the
|
|
183
|
+
// finally below) close it by call id.
|
|
184
|
+
ctx.on('tools/execute', async (exec, next) => {
|
|
185
|
+
model.toolStarted(exec.callId, exec.name)
|
|
186
|
+
publish()
|
|
187
|
+
try {
|
|
188
|
+
return await next()
|
|
189
|
+
} finally {
|
|
190
|
+
model.toolFinished(exec.callId)
|
|
191
|
+
publish()
|
|
192
|
+
}
|
|
193
|
+
})
|
|
194
|
+
|
|
195
|
+
ctx.on('tools/result', (exec) => {
|
|
196
|
+
model.toolFinished(exec.callId)
|
|
197
|
+
publish()
|
|
198
|
+
})
|
|
199
|
+
|
|
200
|
+
// Post-commit feed of appended session-log events; the facts model keeps
|
|
201
|
+
// only title / model / context commits for the tracked session (child and
|
|
202
|
+
// subagent sessions in this process are skipped by the session-id match).
|
|
203
|
+
ctx.on('session/event', (session, event) => {
|
|
204
|
+
switch (event?.type) {
|
|
205
|
+
case 'session/title':
|
|
206
|
+
case 'request/header':
|
|
207
|
+
case 'request/context':
|
|
208
|
+
case 'assistant/message':
|
|
209
|
+
break
|
|
210
|
+
default:
|
|
211
|
+
return
|
|
212
|
+
}
|
|
213
|
+
reporter.reportMetadata(factsModel.observeEvent(session?.header?.id ?? session?.id, event))
|
|
214
|
+
})
|
|
215
|
+
|
|
125
216
|
ctx.on('agent/session-start', (payload) => {
|
|
126
|
-
|
|
217
|
+
const session = payload.agent?.session
|
|
218
|
+
const sessionId = session?.header?.id ?? undefined
|
|
219
|
+
reporter.setSessionId(sessionId)
|
|
220
|
+
|
|
221
|
+
// Absolute jsonl log path, when a locating persistence backend is mounted.
|
|
222
|
+
let sessionPath
|
|
223
|
+
try {
|
|
224
|
+
const located = ctx.get('sessionPersistence')?.locate(session?.header)
|
|
225
|
+
if (located?.kind === 'jsonl' && typeof located.path === 'string' && located.path !== '') {
|
|
226
|
+
sessionPath = located.path
|
|
227
|
+
}
|
|
228
|
+
} catch {
|
|
229
|
+
// Service not mounted in this host: the session id still reports.
|
|
230
|
+
}
|
|
231
|
+
reporter.setSessionPath(sessionPath)
|
|
232
|
+
|
|
127
233
|
reporter.reportSession(payload.source)
|
|
128
234
|
model.setRunning(payload.agent, false)
|
|
129
235
|
publish(true)
|
|
236
|
+
|
|
237
|
+
// Initial display facts: the title via the title service, the rest seeded
|
|
238
|
+
// from the replayed log; static state labels ride the same request. Sent
|
|
239
|
+
// after publish(true) so the authority claim precedes the guarded fields.
|
|
240
|
+
let initialTitle
|
|
241
|
+
try {
|
|
242
|
+
initialTitle = ctx.get('sessionTitle')?.get(session)?.title
|
|
243
|
+
} catch {
|
|
244
|
+
// Service not mounted or session not live: the feed covers the rest.
|
|
245
|
+
}
|
|
246
|
+
reporter.reportMetadata({
|
|
247
|
+
...factsModel.setSession(sessionId, { title: initialTitle, ...seedSessionFacts(session) }),
|
|
248
|
+
...(stateLabels !== undefined ? { state_labels: stateLabels } : {}),
|
|
249
|
+
})
|
|
130
250
|
})
|
|
131
251
|
|
|
132
252
|
ctx.effect(
|
package/src/state.js
CHANGED
|
@@ -1,9 +1,72 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Pure
|
|
2
|
+
* Pure models for the pane's Herdr reports: the semantic agent state, the
|
|
3
|
+
* session display facts (title, model, context), and the small formatting
|
|
4
|
+
* helpers they share.
|
|
3
5
|
*
|
|
4
6
|
* @module @dsh-blue/herdr-agent-state/state
|
|
5
7
|
*/
|
|
6
8
|
|
|
9
|
+
/**
|
|
10
|
+
* Compact token count like the dsh TUI's status bar: `988`, `3.4k`, `12k`,
|
|
11
|
+
* `1.0M`. Negative values clamp to zero.
|
|
12
|
+
* @param {number} count
|
|
13
|
+
* @returns {string}
|
|
14
|
+
*/
|
|
15
|
+
export function formatTokens(count) {
|
|
16
|
+
const value = Math.max(0, Math.round(count))
|
|
17
|
+
if (value < 1000) return String(value)
|
|
18
|
+
if (value < 10000) return `${(value / 1000).toFixed(1)}k`
|
|
19
|
+
if (value < 1000000) return `${Math.round(value / 1000)}k`
|
|
20
|
+
if (value < 10000000) return `${(value / 1000000).toFixed(1)}M`
|
|
21
|
+
return `${Math.round(value / 1000000)}M`
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Context occupancy from one `assistant/message` usage payload. The counts are
|
|
26
|
+
* disjoint, so occupancy is the uncached input plus both cache fields.
|
|
27
|
+
* `inputTokens` is the anchor: a sample without it carries no meaning.
|
|
28
|
+
* @param {{ inputTokens?: number, cacheReadTokens?: number, cacheWriteTokens?: number } | undefined} usage
|
|
29
|
+
* @returns {number | undefined}
|
|
30
|
+
*/
|
|
31
|
+
export function sumUsageTokens(usage) {
|
|
32
|
+
if (typeof usage?.inputTokens !== 'number') return undefined
|
|
33
|
+
return usage.inputTokens + (usage.cacheReadTokens ?? 0) + (usage.cacheWriteTokens ?? 0)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* The `used/window` context display, mirroring the dsh TUI status bar
|
|
38
|
+
* (`34k/1.0M`). Bare `used` when the window is unknown; nothing before the
|
|
39
|
+
* first usage sample.
|
|
40
|
+
* @param {number | undefined} used
|
|
41
|
+
* @param {number | undefined} contextWindow
|
|
42
|
+
* @returns {string | undefined}
|
|
43
|
+
*/
|
|
44
|
+
export function formatContextUsage(used, contextWindow) {
|
|
45
|
+
if (used === undefined) return undefined
|
|
46
|
+
if (!Number.isFinite(contextWindow)) return formatTokens(used)
|
|
47
|
+
return `${formatTokens(used)}/${formatTokens(contextWindow)}`
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** The state-label keys Herdr accepts on pane metadata. */
|
|
51
|
+
const STATE_LABEL_KEYS = ['idle', 'working', 'blocked', 'done', 'unknown']
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Keep only the five known state-label keys with non-blank text, trimmed.
|
|
55
|
+
* @param {Record<string, string> | undefined} labels
|
|
56
|
+
* @returns {Record<string, string> | undefined} undefined when nothing survives
|
|
57
|
+
*/
|
|
58
|
+
export function stateLabelsPayload(labels) {
|
|
59
|
+
const payload = {}
|
|
60
|
+
for (const key of STATE_LABEL_KEYS) {
|
|
61
|
+
const value = labels?.[key]
|
|
62
|
+
if (typeof value === 'string' && value.trim() !== '') payload[key] = value.trim()
|
|
63
|
+
}
|
|
64
|
+
return Object.keys(payload).length > 0 ? payload : undefined
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Pure semantic state model for the pane's Herdr state.
|
|
69
|
+
*/
|
|
7
70
|
export class AgentStateModel {
|
|
8
71
|
constructor() {
|
|
9
72
|
/** Agent objects reported as `running`, by identity (stable per session). */
|
|
@@ -12,6 +75,8 @@ export class AgentStateModel {
|
|
|
12
75
|
this.blockedCount = 0
|
|
13
76
|
/** Label of the most recently opened blocked interaction. */
|
|
14
77
|
this.blockedMessage = undefined
|
|
78
|
+
/** Tool calls that survived approval and are still running, by call id. */
|
|
79
|
+
this.tools = new Map()
|
|
15
80
|
}
|
|
16
81
|
|
|
17
82
|
/**
|
|
@@ -42,7 +107,33 @@ export class AgentStateModel {
|
|
|
42
107
|
}
|
|
43
108
|
|
|
44
109
|
/**
|
|
45
|
-
*
|
|
110
|
+
* A tool call survived approval and is about to run (`tools/execute`).
|
|
111
|
+
* @param {string} callId
|
|
112
|
+
* @param {string} name
|
|
113
|
+
*/
|
|
114
|
+
toolStarted(callId, name) {
|
|
115
|
+
this.tools.set(callId, name)
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* The call settled (`tools/result`, or the execute waterfall's finally).
|
|
120
|
+
* Idempotent; an unknown call id is a no-op.
|
|
121
|
+
* @param {string} callId
|
|
122
|
+
*/
|
|
123
|
+
toolFinished(callId) {
|
|
124
|
+
this.tools.delete(callId)
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** The most recently started still-active tool name, or undefined. */
|
|
128
|
+
currentTool() {
|
|
129
|
+
const names = [...this.tools.values()]
|
|
130
|
+
return names.length > 0 ? names[names.length - 1] : undefined
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* The report derived from the current inputs. A blocked interaction labels
|
|
135
|
+
* itself; a working pane labels itself with the current tool, when one is
|
|
136
|
+
* active.
|
|
46
137
|
* @returns {{ state: 'working' | 'blocked' | 'idle', message?: string }}
|
|
47
138
|
*/
|
|
48
139
|
desired() {
|
|
@@ -51,7 +142,154 @@ export class AgentStateModel {
|
|
|
51
142
|
? { state: 'blocked', message: this.blockedMessage }
|
|
52
143
|
: { state: 'blocked' }
|
|
53
144
|
}
|
|
54
|
-
if (this.runningAgents.size > 0)
|
|
145
|
+
if (this.runningAgents.size > 0) {
|
|
146
|
+
const tool = this.currentTool()
|
|
147
|
+
return tool !== undefined ? { state: 'working', message: tool } : { state: 'working' }
|
|
148
|
+
}
|
|
55
149
|
return { state: 'idle' }
|
|
56
150
|
}
|
|
57
151
|
}
|
|
152
|
+
|
|
153
|
+
/** The token keys this integration reports. */
|
|
154
|
+
const TOKEN_KEYS = ['model', 'ctx']
|
|
155
|
+
|
|
156
|
+
/** Shallow, undefined-tolerant equality for the small token/label payloads. */
|
|
157
|
+
function shallowEqual(a, b) {
|
|
158
|
+
if (a === b) return true
|
|
159
|
+
if (a === undefined || b === undefined) return false
|
|
160
|
+
const keys = new Set([...Object.keys(a), ...Object.keys(b)])
|
|
161
|
+
for (const key of keys) {
|
|
162
|
+
if ((a[key] ?? undefined) !== (b[key] ?? undefined)) return false
|
|
163
|
+
}
|
|
164
|
+
return true
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Tracks which session's display facts are current — title, model, context
|
|
169
|
+
* window, context occupancy — and decides, from session-start seeds and the
|
|
170
|
+
* session-log feed, what changed enough to publish.
|
|
171
|
+
*/
|
|
172
|
+
export class SessionFactsModel {
|
|
173
|
+
constructor() {
|
|
174
|
+
/** Session identity from the latest agent/session-start. */
|
|
175
|
+
this.sessionId = undefined
|
|
176
|
+
/** Latest applicable title for that session. */
|
|
177
|
+
this.title = undefined
|
|
178
|
+
/** Latest model id from request/header config. */
|
|
179
|
+
this.model = undefined
|
|
180
|
+
/** Advertised context window in tokens, when known. */
|
|
181
|
+
this.contextWindow = undefined
|
|
182
|
+
/** Context occupancy from the latest assistant/message usage sample. */
|
|
183
|
+
this.usedTokens = undefined
|
|
184
|
+
/** Last title this model published (change tracking; resets per session). */
|
|
185
|
+
this.lastReportedTitle = undefined
|
|
186
|
+
/**
|
|
187
|
+
* Last tokens snapshot this model published against (present-only). Kept
|
|
188
|
+
* across sessions so a key that becomes unknown is null-cleared rather
|
|
189
|
+
* than left stale on the pane.
|
|
190
|
+
*/
|
|
191
|
+
this.lastReportedTokens = undefined
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* The tracked session changed (agent/session-start). Adopts the new identity
|
|
196
|
+
* and any pre-existing facts — a resumed session's log predates the plugin.
|
|
197
|
+
* Title change tracking resets so an identical title re-publishes; token
|
|
198
|
+
* tracking persists so stale keys are null-cleared.
|
|
199
|
+
* @param {string | undefined} sessionId
|
|
200
|
+
* @param {{ title?: string, model?: string, contextWindow?: number, usedTokens?: number }} [initial]
|
|
201
|
+
* @returns {{ title?: string, tokens?: Record<string, string | null> } | undefined}
|
|
202
|
+
*/
|
|
203
|
+
setSession(sessionId, initial = {}) {
|
|
204
|
+
this.sessionId = sessionId
|
|
205
|
+
this.title = initial?.title
|
|
206
|
+
this.model = typeof initial?.model === 'string' && initial.model !== '' ? initial.model : undefined
|
|
207
|
+
this.contextWindow = Number.isFinite(initial?.contextWindow) ? initial.contextWindow : undefined
|
|
208
|
+
this.usedTokens = Number.isFinite(initial?.usedTokens) ? initial.usedTokens : undefined
|
|
209
|
+
this.lastReportedTitle = undefined
|
|
210
|
+
return this.takePublishable()
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Fold one session/event feed observation for the tracked session. Handles
|
|
215
|
+
* `session/title`, `request/header`, `request/context`, and
|
|
216
|
+
* `assistant/message`; other types and other sessions are ignored, and the
|
|
217
|
+
* first observed session is adopted when none was recorded (plugin reloaded
|
|
218
|
+
* mid-session). A usage-less assistant/message keeps the last occupancy.
|
|
219
|
+
* @param {string | undefined} sessionId
|
|
220
|
+
* @param {{ type: string, data?: unknown }} event
|
|
221
|
+
* @returns {{ title?: string, tokens?: Record<string, string | null> } | undefined}
|
|
222
|
+
*/
|
|
223
|
+
observeEvent(sessionId, event) {
|
|
224
|
+
if (this.sessionId === undefined) this.sessionId = sessionId
|
|
225
|
+
if (sessionId === undefined || sessionId !== this.sessionId) return undefined
|
|
226
|
+
switch (event?.type) {
|
|
227
|
+
case 'session/title': {
|
|
228
|
+
const title = event.data?.title
|
|
229
|
+
if (typeof title === 'string') this.title = title
|
|
230
|
+
break
|
|
231
|
+
}
|
|
232
|
+
case 'request/header': {
|
|
233
|
+
const model = event.data?.header?.config?.model
|
|
234
|
+
if (typeof model === 'string' && model !== '') this.model = model
|
|
235
|
+
break
|
|
236
|
+
}
|
|
237
|
+
case 'request/context': {
|
|
238
|
+
const window = event.data?.contextWindow
|
|
239
|
+
this.contextWindow = Number.isFinite(window) ? window : undefined
|
|
240
|
+
break
|
|
241
|
+
}
|
|
242
|
+
case 'assistant/message': {
|
|
243
|
+
const used = sumUsageTokens(event.data?.usage)
|
|
244
|
+
if (used !== undefined) this.usedTokens = used
|
|
245
|
+
break
|
|
246
|
+
}
|
|
247
|
+
default:
|
|
248
|
+
return undefined
|
|
249
|
+
}
|
|
250
|
+
return this.takePublishable()
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/** The publishable title, when it is a non-blank string. */
|
|
254
|
+
desiredTitle() {
|
|
255
|
+
return typeof this.title === 'string' && this.title.trim() !== '' ? this.title : undefined
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/** The publishable tokens snapshot, present-only. */
|
|
259
|
+
desiredTokens() {
|
|
260
|
+
const tokens = {}
|
|
261
|
+
if (this.model !== undefined) tokens.model = this.model
|
|
262
|
+
const ctx = formatContextUsage(this.usedTokens, this.contextWindow)
|
|
263
|
+
if (ctx !== undefined) tokens.ctx = ctx
|
|
264
|
+
return tokens
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Consume the changed facts once, tracking them as reported. Token payloads
|
|
269
|
+
* are key-level patches: a fresh value is sent as-is, a key that became
|
|
270
|
+
* unknown after being reported is sent as null (explicit clear), and a key
|
|
271
|
+
* never reported is omitted.
|
|
272
|
+
* @returns {{ title?: string, tokens?: Record<string, string | null> } | undefined}
|
|
273
|
+
*/
|
|
274
|
+
takePublishable() {
|
|
275
|
+
const out = {}
|
|
276
|
+
const title = this.desiredTitle()
|
|
277
|
+
if (title !== undefined && title !== this.lastReportedTitle) {
|
|
278
|
+
this.lastReportedTitle = title
|
|
279
|
+
out.title = title
|
|
280
|
+
}
|
|
281
|
+
const current = this.desiredTokens()
|
|
282
|
+
if (!shallowEqual(current, this.lastReportedTokens)) {
|
|
283
|
+
const payload = {}
|
|
284
|
+
for (const key of TOKEN_KEYS) {
|
|
285
|
+
if (current[key] !== undefined) payload[key] = current[key]
|
|
286
|
+
else if (this.lastReportedTokens?.[key] !== undefined) payload[key] = null
|
|
287
|
+
}
|
|
288
|
+
if (Object.keys(payload).length > 0) {
|
|
289
|
+
this.lastReportedTokens = current
|
|
290
|
+
out.tokens = payload
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
return Object.keys(out).length > 0 ? out : undefined
|
|
294
|
+
}
|
|
295
|
+
}
|
package/src/transport.js
CHANGED
|
@@ -3,9 +3,10 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Mirrors Herdr's own bundled Pi integration wire contract: one newline-delimited
|
|
5
5
|
* JSON request per `pane.report_agent` / `pane.report_agent_session` /
|
|
6
|
-
* `pane.release_agent` call
|
|
7
|
-
*
|
|
8
|
-
*
|
|
6
|
+
* `pane.release_agent` call — plus display-only `pane.report_metadata` for the
|
|
7
|
+
* pane title, tokens, and state labels — a fresh single connection per request,
|
|
8
|
+
* a short timeout plus one longer retry, and a failure that never rejects into
|
|
9
|
+
* the host process (Herdr being absent must not disturb the dsh frontend).
|
|
9
10
|
*
|
|
10
11
|
* Only `node:net` is used.
|
|
11
12
|
* @module @dsh-blue/herdr-agent-state/transport
|
|
@@ -24,6 +25,22 @@ export function socketEndpoint(env) {
|
|
|
24
25
|
return process.platform === 'win32' && raw !== '' ? `\\\\.\\pipe\\${raw}` : raw
|
|
25
26
|
}
|
|
26
27
|
|
|
28
|
+
/** Shallow, undefined-tolerant equality for the small metadata payloads. */
|
|
29
|
+
function shallowEqual(a, b) {
|
|
30
|
+
if (a === b) return true
|
|
31
|
+
if (a === undefined || b === undefined) return false
|
|
32
|
+
const keys = new Set([...Object.keys(a), ...Object.keys(b)])
|
|
33
|
+
for (const key of keys) {
|
|
34
|
+
if ((a[key] ?? undefined) !== (b[key] ?? undefined)) return false
|
|
35
|
+
}
|
|
36
|
+
return true
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** A fresh per-request id that stays unique across bursts. */
|
|
40
|
+
function requestId(prefix) {
|
|
41
|
+
return `${prefix}:${Date.now()}:${Math.random().toString(36).slice(2)}`
|
|
42
|
+
}
|
|
43
|
+
|
|
27
44
|
/**
|
|
28
45
|
* Deliver one request over a fresh single connection. A pane report is
|
|
29
46
|
* fire-and-forget, so delivery means the request was written to Herdr's socket
|
|
@@ -71,23 +88,33 @@ export function sendRequest(request, endpoint, timeoutMs = 500, retryMs = 1500)
|
|
|
71
88
|
* Owns the seq counter, the single-flight latest-wins state queue, the session
|
|
72
89
|
* reference, and the transport. Calling `publishState` coalesces bursts: only
|
|
73
90
|
* the newest state is sent, and only when it differs from the last sent one.
|
|
91
|
+
* Metadata (`reportMetadata`) is per-kind deduped and sent directly, like the
|
|
92
|
+
* session report: these values change rarely and never need burst coalescing.
|
|
74
93
|
*/
|
|
75
94
|
export class HerdrReporter {
|
|
76
95
|
/**
|
|
77
|
-
* @param {{ source: string, agent: string, reportSession: boolean, env: Record<string, string | undefined> }} options
|
|
96
|
+
* @param {{ source: string, agent: string, reportSession: boolean, reportTitle?: boolean, reportTokens?: boolean, env: Record<string, string | undefined> }} options
|
|
78
97
|
*/
|
|
79
98
|
constructor(options) {
|
|
80
99
|
this.source = options.source
|
|
81
100
|
this.agent = options.agent
|
|
82
101
|
this.reportSessionRef = options.reportSession
|
|
102
|
+
this.reportTitleRef = options.reportTitle ?? false
|
|
103
|
+
this.reportTokensRef = options.reportTokens ?? false
|
|
83
104
|
this.endpoint = socketEndpoint(options.env)
|
|
84
105
|
this.paneId = options.env.HERDR_PANE_ID ?? ''
|
|
85
106
|
// Wall-clock base keeps seq strictly increasing across process restarts.
|
|
86
107
|
this.seq = Date.now() * 1000
|
|
87
108
|
this.sessionId = undefined
|
|
109
|
+
this.sessionPath = undefined
|
|
88
110
|
this.sendInFlight = false
|
|
89
111
|
this.queued = undefined
|
|
90
112
|
this.lastSent = undefined
|
|
113
|
+
this.lastSentTitle = undefined
|
|
114
|
+
this.lastSentTokens = undefined
|
|
115
|
+
this.lastSentStateLabels = undefined
|
|
116
|
+
/** Token keys ever sent, so release() can clear exactly those. */
|
|
117
|
+
this.sentTokenKeys = new Set()
|
|
91
118
|
}
|
|
92
119
|
|
|
93
120
|
/** Record the session reference to attach to subsequent reports. */
|
|
@@ -95,6 +122,11 @@ export class HerdrReporter {
|
|
|
95
122
|
this.sessionId = id
|
|
96
123
|
}
|
|
97
124
|
|
|
125
|
+
/** Record the session log path (jsonl backends) to attach to session reports. */
|
|
126
|
+
setSessionPath(path) {
|
|
127
|
+
this.sessionPath = typeof path === 'string' && path !== '' ? path : undefined
|
|
128
|
+
}
|
|
129
|
+
|
|
98
130
|
nextSeq() {
|
|
99
131
|
this.seq += 1
|
|
100
132
|
return this.seq
|
|
@@ -102,7 +134,10 @@ export class HerdrReporter {
|
|
|
102
134
|
|
|
103
135
|
sessionParams() {
|
|
104
136
|
if (this.reportSessionRef && this.sessionId !== undefined && this.sessionId !== '') {
|
|
105
|
-
return {
|
|
137
|
+
return {
|
|
138
|
+
agent_session_id: this.sessionId,
|
|
139
|
+
...(this.sessionPath !== undefined ? { agent_session_path: this.sessionPath } : {}),
|
|
140
|
+
}
|
|
106
141
|
}
|
|
107
142
|
return {}
|
|
108
143
|
}
|
|
@@ -121,12 +156,69 @@ export class HerdrReporter {
|
|
|
121
156
|
void this.drain()
|
|
122
157
|
}
|
|
123
158
|
|
|
159
|
+
/**
|
|
160
|
+
* Report display-only Herdr pane metadata. Title and state labels are
|
|
161
|
+
* presentation fields and carry the `agent` / `applies_to_source` guards, so
|
|
162
|
+
* they display exactly while this reporter holds the pane's lifecycle
|
|
163
|
+
* authority; tokens always apply and are this reporter's to clear. One
|
|
164
|
+
* combined request carries whichever kinds changed; nothing is sent when
|
|
165
|
+
* nothing did.
|
|
166
|
+
*
|
|
167
|
+
* @param {{ title?: string, tokens?: Record<string, string | null>, state_labels?: Record<string, string> } | undefined} fields
|
|
168
|
+
*/
|
|
169
|
+
reportMetadata(fields = {}) {
|
|
170
|
+
const params = { pane_id: this.paneId, source: this.source }
|
|
171
|
+
let guarded = false
|
|
172
|
+
|
|
173
|
+
if (
|
|
174
|
+
this.reportTitleRef &&
|
|
175
|
+
typeof fields.title === 'string' &&
|
|
176
|
+
fields.title.trim() !== '' &&
|
|
177
|
+
fields.title !== this.lastSentTitle
|
|
178
|
+
) {
|
|
179
|
+
this.lastSentTitle = fields.title
|
|
180
|
+
params.title = fields.title
|
|
181
|
+
guarded = true
|
|
182
|
+
}
|
|
183
|
+
if (
|
|
184
|
+
fields.state_labels !== undefined &&
|
|
185
|
+
Object.keys(fields.state_labels).length > 0 &&
|
|
186
|
+
!shallowEqual(fields.state_labels, this.lastSentStateLabels)
|
|
187
|
+
) {
|
|
188
|
+
this.lastSentStateLabels = { ...fields.state_labels }
|
|
189
|
+
params.state_labels = { ...fields.state_labels }
|
|
190
|
+
guarded = true
|
|
191
|
+
}
|
|
192
|
+
if (
|
|
193
|
+
this.reportTokensRef &&
|
|
194
|
+
fields.tokens !== undefined &&
|
|
195
|
+
Object.keys(fields.tokens).length > 0 &&
|
|
196
|
+
!shallowEqual(fields.tokens, this.lastSentTokens)
|
|
197
|
+
) {
|
|
198
|
+
this.lastSentTokens = { ...fields.tokens }
|
|
199
|
+
params.tokens = { ...fields.tokens }
|
|
200
|
+
for (const key of Object.keys(fields.tokens)) this.sentTokenKeys.add(key)
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
if (params.title === undefined && params.state_labels === undefined && params.tokens === undefined) return
|
|
204
|
+
if (guarded) {
|
|
205
|
+
params.agent = this.agent
|
|
206
|
+
params.applies_to_source = this.source
|
|
207
|
+
}
|
|
208
|
+
params.seq = this.nextSeq()
|
|
209
|
+
void this.send({
|
|
210
|
+
id: requestId(`${this.source}:meta`),
|
|
211
|
+
method: 'pane.report_metadata',
|
|
212
|
+
params,
|
|
213
|
+
})
|
|
214
|
+
}
|
|
215
|
+
|
|
124
216
|
/** Report the pane's session reference; Herdr exposes it for restore. */
|
|
125
217
|
reportSession(sessionStartSource) {
|
|
126
218
|
const params = this.sessionParams()
|
|
127
219
|
if (Object.keys(params).length === 0) return
|
|
128
220
|
void this.send({
|
|
129
|
-
id: `${this.source}:session
|
|
221
|
+
id: requestId(`${this.source}:session`),
|
|
130
222
|
method: 'pane.report_agent_session',
|
|
131
223
|
params: {
|
|
132
224
|
pane_id: this.paneId,
|
|
@@ -139,10 +231,33 @@ export class HerdrReporter {
|
|
|
139
231
|
})
|
|
140
232
|
}
|
|
141
233
|
|
|
142
|
-
/**
|
|
234
|
+
/**
|
|
235
|
+
* Release this pane's lifecycle authority (on unload or process exit). The
|
|
236
|
+
* guards on presentation metadata are checked when a report arrives, not
|
|
237
|
+
* continuously, so every metadata kind this reporter sent is cleared
|
|
238
|
+
* explicitly alongside the release.
|
|
239
|
+
*/
|
|
143
240
|
release() {
|
|
241
|
+
const clear = {}
|
|
242
|
+
if (this.reportTitleRef && this.lastSentTitle !== undefined) clear.clear_title = true
|
|
243
|
+
if (this.lastSentStateLabels !== undefined) clear.clear_state_labels = true
|
|
244
|
+
if (this.reportTokensRef && this.sentTokenKeys.size > 0) {
|
|
245
|
+
clear.tokens = {}
|
|
246
|
+
for (const key of this.sentTokenKeys) clear.tokens[key] = null
|
|
247
|
+
}
|
|
248
|
+
if (Object.keys(clear).length > 0) {
|
|
249
|
+
this.lastSentTitle = undefined
|
|
250
|
+
this.lastSentStateLabels = undefined
|
|
251
|
+
this.lastSentTokens = undefined
|
|
252
|
+
this.sentTokenKeys.clear()
|
|
253
|
+
void this.send({
|
|
254
|
+
id: requestId(`${this.source}:meta-clear`),
|
|
255
|
+
method: 'pane.report_metadata',
|
|
256
|
+
params: { pane_id: this.paneId, source: this.source, ...clear, seq: this.nextSeq() },
|
|
257
|
+
})
|
|
258
|
+
}
|
|
144
259
|
void this.send({
|
|
145
|
-
id: `${this.source}:release
|
|
260
|
+
id: requestId(`${this.source}:release`),
|
|
146
261
|
method: 'pane.release_agent',
|
|
147
262
|
params: { pane_id: this.paneId, source: this.source, agent: this.agent },
|
|
148
263
|
})
|
|
@@ -157,7 +272,7 @@ export class HerdrReporter {
|
|
|
157
272
|
this.queued = undefined
|
|
158
273
|
const params = this.sessionParams()
|
|
159
274
|
await this.send({
|
|
160
|
-
id: `${this.source}
|
|
275
|
+
id: requestId(`${this.source}:state`),
|
|
161
276
|
method: 'pane.report_agent',
|
|
162
277
|
params: {
|
|
163
278
|
pane_id: this.paneId,
|