pi-pier 0.3.0 → 0.4.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.
Files changed (103) hide show
  1. package/README.md +16 -12
  2. package/package.json +3 -3
  3. package/schemas/role-manifest.schema.json +7 -1
  4. package/skills/pi-herdr-workflow/SKILL.md +4 -5
  5. package/src/ansi-text.ts +62 -11
  6. package/src/ask-dialog.ts +308 -0
  7. package/src/ask-user.ts +113 -112
  8. package/src/bootstrap.ts +10 -13
  9. package/src/compact-coordinator.ts +279 -261
  10. package/src/compact-economics-core.ts +77 -145
  11. package/src/config-catalog-core.ts +77 -197
  12. package/src/config-command.ts +271 -64
  13. package/src/cordis-services.ts +64 -0
  14. package/src/dashboard-command.ts +77 -33
  15. package/src/delegation-core.ts +115 -0
  16. package/src/efficiency-config-core.ts +286 -486
  17. package/src/efficiency-store.ts +16 -29
  18. package/src/gc-core.ts +10 -36
  19. package/src/guards.ts +6 -0
  20. package/src/herdr-client.ts +202 -233
  21. package/src/history-store.ts +38 -23
  22. package/src/index-gates.ts +118 -0
  23. package/src/index-locks.ts +2 -7
  24. package/src/index-master.ts +31 -29
  25. package/src/index-notices.ts +28 -15
  26. package/src/index-pipe.ts +31 -12
  27. package/src/index-roles.ts +306 -0
  28. package/src/index-runtime.ts +16 -6
  29. package/src/index-worker.ts +8 -18
  30. package/src/index.ts +561 -570
  31. package/src/jev-client.ts +12 -17
  32. package/src/jev-core.ts +138 -90
  33. package/src/ledger.ts +13 -21
  34. package/src/lock-core.ts +28 -56
  35. package/src/manifest-compose.ts +35 -20
  36. package/src/observation-core.ts +52 -100
  37. package/src/pane-title.ts +19 -38
  38. package/src/panel-open.ts +47 -0
  39. package/src/pi-surface.ts +34 -22
  40. package/src/pier-options.ts +53 -50
  41. package/src/pipe-channel.ts +44 -59
  42. package/src/platform-paths.ts +19 -52
  43. package/src/{core → plugins}/grid-shape.ts +8 -10
  44. package/src/{core → plugins}/heat-plan.ts +20 -22
  45. package/src/plugins/observation.ts +475 -0
  46. package/src/plugins/subagent.ts +766 -0
  47. package/src/{core → plugins}/terminal.ts +198 -139
  48. package/src/{core → plugins}/todo.ts +73 -134
  49. package/src/progress-core.ts +16 -53
  50. package/src/reconcile-core.ts +24 -33
  51. package/src/reducer-core.ts +26 -64
  52. package/src/reducer-invoker.ts +266 -239
  53. package/src/renderers.ts +108 -84
  54. package/src/role-loader.ts +33 -28
  55. package/src/role-manifest.ts +25 -31
  56. package/src/role-state.ts +119 -0
  57. package/src/roles/worker-default.json +3 -3
  58. package/src/routing-telemetry.ts +136 -0
  59. package/src/runtime-policy.ts +25 -40
  60. package/src/session-tail.ts +65 -41
  61. package/src/settle-wake-core.ts +61 -22
  62. package/src/slim-frame.ts +25 -122
  63. package/src/stale-core.ts +17 -24
  64. package/src/storage-layout.ts +14 -43
  65. package/src/subagent-core.ts +423 -120
  66. package/src/subagent-gc.ts +109 -67
  67. package/src/subagent-poller.ts +383 -24
  68. package/src/subagent-session.ts +516 -0
  69. package/src/subagent-spawn.ts +577 -52
  70. package/src/swallow.ts +7 -17
  71. package/src/terminal-core.ts +187 -134
  72. package/src/todo-core.ts +57 -109
  73. package/src/todo-read-hook.ts +46 -39
  74. package/src/todo-reminder-core.ts +26 -51
  75. package/src/todo-window.ts +11 -12
  76. package/src/todos-service.ts +15 -35
  77. package/src/tool-error.ts +6 -13
  78. package/src/tool-gate.ts +38 -21
  79. package/src/tree-view.ts +162 -0
  80. package/src/vocab.ts +52 -25
  81. package/src/worker-marker.ts +76 -0
  82. package/src/ask-multi.ts +0 -260
  83. package/src/config-guide.ts +0 -361
  84. package/src/core/observation.ts +0 -555
  85. package/src/core/subagent.ts +0 -633
  86. package/src/efficiency-config-io.ts +0 -53
  87. package/src/focus-poller.ts +0 -244
  88. package/src/git-adapter.ts +0 -113
  89. package/src/notice-buffer.ts +0 -23
  90. package/src/subagent-execute.ts +0 -328
  91. package/src/subagent-git-io.ts +0 -80
  92. package/src/subagent-launch.ts +0 -114
  93. package/src/subagent-list-action.ts +0 -69
  94. package/src/subagent-output-action.ts +0 -104
  95. package/src/subagent-output-adapter.ts +0 -27
  96. package/src/subagent-output-core.ts +0 -263
  97. package/src/subagent-poll-loop.ts +0 -305
  98. package/src/subagent-port.ts +0 -27
  99. package/src/subagent-ready.ts +0 -89
  100. package/src/subagent-registry.ts +0 -70
  101. package/src/subagent-resolution.ts +0 -41
  102. package/src/subagent-scope.ts +0 -41
  103. package/src/subagent-session-io.ts +0 -230
