herdr-remote 0.2.13 → 0.2.14

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/dist/tui.mjs CHANGED
@@ -4064,7 +4064,7 @@ function About({ ctx }) {
4064
4064
  children: updateLabel
4065
4065
  }
4066
4066
  ) }),
4067
- /* @__PURE__ */ jsx10(Row, { label: t("about.version"), children: /* @__PURE__ */ jsx10(Text9, { color: theme.muted, children: "0.2.13" }) }),
4067
+ /* @__PURE__ */ jsx10(Row, { label: t("about.version"), children: /* @__PURE__ */ jsx10(Text9, { color: theme.muted, children: "0.2.14" }) }),
4068
4068
  /* @__PURE__ */ jsx10(Row, { label: t("about.configPath"), children: /* @__PURE__ */ jsx10(Text9, { color: theme.muted, children: (0, import_config.configPath)() }) }),
4069
4069
  /* @__PURE__ */ jsx10(Row, { label: t("about.statePath"), children: /* @__PURE__ */ jsx10(Text9, { color: theme.muted, children: (0, import_config.stateDir)() }) }),
4070
4070
  /* @__PURE__ */ jsx10(Box9, { marginTop: 1, children: /* @__PURE__ */ jsx10(Text9, { color: theme.muted, children: t("about.docs") }) }),
package/herdr-plugin.toml CHANGED
@@ -1,6 +1,6 @@
1
1
  id = "herdr.remote.web"
2
2
  name = "Herdr Remote Web"
3
- version = "0.2.13"
3
+ version = "0.2.14"
4
4
  min_herdr_version = "0.9.1"
5
5
  description = "Browser access to Herdr workspaces via local or self-hosted relay"
