@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 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` 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)
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
- that a frontend reports, override the same row id in the profile's
25
- `cordis.patch.yml` (last write wins per row):
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
- plugins:
29
- herdr-agent-state:
30
- config:
31
- agent: blue # default dsh; this frontend's own label
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
- | 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. |
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.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.",
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) 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 waterfallsso it
8
- * works in TUI, web, and headless profiles alike. Outside a Herdr pane it is a
9
- * strict no-op.
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 (config.message === 'none' && report.state === 'blocked') {
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 flow is untouched.
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
- reporter.setSessionId(payload.agent?.session?.header?.id ?? undefined)
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 semantic state model for the pane's Herdr state.
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
- * The report derived from the current inputs.
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) return { state: 'working' }
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, 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).
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 { agent_session_id: this.sessionId }
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:${Date.now()}:${Math.random().toString(36).slice(2)}`,
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
- /** Release this pane's lifecycle authority (on unload or process exit). */
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:${Date.now()}:${Math.random().toString(36).slice(2)}`,
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}:${Date.now()}:${Math.random().toString(36).slice(2)}`,
275
+ id: requestId(`${this.source}:state`),
161
276
  method: 'pane.report_agent',
162
277
  params: {
163
278
  pane_id: this.paneId,