@dsh-blue/herdr-agent-state 0.1.0 → 0.2.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,12 @@
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` — 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)
8
10
  path).
9
11
 
10
12
  It works in **any dsh frontend** — TUIs, the web app, and headless — because it
@@ -21,14 +23,15 @@ dsh plugin --profile <profile> add @dsh-blue/herdr-agent-state
21
23
  ```
22
24
 
23
25
  It inserts a row labelled `herdr-agent-state`. To change the Herdr agent label
24
- that a frontend reports, override the same row id in the profile's
25
- `cordis.patch.yml` (last write wins per row):
26
+ a frontend reports, patch the same row id in the profile's `cordis.patch.yml`
27
+ (an id-targeted patch replaces that row's whole `config`; the schema defaults
28
+ fill any field you omit):
26
29
 
27
30
  ```yaml
28
- plugins:
29
- herdr-agent-state:
30
- config:
31
- agent: blue # default dsh; this frontend's own label
31
+ # ~/.dsh/profiles/<profile>/cordis.patch.yml
32
+ - id: herdr-agent-state
33
+ config:
34
+ agent: blue # default dsh; this frontend's own label
32
35
  ```
33
36
 
34
37
  The plugin is a strict no-op outside a Herdr pane (`HERDR_ENV=1` plus
@@ -66,16 +69,118 @@ The plugin releases the pane's lifecycle authority on unload and process exit,
66
69
  and re-reports on `agent/session-start` so a reload does not leave Herdr with a
67
70
  stale authority.
68
71
 
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
92
+ until the new session produces its first title (usually seconds).
93
+
69
94
  ## Configuration
70
95
 
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. |
96
+ ### Configuration reference
97
+
98
+ | Field | Type | Default | Meaning |
99
+ |---|---|---|---|
100
+ | `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. |
101
+ | `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. |
102
+ | `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. |
103
+ | `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
+ | `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
+ | `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. |
106
+ | `enabled` | boolean | `true` | Kill-switch. Set `false` to disable the reporter in this tree — useful to coexist with another reporter. |
107
+
108
+ ### How to configure it
109
+
110
+ The plugin is a bundle row labelled `herdr-agent-state`, so it is inserted
111
+ automatically when you `dsh plugin add`. Because its `Config` schema gives every
112
+ field a default, you only set what you want to change; the schema fills the
113
+ rest. A profile's `cordis.patch.yml` is a **top-level YAML array of loader patch
114
+ entries**, so you target the row by `id` and replace its `config`:
115
+
116
+ ```yaml
117
+ # ~/.dsh/profiles/<profile>/cordis.patch.yml
118
+ - id: herdr-agent-state
119
+ config:
120
+ agent: blue
121
+ ```
122
+
123
+ The patch replaces the row's whole `config`, so the schema defaults supply any
124
+ field you don't set. Include `name` as a guard — if it ever mismatches the row,
125
+ the patch is skipped with a warning instead of silently applying:
126
+
127
+ ```yaml
128
+ - id: herdr-agent-state
129
+ name: '@dsh-blue/herdr-agent-state'
130
+ config:
131
+ agent: blue
132
+ source: herdr:dsh-agent-state
133
+ transport: socket
134
+ reportSession: true
135
+ title: session
136
+ message: tool
137
+ enabled: true
138
+ ```
139
+
140
+ ### Examples
141
+
142
+ Set the Herdr label to `blue` when Blue hosts the pane (all other fields
143
+ default):
144
+
145
+ ```yaml
146
+ - id: herdr-agent-state
147
+ config:
148
+ agent: blue
149
+ ```
150
+
151
+ Disable session reporting and verbose blocked messages:
152
+
153
+ ```yaml
154
+ - id: herdr-agent-state
155
+ config:
156
+ reportSession: false
157
+ message: none
158
+ ```
159
+
160
+ Disable title reporting (keep state and session reports):
161
+
162
+ ```yaml
163
+ - id: herdr-agent-state
164
+ config:
165
+ title: none
166
+ ```
167
+
168
+ ### Notes
169
+
170
+ - **Changing `source`** re-attributes the pane's authority in Herdr. Keep it at
171
+ the default unless you are deliberately running two reporters in the same
172
+ tree — then give each a distinct `source`, and use `enabled: false` on the one
173
+ you want silent.
174
+ - **`transport: 'cli'` is not implemented.** Setting it throws during plugin
175
+ load (fail-fast), so leave it as `socket`.
176
+ - **`config` is validated** against the schemastery schema at load; an invalid
177
+ value (for example a `transport` that isn't `socket`/`cli`) is rejected, and
178
+ 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.
182
+ - Because a patch replaces the row's whole `config`, any field you don't set
183
+ comes from the schema default — you do not need to copy every field.
79
184
 
80
185
  ## Version compatibility
81
186
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dsh-blue/herdr-agent-state",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "dsh plugin: report agent state (working/blocked/idle) and session reference to Herdr via its pane socket integration. Works in any dsh profile — TUIs, web, headless.",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
package/src/index.js CHANGED
@@ -2,11 +2,12 @@
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 waterfalls so 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), session reference, and
6
+ * session title (as the Herdr pane title) to Herdr's pane socket. It depends
7
+ * only on documented dsh extension points agent lifecycle events, the
8
+ * approval and user-question waterfalls, and the session-log event feed so
9
+ * it works in TUI, web, and headless profiles alike. Outside a Herdr pane it
10
+ * is a strict no-op.
10
11
  *
11
12
  * Ships as plain ESM JavaScript (no build step) so `dsh plugin add` from a git
12
13
  * repo loads it directly without `prepare`/`lib`.
@@ -16,7 +17,7 @@
16
17
 
17
18
  import z from '@deepseek-ai/schemastery'
18
19
 
19
- import { AgentStateModel } from './state.js'
20
+ import { AgentStateModel, SessionTitleModel } from './state.js'
20
21
  import { HerdrReporter, herdrEnabled } from './transport.js'
21
22
 
22
23
  export const name = 'herdr-agent-state'
@@ -44,6 +45,12 @@ export const Config = z.object({
44
45
  transport: z.union([z.const('socket'), z.const('cli')]).default('socket'),
45
46
  /** Report the pane's session reference so Herdr can expose it for restore. */
46
47
  reportSession: z.boolean().default(true),
48
+ /**
49
+ * Which title to publish as the Herdr pane title (display-only metadata).
50
+ * `session` mirrors the dsh session title — first-prompt fallback,
51
+ * LLM-generated, or pinned by `/rename`; `none` disables title reporting.
52
+ */
53
+ title: z.union([z.const('session'), z.const('none')]).default('session'),
47
54
  /** Whether to attach a human label to blocked reports. */
48
55
  message: z.union([z.const('tool'), z.const('none')]).default('tool'),
49
56
  /** Kill-switch for coexisting with another reporter in the same tree. */
@@ -60,7 +67,7 @@ function questionLabel(questions) {
60
67
  /**
61
68
  * Drive one pane's reporter from the live dsh event stream.
62
69
  * @param {import('@deepseek-ai/cordis').Context} ctx
63
- * @param {{ agent: string, source: string, transport: 'socket' | 'cli', reportSession: boolean, message: 'tool' | 'none', enabled: boolean }} config
70
+ * @param {{ agent: string, source: string, transport: 'socket' | 'cli', reportSession: boolean, title: 'session' | 'none', message: 'tool' | 'none', enabled: boolean }} config
64
71
  */
65
72
  export function apply(ctx, config) {
66
73
  if (!config.enabled) return
@@ -75,9 +82,11 @@ export function apply(ctx, config) {
75
82
  source: config.source,
76
83
  agent: config.agent,
77
84
  reportSession: config.reportSession,
85
+ reportTitle: config.title !== 'none',
78
86
  env,
79
87
  })
80
88
  const model = new AgentStateModel()
89
+ const titleModel = new SessionTitleModel()
81
90
 
82
91
  const publish = (force = false) => {
83
92
  const report = model.desired()
@@ -123,12 +132,33 @@ export function apply(ctx, config) {
123
132
  })
124
133
 
125
134
  ctx.on('agent/session-start', (payload) => {
126
- reporter.setSessionId(payload.agent?.session?.header?.id ?? undefined)
135
+ const session = payload.agent?.session
136
+ const sessionId = session?.header?.id ?? undefined
137
+ reporter.setSessionId(sessionId)
127
138
  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.
141
+ let initialTitle
142
+ try {
143
+ initialTitle = ctx.get('sessionTitle')?.get(session)?.title
144
+ } catch {
145
+ // Service not mounted or session not live: the feed covers the rest.
146
+ }
147
+ reporter.reportTitle(titleModel.setSession(sessionId, initialTitle))
128
148
  model.setRunning(payload.agent, false)
129
149
  publish(true)
130
150
  })
131
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))
160
+ })
161
+
132
162
  ctx.effect(
133
163
  () => () => reporter.release(),
134
164
  'herdr-agent-state: release pane lifecycle authority on unload',
package/src/state.js CHANGED
@@ -55,3 +55,66 @@ export class AgentStateModel {
55
55
  return { state: 'idle' }
56
56
  }
57
57
  }
