pi-herdr-agents 3.0.3 → 3.2.0

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
  },
@@ -1,22 +1,119 @@
1
1
  import {
2
2
  buildTaskModelBrief,
3
3
  buildTaskModelInitPrompt,
4
+ collectApprovalOffers,
5
+ DIRECT_WRITER_DESTINATION,
6
+ type NonEmpty,
7
+ type OfferCallback,
8
+ openApprovalOffer,
9
+ selectApprovalRoute,
10
+ type TaskModelBrief,
11
+ type TaskModelFacts,
12
+ type TaskModelInitDestination,
4
13
  type TaskModelRegistryProjector,
5
14
  type TaskModelRegistrySnapshot,
6
15
  } from "../core/config/task-model-init.ts";
7
- import type { ModelConfig } from "../core/config/model-config.ts";
16
+ import type { ModelConfigSnapshot } from "../core/config/model-config.ts";
8
17
  import { projectTaskModelRegistry } from "../adapters/pi/task-model-init.ts";
9
18
 
10
- /** Compose the active Pi projection with current saved choices and the complete init prompt. */
11
- export function initializeTaskModels(input: {
19
+ /** What one init request did. Only `started` submitted a prompt. */
20
+ export type TaskModelInitOutcome =
21
+ | { kind: "started"; destination: string }
22
+ | { kind: "not-started"; reason: string };
23
+
24
+ export interface TaskModelInitInput {
25
+ preferences: string;
26
+ /** Saved settings and the revision of the same bytes; throws when invalid. */
27
+ readConfig(): ModelConfigSnapshot;
12
28
  /** The host supplies its actual active registry to this projector exactly once. */
13
29
  projectActiveRegistry(
14
30
  project: TaskModelRegistryProjector,
15
31
  ): TaskModelRegistrySnapshot;
16
- current: ModelConfig;
17
- preferences: string;
18
- }): string {
19
- const snapshot = input.projectActiveRegistry(projectTaskModelRegistry);
20
- const brief = buildTaskModelBrief(snapshot, input.current, input.preferences);
21
- return buildTaskModelInitPrompt(brief);
32
+ /** Emits the approval request for this brief and returns when listeners did. */
33
+ emitApprovalRequest(brief: TaskModelBrief, offer: OfferCallback): void;
34
+ isToolActive(name: string): boolean;
35
+ /** Hands the prompt to Pi; a throw means Pi did not take it. */
36
+ submit(prompt: string): void;
37
+ }
38
+
39
+ /** Listeners share the brief; freezing it keeps the prompt equal to what they saw. */
40
+ function deepFreeze<T>(value: T): T {
41
+ if (value == null || Object(value) !== value) return value;
42
+ // SAFETY: Object(value) === value holds only for objects and functions.
43
+ for (const item of Object.values(value as object)) deepFreeze(item);
44
+ return Object.freeze(value);
45
+ }
46
+
47
+ function isNonEmpty<T>(values: T[]): values is NonEmpty<T> {
48
+ return values.length > 0;
49
+ }
50
+
51
+ /**
52
+ * Capture one brief, pick where its proposal goes, and submit the prompt. It
53
+ * runs synchronously, so nothing can change between selection and submission.
54
+ * An opened offer that is not submitted is cancelled before this returns.
55
+ */
56
+ export function startTaskModelInit(
57
+ input: TaskModelInitInput,
58
+ ): TaskModelInitOutcome {
59
+ const notStarted = (reason: string): TaskModelInitOutcome => ({
60
+ kind: "not-started",
61
+ reason,
62
+ });
63
+ let snapshot: ModelConfigSnapshot;
64
+ try {
65
+ snapshot = input.readConfig();
66
+ } catch (error) {
67
+ return notStarted(error instanceof Error ? error.message : String(error));
68
+ }
69
+ const { models } = input.projectActiveRegistry(projectTaskModelRegistry);
70
+ if (!isNonEmpty<TaskModelFacts>(models))
71
+ return notStarted(
72
+ "the active registry has no authenticated models, so there is nothing to rank. Configure a provider, then run /subagents-init again",
73
+ );
74
+ const brief = deepFreeze(
75
+ buildTaskModelBrief({
76
+ models,
77
+ current: snapshot.config,
78
+ configRevision: snapshot.revision,
79
+ preferences: input.preferences,
80
+ }),
81
+ );
82
+ const route = selectApprovalRoute(
83
+ collectApprovalOffers((offer) => input.emitApprovalRequest(brief, offer)),
84
+ );
85
+ let destination: TaskModelInitDestination;
86
+ let cancel: () => string | undefined = () => undefined;
87
+ switch (route.kind) {
88
+ case "direct":
89
+ destination = DIRECT_WRITER_DESTINATION;
90
+ break;
91
+ case "refused":
92
+ return notStarted(route.reason);
93
+ case "offer": {
94
+ const opened = openApprovalOffer(route.offer);
95
+ if (opened.kind === "blocked") return notStarted(opened.reason);
96
+ destination = opened.destination;
97
+ cancel = opened.cancel;
98
+ break;
99
+ }
100
+ default: {
101
+ const _exhaustive: never = route;
102
+ return _exhaustive;
103
+ }
104
+ }
105
+ const cancelled = (reason: string): TaskModelInitOutcome => {
106
+ const problem = cancel();
107
+ return notStarted(problem ? `${reason}; ${problem}` : reason);
108
+ };
109
+ if (!input.isToolActive(destination.toolName))
110
+ return cancelled(`${destination.toolName} is not an active tool`);
111
+ try {
112
+ input.submit(buildTaskModelInitPrompt(brief, destination));
113
+ } catch (error) {
114
+ return cancelled(
115
+ `Pi did not accept the init prompt (${error instanceof Error ? error.message : String(error)})`,
116
+ );
117
+ }
118
+ return { kind: "started", destination: destination.toolName };
22
119
  }
@@ -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.0.3",
3
+ "version": "3.2.0",
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",