pi-herdr-agents 2.0.4 → 3.0.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.
Files changed (77) hide show
  1. package/AGENTS.md +20 -14
  2. package/CHANGELOG.md +63 -1
  3. package/CONTEXT.md +86 -32
  4. package/README.md +262 -141
  5. package/RELEASING.md +4 -2
  6. package/config.json.example +0 -3
  7. package/docs/README.md +38 -15
  8. package/docs/adr/0001-btw-ephemeral-side-questions.md +1 -1
  9. package/docs/adr/0002-agent-workflow-skill-runtime-taxonomy.md +1 -1
  10. package/docs/adr/0003-installable-role-packs.md +21 -13
  11. package/docs/adr/0009-remove-workflow-subsystem.md +1 -1
  12. package/docs/adr/0011-explicit-worktree-cleanup.md +1 -1
  13. package/docs/adr/0012-adopt-maestro-seams-in-repo.md +45 -0
  14. package/docs/adr/0013-pack-neutral-execution-host.md +63 -0
  15. package/docs/adr/0014-operator-cancel-terminal-intent.md +134 -0
  16. package/docs/worktree-subagents.md +10 -6
  17. package/maestro/adapters/fake/fake-harness-adapter.ts +263 -0
  18. package/{pi-extension/subagents/activity.ts → maestro/adapters/pi/activity-file.ts} +15 -72
  19. package/{pi-extension/subagents → maestro/adapters/pi/child}/subagent-done.ts +30 -3
  20. package/{pi-extension/subagents → maestro/adapters/pi}/completion.ts +4 -4
  21. package/{pi-extension/subagents → maestro/adapters/pi}/launch.ts +388 -285
  22. package/maestro/adapters/pi/model-registry.ts +46 -0
  23. package/maestro/adapters/pi/pi-harness-adapter.ts +701 -0
  24. package/maestro/adapters/pi/process-identity.ts +303 -0
  25. package/{pi-extension/subagents → maestro/adapters/pi}/session.ts +1 -13
  26. package/maestro/adapters/pi/task-model-init.ts +55 -0
  27. package/maestro/core/activity.ts +29 -0
  28. package/maestro/core/config/model-config.ts +473 -0
  29. package/{pi-extension/subagents → maestro/core/config}/pane-config.ts +4 -6
  30. package/{pi-extension/subagents → maestro/core/config}/persistent-config.ts +4 -6
  31. package/{pi-extension/subagents → maestro/core/config}/role-config.ts +15 -10
  32. package/{pi-extension/subagents → maestro/core/config}/supervision-config.ts +4 -6
  33. package/{pi-extension/subagents → maestro/core/config}/task-model-init.ts +45 -58
  34. package/maestro/core/config/task-model-types.ts +22 -0
  35. package/maestro/core/harness-adapter.ts +117 -0
  36. package/maestro/core/index.ts +3 -0
  37. package/{pi-extension/subagents → maestro/core}/lifecycle.ts +27 -126
  38. package/maestro/core/roles/discovery.ts +555 -0
  39. package/{pi-extension/subagents/runtime-routing.ts → maestro/core/routing.ts} +36 -43
  40. package/maestro/core/shell.ts +3 -0
  41. package/{pi-extension/subagents → maestro/core}/status.ts +3 -7
  42. package/{pi-extension/subagents → maestro/core}/supervision.ts +1 -1
  43. package/maestro/core/surface-provider.ts +185 -0
  44. package/maestro/core/types.ts +449 -0
  45. package/maestro/core/worktree-cleanup.ts +374 -0
  46. package/maestro/core/worktree.ts +93 -0
  47. package/maestro/runtime/index.ts +44 -0
  48. package/maestro/runtime/pi-run-session.ts +1210 -0
  49. package/maestro/runtime/run-session.ts +801 -0
  50. package/maestro/runtime/surface-retention.ts +6 -0
  51. package/maestro/runtime/task-model-init.ts +22 -0
  52. package/maestro/runtime/worktree-operations.ts +627 -0
  53. package/maestro/surfaces/fake/fake-surface-provider.ts +311 -0
  54. package/maestro/surfaces/herdr/herdr-surface-provider.ts +200 -0
  55. package/{pi-extension/subagents → maestro/surfaces/herdr}/herdr.ts +65 -14
  56. package/{pi-extension/subagents → maestro/surfaces/herdr}/terminal.ts +7 -5
  57. package/package.json +10 -9
  58. package/pi-extension/subagents/config-path.ts +5 -1
  59. package/pi-extension/subagents/index.ts +848 -1805
  60. package/pi-extension/subagents/model-registry.ts +46 -0
  61. package/skills/pi-herdr-agents/SKILL.md +276 -0
  62. package/agents/adversarial-reviewer.md +0 -153
  63. package/agents/planner.md +0 -87
  64. package/agents/poteto.md +0 -32
  65. package/agents/reviewer.md +0 -132
  66. package/agents/scout.md +0 -105
  67. package/agents/visual-tester.md +0 -234
  68. package/agents/worker.md +0 -121
  69. package/docs/review-evaluation.md +0 -183
  70. package/pi-extension/subagents/model-config.ts +0 -305
  71. package/pi-extension/subagents/plan-skill.md +0 -280
  72. package/pi-extension/subagents/worktree-cleanup.ts +0 -789
  73. package/skills/orchestrate/SKILL.md +0 -91
  74. package/skills/orchestrate/adversarial-review-example.js +0 -400
  75. package/skills/orchestrate/adversarial-review.md +0 -87
  76. /package/{pi-extension/subagents → maestro/core/config}/type-guards.ts +0 -0
  77. /package/{pi-extension/subagents → maestro/core}/wake.ts +0 -0
