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 +1 -1
- package/herdr-plugin.toml +1 -1
- package/package.json +1 -1
- package/src/agent-status.js +18 -1
- package/src/herdr-api.js +124 -3
- package/src/host-connector.js +41 -4
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.
|
|
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
package/package.json
CHANGED
package/src/agent-status.js
CHANGED
|
@@ -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
|
|
26
|
-
*
|
|
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
|
};
|
package/src/host-connector.js
CHANGED
|
@@ -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
|
-
*
|
|
595
|
-
*
|
|
596
|
-
*
|
|
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
|
|