6
6
  platforms = ["linux", "macos"]
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "herdr-remote",
3
- "version": "0.2.13",
3
+ "version": "0.2.14",
4
4
  "description": "Browser access to Herdr workspaces: plugin, host connector, and configuration TUI",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -51,9 +51,23 @@ function emptyCounts() {
51
51
  *
52
52
  * Sorted by how much each status wants attention, so the capped list keeps the
53
53
  * agents worth naming rather than the first ones Herdr happened to report.
54
+ * `focusedPaneId` and `focusedAgent` also track the live pane selection,
55
+ * including shells and panes Herdr has not added to its agent summary.
54
56
  */
55
57
  function summarizeAgents(snapshot) {
56
58
  const entries = Array.isArray(snapshot?.agents) ? snapshot.agents : [];
59
+ const focusedPaneId = typeof snapshot?.focused_pane_id === 'string' ? snapshot.focused_pane_id : null;
60
+ const focusedPane = focusedPaneId && Array.isArray(snapshot?.panes)
61
+ ? snapshot.panes.find((pane) => pane?.pane_id === focusedPaneId)
62
+ : null;
63
+ const focusedAgentEntry = focusedPaneId
64
+ ? entries.find((entry) => entry?.pane_id === focusedPaneId)
65
+ : null;
66
+ const focusedAgent = typeof focusedPane?.agent === 'string'
67
+ ? focusedPane.agent
68
+ : typeof focusedAgentEntry?.agent === 'string'
69
+ ? focusedAgentEntry.agent
70
+ : null;
57
71
  const counts = emptyCounts();
58
72
  const ranked = [];
59
73
 
@@ -74,6 +88,8 @@ function summarizeAgents(snapshot) {
74
88
  ranked.sort((left, right) => AGENT_STATUSES.indexOf(left.status) - AGENT_STATUSES.indexOf(right.status));
75
89
 
76
90
  return {
91
+ focusedPaneId,
92
+ focusedAgent,
77
93
  counts,
78
94
  // Total rather than a sum the browser has to compute, and the one number a
79
95
  // collapsed chip can show on its own.
@@ -85,6 +101,7 @@ function summarizeAgents(snapshot) {
85
101
  /** Whether two summaries say the same thing, so an unchanged one is not sent. */
86
102
  function sameSummary(left, right) {
87
103
  if (!left || !right) return left === right;
104
+ if (left.focusedPaneId !== right.focusedPaneId || left.focusedAgent !== right.focusedAgent) return false;
88
105
  if (left.total !== right.total) return false;
89
106
  for (const status of AGENT_STATUSES) {
90
107
  if ((left.counts?.[status] || 0) !== (right.counts?.[status] || 0)) return false;
@@ -102,7 +119,7 @@ function sameSummary(left, right) {
102
119
 
103
120
  /** The summary of a workstation with nothing to report, or nothing to ask. */
104
121
  function emptySummary() {
105
- return { counts: emptyCounts(), total: 0, agents: [] };
122
+ return { focusedPaneId: null, focusedAgent: null, counts: emptyCounts(), total: 0, agents: [] };
106
123
  }
107
124
 
108
125
  module.exports = {
package/src/herdr-api.js CHANGED
@@ -22,9 +22,8 @@
22
22
  * `herdr` CLI connects per command for the same reason, and so does this.
23
23
  *
24
24
  * Subscriptions are the exception Herdr does keep open — `events.subscribe`
25
- * holds the connection — but they are no use here: the event that tracks agent
26
- * state is scoped to a single `pane_id`, and the whole-session pane events stay
27
- * silent through minutes of continuous agent work. The caller polls.
25
+ * holds the connection. Focus and agent-detection events let the caller refresh
26
+ * the focused pane immediately, with a slow poll retained as a fallback.
28
27
  *
29
28
  * Nothing here may affect a terminal session. Herdr's server is an ordinary
30
29
  * process that can be restarted under a running herdr-remote, and when it is,
@@ -39,6 +38,10 @@ const REQUEST_TIMEOUT_MS = 10_000;
39
38
  /** An answer this long is not an answer. Guards a socket that never newlines. */
40
39
  const MAX_RESPONSE_BYTES = 8 * 1024 * 1024;
41
40
 
41
+ /** A subscription that disappears should recover without keeping a dead socket busy. */
42
+ const SUBSCRIBE_RETRY_BASE_MS = 1_000;
43
+ const SUBSCRIBE_RETRY_MAX_MS = 10_000;
44
+
42
45
  let requestCounter = 0;
43
46
 
44
47
  /**
@@ -117,7 +120,125 @@ function requestHerdr(socketPath, method, params = {}, options = {}) {
117
120
  });
118
121
  }
119
122
 
123
+ /**
124
+ * Subscribe to Herdr's event stream and reconnect if its local server restarts.
125
+ *
126
+ * The initial response is an acknowledgement (`result.type` is
127
+ * `subscription_started`); later lines are envelopes with `event` and `data`.
128
+ * They share a socket, but are separate NDJSON messages. A subscription never
129
+ * starts or writes to a terminal, so an unavailable socket is only a reason to
130
+ * retry this side channel.
131
+ */
132
+ function subscribeHerdr(socketPath, subscriptions, onEvent, options = {}) {
133
+ const {
134
+ connect = net.connect,
135
+ retryBaseMs = SUBSCRIBE_RETRY_BASE_MS,
136
+ retryMaxMs = SUBSCRIBE_RETRY_MAX_MS,
137
+ } = options;
138
+ let socket = null;
139
+ let reconnectTimer = null;
140
+ let handshakeTimer = null;
141
+ let retryCount = 0;
142
+ let closed = false;
143
+
144
+ const scheduleReconnect = () => {
145
+ if (closed || reconnectTimer) return;
146
+ const delay = Math.min(retryBaseMs * (2 ** retryCount), retryMaxMs);
147
+ retryCount += 1;
148
+ reconnectTimer = setTimeout(() => {
149
+ reconnectTimer = null;
150
+ open();
151
+ }, delay);
152
+ if (typeof reconnectTimer.unref === 'function') reconnectTimer.unref();
153
+ };
154
+
155
+ const open = () => {
156
+ if (closed) return;
157
+ let current;
158
+ try {
159
+ current = connect(socketPath);
160
+ } catch {
161
+ scheduleReconnect();
162
+ return;
163
+ }
164
+ socket = current;
165
+ let buffer = '';
166
+ let requestId = null;
167
+ let acknowledged = false;
168
+ const clearHandshake = () => {
169
+ if (handshakeTimer) clearTimeout(handshakeTimer);
170
+ handshakeTimer = null;
171
+ };
172
+
173
+ current.setEncoding('utf8');
174
+ current.on('connect', () => {
175
+ requestCounter += 1;
176
+ requestId = `herdr-remote:subscription:${requestCounter}`;
177
+ current.write(`${JSON.stringify({
178
+ id: requestId,
179
+ method: 'events.subscribe',
180
+ params: { subscriptions },
181
+ })}\n`);
182
+ handshakeTimer = setTimeout(() => current.destroy(), REQUEST_TIMEOUT_MS);
183
+ if (typeof handshakeTimer.unref === 'function') handshakeTimer.unref();
184
+ });
185
+ current.on('data', (chunk) => {
186
+ buffer += chunk;
187
+ if (Buffer.byteLength(buffer, 'utf8') > MAX_RESPONSE_BYTES) {
188
+ current.destroy();
189
+ return;
190
+ }
191
+ let newline;
192
+ while ((newline = buffer.indexOf('\n')) !== -1) {
193
+ const line = buffer.slice(0, newline);
194
+ buffer = buffer.slice(newline + 1);
195
+ if (!line) continue;
196
+ let message;
197
+ try {
198
+ message = JSON.parse(line);
199
+ } catch {
200
+ current.destroy();
201
+ return;
202
+ }
203
+ if (message?.id === requestId) {
204
+ clearHandshake();
205
+ if (message.error) {
206
+ current.destroy();
207
+ return;
208
+ }
209
+ acknowledged = true;
210
+ retryCount = 0;
211
+ } else if (acknowledged && typeof message?.event === 'string' && message.data && typeof message.data === 'object') {
212
+ try { onEvent(message); } catch {}
213
+ }
214
+ }
215
+ });
216
+ current.on('error', () => {});
217
+ current.on('close', () => {
218
+ clearHandshake();
219
+ if (socket === current) socket = null;
220
+ scheduleReconnect();
221
+ });
222
+ };
223
+
224
+ open();
225
+ return {
226
+ close() {
227
+ if (closed) return;
228
+ closed = true;
229
+ if (reconnectTimer) clearTimeout(reconnectTimer);
230
+ if (handshakeTimer) clearTimeout(handshakeTimer);
231
+ reconnectTimer = null;
232
+ handshakeTimer = null;
233
+ const active = socket;
234
+ socket = null;
235
+ if (active) active.destroy();
236
+ },
237
+ };
238
+ }
239
+
120
240
  module.exports = {
121
241
  REQUEST_TIMEOUT_MS,
122
242
  requestHerdr,
243
+ subscribeHerdr,
123
244
  };
@@ -26,13 +26,20 @@ const {
26
26
  PROTOCOL_VERSION,
27
27
  } = require('herdr-remote-relay/protocol');
28
28
  const { resolveHostPalette } = require('./terminal-palette');
29
- const { requestHerdr } = require('./herdr-api');
29
+ const { requestHerdr, subscribeHerdr } = require('./herdr-api');
30
30
  const { sameSummary, summarizeAgents } = require('./agent-status');
31
31
  const { EXIT_REPLACED, EXIT_AUTH_FAILED } = require('./exit-codes');
32
32
  const { savePastedFile, cleanPastedDir } = require('./pasted-files');
33
33
 
34
34
  /** How often the agent summary is re-read while a browser is watching. */
35
35
  const AGENT_STATUS_POLL_MS = 5_000;
36
+ const AGENT_STATUS_EVENT_DEBOUNCE_MS = 150;
37
+ const AGENT_STATUS_SUBSCRIPTIONS = [
38
+ { type: 'pane.focused' },
39
+ { type: 'tab.focused' },
40
+ { type: 'workspace.focused' },
41
+ { type: 'pane.agent_detected' },
42
+ ];
36
43
 
37
44
  function randomId(prefix) {
38
45
  return `${prefix}-${crypto.randomBytes(9).toString('base64url')}`;
@@ -113,7 +120,10 @@ class HostConnector {
113
120
  this.PtySession = options.PtySession || PtySession;
114
121
  /** The side channel that answers "does anything need me?"; see startAgentStatus. */
115
122
  this.requestHerdr = options.requestHerdr || requestHerdr;
123
+ this.subscribeHerdr = options.subscribeHerdr || subscribeHerdr;
116
124
  this.agentStatusTimer = null;
125
+ this.agentStatusDebounceTimer = null;
126
+ this.agentStatusSubscription = null;
117
127
  this.agentStatusPending = false;
118
128
  this.lastAgentSummary = null;
119
129
  this.fastFailures = 0;
@@ -591,22 +601,49 @@ class HostConnector {
591
601
  * PTYs survive it — so every failure here is a retry, never anything a
592
602
  * terminal session notices.
593
603
  *
594
- * Polled, and one connection per poll: Herdr answers a socket request and
595
- * hangs up, and its only long-lived connection — a subscription — carries no
596
- * event that tracks agent state without naming a pane. See `herdr-api.js`.
604
+ * A long-lived subscription provides fast refreshes on pane and workspace
605
+ * focus changes; the five-second poll still repairs missed events and a
606
+ * restarted Herdr server. Both paths only read the side-channel snapshot.
597
607
  */
598
608
  startAgentStatus() {
599
609
  if (this.agentStatusTimer || this.stopping) return;
600
610
  this.agentStatusTimer = setInterval(() => this.readAgentStatus(), AGENT_STATUS_POLL_MS);
601
611
  if (typeof this.agentStatusTimer.unref === 'function') this.agentStatusTimer.unref();
612
+ try {
613
+ this.agentStatusSubscription = this.subscribeHerdr(
614
+ this.socketPath,
615
+ AGENT_STATUS_SUBSCRIPTIONS,
616
+ () => this.scheduleAgentStatusRead(),
617
+ );
618
+ } catch {
619
+ // A missing or restarting Herdr server leaves the regular poll in place.
620
+ }
602
621
  this.readAgentStatus();
603
622
  }
604
623
 
624
+ scheduleAgentStatusRead() {
625
+ if (!this.agentStatusTimer) return;
626
+ if (this.agentStatusDebounceTimer) clearTimeout(this.agentStatusDebounceTimer);
627
+ this.agentStatusDebounceTimer = setTimeout(() => {
628
+ this.agentStatusDebounceTimer = null;
629
+ this.readAgentStatus();
630
+ }, AGENT_STATUS_EVENT_DEBOUNCE_MS);
631
+ if (typeof this.agentStatusDebounceTimer.unref === 'function') this.agentStatusDebounceTimer.unref();
632
+ }
633
+
605
634
  stopAgentStatus() {
606
635
  if (this.agentStatusTimer) {
607
636
  clearInterval(this.agentStatusTimer);
608
637
  this.agentStatusTimer = null;
609
638
  }
639
+ if (this.agentStatusDebounceTimer) {
640
+ clearTimeout(this.agentStatusDebounceTimer);
641
+ this.agentStatusDebounceTimer = null;
642
+ }
643
+ if (this.agentStatusSubscription) {
644
+ this.agentStatusSubscription.close();
645
+ this.agentStatusSubscription = null;
646
+ }
610
647
  this.lastAgentSummary = null;
611
648
  }
612
649