@@ -0,0 +1,303 @@
1
+ import {
2
+ linkSync,
3
+ mkdirSync,
4
+ readFileSync,
5
+ readlinkSync,
6
+ unlinkSync,
7
+ writeFileSync,
8
+ } from "node:fs";
9
+ import { dirname } from "node:path";
10
+ import {
11
+ isFiniteNumber,
12
+ isRecord,
13
+ isString,
14
+ type JsonObject,
15
+ } from "../../core/config/type-guards.ts";
16
+
17
+ /**
18
+ * Immutable kernel facts naming one Pi child process for its whole life.
19
+ * Mutable command-line text (Pi rewrites `process.title`) is never identity.
20
+ */
21
+ export interface PiProcessIdentity {
22
+ pid: number;
23
+ /** `/proc/<pid>/stat` field 22: clock ticks after boot, fixed at fork. */
24
+ startTime: string;
25
+ /** `/proc/sys/kernel/random/boot_id`; start times are per boot. */
26
+ bootId: string;
27
+ /** `/proc/self/ns/pid` link target; PIDs are per PID namespace. */
28
+ pidNamespace: string;
29
+ }
30
+
31
+ /** One sidecar per session, so a reused run ID can never inherit a record. */
32
+ export function getSubagentProcessIdentityFile(sessionFile: string): string {
33
+ return `${sessionFile}.process.json`;
34
+ }
35
+
36
+ /** The child's self-recorded identity sidecar, bound to one run and session. */
37
+ export interface PiProcessIdentityRecord extends PiProcessIdentity {
38
+ version: 1;
39
+ id: string;
40
+ sessionFile: string;
41
+ }
42
+
43
+ export interface ProcessStat {
44
+ /** Single-letter state; Z (zombie) and X (dead) have exited. */
45
+ state: string;
46
+ ppid: number;
47
+ startTime: string;
48
+ }
49
+
50
+ /** This host's kernel process facts; tests inject a fake. */
51
+ export interface ProcessIdentityProbe {
52
+ /** The calling process's namespace facts; throws when unreadable or unsupported. */
53
+ host(): { bootId: string; pidNamespace: string };
54
+ /** `undefined` only when no such PID exists; throws on any other read failure. */
55
+ stat(pid: number): ProcessStat | undefined;
56
+ /** Sends SIGTERM to a PID; throws ESRCH when it is already gone. */
57
+ terminate(pid: number): void;
58
+ }
59
+
60
+ export type ProcessIdentityState =
61
+ | { kind: "alive" }
62
+ | { kind: "exited" }
63
+ | { kind: "unknown"; reason: string };
64
+
65
+ // `pid (comm) state ppid ...`: comm may hold spaces or parentheses, so fields
66
+ // are counted after the last `)`. Field 3 is index 0; starttime (22) is 19.
67
+ export function parseProcessStat(text: string): ProcessStat {
68
+ const fields = text.slice(text.lastIndexOf(")") + 2).split(" ");
69
+ const ppid = Number(fields[1]);
70
+ const startTime = fields[19];
71
+ if (
72
+ !fields[0] ||
73
+ !Number.isInteger(ppid) ||
74
+ !startTime ||
75
+ !/^\d+$/.test(startTime)
76
+ )
77
+ throw new Error("unparseable /proc stat");
78
+ return { state: fields[0], ppid, startTime };
79
+ }
80
+
81
+ function errnoCode(error: Error): string | undefined {
82
+ // SAFETY: fs and process.kill failures are Node errors with an optional code.
83
+ return (error as NodeJS.ErrnoException).code;
84
+ }
85
+
86
+ /** Linux `/proc` facts; the reader is injectable only for error-path tests. */
87
+ export function createLinuxProcessProbe(
88
+ read: (path: string) => string = (path) => readFileSync(path, "utf8"),
89
+ ): ProcessIdentityProbe {
90
+ return {
91
+ host() {
92
+ if (process.platform !== "linux")
93
+ throw new Error(
94
+ `process identity is unsupported on ${process.platform}; only Linux /proc is read`,
95
+ );
96
+ // /proc must describe this process's own PID namespace before any PID read
97
+ // through it is evidence.
98
+ const self = read("/proc/self/stat");
99
+ parseProcessStat(self);
100
+ if (Number(self.slice(0, self.indexOf(" "))) !== process.pid)
101
+ throw new Error("/proc does not describe this process's PID namespace");
102
+ return {
103
+ bootId: read("/proc/sys/kernel/random/boot_id").trim(),
104
+ pidNamespace: readlinkSync("/proc/self/ns/pid"),
105
+ };
106
+ },
107
+ stat(pid) {
108
+ let text: string;
109
+ try {
110
+ text = read(`/proc/${pid}/stat`);
111
+ } catch (error) {
112
+ const code = error instanceof Error ? errnoCode(error) : undefined;
113
+ if (code === "ENOENT" || code === "ESRCH") return undefined;
114
+ throw error;
115
+ }
116
+ return parseProcessStat(text);
117
+ },
118
+ terminate(pid) {
119
+ process.kill(pid, "SIGTERM");
120
+ },
121
+ };
122
+ }
123
+
124
+ export const linuxProcessProbe = createLinuxProcessProbe();
125
+
126
+ /** The calling process's identity; throws when this host cannot establish it. */
127
+ export function readOwnProcessIdentity(
128
+ probe: ProcessIdentityProbe = linuxProcessProbe,
129
+ ): PiProcessIdentity {
130
+ const host = probe.host();
131
+ const stat = probe.stat(process.pid);
132
+ if (!stat) throw new Error("own /proc stat is missing");
133
+ return { pid: process.pid, startTime: stat.startTime, ...host };
134
+ }
135
+
136
+ /**
137
+ * Publish the record atomically and only once: a torn read is impossible and
138
+ * a later writer (a reload in the same process, or any other process) never
139
+ * replaces the first identity.
140
+ */
141
+ export function writeProcessIdentityRecord(
142
+ file: string,
143
+ record: PiProcessIdentityRecord,
144
+ ): void {
145
+ mkdirSync(dirname(file), { recursive: true });
146
+ const temporary = `${file}.${process.pid}.${Date.now()}.tmp`;
147
+ writeFileSync(temporary, JSON.stringify(record), { flag: "wx" });
148
+ try {
149
+ linkSync(temporary, file);
150
+ } catch (error) {
151
+ if (!(error instanceof Error) || errnoCode(error) !== "EEXIST") throw error;
152
+ } finally {
153
+ unlinkSync(temporary);
154
+ }
155
+ }
156
+
157
+ /** `undefined` until the child has published; throws on a malformed record. */
158
+ export function readProcessIdentityRecord(
159
+ file: string,
160
+ ): PiProcessIdentityRecord | undefined {
161
+ let text: string;
162
+ try {
163
+ text = readFileSync(file, "utf8");
164
+ } catch (error) {
165
+ if (error instanceof Error && errnoCode(error) === "ENOENT")
166
+ return undefined;
167
+ throw error;
168
+ }
169
+ let value: JsonObject | undefined;
170
+ try {
171
+ const parsed: unknown = JSON.parse(text);
172
+ if (isRecord(parsed)) value = parsed;
173
+ } catch {
174
+ // Malformed JSON is the same failure as a malformed record below.
175
+ }
176
+ if (
177
+ !value ||
178
+ value.version !== 1 ||
179
+ !isString(value.id) ||
180
+ !isString(value.sessionFile) ||
181
+ !isFiniteNumber(value.pid) ||
182
+ !Number.isInteger(value.pid) ||
183
+ value.pid <= 0 ||
184
+ !isString(value.startTime) ||
185
+ !/^\d+$/.test(value.startTime) ||
186
+ !isString(value.bootId) ||
187
+ !value.bootId ||
188
+ !isString(value.pidNamespace) ||
189
+ !value.pidNamespace
190
+ )
191
+ throw new Error(`malformed process identity record ${file}`);
192
+ return {
193
+ version: 1,
194
+ id: value.id,
195
+ sessionFile: value.sessionFile,
196
+ pid: value.pid,
197
+ startTime: value.startTime,
198
+ bootId: value.bootId,
199
+ pidNamespace: value.pidNamespace,
200
+ };
201
+ }
202
+
203
+ /**
204
+ * Whether THAT process still exists. Only an absent PID or a zombie is exit
205
+ * evidence; a different start time, another boot or namespace, or any read
206
+ * failure is unknown, never guessed exited.
207
+ */
208
+ export function judgeProcessIdentity(
209
+ identity: PiProcessIdentity,
210
+ probe: ProcessIdentityProbe = linuxProcessProbe,
211
+ ): ProcessIdentityState {
212
+ let stat: ProcessStat | undefined;
213
+ try {
214
+ const host = probe.host();
215
+ if (host.bootId !== identity.bootId)
216
+ return {
217
+ kind: "unknown",
218
+ reason: `process ${identity.pid} was recorded on another boot or host`,
219
+ };
220
+ if (host.pidNamespace !== identity.pidNamespace)
221
+ return {
222
+ kind: "unknown",
223
+ reason: `process ${identity.pid} was recorded in another PID namespace (${identity.pidNamespace}, not ${host.pidNamespace})`,
224
+ };
225
+ stat = probe.stat(identity.pid);
226
+ } catch (error) {
227
+ return {
228
+ kind: "unknown",
229
+ reason: `process ${identity.pid} is unreadable: ${error instanceof Error ? error.message : String(error)}`,
230
+ };
231
+ }
232
+ if (!stat) return { kind: "exited" };
233
+ if (stat.startTime !== identity.startTime)
234
+ return {
235
+ kind: "unknown",
236
+ reason: `PID ${identity.pid} now names a different process (start time ${stat.startTime}, recorded ${identity.startTime})`,
237
+ };
238
+ if (stat.state === "Z" || stat.state === "X") return { kind: "exited" };
239
+ return { kind: "alive" };
240
+ }
241
+
242
+ const MAX_ANCESTRY_DEPTH = 64;
243
+
244
+ /**
245
+ * Accept a child's record only for this run and session, while it is alive in
246
+ * this host's namespace, and only as the pane shell or a descendant of it.
247
+ */
248
+ export function verifyProcessIdentityRecord(
249
+ record: PiProcessIdentityRecord,
250
+ expected: { id: string; sessionFile: string; shellPid: number },
251
+ probe: ProcessIdentityProbe = linuxProcessProbe,
252
+ ): PiProcessIdentity {
253
+ if (record.id !== expected.id || record.sessionFile !== expected.sessionFile)
254
+ throw new Error("process identity record belongs to another run");
255
+ const identity: PiProcessIdentity = {
256
+ pid: record.pid,
257
+ startTime: record.startTime,
258
+ bootId: record.bootId,
259
+ pidNamespace: record.pidNamespace,
260
+ };
261
+ const state = judgeProcessIdentity(identity, probe);
262
+ if (state.kind !== "alive")
263
+ throw new Error(
264
+ state.kind === "exited"
265
+ ? `process ${identity.pid} exited before its identity was verified`
266
+ : state.reason,
267
+ );
268
+ let pid = identity.pid;
269
+ for (let depth = 0; depth < MAX_ANCESTRY_DEPTH; depth++) {
270
+ if (pid === expected.shellPid) return identity;
271
+ const stat = probe.stat(pid);
272
+ if (!stat || stat.ppid <= 0) break;
273
+ pid = stat.ppid;
274
+ }
275
+ throw new Error(
276
+ `process ${identity.pid} is not the Herdr pane shell ${expected.shellPid} or its descendant`,
277
+ );
278
+ }
279
+
280
+ /**
281
+ * SIGTERM only the recorded identity, re-verified immediately before the
282
+ * signal. A mismatch or unreadable state is never signalled. The kernel can
283
+ * still reuse the PID between that read and `kill(2)`; Node exposes no pidfd
284
+ * signal to close the window.
285
+ */
286
+ export function terminateProcessIdentity(
287
+ identity: PiProcessIdentity,
288
+ probe: ProcessIdentityProbe = linuxProcessProbe,
289
+ ): ProcessIdentityState {
290
+ const state = judgeProcessIdentity(identity, probe);
291
+ if (state.kind !== "alive") return state;
292
+ try {
293
+ probe.terminate(identity.pid);
294
+ } catch (error) {
295
+ if (error instanceof Error && errnoCode(error) === "ESRCH")
296
+ return { kind: "exited" };
297
+ return {
298
+ kind: "unknown",
299
+ reason: `SIGTERM to process ${identity.pid} failed: ${error instanceof Error ? error.message : String(error)}`,
300
+ };
301
+ }
302
+ return state;
303
+ }
@@ -23,7 +23,7 @@ import {
23
23
  isString,
24
24
  type JsonObject,
25
25
  type JsonValue,
26
- } from "./type-guards.ts";
26
+ } from "../../core/config/type-guards.ts";
27
27
 
