pi-herdr-agents 3.1.0 → 3.2.1

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.
@@ -66,6 +66,12 @@ import {
66
66
  type ModelRegistryAdapter,
67
67
  } from "../core/routing.ts";
68
68
  import { loadPaneConfig, type PaneConfig } from "../core/config/pane-config.ts";
69
+ import {
70
+ DelegatedMarkers,
71
+ herdrMarkerWriter,
72
+ type DelegatedMarker,
73
+ type MarkerWriter,
74
+ } from "./delegated-marker.ts";
69
75
  import {
70
76
  createRunSession,
71
77
  type RunSession,
@@ -327,6 +333,8 @@ export interface PiRunSessionInfrastructure {
327
333
  supervision: SupervisionCoordinator;
328
334
  /** Kernel process-identity probe for worktree cancel; tests inject it. */
329
335
  processProbe?: ProcessIdentityProbe;
336
+ /** Sidebar marker transport; defaults to this pane's Herdr socket. */
337
+ markerWriter?: MarkerWriter;
330
338
  }
331
339
  // pi-herdr-agents extension
332
340
  export interface DefaultRunSessionOptions {
@@ -340,6 +348,8 @@ export interface DefaultRunSessionOptions {
340
348
  hooks: PiRunSessionHooks;
341
349
  persistent: PiPersistentHostOperations;
342
350
  forcePolling: boolean;
351
+ /** Mark delegated children's panes for the Herdr sidebar plugin. */
352
+ sidebarMarkers?: boolean;
343
353
  }
344
354
  // pi-herdr-agents extension
345
355
  export interface PiWorktreeHandoffInput {
@@ -395,6 +405,7 @@ interface PiEntry {
395
405
  started: PiStartedMetadata;
396
406
  completed?: PiCompletedMetadata;
397
407
  readResumeResult?: PiSettlementIO["readResumeResult"];
408
+ marker?: DelegatedMarker;
398
409
  }
399
410
  // The previous facade carries its live composition explicitly across module replacement.
400
411
  // No module-local lookup can accidentally rediscover an owner.
@@ -413,6 +424,8 @@ interface PiSessionOwner {
413
424
  "surfaceProvider" | "supervision" | "processProbe"
414
425
  >;
415
426
  worktreeOperations: WorktreeOperations;
427
+ /** Absent outside Herdr: without a socket nothing can be marked. */
428
+ markers?: DelegatedMarkers;
416
429
  }
417
430
 
418
431
  // A detached close can outlive settlement without capturing a child, metadata,
@@ -426,6 +439,21 @@ function temporarySurfaceRelease(
426
439
  };
427
440
  }
428
441
 
442
+ function createDelegatedMarkers(
443
+ infrastructure: PiRunSessionInfrastructure | undefined,
444
+ probe: ProcessIdentityProbe | undefined,
445
+ ): DelegatedMarkers | undefined {
446
+ // Injected infrastructure never falls back to the ambient Herdr socket.
447
+ if (infrastructure) {
448
+ const write = infrastructure.markerWriter;
449
+ return write ? new DelegatedMarkers({ write, probe }) : undefined;
450
+ }
451
+ const socketPath = process.env.HERDR_SOCKET_PATH;
452
+ return socketPath
453
+ ? new DelegatedMarkers({ write: herdrMarkerWriter(socketPath), probe })
454
+ : undefined;
455
+ }
456
+
429
457
  // Capture only resume presentation scalars, not the child or its owning registry.
430
458
  function resumeResultReader(
431
459
  sessionFile: string,
@@ -682,6 +710,20 @@ export function createDefaultRunSession(
682
710
  // Replacements must invoke the original kernel's invocation closure, which
683
711
  // reads this shared current binding, not the old facade's options.
684
712
  if (adopted) resumePi = state.resumePi;
713
+ // Live markers and their sequence survive a reload with their runs.
714
+ state.markers ??= createDelegatedMarkers(
715
+ options.infrastructure,
716
+ state.infrastructure.processProbe,
717
+ );
718
+ // Turning the setting off retires every marker now, pending ones included;
719
+ // a retired marker never writes live again, even if the setting returns.
720
+ if (!options.sidebarMarkers)
721
+ for (const entry of state.entries.values()) entry.marker?.retire();
722
+
723
+ /** Markers for new launches, only while the sidebar setting is on. */
724
+ function enabledMarkers(): DelegatedMarkers | undefined {
725
+ return state.options.sidebarMarkers ? state.markers : undefined;
726
+ }
685
727
 
686
728
  function prune(id: string) {
687
729
  if (!state.kernel.getTask(id)) state.entries.delete(id);
@@ -712,11 +754,11 @@ export function createDefaultRunSession(
712
754
  };
713
755
  }
714
756
  function observed(child: PiRunRecord, observation: RunObservation) {
715
- if (
716
- child.lifecycle.delivery === "suppressed" ||
717
- ![...state.entries.values()].some((entry) => entry.record === child)
718
- )
719
- return;
757
+ const entry = [...state.entries.values()].find(
758
+ (candidate) => candidate.record === child,
759
+ );
760
+ if (child.lifecycle.delivery === "suppressed" || !entry) return;
761
+ entry.marker?.renew();
720
762
  if (observation.kind === "local-evidence" && child.persistent) {
721
763
  try {
722
764
  state.options.persistent.drain(child, io);
@@ -745,6 +787,7 @@ export function createDefaultRunSession(
745
787
  parentRuntime: snapshot.parentRuntime,
746
788
  supervision: state.infrastructure.supervision,
747
789
  processProbe: state.infrastructure.processProbe,
790
+ recordProcessIdentity: enabledMarkers() !== undefined,
748
791
  operations: launchOperations(snapshot),
749
792
  onObservation(child, kind) {
750
793
  const at = Date.now();
@@ -794,7 +837,10 @@ export function createDefaultRunSession(
794
837
  };
795
838
  if (suppressed)
796
839
  child.lifecycle = markDelivery(child.lifecycle, "suppressed");
797
- else state.entries.set(task.id, entry);
840
+ else {
841
+ entry.marker = enabledMarkers()?.attach(child);
842
+ state.entries.set(task.id, entry);
843
+ }
798
844
  return {
799
845
  handle,
800
846
  adapter: owner,
@@ -805,6 +851,8 @@ export function createDefaultRunSession(
805
851
  ),
806
852
  observe: (at) => hydrate(child, at, "refresh"),
807
853
  finalize: async (result) => {
854
+ // Clear before delivery; a retained worktree root keeps its pane.
855
+ entry.marker?.retire();
808
856
  const at = Date.now();
809
857
  let evidence = result.evidence;
810
858
  if (evidence)
@@ -1153,8 +1201,11 @@ export function createDefaultRunSession(
1153
1201
  suppress(id) {
1154
1202
  for (const acquisition of state.pending)
1155
1203
  if (acquisition.taskId === id) acquisition.suppressed = true;
1156
- const r = live(id)?.record;
1204
+ const suppressed = live(id);
1205
+ const r = suppressed?.record;
1157
1206
  if (r) r.lifecycle = markDelivery(r.lifecycle, "suppressed");
1207
+ // Nothing renews an unsupervised run's marker; clear it now.
1208
+ suppressed?.marker?.retire();
1158
1209
  state.kernel.suppress(id);
1159
1210
  state.entries.delete(id);
1160
1211
  },
@@ -0,0 +1,165 @@
1
+ import { connect } from "node:net";
2
+ import {
3
+ isNonEmptyString,
4
+ isRecord,
5
+ isString,
6
+ type JsonObject,
7
+ } from "../../core/config/type-guards.ts";
8
+
9
+ export const HERDR_SOCKET_TIMEOUT_MS = 2_000;
10
+ export const HERDR_SOCKET_MAX_REPLY_BYTES = 64 * 1024;
11
+ const MAX_REPORTED_CHARS = 200;
12
+
13
+ export type HerdrSocketReply =
14
+ | { kind: "result"; result: JsonObject }
15
+ /** Herdr answered with an error object. */
16
+ | { kind: "error"; code: string; message: string }
17
+ /** No usable answer: refused, closed, timed out, oversized, malformed, or another id. */
18
+ | { kind: "failed"; reason: string };
19
+
20
+ function bounded(text: string): string {
21
+ return text.replace(/\p{Cc}+/gu, " ").slice(0, MAX_REPORTED_CHARS);
22
+ }
23
+
24
+ let requestCount = 0;
25
+
26
+ /**
27
+ * One request on one connection: Herdr closes ordinary connections after a
28
+ * reply. Settles once with the first newline-terminated reply, or fails on a
29
+ * timeout, an oversized reply, or a connection that ends first. The socket
30
+ * and timer are unreferenced, so a pending request never keeps Pi running.
31
+ */
32
+ export function requestHerdrSocket(
33
+ socketPath: string,
34
+ method: string,
35
+ params: JsonObject,
36
+ options: { timeoutMs?: number; maxReplyBytes?: number } = {},
37
+ ): Promise<HerdrSocketReply> {
38
+ const timeoutMs = options.timeoutMs ?? HERDR_SOCKET_TIMEOUT_MS;
39
+ const maxReplyBytes = options.maxReplyBytes ?? HERDR_SOCKET_MAX_REPLY_BYTES;
40
+ const id = `pi-herdr-agents:${process.pid}:${++requestCount}`;
41
+ return new Promise((resolve) => {
42
+ let settled = false;
43
+ let received = Buffer.alloc(0);
44
+ const socket = connect(socketPath);
45
+ socket.unref();
46
+ const finish = (reply: HerdrSocketReply) => {
47
+ if (settled) return;
48
+ settled = true;
49
+ clearTimeout(timer);
50
+ socket.destroy();
51
+ resolve(reply);
52
+ };
53
+ const timer = setTimeout(
54
+ () =>
55
+ finish({
56
+ kind: "failed",
57
+ reason: `${method} got no reply in ${timeoutMs}ms`,
58
+ }),
59
+ timeoutMs,
60
+ );
61
+ timer.unref();
62
+ socket.on("connect", () => {
63
+ socket.write(`${JSON.stringify({ id, method, params })}\n`);
64
+ });
65
+ socket.on("error", (error) =>
66
+ finish({ kind: "failed", reason: bounded(error.message) }),
67
+ );
68
+ socket.on("close", () =>
69
+ finish({
70
+ kind: "failed",
71
+ reason: `${method}: connection closed before a reply`,
72
+ }),
73
+ );
74
+ socket.on("data", (chunk: Buffer) => {
75
+ received = Buffer.concat([received, chunk]);
76
+ const end = received.indexOf(10);
77
+ if ((end < 0 ? received.length : end) > maxReplyBytes) {
78
+ finish({
79
+ kind: "failed",
80
+ reason: `${method} reply exceeds ${maxReplyBytes} bytes`,
81
+ });
82
+ return;
83
+ }
84
+ if (end >= 0)
85
+ finish(
86
+ parseHerdrSocketReply(received.subarray(0, end).toString("utf8"), id),
87
+ );
88
+ });
89
+ });
90
+ }
91
+
92
+ /**
93
+ * Herdr answers `ok` even when it ignores a write whose `seq` is not newer
94
+ * than the last one it applied from this source, so `answered` is not proof
95
+ * that the token changed.
96
+ */
97
+ export type PaneTokenOutcome = "answered" | "pane-missing" | "failed";
98
+
99
+ export interface PaneTokenWrite {
100
+ paneId: string;
101
+ source: string;
102
+ seq: number;
103
+ token: string;
104
+ /** `null` clears the token. */
105
+ value: string | null;
106
+ ttlMs?: number;
107
+ }
108
+
109
+ /** One sequenced `pane.report_metadata` token patch. */
110
+ export async function reportPaneToken(
111
+ socketPath: string,
112
+ write: PaneTokenWrite,
113
+ options?: { timeoutMs?: number },
114
+ ): Promise<PaneTokenOutcome> {
115
+ const params: JsonObject = {
116
+ pane_id: write.paneId,
117
+ source: write.source,
118
+ seq: write.seq,
119
+ tokens: { [write.token]: write.value },
120
+ };
121
+ if (write.ttlMs !== undefined) params.ttl_ms = write.ttlMs;
122
+ const reply = await requestHerdrSocket(
123
+ socketPath,
124
+ "pane.report_metadata",
125
+ params,
126
+ options,
127
+ );
128
+ if (reply.kind === "result") return "answered";
129
+ if (reply.kind === "error" && reply.code === "pane_not_found")
130
+ return "pane-missing";
131
+ return "failed";
132
+ }
133
+
134
+ /** Parses one reply line; a reply for another request id is never accepted. */
135
+ export function parseHerdrSocketReply(
136
+ line: string,
137
+ id: string,
138
+ ): HerdrSocketReply {
139
+ let reply: JsonObject | undefined;
140
+ try {
141
+ const parsed: unknown = JSON.parse(line);
142
+ if (isRecord(parsed)) reply = parsed;
143
+ } catch {
144
+ // Malformed JSON is the same failure as a non-object reply below.
145
+ }
146
+ if (!reply) return { kind: "failed", reason: "reply is not a JSON object" };
147
+ if (reply.id !== id)
148
+ return {
149
+ kind: "failed",
150
+ reason: `reply id ${bounded(JSON.stringify(reply.id) ?? "missing")} is not ${id}`,
151
+ };
152
+ if (isRecord(reply.error))
153
+ return {
154
+ kind: "error",
155
+ code: isNonEmptyString(reply.error.code)
156
+ ? bounded(reply.error.code)
157
+ : "herdr_error",
158
+ message: isString(reply.error.message)
159
+ ? bounded(reply.error.message)
160
+ : "",
161
+ };
162
+ if (!isRecord(reply.result))
163
+ return { kind: "failed", reason: "reply has no result" };
164
+ return { kind: "result", result: reply.result };
165
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-herdr-agents",
3
- "version": "3.1.0",
3
+ "version": "3.2.1",
4
4
  "description": "Asynchronous Pi subagents in Herdr, with optional isolated Git worktrees",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -30,10 +30,10 @@
30
30
  "type": "module",
31
31
  "scripts": {
32
32
  "changelog": "auto-changelog",
33
- "format": "biome format --write pi-extension test maestro",
34
- "format:check": "biome format pi-extension test maestro",
35
- "lint": "oxlint pi-extension test maestro",
36
- "test": "node --experimental-strip-types --test test/test.ts test/worktree-cleanup.test.ts test/launch.test.ts test/runtime-routing.test.ts test/release-workflow.test.ts test/package-manifest.test.js test/maestro/*.test.ts",
33
+ "format": "biome format --write pi-extension test maestro plugins",
34
+ "format:check": "biome format pi-extension test maestro plugins",
35
+ "lint": "oxlint pi-extension test maestro plugins",
36
+ "test": "node --experimental-strip-types --test test/test.ts test/worktree-cleanup.test.ts test/launch.test.ts test/runtime-routing.test.ts test/release-workflow.test.ts test/package-manifest.test.js test/sidebar-plugin.test.js test/maestro/*.test.ts",
37
37
  "test:integration": "node --experimental-strip-types --test --test-concurrency=1 test/integration/*.test.ts",
38
38
  "test:integration:live": "PI_TEST_LIVE=1 node --experimental-strip-types --test --test-concurrency=1 test/integration/*.test.ts",
39
39
  "version": "auto-changelog --package && git add CHANGELOG.md"
@@ -61,7 +61,7 @@
61
61
  "devDependencies": {
62
62
  "@biomejs/biome": "2.5.11",
63
63
  "@earendil-works/pi-ai": "^1.0.0",
64
- "@earendil-works/pi-coding-agent": "^1.0.0",
64
+ "@earendil-works/pi-coding-agent": "^1.1.0",
65
65
  "@earendil-works/pi-tui": "^1.0.0",
66
66
  "@oxlint/plugins": "^1.80.0",
67
67
  "@sinclair/typebox": "^0.34.52",
@@ -38,6 +38,7 @@ import {
38
38
  type CancelReport,
39
39
  } from "../../maestro/runtime/index.ts";
40
40
  import { loadSupervisionConfig } from "../../maestro/core/config/supervision-config.ts";
41
+ import { loadSidebarConfig } from "../../maestro/core/config/sidebar-config.ts";
41
42
  import {
42
43
  discoverAgentCatalog as discoverCoreAgentCatalog,
43
44
  ROLE_PACK_DISCOVERY_EVENT,
@@ -573,6 +574,10 @@ const supervisionConfig = loadSupervisionConfig(
573
574
  getSubagentsConfigDir(),
574
575
  getSubagentsConfigExamplePath(),
575
576
  );
577
+ const sidebarConfig = loadSidebarConfig(
578
+ getSubagentsConfigDir(),
579
+ getSubagentsConfigExamplePath(),
580
+ );
576
581
 
577
582
  const MAX_RESULT_PRESENTATION_CHARS = 16_000;
578
583
  const MAX_SESSION_REFERENCE_CHARS = 10_000;
@@ -2447,6 +2452,7 @@ export default function subagentsExtension(
2447
2452
  configExamplePath: getSubagentsConfigExamplePath(),
2448
2453
  roles: [],
2449
2454
  forcePolling: supervisionConfig.forcePolling,
2455
+ sidebarMarkers: sidebarConfig.enabled,
2450
2456
  infrastructure: options.infrastructure,
2451
2457
  getLaunchSnapshot() {
2452
2458
  const ctx = runtime.latestCtx;
@@ -0,0 +1,242 @@
1
+ # Pi Herdr Agents Sidebar
2
+
3
+ ![Pi Herdr Agents: a parent Pi session delegating to parallel child agents in dedicated Herdr panes, an isolated worktree and a retained session, with a live status widget.](https://raw.githubusercontent.com/giuseppecrj/pi-herdr-agents/main/docs/assets/pi-herdr-agents-gallery.png)
4
+
5
+ > **Compatibility.** The npm release `pi-herdr-agents@3.1.0` predates sidebar support. Use a local checkout that contains this plugin and the `sidebar.enabled` setting, or a later release that includes both.
6
+
7
+ An optional [Herdr](https://herdr.dev) plugin for [Pi Herdr Agents](../../README.md). It keeps your main agents easy to find in Herdr's Agents view while [Pi](https://github.com/earendil-works/pi) runs delegated children.
8
+
9
+ The plugin adds two actions. The extension in the main package marks each delegated child's pane, and Focus hides the marked rows.
10
+
11
+ ## Features
12
+
13
+ - **Focus.** Hides delegated Pi children from the Agents view, except blocked children and children whose status is unknown.
14
+ - **All.** Removes Focus if this plugin set it, then shows every agent again. If another tool replaced the view, All leaves that view in place and reports which tool owns it.
15
+ - **Display only.** Neither action closes, moves, or signals anything. Hidden children keep running, keep their panes, and still raise notifications. `herdr agent list` still lists them.
16
+ - **No automatic Focus.** Installing, linking, or enabling the plugin never turns Focus on. You invoke it.
17
+ - **Opt-in markers.** The extension writes pane markers only when you set `sidebar.enabled` to `true`.
18
+
19
+ ## Requirements
20
+
21
+ - Herdr `0.9.2-preview.2026-09-29-8e78f929d8f0`, the only build the plugin was tested on. Herdr accepts only a plain version in `min_herdr_version` and reports this preview as `0.9.2`, so the manifest declares `0.9.2`. Herdr then also accepts other 0.9.2 builds and newer ones, all untested. A build without the agent view API makes Focus and All fail with Herdr's error.
22
+ - Linux. The extension identifies child processes through `/proc`, so it marks nothing on other systems, and the manifest declares only `linux`.
23
+ - `node` on the `PATH` of the Herdr server. Herdr runs the actions as `node sidebar.mjs focus` and `node sidebar.mjs all`.
24
+ - The `pi-herdr-agents` extension, installed in Pi from a revision that supports `sidebar.enabled`, with that setting turned on. Without markers, Focus hides nothing.
25
+
26
+ ## Install
27
+
28
+ Setup has two parts. Install the Herdr plugin, then enable markers in a compatible Pi extension. The plugin alone hides nothing, and the setting alone adds no actions.
29
+
30
+ ### 1. Install the Herdr plugin
31
+
32
+ Review the plugin source, then link its directory from your local checkout:
33
+
34
+ ```bash
35
+ herdr plugin link /path/to/pi-herdr-agents/plugins/sidebar
36
+ ```
37
+
38
+ Once the GitHub default branch contains `plugins/sidebar`, you can install the subdirectory instead. Herdr shows the manifest and commands for review:
39
+
40
+ ```bash
41
+ herdr plugin install giuseppecrj/pi-herdr-agents/plugins/sidebar
42
+ ```
43
+
44
+ For another published branch, tag, or commit, add `--ref` followed by that revision. The revision must contain the plugin directory.
45
+
46
+ ### 2. Turn on markers in the Pi extension
47
+
48
+ The Pi extension must also contain sidebar support. If you do not already load a compatible copy, install the same local checkout in Pi:
49
+
50
+ ```bash
51
+ pi install /path/to/pi-herdr-agents
52
+ ```
53
+
54
+ Use `pi list` to check your existing package sources. If another copy of Pi Herdr Agents is enabled, disable that copy with `pi config` before loading the checkout. Do not load both copies. Start Pi inside Herdr.
55
+
56
+ 1. Open `$PI_CODING_AGENT_DIR/herdr-agents/config.json`. The default is `~/.pi/agent/herdr-agents/config.json`.
57
+ 2. If the file does not exist, copy `config.json.example` from the extension there first. The extension rejects a `config.json` without its `status` section.
58
+ 3. Merge the `sidebar` section into the file. Keep the existing `status`, `models`, and other sections. Do not replace the file.
59
+
60
+ ```json
61
+ {
62
+ "sidebar": {
63
+ "enabled": true
64
+ }
65
+ }
66
+ ```
67
+
68
+ 4. Run `/reload` in each parent Pi session, or start a new one.
69
+ 5. Launch new children. Children that were already running stay unmarked.
70
+ 6. Invoke Focus yourself. See [Use Focus and All](#use-focus-and-all).
71
+
72
+ ### Check the result
73
+
74
+ After you launch a new child and run Focus, a marked child with status `idle`, `working`, or `done` disappears from the Agents view while your main session stays. Blocked children remain visible. Run All to show the hidden row again. If nothing changes, read the action log:
75
+
76
+ ```bash
77
+ herdr plugin log list --plugin pi-herdr-agents.sidebar
78
+ ```
79
+
80
+ ## Contents
81
+
82
+ - [Requirements](#requirements), [Install](#install), [Use Focus and All](#use-focus-and-all)
83
+ - [Troubleshooting](#troubleshooting), [Safety](#safety), [Uninstall](#uninstall)
84
+ - [Which rows Focus hides](#which-rows-focus-hides), [What a marker proves](#what-a-marker-proves), [Reusing a delegated pane](#reusing-a-delegated-pane), [Limits](#limits)
85
+ - Main package: [Pi Herdr Agents](../../README.md) and its [sidebar markers](../../README.md#sidebar-markers) setting
86
+
87
+ ## Use Focus and All
88
+
89
+ Run an action from the command line:
90
+
91
+ ```bash
92
+ herdr plugin action invoke pi-herdr-agents.sidebar.focus
93
+ herdr plugin action invoke pi-herdr-agents.sidebar.all
94
+ ```
95
+
96
+ To bind a key, add an entry like this to your Herdr config and pick a key you do not already use:
97
+
98
+ ```toml
99
+ [[keys.command]]
100
+ key = "prefix+g"
101
+ type = "plugin_action"
102
+ command = "pi-herdr-agents.sidebar.focus"
103
+ description = "hide delegated Pi children"
104
+ ```
105
+
106
+ Herdr keeps one agent view for the whole server. Focus replaces the view that was active, including a view that another tool set. All sends its clear with this plugin as the source, so Herdr clears the view only if this plugin owns it. If another tool replaced Focus, All leaves that view in place and reports which tool owns it.
107
+
108
+ Each action writes one message to its log. Read it with `herdr plugin log list --plugin pi-herdr-agents.sidebar`. The exit status tells you what happened:
109
+
110
+ | Exit status | Meaning |
111
+ | --- | --- |
112
+ | `0` | Focus is on, or All found no view left. |
113
+ | `1` | The action failed. Herdr refused the request, sent no usable reply within 3 seconds, or still reports this plugin's view after All. The action also fails when it runs outside Herdr or as another plugin. The message names the cause. |
114
+ | `2` | The action name was not `focus` or `all`. |
115
+ | `3` | All left another tool's view in place. |
116
+
117
+ A Herdr server restart drops the view and every marker. The plugin has no startup hook, so run Focus again after a restart if you want it.
118
+
119
+ ## Troubleshooting
120
+
121
+ - **A child you expect to be hidden is still visible.** Check that `sidebar.enabled` is `true`, that you ran `/reload` in the parent, and that the child was launched after the reload. Then run Focus again. A blocked child or a child with unknown status is always shown.
122
+ - **A row you expect is missing.** Run All. Markers change only what the Agents view shows.
123
+ - **Focus or All exits with status `1`.** Read the log with the command above. The message names the cause, such as a Herdr build without the agent view API.
124
+ - **All exits with status `3`.** Another tool owns the view. All leaves it in place.
125
+ - **A new agent in a delegated pane is hidden.** See [Reusing a delegated pane](#reusing-a-delegated-pane).
126
+
127
+ ## Safety
128
+
129
+ - Markers are display only. They are not a security boundary.
130
+ - The plugin sends requests to your local Herdr server. It does not close, move, or signal panes.
131
+ - With `sidebar.enabled` on, the parent records each child's process identity and reports one pane token, `piha_delegated_v1`, through the Herdr socket. With it off, the extension writes no pane metadata. See [Safety and uninstall](../../README.md#safety-and-uninstall) in the main README.
132
+
133
+ ## Uninstall
134
+
135
+ 1. Run All, or skip this step. Herdr also drops this plugin's view when you disable, unlink, or uninstall the plugin.
136
+ 2. Remove the plugin. Use `uninstall` for a GitHub install and `unlink` for a linked directory:
137
+
138
+ ```bash
139
+ herdr plugin uninstall pi-herdr-agents.sidebar
140
+ herdr plugin unlink pi-herdr-agents.sidebar
141
+ ```
142
+
143
+ 3. Set `sidebar.enabled` to `false`, or delete the `sidebar` key, and run `/reload` in each parent session. The reload clears the markers of children that are still running and stops renewing them. A marker whose clear does not reach Herdr expires within 15 seconds.
144
+
145
+ ## Which rows Focus hides
146
+
147
+ The extension writes the pane token `piha_delegated_v1` with the value `live`
148
+ on a delegated child's pane. Focus hides a row only when that token is `live`
149
+ and the agent status is `idle`, `working`, or `done`. Focus sends this
150
+ filter, with no sort:
151
+
152
+ ```json
153
+ {
154
+ "op": "not",
155
+ "filter": {
156
+ "op": "all",
157
+ "filters": [
158
+ { "op": "eq", "field": { "token": "piha_delegated_v1" }, "value": "live" },
159
+ { "op": "in", "field": "status", "values": ["idle", "working", "done"] }
160
+ ]
161
+ }
162
+ }
163
+ ```
164
+
165
+ | Agent | Marker | Focus |
166
+ | --- | --- | --- |
167
+ | Main session or manually started agent | none | shown |
168
+ | `/worktree` handoff session | none | shown |
169
+ | Delegated child: fresh, resumed, persistent, or in a managed worktree | `live` | hidden |
170
+ | Delegated child that Herdr reports as blocked | `live` | shown |
171
+ | Agent whose status is unknown | any | shown |
172
+ | Pane with a missing marker or any other value | missing or other | shown |
173
+ | Retained worktree shell after its child exits | cleared | shown |
174
+
175
+ The `done` status means idle and not yet seen. It does not mean that anyone
176
+ accepted the child's work, so Focus hides it like any idle child.
177
+
178
+ ## What a marker proves
179
+
180
+ A `live` marker means the extension classified the pane's process as a
181
+ delegated child within the last 15 seconds. It does not prove that the same
182
+ process occupies the pane when Herdr draws the row.
183
+
184
+ The parent Pi session writes the marker after its child records its own
185
+ process identity and the parent verifies it. The parent renews the marker every
186
+ 4 to 5 seconds while that exact process (PID and start time) is alive. Renewal
187
+ runs on the parent's existing supervision checks, so if those checks stall,
188
+ the marker expires and the child reappears. The parent clears the marker when
189
+ the child finishes, when it stops supervising the child, or when it finds that
190
+ process gone or unverifiable. The marker belongs to the pane, not to the process.
191
+ Renewal checks only the original process, so a different process in the same
192
+ pane can be hidden for as long as the original process stays alive and
193
+ supervised. See [Reusing a delegated pane](#reusing-a-delegated-pane).
194
+
195
+ Each write carries a 15-second time-to-live and a sequence number that
196
+ increases across all of a parent's writes. The 15-second guarantee counts from
197
+ the last write Herdr accepted. It does not restart when the pane's occupant
198
+ changes or the original process is suspended. Herdr ignores a write whose
199
+ sequence number is not higher than the last one it applied, so a delayed
200
+ renewal cannot undo a clear. The extension does not retry a failed write and
201
+ stops writing after three failures in a row.
202
+
203
+ Some leftover markers still occur:
204
+
205
+ - If the parent process exits or crashes, its last marker stays until its
206
+ time-to-live ends, up to 15 seconds after the last write Herdr applied.
207
+ - A renewal that Herdr processes late starts a new 15-second period, unless a
208
+ clear with a higher sequence number has already arrived.
209
+ - A new agent started in the same pane during that window stays hidden by
210
+ Focus until the marker expires.
211
+ - If the parent restarts while the same Herdr server keeps running, and the
212
+ system clock has moved backwards, the new parent's sequence numbers can fall
213
+ behind the last ones Herdr applied. Herdr then ignores its writes until the
214
+ clock catches up. A Herdr server restart resets the sequence state and drops
215
+ every marker.
216
+
217
+ Run All whenever a row you expect is missing. Markers change only what the
218
+ Agents view shows. They are not a security boundary.
219
+
220
+ ### Reusing a delegated pane
221
+
222
+ The marker follows the pane, and the parent keeps renewing it while the
223
+ original child process is alive and supervised. A child suspended with Ctrl-Z
224
+ is still alive, so if you start another agent in that pane by hand, Focus can
225
+ hide the new agent until the original child is stopped or no longer
226
+ supervised. Run All before you reuse a delegated pane, or start the new agent
227
+ in a fresh pane. To end renewal, stop the child with the host's normal
228
+ controls. The last marker then lasts at most 15 seconds after the last write
229
+ Herdr accepted. The first version of the plugin has this limitation, and no
230
+ fix for pane reuse is planned for it.
231
+
232
+ ## Limits
233
+
234
+ - Tested only on Linux with the Herdr build named above, one local server, and
235
+ one attached client. Remote machines and several attached clients are
236
+ untested.
237
+ - The marker follows the pane, not the process. See
238
+ [Reusing a delegated pane](#reusing-a-delegated-pane).
239
+ - Herdr allows at most 32 sequenced metadata sources per pane. The extension
240
+ uses the single source `pi-herdr-agents` for every marker.
241
+ - The plugin has no popup, settings, or startup hook, and it does not hide
242
+ workspaces or notifications.
@@ -0,0 +1,21 @@
1
+ id = "pi-herdr-agents.sidebar"
2
+ name = "Pi Herdr Agents Sidebar"
3
+ version = "0.1.0"
4
+ # Tested only on 0.9.2-preview.2026-09-29-8e78f929d8f0. Herdr accepts only a
5
+ # plain version here and reports that preview as 0.9.2, so any 0.9.2 build,
6
+ # including a stable one, passes this check untested. See README.md.
7
+ min_herdr_version = "0.9.2"
8
+ description = "Hide delegated Pi children from the Agents view (Focus) or show every agent (All)"
9
+ platforms = ["linux"]
10
+
11
+ [[actions]]
12
+ id = "focus"
13
+ title = "Pi agents: Focus (hide delegated children)"
14
+ contexts = ["global"]
15
+ command = ["node", "sidebar.mjs", "focus"]
16
+
17
+ [[actions]]
18
+ id = "all"
19
+ title = "Pi agents: All (show every agent)"
20
+ contexts = ["global"]
21
+ command = ["node", "sidebar.mjs", "all"]