pi-herdr-agents 3.0.2 → 3.1.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 (33) hide show
  1. package/AGENTS.md +2 -0
  2. package/CHANGELOG.md +42 -1
  3. package/CONTEXT.md +25 -4
  4. package/README.md +126 -13
  5. package/RELEASING.md +2 -0
  6. package/docs/README.md +2 -1
  7. package/docs/adr/0011-explicit-worktree-cleanup.md +1 -1
  8. package/docs/adr/0015-report-opened-primary-workspace.md +121 -0
  9. package/docs/worktree-subagents.md +5 -3
  10. package/maestro/adapters/pi/activity-file.ts +11 -1
  11. package/maestro/adapters/pi/launch.ts +29 -15
  12. package/maestro/adapters/pi/process-identity.ts +38 -0
  13. package/maestro/core/activity.ts +1 -1
  14. package/maestro/core/config/model-config.ts +83 -10
  15. package/maestro/core/config/task-model-init.ts +208 -13
  16. package/maestro/core/config/task-model-types.ts +19 -1
  17. package/maestro/core/config/type-guards.ts +5 -0
  18. package/maestro/core/opened-primary-workspace.ts +108 -0
  19. package/maestro/core/supervision.ts +38 -13
  20. package/maestro/core/surface-provider.ts +22 -0
  21. package/maestro/core/wake.ts +37 -0
  22. package/maestro/core/worktree-cleanup.ts +52 -1
  23. package/maestro/core/worktree.ts +2 -0
  24. package/maestro/runtime/index.ts +2 -1
  25. package/maestro/runtime/task-model-init.ts +106 -9
  26. package/maestro/runtime/worktree-operations.ts +2 -0
  27. package/maestro/surfaces/fake/fake-surface-provider.ts +8 -0
  28. package/maestro/surfaces/herdr/herdr-surface-provider.ts +12 -1
  29. package/maestro/surfaces/herdr/herdr.ts +345 -49
  30. package/maestro/surfaces/herdr/terminal.ts +0 -10
  31. package/package.json +1 -1
  32. package/pi-extension/subagents/index.ts +170 -16
  33. package/skills/pi-herdr-agents/SKILL.md +16 -4
@@ -1,6 +1,7 @@
1
1
  import type {
2
2
  AgentToolResult,
3
3
  ExtensionAPI,
4
+ ExtensionCommandContext,
4
5
  ExtensionContext,
5
6
  SessionShutdownEvent,
6
7
  } from "@earendil-works/pi-coding-agent";
@@ -19,8 +20,9 @@ import { randomUUID } from "node:crypto";
19
20
  import type { RunCancellation, Task } from "../../maestro/core/types.ts";
