@dsh-blue/herdr-agent-state 0.2.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 +80 -29
- package/package.json +2 -2
- package/src/index.js +116 -26
- package/src/state.js +210 -35
- package/src/transport.js +114 -45
package/README.md
CHANGED
|
@@ -1,12 +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
|
-
|
|
8
|
-
|
|
9
|
-
|
|
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)
|
|
10
11
|
path).
|
|
11
12
|
|
|
12
13
|
It works in **any dsh frontend** — TUIs, the web app, and headless — because it
|
|
@@ -60,6 +61,13 @@ dsh plugin --profile <profile> add dsh-blue/herdr-agent-state#<40-char-sha>
|
|
|
60
61
|
| `blocked` | an `approval/request` or `user-questions/request` waterfall is awaiting an answer |
|
|
61
62
|
| `idle` | no agent running and nothing pending |
|
|
62
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
|
+
|
|
63
71
|
Blocked observations are **passive**: the plugin calls `await next()` and returns
|
|
64
72
|
the downstream decision unchanged, so approval and question flows are never
|
|
65
73
|
altered. Reports are coalesced (latest value wins) and tagged with a strictly
|
|
@@ -69,27 +77,38 @@ The plugin releases the pane's lifecycle authority on unload and process exit,
|
|
|
69
77
|
and re-reports on `agent/session-start` so a reload does not leave Herdr with a
|
|
70
78
|
stale authority.
|
|
71
79
|
|
|
72
|
-
## How it reports
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
the
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
restart.
|
|
91
|
-
- Herdr trims and caps the text; after a `/clear` the previous title stays
|
|
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
|
|
92
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`.
|
|
93
112
|
|
|
94
113
|
## Configuration
|
|
95
114
|
|
|
@@ -103,6 +122,9 @@ title through Herdr's display-only `pane.report_metadata` channel:
|
|
|
103
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. |
|
|
104
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. |
|
|
105
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. |
|
|
106
128
|
| `enabled` | boolean | `true` | Kill-switch. Set `false` to disable the reporter in this tree — useful to coexist with another reporter. |
|
|
107
129
|
|
|
108
130
|
### How to configure it
|
|
@@ -134,6 +156,14 @@ the patch is skipped with a warning instead of silently applying:
|
|
|
134
156
|
reportSession: true
|
|
135
157
|
title: session
|
|
136
158
|
message: tool
|
|
159
|
+
workingMessage: tool
|
|
160
|
+
tokens: auto
|
|
161
|
+
stateLabels:
|
|
162
|
+
idle: ''
|
|
163
|
+
working: ''
|
|
164
|
+
blocked: ''
|
|
165
|
+
done: ''
|
|
166
|
+
unknown: ''
|
|
137
167
|
enabled: true
|
|
138
168
|
```
|
|
139
169
|
|
|
@@ -165,6 +195,26 @@ Disable title reporting (keep state and session reports):
|
|
|
165
195
|
title: none
|
|
166
196
|
```
|
|
167
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
|
+
|
|
168
218
|
### Notes
|
|
169
219
|
|
|
170
220
|
- **Changing `source`** re-attributes the pane's authority in Herdr. Keep it at
|
|
@@ -176,9 +226,10 @@ Disable title reporting (keep state and session reports):
|
|
|
176
226
|
- **`config` is validated** against the schemastery schema at load; an invalid
|
|
177
227
|
value (for example a `transport` that isn't `socket`/`cli`) is rejected, and
|
|
178
228
|
the plugin fails to load rather than running half-configured.
|
|
179
|
-
- The pane title
|
|
180
|
-
reports; changing `source` mid-session affects
|
|
181
|
-
affects lifecycle authority.
|
|
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.
|
|
182
233
|
- Because a patch replaces the row's whole `config`, any field you don't set
|
|
183
234
|
comes from the schema default — you do not need to copy every field.
|
|
184
235
|
|
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,12 +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
|
-
*
|
|
10
|
-
*
|
|
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.
|
|
11
12
|
*
|
|
12
13
|
* Ships as plain ESM JavaScript (no build step) so `dsh plugin add` from a git
|
|
13
14
|
* repo loads it directly without `prepare`/`lib`.
|
|
@@ -17,7 +18,7 @@
|
|
|
17
18
|
|
|
18
19
|
import z from '@deepseek-ai/schemastery'
|
|
19
20
|
|
|
20
|
-
import { AgentStateModel,
|
|
21
|
+
import { AgentStateModel, SessionFactsModel, stateLabelsPayload, sumUsageTokens } from './state.js'
|
|
21
22
|
import { HerdrReporter, herdrEnabled } from './transport.js'
|
|
22
23
|
|
|
23
24
|
export const name = 'herdr-agent-state'
|
|
@@ -53,6 +54,24 @@ export const Config = z.object({
|
|
|
53
54
|
title: z.union([z.const('session'), z.const('none')]).default('session'),
|
|
54
55
|
/** Whether to attach a human label to blocked reports. */
|
|
55
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({}),
|
|
56
75
|
/** Kill-switch for coexisting with another reporter in the same tree. */
|
|
57
76
|
enabled: z.boolean().default(true),
|
|
58
77
|
})
|
|
@@ -64,10 +83,34 @@ function questionLabel(questions) {
|
|
|
64
83
|
return questions.length > 1 ? `${text} (+${questions.length - 1} more)` : text
|
|
65
84
|
}
|
|
66
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
|
+
|
|
67
110
|
/**
|
|
68
111
|
* Drive one pane's reporter from the live dsh event stream.
|
|
69
112
|
* @param {import('@deepseek-ai/cordis').Context} ctx
|
|
70
|
-
* @param {{ agent: string, source: string, transport: 'socket' | 'cli', reportSession: boolean, title: 'session' | 'none', 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
|
|
71
114
|
*/
|
|
72
115
|
export function apply(ctx, config) {
|
|
73
116
|
if (!config.enabled) return
|
|
@@ -83,14 +126,18 @@ export function apply(ctx, config) {
|
|
|
83
126
|
agent: config.agent,
|
|
84
127
|
reportSession: config.reportSession,
|
|
85
128
|
reportTitle: config.title !== 'none',
|
|
129
|
+
reportTokens: config.tokens !== 'none',
|
|
86
130
|
env,
|
|
87
131
|
})
|
|
88
132
|
const model = new AgentStateModel()
|
|
89
|
-
const
|
|
133
|
+
const factsModel = new SessionFactsModel()
|
|
134
|
+
const stateLabels = stateLabelsPayload(config.stateLabels)
|
|
90
135
|
|
|
91
136
|
const publish = (force = false) => {
|
|
92
137
|
const report = model.desired()
|
|
93
|
-
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') {
|
|
94
141
|
reporter.publishState({ state: report.state }, force)
|
|
95
142
|
} else {
|
|
96
143
|
reporter.publishState(report, force)
|
|
@@ -108,7 +155,7 @@ export function apply(ctx, config) {
|
|
|
108
155
|
})
|
|
109
156
|
|
|
110
157
|
// Observers only: they delegate with `await next()` and never alter the
|
|
111
|
-
// downstream decision, so the approval / question
|
|
158
|
+
// downstream decision, so the approval / question / tool flows are untouched.
|
|
112
159
|
ctx.on('approval/request', async (req, next) => {
|
|
113
160
|
model.setBlocked(true, req.reason ?? req.toolName)
|
|
114
161
|
publish()
|
|
@@ -131,32 +178,75 @@ export function apply(ctx, config) {
|
|
|
131
178
|
}
|
|
132
179
|
})
|
|
133
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
|
+
|
|
134
216
|
ctx.on('agent/session-start', (payload) => {
|
|
135
217
|
const session = payload.agent?.session
|
|
136
218
|
const sessionId = session?.header?.id ?? undefined
|
|
137
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
|
+
|
|
138
233
|
reporter.reportSession(payload.source)
|
|
139
|
-
|
|
140
|
-
|
|
234
|
+
model.setRunning(payload.agent, false)
|
|
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.
|
|
141
240
|
let initialTitle
|
|
142
241
|
try {
|
|
143
242
|
initialTitle = ctx.get('sessionTitle')?.get(session)?.title
|
|
144
243
|
} catch {
|
|
145
244
|
// Service not mounted or session not live: the feed covers the rest.
|
|
146
245
|
}
|
|
147
|
-
reporter.
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
// Post-commit feed of appended session-log events; keep only title commits
|
|
153
|
-
// for the tracked session (child/subagent sessions in this process are
|
|
154
|
-
// skipped by the session-id match inside the model).
|
|
155
|
-
ctx.on('session/event', (session, event) => {
|
|
156
|
-
if (event.type !== 'session/title') return
|
|
157
|
-
const title = event.data?.title
|
|
158
|
-
if (typeof title !== 'string') return
|
|
159
|
-
reporter.reportTitle(titleModel.observeTitle(session?.header?.id ?? session?.id, title))
|
|
246
|
+
reporter.reportMetadata({
|
|
247
|
+
...factsModel.setSession(sessionId, { title: initialTitle, ...seedSessionFacts(session) }),
|
|
248
|
+
...(stateLabels !== undefined ? { state_labels: stateLabels } : {}),
|
|
249
|
+
})
|
|
160
250
|
})
|
|
161
251
|
|
|
162
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,70 +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
|
}
|
|
58
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
|
+
|
|
59
167
|
/**
|
|
60
|
-
* Tracks which session's
|
|
61
|
-
*
|
|
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.
|
|
62
171
|
*/
|
|
63
|
-
export class
|
|
172
|
+
export class SessionFactsModel {
|
|
64
173
|
constructor() {
|
|
65
174
|
/** Session identity from the latest agent/session-start. */
|
|
66
175
|
this.sessionId = undefined
|
|
67
176
|
/** Latest applicable title for that session. */
|
|
68
177
|
this.title = undefined
|
|
69
|
-
/**
|
|
70
|
-
this.
|
|
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
|
|
71
192
|
}
|
|
72
193
|
|
|
73
194
|
/**
|
|
74
195
|
* The tracked session changed (agent/session-start). Adopts the new identity
|
|
75
|
-
* and any pre-existing
|
|
76
|
-
*
|
|
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.
|
|
77
199
|
* @param {string | undefined} sessionId
|
|
78
|
-
* @param {string
|
|
79
|
-
* @returns {string
|
|
200
|
+
* @param {{ title?: string, model?: string, contextWindow?: number, usedTokens?: number }} [initial]
|
|
201
|
+
* @returns {{ title?: string, tokens?: Record<string, string | null> } | undefined}
|
|
80
202
|
*/
|
|
81
|
-
setSession(sessionId,
|
|
203
|
+
setSession(sessionId, initial = {}) {
|
|
82
204
|
this.sessionId = sessionId
|
|
83
|
-
this.title =
|
|
84
|
-
this.
|
|
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
|
|
85
210
|
return this.takePublishable()
|
|
86
211
|
}
|
|
87
212
|
|
|
88
213
|
/**
|
|
89
|
-
*
|
|
90
|
-
*
|
|
91
|
-
*
|
|
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.
|
|
92
219
|
* @param {string | undefined} sessionId
|
|
93
|
-
* @param {string}
|
|
94
|
-
* @returns {string
|
|
220
|
+
* @param {{ type: string, data?: unknown }} event
|
|
221
|
+
* @returns {{ title?: string, tokens?: Record<string, string | null> } | undefined}
|
|
95
222
|
*/
|
|
96
|
-
|
|
223
|
+
observeEvent(sessionId, event) {
|
|
97
224
|
if (this.sessionId === undefined) this.sessionId = sessionId
|
|
98
225
|
if (sessionId === undefined || sessionId !== this.sessionId) return undefined
|
|
99
|
-
|
|
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
|
+
}
|
|
100
250
|
return this.takePublishable()
|
|
101
251
|
}
|
|
102
252
|
|
|
103
|
-
/**
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
*/
|
|
107
|
-
desired() {
|
|
108
|
-
return typeof this.title === 'string' && this.title.trim() !== ''
|
|
109
|
-
? { title: this.title }
|
|
110
|
-
: undefined
|
|
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
|
|
111
256
|
}
|
|
112
257
|
|
|
113
|
-
/**
|
|
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
|
+
*/
|
|
114
274
|
takePublishable() {
|
|
115
|
-
const
|
|
116
|
-
|
|
117
|
-
this.
|
|
118
|
-
|
|
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
|
|
119
294
|
}
|
|
120
295
|
}
|
package/src/transport.js
CHANGED
|
@@ -3,10 +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 — plus
|
|
7
|
-
*
|
|
8
|
-
* one longer retry, and a failure that never rejects into
|
|
9
|
-
* (Herdr being absent must not disturb the dsh frontend).
|
|
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).
|
|
10
10
|
*
|
|
11
11
|
* Only `node:net` is used.
|
|
12
12
|
* @module @dsh-blue/herdr-agent-state/transport
|
|
@@ -25,6 +25,22 @@ export function socketEndpoint(env) {
|
|
|
25
25
|
return process.platform === 'win32' && raw !== '' ? `\\\\.\\pipe\\${raw}` : raw
|
|
26
26
|
}
|
|
27
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
|
+
|
|
28
44
|
/**
|
|
29
45
|
* Deliver one request over a fresh single connection. A pane report is
|
|
30
46
|
* fire-and-forget, so delivery means the request was written to Herdr's socket
|
|
@@ -72,25 +88,33 @@ export function sendRequest(request, endpoint, timeoutMs = 500, retryMs = 1500)
|
|
|
72
88
|
* Owns the seq counter, the single-flight latest-wins state queue, the session
|
|
73
89
|
* reference, and the transport. Calling `publishState` coalesces bursts: only
|
|
74
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.
|
|
75
93
|
*/
|
|
76
94
|
export class HerdrReporter {
|
|
77
95
|
/**
|
|
78
|
-
* @param {{ source: string, agent: string, reportSession: boolean, reportTitle?: 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
|
|
79
97
|
*/
|
|
80
98
|
constructor(options) {
|
|
81
99
|
this.source = options.source
|
|
82
100
|
this.agent = options.agent
|
|
83
101
|
this.reportSessionRef = options.reportSession
|
|
84
102
|
this.reportTitleRef = options.reportTitle ?? false
|
|
103
|
+
this.reportTokensRef = options.reportTokens ?? false
|
|
85
104
|
this.endpoint = socketEndpoint(options.env)
|
|
86
105
|
this.paneId = options.env.HERDR_PANE_ID ?? ''
|
|
87
106
|
// Wall-clock base keeps seq strictly increasing across process restarts.
|
|
88
107
|
this.seq = Date.now() * 1000
|
|
89
108
|
this.sessionId = undefined
|
|
109
|
+
this.sessionPath = undefined
|
|
90
110
|
this.sendInFlight = false
|
|
91
111
|
this.queued = undefined
|
|
92
112
|
this.lastSent = undefined
|
|
93
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()
|
|
94
118
|
}
|
|
95
119
|
|
|
96
120
|
/** Record the session reference to attach to subsequent reports. */
|
|
@@ -98,6 +122,11 @@ export class HerdrReporter {
|
|
|
98
122
|
this.sessionId = id
|
|
99
123
|
}
|
|
100
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
|
+
|
|
101
130
|
nextSeq() {
|
|
102
131
|
this.seq += 1
|
|
103
132
|
return this.seq
|
|
@@ -105,7 +134,10 @@ export class HerdrReporter {
|
|
|
105
134
|
|
|
106
135
|
sessionParams() {
|
|
107
136
|
if (this.reportSessionRef && this.sessionId !== undefined && this.sessionId !== '') {
|
|
108
|
-
return {
|
|
137
|
+
return {
|
|
138
|
+
agent_session_id: this.sessionId,
|
|
139
|
+
...(this.sessionPath !== undefined ? { agent_session_path: this.sessionPath } : {}),
|
|
140
|
+
}
|
|
109
141
|
}
|
|
110
142
|
return {}
|
|
111
143
|
}
|
|
@@ -124,12 +156,69 @@ export class HerdrReporter {
|
|
|
124
156
|
void this.drain()
|
|
125
157
|
}
|
|
126
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
|
+
|
|
127
216
|
/** Report the pane's session reference; Herdr exposes it for restore. */
|
|
128
217
|
reportSession(sessionStartSource) {
|
|
129
218
|
const params = this.sessionParams()
|
|
130
219
|
if (Object.keys(params).length === 0) return
|
|
131
220
|
void this.send({
|
|
132
|
-
id: `${this.source}:session
|
|
221
|
+
id: requestId(`${this.source}:session`),
|
|
133
222
|
method: 'pane.report_agent_session',
|
|
134
223
|
params: {
|
|
135
224
|
pane_id: this.paneId,
|
|
@@ -142,53 +231,33 @@ export class HerdrReporter {
|
|
|
142
231
|
})
|
|
143
232
|
}
|
|
144
233
|
|
|
145
|
-
/**
|
|
146
|
-
* Report the dsh session title as display-only Herdr pane metadata. The
|
|
147
|
-
* `agent` and `applies_to_source` guards scope the report to exactly the
|
|
148
|
-
* moments this reporter holds the pane's lifecycle authority; `release()`
|
|
149
|
-
* clears the title explicitly when that authority is given up.
|
|
150
|
-
* @param {string | undefined} title
|
|
151
|
-
*/
|
|
152
|
-
reportTitle(title) {
|
|
153
|
-
if (!this.reportTitleRef) return
|
|
154
|
-
if (typeof title !== 'string' || title.trim() === '') return
|
|
155
|
-
if (title === this.lastSentTitle) return
|
|
156
|
-
this.lastSentTitle = title
|
|
157
|
-
void this.send({
|
|
158
|
-
id: `${this.source}:title:${Date.now()}:${Math.random().toString(36).slice(2)}`,
|
|
159
|
-
method: 'pane.report_metadata',
|
|
160
|
-
params: {
|
|
161
|
-
pane_id: this.paneId,
|
|
162
|
-
source: this.source,
|
|
163
|
-
agent: this.agent,
|
|
164
|
-
applies_to_source: this.source,
|
|
165
|
-
title,
|
|
166
|
-
seq: this.nextSeq(),
|
|
167
|
-
},
|
|
168
|
-
})
|
|
169
|
-
}
|
|
170
|
-
|
|
171
234
|
/**
|
|
172
235
|
* Release this pane's lifecycle authority (on unload or process exit). The
|
|
173
|
-
*
|
|
174
|
-
* so
|
|
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.
|
|
175
239
|
*/
|
|
176
240
|
release() {
|
|
177
|
-
|
|
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) {
|
|
178
249
|
this.lastSentTitle = undefined
|
|
250
|
+
this.lastSentStateLabels = undefined
|
|
251
|
+
this.lastSentTokens = undefined
|
|
252
|
+
this.sentTokenKeys.clear()
|
|
179
253
|
void this.send({
|
|
180
|
-
id: `${this.source}:
|
|
254
|
+
id: requestId(`${this.source}:meta-clear`),
|
|
181
255
|
method: 'pane.report_metadata',
|
|
182
|
-
params: {
|
|
183
|
-
pane_id: this.paneId,
|
|
184
|
-
source: this.source,
|
|
185
|
-
clear_title: true,
|
|
186
|
-
seq: this.nextSeq(),
|
|
187
|
-
},
|
|
256
|
+
params: { pane_id: this.paneId, source: this.source, ...clear, seq: this.nextSeq() },
|
|
188
257
|
})
|
|
189
258
|
}
|
|
190
259
|
void this.send({
|
|
191
|
-
id: `${this.source}:release
|
|
260
|
+
id: requestId(`${this.source}:release`),
|
|
192
261
|
method: 'pane.release_agent',
|
|
193
262
|
params: { pane_id: this.paneId, source: this.source, agent: this.agent },
|
|
194
263
|
})
|
|
@@ -203,7 +272,7 @@ export class HerdrReporter {
|
|
|
203
272
|
this.queued = undefined
|
|
204
273
|
const params = this.sessionParams()
|
|
205
274
|
await this.send({
|
|
206
|
-
id: `${this.source}
|
|
275
|
+
id: requestId(`${this.source}:state`),
|
|
207
276
|
method: 'pane.report_agent',
|
|
208
277
|
params: {
|
|
209
278
|
pane_id: this.paneId,
|