28
28
  export interface SessionEntry {
29
29
  type: string;
@@ -596,18 +596,6 @@ function getForkContentLines(parentSessionFile: string): string[] {
596
596
  });
597
597
  }
598
598
 
599
- export function createBtwSessionSnapshot(
600
- parentSessionFile: string,
601
- leafId: string,
602
- ): string {
603
- const detached = SessionManager.open(parentSessionFile);
604
- const childSessionFile = detached.createBranchedSession(leafId);
605
- if (!childSessionFile || !existsSync(childSessionFile)) {
606
- throw new Error("Pi did not persist the BTW child session");
607
- }
608
- return childSessionFile;
609
- }
610
-
611
599
  export function seedSubagentSessionFile(params: {
612
600
  mode: SeededSubagentSessionMode;
613
601
  parentSessionFile: string;
@@ -0,0 +1,55 @@
1
+ import { getSupportedThinkingLevels, type Model } from "@earendil-works/pi-ai";
2
+ import type {
3
+ TaskModelRegistrySource,
4
+ TaskModelRegistrySnapshot,
5
+ } from "../../core/config/task-model-init.ts";
6
+
7
+ const AUTH_SOURCES = new Set([
8
+ "stored",
9
+ "runtime",
10
+ "environment",
11
+ "fallback",
12
+ "models_json_key",
13
+ "models_json_command",
14
+ ]);
15
+
16
+ /** Project only nonsecret registry facts; never resolve authentication or refresh providers. */
17
+ export function projectTaskModelRegistry(
18
+ registry: TaskModelRegistrySource,
19
+ ): TaskModelRegistrySnapshot {
20
+ const extensionProviders = registry.getRegisteredProviderIds?.();
21
+ const models = registry.getAvailable().map((model) => {
22
+ const source = registry.getProviderAuthStatus?.(model.provider)?.source;
23
+ return {
24
+ ref: `${model.provider}/${model.id}`,
25
+ provider: model.provider,
26
+ id: model.id,
27
+ name: model.name,
28
+ extensionRegistered: extensionProviders?.includes(model.provider),
29
+ auth: {
30
+ configured: true as const,
31
+ source: source && AUTH_SOURCES.has(source) ? source : undefined,
32
+ },
33
+ reasoning: model.reasoning,
34
+ // SAFETY: the SDK helper reads only reasoning and thinkingLevelMap.
35
+ // Pass the original active model, not a reconstructed SDK model.
36
+ supportedThinkingLevels: getSupportedThinkingLevels(model as Model<any>),
37
+ input: model.input?.filter(
38
+ (value) => value === "text" || value === "image",
39
+ ),
40
+ contextWindow: model.contextWindow,
41
+ maxTokens: model.maxTokens,
42
+ cost: model.cost && {
43
+ input: model.cost.input,
44
+ output: model.cost.output,
45
+ cacheRead: model.cost.cacheRead,
46
+ cacheWrite: model.cost.cacheWrite,
47
+ },
48
+ };
49
+ });
50
+ models.sort((a, b) => {
51
+ if (a.ref === b.ref) return 0;
52
+ return a.ref < b.ref ? -1 : 1;
53
+ });
54
+ return { models };
55
+ }
@@ -0,0 +1,29 @@
1
+ import { isString } from "./config/type-guards.ts";
2
+ import type {
3
+ ActivityReadResult,
4
+ SubagentActivityScope,
5
+ SubagentActivityState,
6
+ } from "./types.ts";
7
+
8
+ const KNOWN_SCOPES = new Set<SubagentActivityScope>([
9
+ "agent",
10
+ "turn",
11
+ "provider",
12
+ "streaming",
13
+ "tool",
14
+ ]);
15
+
16
+ export function isSubagentActivityScope(
17
+ value: any,
18
+ ): value is SubagentActivityScope {
19
+ return isString(value) && KNOWN_SCOPES.has(value);
20
+ }
21
+
22
+ // pi-herdr-agents extension
23
+ export function projectActivity(
24
+ state: SubagentActivityState | undefined,
25
+ ): ActivityReadResult {
26
+ return state === undefined
27
+ ? { ok: false, reason: "missing" }
28
+ : { ok: true, activity: state };
29
+ }