@dorokuma/herdsman-pi 0.11.4 → 0.11.6
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/LICENSE +23 -0
- package/README.md +1 -1
- package/package.json +2 -2
- package/src/daemon-client.ts +4 -3
- package/src/index.ts +118 -13
package/LICENSE
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Ryo Nakae
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
22
|
+
|
|
23
|
+
Copyright (c) 2026 dorokuma (herdsman)
|
package/README.md
CHANGED
|
@@ -12,7 +12,7 @@ pi install npm:@dorokuma/herdsman-pi
|
|
|
12
12
|
herdsman daemon start
|
|
13
13
|
```
|
|
14
14
|
|
|
15
|
-
When Pi runs inside Herdr, this extension connects to the Herdsman daemon and registers its exact Pi session path as presence identity. It does not send per-turn tool-result or final-message telemetry.
|
|
15
|
+
When Pi runs inside Herdr, this extension connects to the Herdsman daemon and registers its exact Pi session path as presence identity. After register it sends `agent.ping` at least every 30 seconds so an idle owner is not dropped by the daemon heartbeat. It does not send per-turn tool-result or final-message telemetry.
|
|
16
16
|
|
|
17
17
|
Enter these commands in Pi, not in a shell:
|
|
18
18
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dorokuma/herdsman-pi",
|
|
3
|
-
"version": "0.11.
|
|
3
|
+
"version": "0.11.6",
|
|
4
4
|
"description": "Pi extension bridge for Herdsman agent history. Forked from @ryonakae/herdsman.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"keywords": [
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
"license": "MIT",
|
|
12
12
|
"files": [
|
|
13
13
|
"src",
|
|
14
|
-
"
|
|
14
|
+
"LICENSE"
|
|
15
15
|
],
|
|
16
16
|
"publishConfig": {
|
|
17
17
|
"access": "public"
|
package/src/daemon-client.ts
CHANGED
|
@@ -245,11 +245,12 @@ export class ReconnectingDaemonClient {
|
|
|
245
245
|
code: message.error.code,
|
|
246
246
|
});
|
|
247
247
|
pending.reject(error);
|
|
248
|
-
// Optional methods (turn completion signaling) are rejected
|
|
249
|
-
// failure but must not mark the daemon incompatible: an older
|
|
250
|
-
// answers "Unknown method" and the connection has to stay alive.
|
|
248
|
+
// Optional methods (turn completion signaling, keepalive ping) are rejected
|
|
249
|
+
// like any other failure but must not mark the daemon incompatible: an older
|
|
250
|
+
// daemon answers "Unknown method" and the connection has to stay alive.
|
|
251
251
|
if (
|
|
252
252
|
pending.method !== "agent.turn.completed" &&
|
|
253
|
+
pending.method !== "agent.ping" &&
|
|
253
254
|
/unknown|not found|unsupported|method/i.test(error.message)
|
|
254
255
|
) {
|
|
255
256
|
const incompatible = new Error("Herdsman daemon version is incompatible; restart the session");
|
package/src/index.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { sanitizeText } from "./sanitize-text.js";
|
|
|
3
3
|
import { appendFileSync, mkdirSync } from "node:fs";
|
|
4
4
|
import { homedir } from "node:os";
|
|
5
5
|
import { dirname, isAbsolute, join } from "node:path";
|
|
6
|
+
import { stripVTControlCharacters } from "node:util";
|
|
6
7
|
|
|
7
8
|
export type HerdsmanPiLogLevel = "info" | "warn" | "error";
|
|
8
9
|
|
|
@@ -180,6 +181,7 @@ const HERDR_REQUIRED_MESSAGE = "Herdsman requires a Herdr workspace";
|
|
|
180
181
|
const RECONNECTING_MESSAGE = "Herdsman is reconnecting · try again shortly";
|
|
181
182
|
export const MAX_ACK_ATTEMPTS = 5;
|
|
182
183
|
export const ACK_BACKOFF_CAP_MS = 30_000;
|
|
184
|
+
const KEEPALIVE_INTERVAL_MS = 30_000;
|
|
183
185
|
|
|
184
186
|
type AckFailureClass = "terminal" | "resync" | "transient";
|
|
185
187
|
|
|
@@ -267,8 +269,23 @@ export function createHerdsmanPiExtension(options: ExtensionOptions = {}) {
|
|
|
267
269
|
};
|
|
268
270
|
options.onStateExposed?.(state);
|
|
269
271
|
let activeContext: PiContext | undefined;
|
|
272
|
+
let keepaliveTimer: ReturnType<typeof setInterval> | undefined;
|
|
270
273
|
let wakeGeneration = 0;
|
|
271
274
|
|
|
275
|
+
const stopKeepalive = () => {
|
|
276
|
+
if (!keepaliveTimer) return;
|
|
277
|
+
clearInterval(keepaliveTimer);
|
|
278
|
+
keepaliveTimer = undefined;
|
|
279
|
+
};
|
|
280
|
+
|
|
281
|
+
const startKeepalive = (client: HerdsmanDaemonClient) => {
|
|
282
|
+
stopKeepalive();
|
|
283
|
+
keepaliveTimer = setInterval(() => {
|
|
284
|
+
void client.request("agent.ping", {}).catch(() => undefined);
|
|
285
|
+
}, KEEPALIVE_INTERVAL_MS);
|
|
286
|
+
keepaliveTimer.unref?.();
|
|
287
|
+
};
|
|
288
|
+
|
|
272
289
|
const setHerdsmanUi = (ctx: PiContext | undefined) => {
|
|
273
290
|
if (!ctx) return;
|
|
274
291
|
const footerState: HerdsmanFooterState = state.reconnectingFromOn
|
|
@@ -744,12 +761,14 @@ export function createHerdsmanPiExtension(options: ExtensionOptions = {}) {
|
|
|
744
761
|
snapshot
|
|
745
762
|
? {
|
|
746
763
|
...snapshot,
|
|
747
|
-
agents: snapshot.agents.
|
|
748
|
-
const nextAgents =
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
764
|
+
agents: snapshot.agents.flatMap((agent) => {
|
|
765
|
+
const nextAgents =
|
|
766
|
+
next?.agents.filter((candidate) => candidate.paneId === agent.paneId) ?? [];
|
|
767
|
+
if (nextAgents.length === 0) return [];
|
|
768
|
+
const nextAgent = nextAgents.find(
|
|
769
|
+
(candidate) => !agent.id || !candidate.id || agent.id === candidate.id,
|
|
752
770
|
);
|
|
771
|
+
return nextAgent ? [nextAgent] : [];
|
|
753
772
|
}),
|
|
754
773
|
}
|
|
755
774
|
: undefined;
|
|
@@ -782,11 +801,13 @@ export function createHerdsmanPiExtension(options: ExtensionOptions = {}) {
|
|
|
782
801
|
})
|
|
783
802
|
.then((response) => {
|
|
784
803
|
state.connected = true;
|
|
804
|
+
startKeepalive(client);
|
|
785
805
|
applyConnectionStateResponse(response as ConnectionStateResponse, ctx, {
|
|
786
806
|
notifyReconnectLoss: true,
|
|
787
807
|
});
|
|
788
808
|
})
|
|
789
809
|
.catch((error) => {
|
|
810
|
+
stopKeepalive();
|
|
790
811
|
state.connected = false;
|
|
791
812
|
const incompatibleMessage =
|
|
792
813
|
error instanceof Error && /incompatible/i.test(error.message)
|
|
@@ -865,12 +886,19 @@ export function createHerdsmanPiExtension(options: ExtensionOptions = {}) {
|
|
|
865
886
|
loseRole(ctx);
|
|
866
887
|
return;
|
|
867
888
|
}
|
|
889
|
+
stopKeepalive();
|
|
868
890
|
state.client?.close();
|
|
869
891
|
const client = options.clientFactory?.() ?? new ReconnectingDaemonClient({ socketPath: defaultSocketPath() });
|
|
870
892
|
client.resetForSession?.();
|
|
893
|
+
const closeClient = client.close.bind(client);
|
|
894
|
+
client.close = () => {
|
|
895
|
+
stopKeepalive();
|
|
896
|
+
closeClient();
|
|
897
|
+
};
|
|
871
898
|
state.client = client;
|
|
872
899
|
client.onConnected = () => registerPresence(ctx);
|
|
873
900
|
client.onDisconnected = () => {
|
|
901
|
+
stopKeepalive();
|
|
874
902
|
state.connected = false;
|
|
875
903
|
markDisconnected(activeContext);
|
|
876
904
|
};
|
|
@@ -878,6 +906,7 @@ export function createHerdsmanPiExtension(options: ExtensionOptions = {}) {
|
|
|
878
906
|
});
|
|
879
907
|
|
|
880
908
|
pi.on("session_shutdown", () => {
|
|
909
|
+
stopKeepalive();
|
|
881
910
|
state.connected = false;
|
|
882
911
|
loseRole(activeContext);
|
|
883
912
|
state.deliveredBatch = undefined;
|
|
@@ -1161,11 +1190,44 @@ export function formatHiddenAgentContext(input: {
|
|
|
1161
1190
|
agent: agent.agent ?? "unknown",
|
|
1162
1191
|
name: agent.name,
|
|
1163
1192
|
});
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1193
|
+
const paneId = agent.paneId ?? "unknown";
|
|
1194
|
+
const status = agent.agentStatus ?? "unknown";
|
|
1195
|
+
const prefix = `- ${identity} ${paneId} ${status}`;
|
|
1196
|
+
|
|
1197
|
+
const rawAgent = record(agent);
|
|
1198
|
+
const tabTitleCandidate =
|
|
1199
|
+
stringValue(rawAgent.terminalTitle) ?? stringValue(rawAgent.label);
|
|
1200
|
+
const tabTitleCleaned =
|
|
1201
|
+
tabTitleCandidate !== null ? sanitizeAndCleanContextText(tabTitleCandidate) : "";
|
|
1202
|
+
const tabTitle =
|
|
1203
|
+
tabTitleCleaned.length > 0 ? truncateSummary(tabTitleCleaned, 60) : null;
|
|
1204
|
+
|
|
1205
|
+
const rawHistory = record(history);
|
|
1206
|
+
const lastAssistantRecord = record(history.lastAssistantMessage);
|
|
1207
|
+
const timeCandidate =
|
|
1208
|
+
lastAssistantRecord.timestamp ??
|
|
1209
|
+
history.updatedAt ??
|
|
1210
|
+
rawHistory.updatedAt ??
|
|
1211
|
+
rawAgent.updatedAt ??
|
|
1212
|
+
rawAgent.time;
|
|
1213
|
+
const formattedTime = formatTimestamp(timeCandidate);
|
|
1214
|
+
|
|
1215
|
+
const assistantRaw = history.lastAssistantMessage?.text;
|
|
1216
|
+
const assistantCleaned =
|
|
1217
|
+
assistantRaw !== undefined && assistantRaw !== null
|
|
1218
|
+
? sanitizeAndCleanContextText(assistantRaw)
|
|
1219
|
+
: "";
|
|
1220
|
+
const assistantSummary =
|
|
1221
|
+
assistantCleaned.length > 0 ? truncateSummary(assistantCleaned, 100) : null;
|
|
1222
|
+
|
|
1223
|
+
const segments = [
|
|
1224
|
+
prefix,
|
|
1225
|
+
tabTitle,
|
|
1226
|
+
formattedTime,
|
|
1227
|
+
assistantSummary,
|
|
1228
|
+
].filter((segment): segment is string => segment !== null && segment.length > 0);
|
|
1229
|
+
|
|
1230
|
+
return segments.join(" · ");
|
|
1169
1231
|
}),
|
|
1170
1232
|
"Use herdsman agent get/read if details are needed.",
|
|
1171
1233
|
].join("\n");
|
|
@@ -1183,7 +1245,7 @@ export function formatHiddenAgentUpdates(events: AgentEventWireRecord[]): string
|
|
|
1183
1245
|
});
|
|
1184
1246
|
return [
|
|
1185
1247
|
`- ${event.type} ${identity} ${event.paneId ?? "unknown"}`,
|
|
1186
|
-
` last assistant: ${
|
|
1248
|
+
` last assistant: ${sanitizeAndCleanContextText(history.lastAssistantMessage?.text ?? "")}`,
|
|
1187
1249
|
` event: ${event.id}`,
|
|
1188
1250
|
].join("\n");
|
|
1189
1251
|
}),
|
|
@@ -1244,6 +1306,49 @@ function record(value: unknown): Record<string, unknown> {
|
|
|
1244
1306
|
return typeof value === "object" && value !== null ? (value as Record<string, unknown>) : {};
|
|
1245
1307
|
}
|
|
1246
1308
|
|
|
1247
|
-
function
|
|
1248
|
-
return value
|
|
1309
|
+
function cleanContextText(value: string): string {
|
|
1310
|
+
return stripVTControlCharacters(value)
|
|
1311
|
+
.replace(
|
|
1312
|
+
/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f-\u009f\u00ad\u180e\u200b-\u200f\u202a-\u202e\u2060-\u2064\u2066-\u2069\ufeff]/g,
|
|
1313
|
+
"",
|
|
1314
|
+
)
|
|
1315
|
+
.replace(/\s+/g, " ")
|
|
1316
|
+
.trim();
|
|
1317
|
+
}
|
|
1318
|
+
|
|
1319
|
+
function sanitizeAndCleanContextText(value: string): string {
|
|
1320
|
+
const preCleaned = cleanContextText(value);
|
|
1321
|
+
if (!preCleaned) return "";
|
|
1322
|
+
const sanitized = sanitizeText(preCleaned).text;
|
|
1323
|
+
return cleanContextText(sanitized);
|
|
1324
|
+
}
|
|
1325
|
+
|
|
1326
|
+
function truncateSummary(value: string, limit = 100): string {
|
|
1327
|
+
if (value.length <= limit) return value;
|
|
1328
|
+
const maxSearch = Math.min(value.length, limit + 8);
|
|
1329
|
+
const searchSlice = value.slice(0, maxSearch);
|
|
1330
|
+
const lastSpaceIndex = searchSlice.search(/\s[^\s]*$/);
|
|
1331
|
+
if (lastSpaceIndex >= limit - 20) {
|
|
1332
|
+
return `${value.slice(0, lastSpaceIndex)}…`;
|
|
1333
|
+
}
|
|
1334
|
+
return `${value.slice(0, limit)}…`;
|
|
1335
|
+
}
|
|
1336
|
+
|
|
1337
|
+
function formatTimestamp(value: unknown): string | null {
|
|
1338
|
+
if (typeof value === "string") {
|
|
1339
|
+
const trimmed = value.trim();
|
|
1340
|
+
if (/^\d{2}:\d{2}:\d{2}$/.test(trimmed)) return trimmed;
|
|
1341
|
+
if (/^\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/.test(trimmed)) return trimmed;
|
|
1342
|
+
}
|
|
1343
|
+
if (!value) return null;
|
|
1344
|
+
const date = value instanceof Date ? value : new Date(value as string | number);
|
|
1345
|
+
if (Number.isNaN(date.getTime())) return null;
|
|
1346
|
+
const pad = (n: number) => String(n).padStart(2, "0");
|
|
1347
|
+
const month = pad(date.getMonth() + 1);
|
|
1348
|
+
const day = pad(date.getDate());
|
|
1349
|
+
const hours = pad(date.getHours());
|
|
1350
|
+
const minutes = pad(date.getMinutes());
|
|
1351
|
+
const seconds = pad(date.getSeconds());
|
|
1352
|
+
return `${month}-${day} ${hours}:${minutes}:${seconds}`;
|
|
1249
1353
|
}
|
|
1354
|
+
|