package/src/ask-user.ts CHANGED
@@ -1,30 +1,31 @@
1
1
  /**
2
- * ask_user_question I/O: normalize params, select+Other dialog, result envelope.
3
- *
4
- * Why: the herdr gate (blocked / pi-ask / widget collapse) stays in index.ts.
5
- * This module is the questionnaire — authored options plus a runtime Other row —
6
- * so the model cannot forget a free-text escape and Esc is a real decline.
2
+ * ask_user_question I/O: normalize params, select+Other dialog, result envelope. The herdr gate
3
+ * (blocked / pi-ask / widget collapse) stays in index.ts; this module owns the questionnaire — authored
4
+ * options plus a runtime Other row, so the model cannot forget a free-text escape and Esc is a real decline.
7
5
  */
8
6
  import { Type } from 'typebox';
9
- import { runMultiSelect } from './ask-multi.ts';
7
+ import { OTHER_ROW_LABEL, numberedOptionLine, runSelectDialog } from './ask-dialog.ts';
10
8
 
11
9
  export const ASK_TOOL_NAME = 'ask_user_question';
12
- export const OTHER_OPTION = 'Other (type your own)';
13
- export const OTHER_ANSWER = 'Other';
14
- export const RECOMMENDED_SUFFIX = ' (Recommended)';
15
- export const MIN_OPTIONS = 2;
16
- export const MAX_OPTIONS = 5;
17
- export const MAX_QUESTIONS = 4;
18
- export const MAX_LABEL_CHARS = 60;
10
+ export const OTHER_OPTION = OTHER_ROW_LABEL;
19
11
 
20
12
  export const DECLINE_TEXT =
21
13
  'User declined to answer the questions. Continue with your best judgment, or ask different questions.';
22
14
  export const NO_UI_TEXT =
23
15
  'Error: UI not available (running in non-interactive mode). The user never saw the questions — do NOT treat this as a decline. Ask the questions as plain chat text instead, without using this tool.';
24
16
 
17
+ /** Answer kind reported for the free-text row (the questions themselves are the detail). */
18
+ const OTHER_ANSWER = 'Other';
19
+ const MIN_OPTIONS = 2;
20
+ const MAX_OPTIONS = 5;
21
+ const MAX_QUESTIONS = 4;
22
+ const MAX_LABEL_CHARS = 60;
23
+
25
24
  const CUSTOM_ANSWER_TITLE = 'Type your answer:';
26
25
  const MULTI_INSTRUCTIONS =
27
26
  'Enter the numbers of all that apply, comma-separated (e.g. "1,3"), or type a custom answer as plain text.';
27
+ const MULTI_NO_OTHER_INSTRUCTIONS =
28
+ 'Enter the numbers of all that apply, comma-separated (e.g. "1,3"). There is no free-text answer for this question.';
28
29
  const NUMBERED_HINT = 'Enter a number, or type a custom answer.';
29
30
 