58
+
59
+ /**
60
+ * Tracks which session's title is current and the latest applicable title,
61
+ * deciding when a title observation should be published.
62
+ */
63
+ export class SessionTitleModel {
64
+ constructor() {
65
+ /** Session identity from the latest agent/session-start. */
66
+ this.sessionId = undefined
67
+ /** Latest applicable title for that session. */
68
+ this.title = undefined
69
+ /** Last title this model told the caller to publish (change tracking). */
70
+ this.lastReported = undefined
71
+ }
72
+
73
+ /**
74
+ * 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.
77
+ * @param {string | undefined} sessionId
78
+ * @param {string | undefined} [initialTitle]
79
+ * @returns {string | undefined} the title to publish now, if any
80
+ */
81
+ setSession(sessionId, initialTitle) {
82
+ this.sessionId = sessionId
83
+ this.title = initialTitle
84
+ this.lastReported = undefined
85
+ return this.takePublishable()
86
+ }
87
+
88
+ /**
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).
92
+ * @param {string | undefined} sessionId
93
+ * @param {string} title
94
+ * @returns {string | undefined} the title to publish now, if changed
95
+ */
96
+ observeTitle(sessionId, title) {
97
+ if (this.sessionId === undefined) this.sessionId = sessionId
98
+ if (sessionId === undefined || sessionId !== this.sessionId) return undefined
99
+ this.title = title
100
+ return this.takePublishable()
101
+ }
102
+
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
111
+ }
112
+
113
+ /** Consume the desired report once, tracking it as reported. */
114
+ 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
119
+ }
120
+ }
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 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).
9
10
  *
