@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 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` its session reference, and its session title (as the Herdr
5
- pane title) to [Herdr](https://herdr.dev/) through Herdr's pane socket
6
- integration. It lets Herdr's sidebar show where the agent actually is, surface
7
- waiting agents, name panes after the conversation, and expose the session for
8
- restore, **without any change to Herdr** (Herdr's [custom
9
- 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)
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 the title
73
-
74
- The plugin mirrors the dsh session title the first-prompt fallback, the
75
- LLM-generated refinement, or a title pinned with `/rename` as the Herdr pane
76
- title through Herdr's display-only `pane.report_metadata` channel:
77
-
78
- - Titles are observed on the same `session/title` session-log feed the dsh TUI
79
- itself renders, filtered to the session the pane's agent is currently
80
- running (subagent sessions in the same process are skipped).
81
- - A resumed session's existing title is read directly at `agent/session-start`
82
- (past title events are replay seeds that never re-enter the live feed), so a
83
- restored pane is named immediately.
84
- - The report carries the same `source` and `agent` as the state reports plus
85
- an `applies_to_source` guard, so the title is accepted exactly while this
86
- reporter holds the pane's lifecycle authority. Herdr checks that guard when
87
- the report arrives (not continuously), so the reporter also clears the title
88
- when it releases the pane's authority. A title never affects waits,
89
- notifications, or rollups, and is not restored across a Herdr server
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 rides the same `source` and `agent` guards as the state
180
- reports; changing `source` mid-session affects the title the same way it
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.2.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,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), session reference, and
6
- * session title (as the Herdr pane title) to Herdr's pane socket. It depends
7
- * only on documented dsh extension pointsagent lifecycle events, the
8
- * approval and user-question waterfalls, and the session-log event feedso
9
- * it works in TUI, web, and headless profiles alike. Outside a Herdr pane it
10
- * is a 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 pointsagent 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, SessionTitleModel } from './state.js'
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 titleModel = new SessionTitleModel()
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 (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') {
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 flow is untouched.
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
- // A resumed session's past titles are constructor-seed log events that
140
- // never reach the session/event feed, so read the current one directly.
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.reportTitle(titleModel.setSession(sessionId, initialTitle))
148
- model.setRunning(payload.agent, false)
149
- publish(true)
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 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,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) 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
  }
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 title is current and the latest applicable title,
61
- * deciding when a title observation should be published.
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 SessionTitleModel {
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
- /** Last title this model told the caller to publish (change tracking). */
70
- this.lastReported = 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
71
192
  }
72
193
 
73
194
  /**
74
195
  * The tracked session changed (agent/session-start). Adopts the new identity
75
- * and any pre-existing title (a resumed session's title predates the plugin),
76
- * resetting change tracking so an identical title re-publishes.
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 | undefined} [initialTitle]
79
- * @returns {string | undefined} the title to publish now, if any
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, initialTitle) {
203
+ setSession(sessionId, initial = {}) {
82
204
  this.sessionId = sessionId
83
- this.title = initialTitle
84
- this.lastReported = undefined
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
- * One session/title observation from the session/event firehose. Ignores
90
- * other sessions' titles and unchanged text; adopts the first observed
91
- * session when none was recorded (plugin reloaded mid-session).
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} title
94
- * @returns {string | undefined} the title to publish now, if changed
220
+ * @param {{ type: string, data?: unknown }} event
221
+ * @returns {{ title?: string, tokens?: Record<string, string | null> } | undefined}
95
222
  */
96
- observeTitle(sessionId, title) {
223
+ observeEvent(sessionId, event) {
97
224
  if (this.sessionId === undefined) this.sessionId = sessionId
98
225
  if (sessionId === undefined || sessionId !== this.sessionId) return undefined
99
- this.title = title
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
- * The report derived from the current inputs.
105
- * @returns {{ title: string } | undefined}
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
- /** Consume the desired report once, tracking it as reported. */
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 report = this.desired()
116
- if (report === undefined || report.title === this.lastReported) return undefined
117
- this.lastReported = report.title
118
- return report.title
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 the display-only `pane.report_metadata` for
7
- * the pane title — a fresh single connection per request, a short timeout plus
8
- * one longer retry, and a failure that never rejects into the host process
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 { agent_session_id: this.sessionId }
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:${Date.now()}:${Math.random().toString(36).slice(2)}`,
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
- * guard on a reported title is checked at acceptance time, not continuously,
174
- * so a title this reporter set is cleared explicitly alongside the release.
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
- if (this.reportTitleRef && this.lastSentTitle !== undefined) {
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}:title-clear:${Date.now()}:${Math.random().toString(36).slice(2)}`,
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:${Date.now()}:${Math.random().toString(36).slice(2)}`,
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}:${Date.now()}:${Math.random().toString(36).slice(2)}`,
275
+ id: requestId(`${this.source}:state`),
207
276
  method: 'pane.report_agent',
208
277
  params: {
209
278
  pane_id: this.paneId,