30
31
  const RESERVED_LOWER: Record<string, true> = {
@@ -72,7 +73,6 @@ export const ASK_TOOL_DESCRIPTION = [
72
73
  'Use recommended (0-based) to mark the default; "(Recommended)" is added automatically — do NOT write it in the label.',
73
74
  'Use multi: true when several options can apply — the TUI opens an interactive toggle list (space toggles a row, a toggles all, enter confirms, esc declines) instead of making the human type numbers.',
74
75
  'Set allowOther: false when the authored choices are exhaustive and a free-text answer would be misleading.',
75
- 'The answer comes back as the tool result; then continue your work.',
76
76
  ].join(' ');
77
77
 
78
78
  export const ASK_PROMPT_GUIDELINES = [
@@ -121,6 +121,9 @@ export type AskUi = {
121
121
  input: (title: string, placeholder?: string, opts?: { signal?: AbortSignal }) => Promise<string | undefined>;
122
122
  /** TUI-only: full custom components (absent in RPC mode, where it returns undefined). */
123
123
  custom?: (factory: unknown) => Promise<unknown>;
124
+ /** TUI-only helpers from pi's extension UI context; wired for ctrl+o parity with pi's selector. */
125
+ getToolsExpanded?: () => boolean;
126
+ setToolsExpanded?: (expanded: boolean) => void;
124
127
  };
125
128
 
126
129
  export type PrepareAskResult =
@@ -179,9 +182,7 @@ function parseOptions(raw: unknown): { ok: true; options: AskOption[] } | { ok:
179
182
  for (const item of raw) {
180
183
  const rec = asRecord(item);
181
184
  const rawLabel = typeof rec?.label === 'string' ? rec.label.trim() : '';
182
- // B3: models often repeat the UI's own marker (`label: 'Redis (Recommended)'`), which rendered
183
- // as "Redis (Recommended) (Recommended)". Only a trailing bracketed marker is stripped, so a
184
- // label that merely mentions the word ("Recommended approach") survives untouched.
185
+ // B3: models repeat the marker the UI appends ("Redis (Recommended)"); strip only a trailing one.
185
186
  const label = rawLabel.replace(/\s*[([]\s*recommended\s*[)\]]\s*$/i, '').trim();
186
187
  if (!label) {
187
188
  return fail(
@@ -290,15 +291,10 @@ export function gateLabel(spec: AskSpec): string {
290
291
  return extra > 0 ? `${first} (+${extra})` : first;
291
292
  }
292
293
 
293
- export function formatAuthoredLine(option: AskOption, index: number, recommended?: number): string {
294
- const rec = recommended === index ? RECOMMENDED_SUFFIX : '';
295
- const desc = option.description ? ` — ${option.description}` : '';
296
- return `${index + 1}. ${option.label}${rec}${desc}`;
297
- }
298
-
294
+ /** Typed-prompt list: numbered authored options plus the Other row when it is offered. */
299
295
  export function optionLines(question: AskQuestion): string[] {
300
- const lines = question.options.map((option, i) => formatAuthoredLine(option, i, question.recommended));
301
- if (question.allowOther !== false) lines.push(`${question.options.length + 1}. ${OTHER_OPTION}`);
296
+ const lines = question.options.map((option, i) => numberedOptionLine(option, i, question.recommended));
297
+ if (question.allowOther) lines.push(`${question.options.length + 1}. ${OTHER_OPTION}`);
302
298
  return lines;
303
299
  }
304
300
 
@@ -316,89 +312,55 @@ function withId(question: AskQuestion, answer: AskAnswer): AskAnswer {
316
312
  return question.id ? { ...answer, id: question.id } : answer;
317
313
  }
318
314
 
319
- async function askSingle(ui: AskUi, question: AskQuestion, signal?: AbortSignal): Promise<AskAnswer | undefined> {
320
- const lines = optionLines(question);
321
- const opts = signal ? { signal } : undefined;
322
- const chosen = ui.select
323
- ? await ui.select(question.question, lines, opts)
324
- : await ui.input(`${question.question}\n\n${lines.join('\n')}\n\n${NUMBERED_HINT}`, '1', opts);
325
- if (chosen == null) return undefined;
326
- const trimmed = chosen.trim();
327
- if (!trimmed) return undefined;
328
- const idx = parseChoice(trimmed, lines);
329
- if (idx != null && idx < question.options.length) {
330
- return withId(question, {
331
- question: question.question,
332
- kind: 'option',
333
- answer: question.options[idx]!.label,
334
- });
335
- }
336
- if (idx === question.options.length) {
337
- const typed = await ui.input(`${question.question}\n\n${CUSTOM_ANSWER_TITLE}`, '', opts);
338
- if (typed == null || !typed.trim()) return undefined;
339
- return withId(question, {
340
- question: question.question,
341
- kind: 'custom',
342
- answer: OTHER_ANSWER,
343
- customInput: typed.trim(),
344
- });
345
- }
346
- if (!ui.select) {
347
- return withId(question, {
348
- question: question.question,
349
- kind: 'custom',
350
- answer: OTHER_ANSWER,
351
- customInput: trimmed,
352
- });
353
- }
354
- return undefined;
315
+ /** Free-text answer behind the Other row; shared by the dialog and the typed-prompt fallback. */
316
+ async function askOtherText(ui: AskUi, question: AskQuestion, signal?: AbortSignal): Promise<AskAnswer | undefined> {
317
+ const typed = await ui.input(`${question.question}\n\n${CUSTOM_ANSWER_TITLE}`, '', signal ? { signal } : undefined);
318
+ if (typed == null || !typed.trim()) return undefined;
319
+ return withId(question, {
320
+ question: question.question,
321
+ kind: 'custom',
322
+ answer: OTHER_ANSWER,
323
+ customInput: typed.trim(),
324
+ });
355
325
  }
356
326
 
357
- async function askMulti(ui: AskUi, question: AskQuestion, signal?: AbortSignal): Promise<AskAnswer | undefined> {
358
- // TUI path: a real toggle list. ctx.ui.custom is absent in RPC mode (or returns undefined),
359
- // in which case we fall through to the typed-index prompt below.
360
- if (typeof ui.custom === 'function') {
361
- const outcome = await runMultiSelect(ui.custom, question.question, {
362
- options: question.options,
363
- allowOther: question.allowOther,
364
- ...(question.recommended !== undefined ? { recommended: question.recommended } : {}),
365
- });
366
- if (outcome?.kind === 'cancel') return undefined;
367
- if (outcome?.kind === 'other') {
368
- const typed = await ui.input(`${question.question}\n\n${CUSTOM_ANSWER_TITLE}`, '', signal ? { signal } : undefined);
369
- if (typed == null || !typed.trim()) return undefined;
370
- return withId(question, {
371
- question: question.question,
372
- kind: 'custom',
373
- answer: OTHER_ANSWER,
374
- customInput: typed.trim(),
375
- });
376
- }
377
- if (outcome?.kind === 'selected') {
378
- return withId(question, { question: question.question, kind: 'multi', answer: null, selected: outcome.labels });
379
- }
380
- }
381
- const list = question.options
382
- .map((option, i) => formatAuthoredLine(option, i, question.recommended))
383
- .join('\n');
384
- const hint = question.allowOther === false
385
- ? `Enter the numbers of all that apply, comma-separated (e.g. "1,3"). There is no free-text answer for this question.`
386
- : MULTI_INSTRUCTIONS;
387
- const value = await ui.input(
388
- `${question.question}\n\n${list}\n\n${hint}`,
389
- '1,3',
390
- signal ? { signal } : undefined,
391
- );
392
- if (value == null) return undefined;
393
- const trimmed = value.trim();
327
+ /**
328
+ * Interpret one typed answer. Single-select: a listed line or its number takes that option, the Other
329
+ * number opens the free-text prompt, anything else is a custom answer — but only on the typed fallback,
330
+ * since a real select dialog can only return a listed row. Multi-select: comma/space separated numbers,
331
+ * the Other number alone opens free text, an empty submission is an empty selection. Returns 'other' to
332
+ * ask for free text, undefined to decline.
333
+ */
334
+ function parseTypedPicks(
335
+ question: AskQuestion,
336
+ lines: readonly string[],
337
+ raw: string,
338
+ typedFallback: boolean,
339
+ ): AskAnswer[] | 'other' | undefined {
340
+ const trimmed = raw.trim();
341
+ const otherIndex = question.options.length;
394
342
  if (!trimmed) {
395
- return withId(question, { question: question.question, kind: 'multi', answer: null, selected: [] });
343
+ return question.multi
344
+ ? [withId(question, { question: question.question, kind: 'multi', answer: null, selected: [] })]
345
+ : undefined;
346
+ }
347
+ if (!question.multi) {
348
+ const idx = parseChoice(trimmed, lines);
349
+ if (idx === otherIndex) return 'other';
350
+ if (idx != null && idx < otherIndex) {
351
+ return [withId(question, { question: question.question, kind: 'option', answer: question.options[idx]!.label })];
352
+ }
353
+ return typedFallback
354
+ ? [withId(question, { question: question.question, kind: 'custom', answer: OTHER_ANSWER, customInput: trimmed })]
355
+ : undefined;
396
356
  }
357
+ // The Other row is numbered like the authored options; typing it alone opens the free-text prompt.
358
+ if (trimmed.split(/[,\s]+/).length === 1 && parseChoice(trimmed, lines) === otherIndex) return 'other';
397
359
  const tokens = trimmed.split(/[,\s]+/).filter((tok) => tok.length > 0);
398
360
  const indices = tokens.map((tok) => {
399
361
  if (!/^\d+\.?$/.test(tok)) return null;
400
362
  const idx = Number.parseInt(tok, 10) - 1;
401
- return idx >= 0 && idx < question.options.length ? idx : null;
363
+ return idx >= 0 && idx < otherIndex ? idx : null;
402
364
  });
403
365
  if (indices.every((i): i is number => i != null)) {
404
366
  const selected: string[] = [];
@@ -406,16 +368,57 @@ async function askMulti(ui: AskUi, question: AskQuestion, signal?: AbortSignal):
406
368
  const label = question.options[i]!.label;
407
369
  if (!selected.includes(label)) selected.push(label);
408
370
  }
409
- return withId(question, { question: question.question, kind: 'multi', answer: null, selected });
371
+ return [withId(question, { question: question.question, kind: 'multi', answer: null, selected })];
410
372
  }
411
- // Unparsable input is a free-text answer only when the free-text row was offered.
412
- if (question.allowOther === false) return undefined;
413
- return withId(question, {
414
- question: question.question,
415
- kind: 'custom',
416
- answer: OTHER_ANSWER,
417
- customInput: trimmed,
418
- });
373
+ return question.allowOther
374
+ ? [withId(question, { question: question.question, kind: 'custom', answer: OTHER_ANSWER, customInput: trimmed })]
375
+ : undefined;
376
+ }
377
+
378
+ /** Ask one question, both modes: the pier dialog, then the host's select dialog (single only), then
379
+ * the typed prompt. `ui.custom` is absent (or resolves undefined) in RPC mode, hence every rung. */
380
+ async function askQuestion(ui: AskUi, question: AskQuestion, signal?: AbortSignal): Promise<AskAnswer | undefined> {
381
+ const { multi } = question;
382
+ if (typeof ui.custom === 'function') {
383
+ const toggleToolsExpanded = ui.getToolsExpanded && ui.setToolsExpanded
384
+ ? (): void => ui.setToolsExpanded!(!ui.getToolsExpanded!())
385
+ : undefined;
386
+ const outcome = await runSelectDialog(
387
+ ui.custom,
388
+ question.question,
389
+ {
390
+ options: question.options,
391
+ allowOther: question.allowOther,
392
+ multi,
393
+ ...(question.recommended !== undefined ? { recommended: question.recommended } : {}),
394
+ },
395
+ { ...(signal ? { signal } : {}), ...(toggleToolsExpanded ? { toggleToolsExpanded } : {}) },
396
+ );
397
+ if (outcome?.kind === 'cancel') return undefined;
398
+ if (outcome?.kind === 'other') return askOtherText(ui, question, signal);
399
+ if (outcome?.kind === 'selected') {
400
+ return withId(
401
+ question,
402
+ multi
403
+ ? { question: question.question, kind: 'multi', answer: null, selected: outcome.labels }
404
+ : { question: question.question, kind: 'option', answer: outcome.labels[0]! },
405
+ );
406
+ }
407
+ }
408
+ const lines = optionLines(question);
409
+ const opts = signal ? { signal } : undefined;
410
+ // A multi question has no single-pick host dialog; its typed prompt is always the fallback.
411
+ const select = multi ? undefined : ui.select;
412
+ const hint = multi
413
+ ? (question.allowOther ? MULTI_INSTRUCTIONS : MULTI_NO_OTHER_INSTRUCTIONS)
414
+ : NUMBERED_HINT;
415
+ const raw = select
416
+ ? await select(question.question, lines, opts)
417
+ : await ui.input(`${question.question}\n\n${lines.join('\n')}\n\n${hint}`, multi ? '1,3' : '1', opts);
418
+ if (raw == null) return undefined;
419
+ const picks = parseTypedPicks(question, lines, raw, select === undefined);
420
+ if (picks === 'other') return askOtherText(ui, question, signal);
421
+ return picks?.[0];
419
422
  }
420
423
 
421
424
  export function formatAskContent(details: AskDetails): string {
@@ -448,9 +451,7 @@ export async function runAsk(spec: AskSpec, ui: AskUi, signal?: AbortSignal): Pr
448
451
  }
449
452
  const answers: AskAnswer[] = [];
450
453
  for (const question of spec.questions) {
451
- const answer = question.multi
452
- ? await askMulti(ui, question, signal)
453
- : await askSingle(ui, question, signal);
454
+ const answer = await askQuestion(ui, question, signal);
454
455
  if (!answer) return okResult({ answers, cancelled: true });
455
456
  answers.push(answer);
456
457
  }
package/src/bootstrap.ts CHANGED
@@ -1,13 +1,10 @@
1
1
  /**
2
2
  * Bootstrap the master Cordis tree (D78 mount topology, D80 ecosystem adoption, D81 split).
3
3
  *
4
- * Load the group, timer, and HMR plugins in order so declarations resolve and HMR stays a
5
- * development-only path (`--expose-internals` plus PI_HERDR_HMR=1); production has no watcher.
6
- *
7
- * Workers bypass this module (C3/D81) because short-lived processes mount manually without
8
- * loader or HMR; this module and subagent-scope.ts are master-only dynamic imports.
9
- *
10
- * Core modules will gradually become loader entries; manual mounts remain non-hot-reloaded (D80③).
4
+ * Loads group, timer and HMR plugins in order so declarations resolve, with HMR as a development-only
5
+ * path (`--expose-internals` plus PIER_HMR=1); production has no watcher. Workers bypass this module
6
+ * (C3/D81): short-lived processes mount manually, and this module plus subagent-poller.ts are the
7
+ * master-only dynamic imports.
11
8
  */
12
9
  import { Context } from '@deepseek-ai/cordis';
13
10
  import Loader from '@deepseek-ai/cordis-plugin-loader';
@@ -15,6 +12,7 @@ import Group from '@deepseek-ai/cordis-plugin-group';
15
12
  import { pathToFileURL, fileURLToPath } from 'node:url';
16
13
  import * as path from 'node:path';
17
14
  import { DisposeLedger } from './ledger.ts';
15
+ import { cordisProvide } from './cordis-services.ts';
18
16
  import { pierOption } from './pier-options.ts';
19
17
 
20
18
  export interface BootstrapHooks {
@@ -24,9 +22,8 @@ export interface BootstrapHooks {
24
22
  export interface CordisApp {
25
23
  /** Root owns subagent scopes and lifecycle effects. */
26
24
  root: Context;
27
- /** False preserves the bare-tree fallback, matching the baseline behavior. */
25
+ /** False means the loader was not mounted (bare-tree fallback). */
28
26
  loaderReady: boolean;
29
- /** HMR is enabled only in the development posture. */
30
27
  hmrActive: boolean;
31
28
  /** Shared ledger compensates HMR disposal and handles session_shutdown cleanup. */
32
29
  ledger: DisposeLedger;
@@ -41,11 +38,11 @@ export async function createCordisApp(hooks: BootstrapHooks = {}): Promise<Cordi
41
38
  const root = new Context();
42
39
  const ledger = new DisposeLedger();
43
40
  // Inject the ledger through Cordis DI because loader transforms entry config and cannot carry live instances.
44
- root.provide('pi-herdr.ledger', ledger);
41
+ cordisProvide(root, 'pi-herdr.ledger', ledger);
45
42
  if (hooks.onDispose) {
46
43
  root.effect(() => () => { hooks.onDispose?.(); }, 'session-root');
47
44
  }
48
- // Resolve relative entry names (./core/...) from this module's directory.
45
+ // Resolve relative entry names (./plugins/...) from this module's directory.
49
46
  root.baseUrl = pathToFileURL(path.dirname(fileURLToPath(import.meta.url))).href + '/';
50
47
 
51
48
  let loaderReady = false;
@@ -78,8 +75,8 @@ export async function createCordisApp(hooks: BootstrapHooks = {}): Promise<Cordi
78
75
  });
79
76
  hmrActive = true;
80
77
  console.error('[pi-herdr] hmr active (dev posture: --expose-internals + PI_HERDR_HMR=1)');
81
- // D80⑤: HMR already disposes old fiber effects; this hook additionally retires old pi-surface generations.
82
- // d87 keeps generations registered after the reload boundary alive; disposeKey retires only older entries.
78
+ // HMR already disposes old fiber effects; disposeKey additionally retires the older pi-surface
79
+ // generations while keeping generations registered after the reload boundary alive (D80⑤/d87).
83
80
  // Cordis' own HMR boundary event (not part of pi's typed Events map).
84
81
  (root as unknown as { on(name: string, fn: (reloads: unknown) => void): void }).on('hmr/reload', (reloads: unknown) => {
85
82
  try {