10
11
  * Only `node:net` is used.
11
12
  * @module @dsh-blue/herdr-agent-state/transport
@@ -74,12 +75,13 @@ export function sendRequest(request, endpoint, timeoutMs = 500, retryMs = 1500)
74
75
  */
75
76
  export class HerdrReporter {
76
77
  /**
77
- * @param {{ source: string, agent: string, reportSession: boolean, env: Record<string, string | undefined> }} options
78
+ * @param {{ source: string, agent: string, reportSession: boolean, reportTitle?: boolean, env: Record<string, string | undefined> }} options
78
79
  */
79
80
  constructor(options) {
80
81
  this.source = options.source
81
82
  this.agent = options.agent
82
83
  this.reportSessionRef = options.reportSession
84
+ this.reportTitleRef = options.reportTitle ?? false
83
85
  this.endpoint = socketEndpoint(options.env)
84
86
  this.paneId = options.env.HERDR_PANE_ID ?? ''
85
87
  // Wall-clock base keeps seq strictly increasing across process restarts.
@@ -88,6 +90,7 @@ export class HerdrReporter {
88
90
  this.sendInFlight = false
89
91
  this.queued = undefined
90
92
  this.lastSent = undefined
93
+ this.lastSentTitle = undefined
91
94
  }
92
95
 
93
96
  /** Record the session reference to attach to subsequent reports. */
@@ -139,8 +142,51 @@ export class HerdrReporter {
139
142
  })
140
143
  }
141
144
 
142
- /** Release this pane's lifecycle authority (on unload or process exit). */
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
+ /**
172
+ * 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.
175
+ */
143
176
  release() {
177
+ if (this.reportTitleRef && this.lastSentTitle !== undefined) {
178
+ this.lastSentTitle = undefined
179
+ void this.send({
180
+ id: `${this.source}:title-clear:${Date.now()}:${Math.random().toString(36).slice(2)}`,
181
+ method: 'pane.report_metadata',
182
+ params: {
183
+ pane_id: this.paneId,
184
+ source: this.source,
185
+ clear_title: true,
186
+ seq: this.nextSeq(),
187
+ },
188
+ })
189
+ }
144
190
  void this.send({
145
191
  id: `${this.source}:release:${Date.now()}:${Math.random().toString(36).slice(2)}`,
146
192
  method: 'pane.release_agent',