20
21
  import {
21
22
  createDefaultRunSession,
22
- initializeTaskModels,
23
+ startTaskModelInit,
23
24
  observePiActivity,
25
+ type TaskModelInitOutcome,
24
26
  type PiRunSession,
25
27
  type PiRunSessionInfrastructure,
26
28
  type PiRunRecord,
@@ -60,9 +62,17 @@ import { wrapPiModelRegistry } from "./model-registry.ts";
60
62
  import {
61
63
  loadModelConfig,
62
64
  MISSING_CONFIG_REVISION,
65
+ parseRankingBasis,
66
+ readModelConfigSnapshot,
63
67
  resolveModelDefault,
64
68
  writeTaskModelConfig,
65
69
  } from "../../maestro/core/config/model-config.ts";
70
+ import {
71
+ type OfferCallback,
72
+ type TaskModelBrief,
73
+ TASK_MODEL_INIT_APPROVAL_EVENT,
74
+ TASK_MODEL_INIT_START_EVENT,
75
+ } from "../../maestro/core/config/task-model-init.ts";
66
76
  import {
67
77
  TASK_CATEGORIES,
68
78
  TASK_CATEGORY_DESCRIPTIONS,
@@ -98,6 +108,7 @@ import { isSubagentActivityScope } from "../../maestro/core/activity.ts";
98
108
  import type { SubagentActivityState } from "../../maestro/core/types.ts";
99
109
  import {
100
110
  isFiniteNumber,
111
+ isFunction,
101
112
  isPlainObject,
102
113
  isString,
103
114
  } from "../../maestro/core/config/type-guards.ts";
@@ -344,6 +355,47 @@ function discoverAgentDefinitions(
344
355
  return discoverAgentCatalog(pi).agents;
345
356
  }
346
357
 
358
+ /**
359
+ * Payload of TASK_MODEL_INIT_APPROVAL_EVENT. `context` is the live context of
360
+ * the command that started init. It passes between trusted in-process
361
+ * extensions and is current only while the request is emitted.
362
+ */
363
+ interface TaskModelApprovalRequest {
364
+ apiVersion: 1;
365
+ brief: TaskModelBrief;
366
+ context: ExtensionCommandContext;
367
+ offer: OfferCallback;
368
+ }
369
+
370
+ function hasMethods(value: any, names: readonly string[]): boolean {
371
+ return value != null && names.every((name) => isFunction(value[name]));
372
+ }
373
+
374
+ /**
375
+ * Read a TASK_MODEL_INIT_START_EVENT v1 payload. A v1 request whose context or
376
+ * preferences cannot run init still gets an offer, so its emitter reports why
377
+ * instead of concluding that no host is loaded.
378
+ */
379
+ function parseInitStartRequest(raw: any):
380
+ | {
381
+ offer: OfferCallback;
382
+ input?: { context: ExtensionCommandContext; preferences: string };
383
+ }
384
+ | undefined {
385
+ if (raw?.apiVersion !== 1 || !isFunction(raw.offer)) return undefined;
386
+ const { offer, context, preferences } = raw;
387
+ const usable =
388
+ isString(preferences) &&
389
+ hasMethods(context, ["isIdle", "hasPendingMessages"]) &&
390
+ hasMethods(context.modelRegistry, ["getAvailable"]);
391
+ return {
392
+ offer: (value) => offer.call(raw, value),
393
+ // The members init reads were checked above; the rest is the emitting
394
+ // command's own live context.
395
+ input: usable ? { context, preferences } : undefined,
396
+ };
397
+ }
398
+
347
399
  function missingRoleMessage(agentName: string): string {
348
400
  return `Agent "${agentName}" was not found. pi-herdr-agents ships no roles: define it in .pi/agents or the global agents directory, install a role pack that provides it, or omit agent for a bare launch.`;
349
401
  }
@@ -684,6 +736,16 @@ function formatWorktreeHandoff(worktree: WorktreeHandoff): string {
684
736
  return lines.join("\n");
685
737
  }
686
738
 
739
+ function launchDiagnosticsText(
740
+ diagnostics: readonly string[] | undefined,
741
+ prefix: string,
742
+ suffix: string,
743
+ ): string {
744
+ return diagnostics?.length
745
+ ? `${prefix}Launch diagnostics: ${diagnostics.join("; ")}.${suffix}`
746
+ : "";
747
+ }
748
+
687
749
  function resolveResultPresentation(
688
750
  result: Pick<
689
751
  SubagentResult,
@@ -2606,7 +2668,7 @@ export default function subagentsExtension(
2606
2668
  pi.registerTool({
2607
2669
  name: "subagents_write_task_models",
2608
2670
  label: "Write task model preferences",
2609
- description: `Validate and atomically replace models.tasks and models.tasksMeta in the durable Pi agent config, preserving unrelated settings. Supported categories: ${TASK_CATEGORIES.join(", ")}. Partial nonempty categories are accepted; omitted categories are removed. Rejects duplicate exact refs within a category. Review the active authenticated registry and existing preferences first. Optional expectedConfigRevision makes the write conditional: it fails without replacing configuration when the config file no longer matches the revision the proposal was read from. Cooperating writes are serialized and fail on contention rather than waiting. Returns normalized saved preferences, missing categories, and the configRevision of the written file; reload required.`,
2671
+ description: `Validate and atomically replace models.tasks and models.tasksMeta in the durable Pi agent config, preserving unrelated settings. Supported categories: ${TASK_CATEGORIES.join(", ")}. Partial nonempty categories are accepted; omitted categories are removed. Rejects duplicate exact refs within a category. Review the active authenticated registry and existing preferences first. Optional expectedConfigRevision makes the write conditional: it fails without replacing configuration when the config file no longer matches the revision the proposal was read from. Optional basis records what the ranking rests on; its kind must equal tasksMeta.method, and it is returned but not saved. Cooperating writes are serialized and fail on contention rather than waiting. Returns normalized saved preferences, missing categories, the configRevision of the written file, and any basis; reload required.`,
2610
2672
  parameters: Type.Object({
2611
2673
  tasks: Type.Object(
2612
2674
  Object.fromEntries(
@@ -2640,6 +2702,37 @@ export default function subagentsExtension(
2640
2702
  },
2641
2703
  ),
2642
2704
  ),
2705
+ basis: Type.Optional(
2706
+ Type.Union(
2707
+ [
2708
+ Type.Object(
2709
+ { kind: Type.Literal("registry-only") },
2710
+ { additionalProperties: false },
2711
+ ),
2712
+ Type.Object(
2713
+ {
2714
+ kind: Type.Literal("research"),
2715
+ sources: Type.Array(
2716
+ Type.Object(
2717
+ {
2718
+ url: Type.String({ minLength: 1 }),
2719
+ influence: Type.String({ minLength: 1 }),
2720
+ },
2721
+ { additionalProperties: false },
2722
+ ),
2723
+ { minItems: 1 },
2724
+ ),
2725
+ uncertainty: Type.String({ minLength: 1 }),
2726
+ },
2727
+ { additionalProperties: false },
2728
+ ),
2729
+ ],
2730
+ {
2731
+ description:
2732
+ 'What the ranking rests on, as submitted by the proposer and not independently verified: {"kind":"registry-only"}, or {"kind":"research"} with the http(s) sources consulted in this run, how each informed the ranking, and the remaining uncertainty. kind must equal tasksMeta.method. Not saved. Omit for a legacy write.',
2733
+ },
2734
+ ),
2735
+ ),
2643
2736
  }),
2644
2737
  execute: async (_id, params, _signal, _update, ctx) => {
2645
2738
  const registry = wrapPiModelRegistry(ctx.modelRegistry);
@@ -2647,6 +2740,10 @@ export default function subagentsExtension(
2647
2740
  const tasks = params.tasks as TaskPreferences;
2648
2741
  // SAFETY: TypeBox validates the tool payload; the write seam performs stricter schema validation.
2649
2742
  const tasksMeta = params.tasksMeta as TaskPreferencesMeta;
2743
+ // Present-but-invalid values (including null) fail before anything is written.
2744
+ const basis = Object.hasOwn(params, "basis")
2745
+ ? parseRankingBasis(params.basis, tasksMeta.method)
2746
+ : undefined;
2650
2747
  const saved = writeTaskModelConfig(
2651
2748
  getSubagentsConfigPath(),
2652
2749
  getSubagentsConfigExamplePath(),
@@ -2663,14 +2760,15 @@ export default function subagentsExtension(
2663
2760
  ? { expectedConfigRevision: params.expectedConfigRevision }
2664
2761
  : {},
2665
2762
  );
2763
+ const details = basis ? { ...saved, basis } : saved;
2666
2764
  return {
2667
2765
  content: [
2668
2766
  {
2669
2767
  type: "text",
2670
- text: `Wrote task model preferences. Reload required.\n${JSON.stringify(saved, null, 2)}`,
2768
+ text: `Wrote task model preferences. Reload required.\n${JSON.stringify(details, null, 2)}`,
2671
2769
  },
2672
2770
  ],
2673
- details: saved,
2771
+ details,
2674
2772
  };
2675
2773
  },
2676
2774
  });
@@ -2833,6 +2931,7 @@ export default function subagentsExtension(
2833
2931
  (running.worktree
2834
2932
  ? ` in worktree ${running.worktree.path} on branch ${running.worktree.branch}. `
2835
2933
  : ". ") +
2934
+ launchDiagnosticsText(running.worktree?.diagnostics, "", " ") +
2836
2935
  `Do NOT generate or assume any results — you have no idea what the sub-agent will do or produce. ` +
2837
2936
  `The results will be delivered to you automatically as a steer message when the sub-agent finishes. ` +
2838
2937
  `Until then, move on to other work or tell the user you're waiting.`,
@@ -3316,21 +3415,73 @@ export default function subagentsExtension(
3316
3415
  },
3317
3416
  });
3318
3417
 
3319
- if (!process.env.PI_SUBAGENT_ID)
3418
+ if (!process.env.PI_SUBAGENT_ID) {
3419
+ const runTaskModelInit = (
3420
+ ctx: ExtensionCommandContext,
3421
+ preferences: string,
3422
+ ): TaskModelInitOutcome => {
3423
+ if (!ctx.isIdle() || ctx.hasPendingMessages())
3424
+ return {
3425
+ kind: "not-started",
3426
+ reason:
3427
+ "a turn is in progress or messages are queued. Nothing was queued; retry when idle",
3428
+ };
3429
+ // Pi refuses a prompt without a model only after submission; refuse first.
3430
+ if (!ctx.model)
3431
+ return {
3432
+ kind: "not-started",
3433
+ reason:
3434
+ "no model is selected, so Pi cannot run the init prompt. Select a model, then retry",
3435
+ };
3436
+ return startTaskModelInit({
3437
+ preferences,
3438
+ readConfig: () => readModelConfigSnapshot(getSubagentsConfigDir()),
3439
+ projectActiveRegistry: (project) => project(ctx.modelRegistry),
3440
+ emitApprovalRequest: (brief, offer) =>
3441
+ pi.events.emit(
3442
+ TASK_MODEL_INIT_APPROVAL_EVENT,
3443
+ Object.freeze({
3444
+ apiVersion: 1,
3445
+ brief,
3446
+ context: ctx,
3447
+ offer,
3448
+ } satisfies TaskModelApprovalRequest),
3449
+ ),
3450
+ isToolActive: (name) => pi.getActiveTools().includes(name),
3451
+ submit: (prompt) => pi.sendUserMessage(prompt),
3452
+ });
3453
+ };
3320
3454
  pi.registerCommand("subagents-init", {
3321
3455
  description:
3322
3456
  "Draft task-category model preferences from the live registry; optional arguments set ranking preferences",
3323
3457
  handler: async (args, ctx) => {
3324
- const registry = ctx.modelRegistry;
3325
- const current = loadModelConfig(getSubagentsConfigDir());
3326
- const prompt = initializeTaskModels({
3327
- projectActiveRegistry: (project) => project(registry),
3328
- current,
3329
- preferences: args,
3330
- });
3331
- pi.sendUserMessage(prompt);
3458
+ const outcome = runTaskModelInit(ctx, args);
3459
+ if (outcome.kind === "not-started")
3460
+ ctx.ui.notify(
3461
+ `Task-model init not started: ${outcome.reason}. Nothing was written.`,
3462
+ "warning",
3463
+ );
3332
3464
  },
3333
3465
  });
3466
+ // Another extension's command may start the same init; it renders the outcome.
3467
+ const stopInitStart = pi.events.on(TASK_MODEL_INIT_START_EVENT, (raw) => {
3468
+ const request = parseInitStartRequest(raw);
3469
+ if (!request) return;
3470
+ const { input } = request;
3471
+ request.offer({
3472
+ owner: "pi-herdr-agents",
3473
+ start: (): TaskModelInitOutcome =>
3474
+ input
3475
+ ? runTaskModelInit(input.context, input.preferences)
3476
+ : {
3477
+ kind: "not-started",
3478
+ reason:
3479
+ "the start request lacks a usable command context or preferences text",
3480
+ },
3481
+ });
3482
+ });
3483
+ pi.on("session_shutdown", () => stopInitStart());
3484
+ }
3334
3485
 
3335
3486
  pi.registerCommand("worktree", {
3336
3487
  description: parentSession
@@ -3462,10 +3613,13 @@ export default function subagentsExtension(
3462
3613
  throw new Error("Worktree handoff did not return worktree metadata");
3463
3614
  }
3464
3615
  ctx.ui.notify(
3465
- result.focusError
3616
+ (result.focusError
3466
3617
  ? `Worktree launched, but workspace focus failed: ${result.focusError}\nWorktree: ${worktree.path}`
3467
- : `Worktree launched in ${worktree.path} (workspace ${worktree.workspaceId}).`,
3468
- result.focusError ? "warning" : "info",
3618
+ : `Worktree launched in ${worktree.path} (workspace ${worktree.workspaceId}).`) +
3619
+ launchDiagnosticsText(worktree.diagnostics, "\n", ""),
3620
+ result.focusError || worktree.diagnostics?.length
3621
+ ? "warning"
3622
+ : "info",
3469
3623
  );
3470
3624
  } catch (error) {
3471
3625
  ctx.ui.notify(
@@ -210,7 +210,12 @@ independent writer.
210
210
  blockers. Startup does not scan it.
211
211
  - `worktree_remove({ target, preserve? })` / `/worktree remove <target>
212
212
  [--preserve]` is explicit, parent-only, and needs separate authorization;
213
- stopping or interrupting a child never authorizes cleanup. Dirty work is
213
+ stopping or interrupting a child never authorizes cleanup. Removal never
214
+ closes the source repository's primary workspace. If this process's create
215
+ appears to have opened that workspace and it still looks untouched, the
216
+ result suggests `herdr workspace close <id>` if you haven't used it. Another
217
+ session or a restarted process says nothing about it. Primary-workspace
218
+ snapshot failures at launch appear as launch diagnostics. Dirty work is
214
219
  blocked unless committed first or `preserve: true` makes a WIP commit (SHA
215
220
  reported). Eligibility is rechecked at removal and fails closed (unknown
216
221
  state, live holder, untracked files, conflicts, detached HEAD, locks,
@@ -240,12 +245,19 @@ changes. Without a real file, only non-model settings fall back to the example.
240
245
  | `roles.bundled` | Deprecated no-op; remove it |
241
246
 
242
247
  Model values must be exact authenticated IDs. `/subagents-init [preferences]`
243
- drafts task preferences from the live model registry and saves them through
244
- `subagents_write_task_models` (parent-only; validates, atomically replaces
245
- `models.tasks` and `tasksMeta`, preserves other settings). Payload: `tasks`
248
+ drafts task preferences from the live model registry and the saved config's
249
+ `configRevision`, then saves them through `subagents_write_task_models`
250
+ (parent-only; validates, atomically replaces `models.tasks` and `tasksMeta`,
251
+ preserves other settings). When one loaded extension offers to approve the
252
+ write, the init prompt names that extension's tool instead; follow the prompt
253
+ and do not call the writer around it. Payload: `tasks`
246
254
  (partial but nonempty categories; empty `tasks: {}` and duplicate refs within a
247
255
  category are rejected) and required `tasksMeta`. Warning: categories you omit
248
256
  are removed from the saved config, so send every category you want to keep.
257
+ Optional `basis` states what the ranking rests on and is not saved: use
258
+ `{"kind":"registry-only"}` unless sources consulted in this run informed it,
259
+ then `{"kind":"research","sources":[{"url":"https://...","influence":"how it changed the ranking"}],"uncertainty":"what remains unknown"}`.
260
+ Its kind must equal `tasksMeta.method`.
249
261
  Optional `expectedConfigRevision` (`sha256:` plus 64 lowercase hex digits of the exact file bytes,
250
262
  or `missing`) makes the write conditional; a stale revision fails with
251
263
  `Stale task model config revision`: re-read, re-propose, re-approve, do not