@dorokuma/herdsman-pi 0.11.4 → 0.11.5

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 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.4",
3
+ "version": "0.11.5",
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
- "../LICENSE"
14
+ "LICENSE"
15
15
  ],
16
16
  "publishConfig": {
17
17
  "access": "public"
@@ -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 like any other
249
- // failure but must not mark the daemon incompatible: an older daemon
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.filter((agent) => {
748
- const nextAgents = next?.agents.filter((candidate) => candidate.paneId === agent.paneId) ?? [];
749
- if (nextAgents.length === 0) return false;
750
- return nextAgents.some(
751
- (nextAgent) => !agent.id || !nextAgent.id || agent.id === nextAgent.id,
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
- return [
1165
- `- ${identity} ${agent.paneId ?? "unknown"} ${agent.agentStatus ?? "unknown"}`,
1166
- ` last user: ${oneLine(sanitizeText(history.lastUserMessage?.text ?? "").text)}`,
1167
- ` last assistant: ${oneLine(sanitizeText(history.lastAssistantMessage?.text ?? "").text)}`,
1168
- ].join("\n");
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");
@@ -1247,3 +1309,50 @@ function record(value: unknown): Record<string, unknown> {
1247
1309
  function oneLine(value: string): string {
1248
1310
  return value.replace(/\s+/g, " ");
1249
1311
  }
1312
+
1313
+ function cleanContextText(value: string): string {
1314
+ return stripVTControlCharacters(value)
1315
+ .replace(
1316
+ /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f-\u009f\u00ad\u180e\u200b-\u200f\u202a-\u202e\u2060-\u2064\u2066-\u2069\ufeff]/g,
1317
+ "",
1318
+ )
1319
+ .replace(/\s+/g, " ")
1320
+ .trim();
1321
+ }
1322
+
1323
+ function sanitizeAndCleanContextText(value: string): string {
1324
+ const preCleaned = cleanContextText(value);
1325
+ if (!preCleaned) return "";
1326
+ const sanitized = sanitizeText(preCleaned).text;
1327
+ return cleanContextText(sanitized);
1328
+ }
1329
+
1330
+ function truncateSummary(value: string, limit = 100): string {
1331
+ if (value.length <= limit) return value;
1332
+ const maxSearch = Math.min(value.length, limit + 8);
1333
+ const searchSlice = value.slice(0, maxSearch);
1334
+ const lastSpaceIndex = searchSlice.search(/\s[^\s]*$/);
1335
+ if (lastSpaceIndex >= limit - 20) {
1336
+ return `${value.slice(0, lastSpaceIndex)}…`;
1337
+ }
1338
+ return `${value.slice(0, limit)}…`;
1339
+ }
1340
+
1341
+ function formatTimestamp(value: unknown): string | null {
1342
+ if (typeof value === "string") {
1343
+ const trimmed = value.trim();
1344
+ if (/^\d{2}:\d{2}:\d{2}$/.test(trimmed)) return trimmed;
1345
+ if (/^\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/.test(trimmed)) return trimmed;
1346
+ }
1347
+ if (!value) return null;
1348
+ const date = value instanceof Date ? value : new Date(value as string | number);
1349
+ if (Number.isNaN(date.getTime())) return null;
1350
+ const pad = (n: number) => String(n).padStart(2, "0");
1351
+ const month = pad(date.getMonth() + 1);
1352
+ const day = pad(date.getDate());
1353
+ const hours = pad(date.getHours());
1354
+ const minutes = pad(date.getMinutes());
1355
+ const seconds = pad(date.getSeconds());
1356
+ return `${month}-${day} ${hours}:${minutes}:${seconds}`;
1357
+ }
1358
+