pi-pier 0.3.0 → 0.3.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 (96) hide show
  1. package/README.md +12 -11
  2. package/package.json +2 -2
  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 +8 -12
  9. package/src/compact-coordinator.ts +278 -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/dashboard-command.ts +38 -19
  14. package/src/efficiency-config-core.ts +287 -484
  15. package/src/efficiency-store.ts +16 -29
  16. package/src/focus-poller.ts +14 -35
  17. package/src/gc-core.ts +10 -36
  18. package/src/herdr-client.ts +82 -208
  19. package/src/history-store.ts +36 -23
  20. package/src/index-gates.ts +124 -0
  21. package/src/index-locks.ts +2 -7
  22. package/src/index-master.ts +24 -18
  23. package/src/index-notices.ts +28 -15
  24. package/src/index-pipe.ts +17 -12
  25. package/src/index-roles.ts +306 -0
  26. package/src/index-runtime.ts +2 -6
  27. package/src/index-worker.ts +4 -8
  28. package/src/index.ts +305 -564
  29. package/src/jev-client.ts +12 -17
  30. package/src/jev-core.ts +137 -87
  31. package/src/ledger.ts +13 -21
  32. package/src/lock-core.ts +28 -56
  33. package/src/manifest-compose.ts +35 -20
  34. package/src/observation-core.ts +52 -100
  35. package/src/pane-title.ts +19 -38
  36. package/src/pi-surface.ts +34 -22
  37. package/src/pier-options.ts +54 -50
  38. package/src/pipe-channel.ts +27 -59
  39. package/src/platform-paths.ts +19 -52
  40. package/src/{core → plugins}/grid-shape.ts +8 -10
  41. package/src/{core → plugins}/heat-plan.ts +20 -22
  42. package/src/plugins/observation.ts +475 -0
  43. package/src/plugins/subagent.ts +728 -0
  44. package/src/{core → plugins}/terminal.ts +167 -127
  45. package/src/{core → plugins}/todo.ts +61 -100
  46. package/src/progress-core.ts +16 -53
  47. package/src/reconcile-core.ts +24 -33
  48. package/src/reducer-core.ts +24 -60
  49. package/src/reducer-invoker.ts +266 -239
  50. package/src/renderers.ts +76 -87
  51. package/src/role-loader.ts +33 -28
  52. package/src/role-manifest.ts +23 -28
  53. package/src/role-state.ts +119 -0
  54. package/src/routing-telemetry.ts +136 -0
  55. package/src/runtime-policy.ts +25 -40
  56. package/src/session-tail.ts +65 -41
  57. package/src/settle-wake-core.ts +52 -21
  58. package/src/slim-frame.ts +25 -122
  59. package/src/stale-core.ts +17 -24
  60. package/src/storage-layout.ts +14 -43
  61. package/src/subagent-core.ts +253 -118
  62. package/src/subagent-gc.ts +109 -67
  63. package/src/subagent-poller.ts +367 -24
  64. package/src/subagent-session.ts +516 -0
  65. package/src/subagent-spawn.ts +530 -52
  66. package/src/swallow.ts +7 -17
  67. package/src/terminal-core.ts +125 -106
  68. package/src/todo-core.ts +57 -109
  69. package/src/todo-read-hook.ts +23 -39
  70. package/src/todo-reminder-core.ts +26 -51
  71. package/src/todo-window.ts +11 -12
  72. package/src/todos-service.ts +15 -35
  73. package/src/tool-error.ts +6 -13
  74. package/src/tool-gate.ts +38 -21
  75. package/src/vocab.ts +52 -25
  76. package/src/ask-multi.ts +0 -260
  77. package/src/config-guide.ts +0 -361
  78. package/src/core/observation.ts +0 -555
  79. package/src/core/subagent.ts +0 -633
  80. package/src/efficiency-config-io.ts +0 -53
  81. package/src/git-adapter.ts +0 -113
  82. package/src/notice-buffer.ts +0 -23
  83. package/src/subagent-execute.ts +0 -328
  84. package/src/subagent-git-io.ts +0 -80
  85. package/src/subagent-launch.ts +0 -114
  86. package/src/subagent-list-action.ts +0 -69
  87. package/src/subagent-output-action.ts +0 -104
  88. package/src/subagent-output-adapter.ts +0 -27
  89. package/src/subagent-output-core.ts +0 -263
  90. package/src/subagent-poll-loop.ts +0 -305
  91. package/src/subagent-port.ts +0 -27
  92. package/src/subagent-ready.ts +0 -89
  93. package/src/subagent-registry.ts +0 -70
  94. package/src/subagent-resolution.ts +0 -41
  95. package/src/subagent-scope.ts +0 -41
  96. package/src/subagent-session-io.ts +0 -230
package/src/pi-surface.ts CHANGED
@@ -1,13 +1,11 @@
1
1
  /**
2
2
  * Proxy the pi ExtensionAPI registration surface (D79).
3
3
  *
4
- * pi exposes registration without unregister APIs: tools overwrite by name, but event
5
- * listeners accumulate. HMR therefore needs tombstoned, generation-scoped wrappers so
6
- * old listeners become inert while the latest generation remains active.
7
- *
8
- * Because HMR emits reload after mounting the replacement (d87), mounting a key retires
9
- * older generations immediately. The ledger then collects only generations registered
10
- * through the reload boundary; explicit disposal retires every generation.
4
+ * pi 0.86+ `pi.on()` returns an unsubscribe, so event handlers are truly removed when their generation
5
+ * retires (RFC docs/rfc-pi-0.86-dynamic-tools.md §5). Tools and commands have no unregister API: tools
6
+ * overwrite by name, so retired generations keep tombstoned wrappers there. HMR emits reload after
7
+ * mounting the replacement, so mounting a key retires older generations immediately; the ledger then
8
+ * collects only generations registered through the reload boundary.
11
9
  */
12
10
  import type { DisposeLedger } from './ledger.ts';
13
11
 
@@ -28,6 +26,19 @@ interface Group {
28
26
  alive: boolean;
29
27
  /** Monotonic mount generation used by the ledger disposer to determine its collection boundary. */
30
28
  epoch: number;
29
+ unsubscribes: Array<() => void>;
30
+ }
31
+
32
+ /** Flip the tombstone and drain any native unsubscribes; retirement must never throw. */
33
+ function retireGroup(group: Group): void {
34
+ group.alive = false;
35
+ for (const off of group.unsubscribes) {
36
+ try {
37
+ off();
38
+ } catch {
39
+ }
40
+ }
41
+ group.unsubscribes.length = 0;
31
42
  }
32
43
 
33
44
  /** Proxy for pi's registration surface (D79); non-registration methods (append/exec/setActiveTools, etc.) pass through raw. */
@@ -51,16 +62,13 @@ export class PiSurface<P extends object> {
51
62
  return this.pi;
52
63
  }
53
64
 
54
- /**
55
- * Get a module-scoped registration surface. Each call creates a new generation, retiring older
56
- * generations for the same key so HMR replacement and manual remounting share one lifecycle.
57
- * The ledger registers each key once and registers it again only after its entry is consumed.
58
- */
65
+ /** Get a module-scoped registration surface; each call creates a new generation, retiring older ones
66
+ * for the same key. The ledger registers each key once and again only after its entry is consumed. */
59
67
  forModule(key: string): ScopedSurface {
60
68
  this.epochCounter += 1;
61
69
  const epoch = this.epochCounter;
62
- for (const g of this.generations.get(key) ?? []) g.alive = false;
63
- const group: Group = { alive: true, epoch };
70
+ for (const g of this.generations.get(key) ?? []) retireGroup(g);
71
+ const group: Group = { alive: true, epoch, unsubscribes: [] };
64
72
  this.generations.set(key, [group]);
65
73
  this.groups.set(key, group);
66
74
  if (this.ledger && !this.ledgerEntryEpoch.has(key)) {
@@ -72,7 +80,7 @@ export class PiSurface<P extends object> {
72
80
  key,
73
81
  (this.generations.get(key) ?? []).filter((g) => {
74
82
  if (g.epoch <= bound) {
75
- g.alive = false;
83
+ retireGroup(g);
76
84
  return false;
77
85
  }
78
86
  return true;
@@ -94,9 +102,8 @@ export class PiSurface<P extends object> {
94
102
  );
95
103
  },
96
104
  registerCommand: (name, options) => {
97
- // pi stores commands as Map<name> (verified in the dist implementation), so same-name
98
- // registration replaces safely; the handler tombstone is a symmetry safeguard even though
99
- // replacement means the old handler will no longer be called.
105
+ // pi stores commands as Map<name>, so same-name registration replaces safely; the handler
106
+ // tombstone is a symmetry safeguard even though the old handler will no longer be called.
100
107
  const handler = options.handler as ((...a: unknown[]) => unknown) | undefined;
101
108
  const wrapped = handler
102
109
  ? (...a: unknown[]) => (group!.alive ? handler(...a) : undefined)
@@ -105,23 +112,28 @@ export class PiSurface<P extends object> {
105
112
  .registerCommand?.(name, wrapped ? { ...options, handler: wrapped } : options);
106
113
  },
107
114
  on: (event, handler) => {
115
+ // Pass-through while alive, tombstone after retirement; when pi (0.86+) returns an unsubscribe,
116
+ // retirement also removes the registration so HMR churn cannot grow the dispatch list.
108
117
  const wrapped = (...a: unknown[]) => (group!.alive ? handler(...a) : undefined);
109
- (this.pi as { on?: (e: string, h: (...a: unknown[]) => unknown) => void }).on?.(event, wrapped);
118
+ const unsubscribe: unknown = (
119
+ this.pi as { on?: (e: string, h: (...a: unknown[]) => unknown) => unknown }
120
+ ).on?.(event, wrapped);
121
+ if (typeof unsubscribe === 'function') {
122
+ group.unsubscribes.push(unsubscribe as () => void);
123
+ }
110
124
  },
111
125
  };
112
126
  }
113
127
 
114
- /** Retire all generations for a key during explicit disposal; return false when the key is absent. */
115
128
  disposeModule(key: string): boolean {
116
129
  const had = this.groups.has(key);
117
- for (const g of this.generations.get(key) ?? []) g.alive = false;
130
+ for (const g of this.generations.get(key) ?? []) retireGroup(g);
118
131
  this.generations.delete(key);
119
132
  this.groups.delete(key);
120
133
  this.ledgerEntryEpoch.delete(key);
121
134
  return had;
122
135
  }
123
136
 
124
- /** Number of live module groups, for tests and diagnostics. */
125
137
  get moduleCount(): number {
126
138
  return this.groups.size;
127
139
  }
@@ -1,63 +1,71 @@
1
1
  /**
2
- * B10: environment naming. Two prefixes grew side by side — `PI_HERDR_*` (mostly older tuning knobs)
3
- * and `PIER_*` (newer ones). Telling them apart by memory is impossible, so this module fixes one
4
- * rule and enforces it in code:
2
+ * B10 environment naming. `PIER_*` is the canonical namespace for *pier options* (timeouts, windows,
3
+ * toggles); the historical `PI_HERDR_*` spelling stays accepted as a fallback alias.
5
4
  *
6
- * - `PIER_*` is the canonical namespace for *pier options* (timeouts, windows, toggles). It reads
7
- * the historical `PI_HERDR_*` spelling as a fallback alias, so existing shells keep working.
8
- * - `PI_HERDR_SUBAGENT`, `PI_HERDR_ROLE_MANIFEST`, `PI_HERDR_TUI`, `PI_HERDR_META_KEY` stay as they
9
- * are: those are *contracts* handed to the child process by pier itself (or read by pi/herdr), and
10
- * renaming them would split running workers from their parent.
5
+ * `PI_HERDR_SUBAGENT`, `PI_HERDR_ROLE_MANIFEST`, `PI_HERDR_TUI`, `PI_HERDR_META_KEY` stay as they are:
6
+ * those are *contracts* handed to the child process by pier itself (or read by pi/herdr), and renaming
7
+ * them would split running workers from their parent.
11
8
  *
12
- * `pierOption()` is the single reader; `pierConfigCatalog()` feeds `/pier-config` so the knobs are
13
- * discoverable in one place instead of only by reading the source.
9
+ * `pierOption()` is the single reader; this registry is also the single source of the numeric
10
+ * bounds/defaults behind `runtimePolicy` (runtime-policy.ts) and the `/pier-config` catalog
11
+ * (config-catalog-core.ts), so a knob cannot mean three different things.
14
12
  */
13
+
14
+ /** RuntimePolicy field fed by an integer option (see runtime-policy.ts). */
15
+ export type PolicyField =
16
+ | 'subagentTimeoutMs'
17
+ | 'gcTickMs'
18
+ | 'pollIntervalMs'
19
+ | 'settlementWindowMs'
20
+ | 'observationWindowMs'
21
+ | 'foregroundPatienceMs'
22
+ | 'sessionTtlSeconds'
23
+ | 'gitTimeoutMs'
24
+ | 'readinessTimeoutMs';
25
+
15
26
  export interface OptionSpec {
16
- /** Canonical name. */
17
27
  readonly name: string;
18
28
  /** Historical `PI_HERDR_*` spelling still accepted. */
19
29
  readonly legacy?: string;
20
30
  /** Default when neither name is set. */
21
31
  readonly fallback: string;
22
32
  readonly description: string;
33
+ /** Integer option: smallest value the readers accept (omitted for string/enum options). */
34
+ readonly min?: number;
35
+ /** Integer option read into `RuntimePolicy`. */
36
+ readonly policy?: PolicyField;
23
37
  }
24
38
 
25
- /** Every pier option, with its alias and default. Keep in sync with `runtimePolicy`/config readers. */
39
+ /** Every pier option, with its alias, default and bounds. */
26
40
  export const PIER_OPTIONS: readonly OptionSpec[] = [
27
- // Numeric fallbacks MUST equal runtime-policy.ts (guarded by test/pier-options.test.ts): the table is
28
- // what `/pier-config doctor` shows, so a hand-typed number here is a documented lie.
29
- { name: 'PIER_GIT_TIMEOUT_MS', legacy: 'PI_HERDR_GIT_TIMEOUT_MS', fallback: '10000', description: 'git command timeout (worktree/status batches)' },
30
- { name: 'PIER_SUBAGENT_TIMEOUT_MS', legacy: 'PI_HERDR_SUBAGENT_TIMEOUT_MS', fallback: '600000', description: 'subagent inactivity budget before forced termination' },
31
- { name: 'PIER_READY_TIMEOUT_MS', legacy: 'PI_HERDR_READY_TIMEOUT_MS', fallback: '90000', description: 'how long to wait for a spawned pane to open its pipe' },
32
- { name: 'PIER_OBSERVATION_WINDOW_MS', legacy: 'PI_HERDR_OBSERVATION_WINDOW_MS', fallback: '30000', description: 'observation window before a subagent is called settled' },
33
- { name: 'PIER_POLL_INTERVAL_MS', legacy: 'PI_HERDR_POLL_INTERVAL_MS', fallback: '30000', description: 'subagent poll cadence' },
34
- { name: 'PIER_GC_TICK_MS', legacy: 'PI_HERDR_GC_TICK_MS', fallback: '30000', description: 'idle GC sweep cadence (panes/worktrees)' },
35
- { name: 'PIER_FOCUS_POLL_MS', fallback: '1500', description: 'pane-focus sampling cadence for the heat layout (0 disables; 0.9.1+ defaults to 0)' },
41
+ { name: 'PIER_GIT_TIMEOUT_MS', legacy: 'PI_HERDR_GIT_TIMEOUT_MS', fallback: '10000', min: 1, policy: 'gitTimeoutMs', description: 'git command timeout (worktree/status batches)' },
42
+ { name: 'PIER_SUBAGENT_TIMEOUT_MS', legacy: 'PI_HERDR_SUBAGENT_TIMEOUT_MS', fallback: '600000', min: 1000, policy: 'subagentTimeoutMs', description: 'subagent inactivity budget before forced termination' },
43
+ { name: 'PIER_READY_TIMEOUT_MS', legacy: 'PI_HERDR_READY_TIMEOUT_MS', fallback: '90000', min: 1000, policy: 'readinessTimeoutMs', description: 'how long to wait for a spawned pane to open its pipe (a dead pane fails fast)' },
44
+ { name: 'PIER_OBSERVATION_WINDOW_MS', legacy: 'PI_HERDR_OBSERVATION_WINDOW_MS', fallback: '30000', min: 0, policy: 'observationWindowMs', description: 'observation window before a subagent is called settled' },
45
+ { name: 'PIER_POLL_INTERVAL_MS', legacy: 'PI_HERDR_POLL_INTERVAL_MS', fallback: '30000', min: 1000, policy: 'pollIntervalMs', description: 'subagent poll cadence' },
46
+ { name: 'PIER_GC_TICK_MS', legacy: 'PI_HERDR_GC_TICK_MS', fallback: '30000', min: 1000, policy: 'gcTickMs', description: 'idle GC sweep cadence (panes/worktrees)' },
47
+ { name: 'PIER_SETTLEMENT_WINDOW_MS', legacy: 'PI_HERDR_SETTLEMENT_WINDOW_MS', fallback: '60000', min: 0, policy: 'settlementWindowMs', description: 'settlement notice window / machine-inject grace / takeover idle' },
48
+ { name: 'PIER_FOREGROUND_PATIENCE_MS', legacy: 'PI_HERDR_FOREGROUND_PATIENCE_MS', fallback: '300000', min: 0, policy: 'foregroundPatienceMs', description: 'foreground patience before demoting a subagent to background' },
49
+ { name: 'PIER_SESSION_TTL_SECONDS', legacy: 'PI_HERDR_SESSION_TTL_SECONDS', fallback: '600', min: 0, policy: 'sessionTtlSeconds', description: 'session retention after a subagent exits, before GC' },
50
+ { name: 'PIER_FOCUS_POLL_MS', fallback: '1500', min: 0, description: 'pane-focus sampling cadence for the heat layout (0 disables; event-first on herdr 0.9.1+)' },
36
51
  { name: 'PIER_TERMINAL_PROMPT', legacy: 'PI_HERDR_TERMINAL_PROMPT', fallback: '(auto from $SHELL)', description: 'prompt strategy for terminal readiness: bash|zsh|powershell|pwsh' },
37
52
  { name: 'PIER_SLIM_FRAME', legacy: 'PI_HERDR_SLIM_FRAME', fallback: '1', description: 'mutation frame around todo-tool cards (0 disables)' },
38
- { name: 'PIER_TRACE', legacy: 'PI_HERDR_TRACE', fallback: '', description: 'write diagnostics (renderers, swallowed errors) to stderr' },
39
- { name: 'PIER_TERM_IDLE_MS', legacy: 'PI_HERDR_TERM_IDLE_MS', fallback: '1800000', description: 'idle time before pier nudges about an open terminal' },
40
- { name: 'PIER_TERM_GRACE_MS', legacy: 'PI_HERDR_TERM_GRACE_MS', fallback: '30000', description: 'settle grace before the terminal idle nudge' },
41
- { name: 'PIER_TERM_READ_MAX', legacy: 'PI_HERDR_TERM_READ_MAX', fallback: '8000', description: 'characters returned per terminal read' },
42
- { name: 'PIER_TODO_GRACE_MS', legacy: 'PI_HERDR_TODO_GRACE_MS', fallback: '30000', description: 'settle grace before the unfinished-todo reminder' },
53
+ { name: 'PIER_TRACE', legacy: 'PI_HERDR_TRACE', fallback: '', description: 'write diagnostics (renderers, swallowed errors) to stderr (or to this file when it is a path)' },
54
+ { name: 'PIER_TERM_IDLE_MS', legacy: 'PI_HERDR_TERM_IDLE_MS', fallback: '1800000', min: 1, description: 'idle time before pier nudges about an open terminal (0/NaN falls back to the default)' },
55
+ { name: 'PIER_TERM_GRACE_MS', legacy: 'PI_HERDR_TERM_GRACE_MS', fallback: '30000', min: 1, description: 'settle grace before the terminal idle nudge (0/NaN falls back to the default)' },
56
+ { name: 'PIER_TERM_READ_MAX', legacy: 'PI_HERDR_TERM_READ_MAX', fallback: '8000', min: 1, description: 'characters returned per terminal read (0/NaN falls back to the default)' },
57
+ { name: 'PIER_TODO_GRACE_MS', legacy: 'PI_HERDR_TODO_GRACE_MS', fallback: '30000', min: 1, description: 'settle grace before the unfinished-todo reminder (0/NaN falls back to the default)' },
43
58
  { name: 'PIER_HMR', legacy: 'PI_HERDR_HMR', fallback: '', description: 'dev: enable the cordis HMR boundary (requires --expose-internals)' },
44
- { name: 'PIER_SETTLEMENT_WINDOW_MS', legacy: 'PI_HERDR_SETTLEMENT_WINDOW_MS', fallback: '60000', description: 'settlement notice window / machine-inject grace / takeover idle' },
45
- { name: 'PIER_FOREGROUND_PATIENCE_MS', legacy: 'PI_HERDR_FOREGROUND_PATIENCE_MS', fallback: '300000', description: 'foreground patience before demoting a subagent to background' },
46
- { name: 'PIER_SESSION_TTL_SECONDS', legacy: 'PI_HERDR_SESSION_TTL_SECONDS', fallback: '600', description: 'session retention after a subagent exits, before GC' },
47
59
  { name: 'PIER_ISOLATE_SWEEP_ORPHANS', legacy: 'PI_HERDR_ISOLATE_SWEEP_ORPHANS', fallback: '', description: 'opt-in sweeping of isolate worktrees this session never registered' },
48
60
  ];
49
61
 
50
- /**
51
- * Read a pier option: canonical name first, then the legacy alias, then the fallback.
52
- * An empty string counts as unset for both names (shells export empty vars easily).
53
- */
62
+ /** Canonical name → legacy alias → fallback; an empty string counts as unset for both. */
54
63
  export function pierOption(
55
64
  name: string,
56
65
  env: NodeJS.ProcessEnv = process.env,
57
66
  ): string | undefined {
58
67
  const spec = PIER_OPTIONS.find((o) => o.name === name);
59
- const legacy = spec?.legacy;
60
- for (const key of [name, legacy]) {
68
+ for (const key of [name, spec?.legacy]) {
61
69
  if (!key) continue;
62
70
  const raw = env[key];
63
71
  if (typeof raw === 'string' && raw.trim() !== '') return raw;
@@ -65,18 +73,6 @@ export function pierOption(
65
73
  return undefined;
66
74
  }
67
75
 
68
- /**
69
- * Human-readable option table for `/pier-config doctor`, one line per option:
70
- * `NAME = value (source) — description`. Legacy env names are called out so a stale export is visible.
71
- */
72
- export function formatOptionRows(env: NodeJS.ProcessEnv = process.env): string[] {
73
- return pierOptionRows(env).map((row) => {
74
- const source = row.source === 'env' ? 'env' : row.source === 'legacy-env' ? 'env (legacy name)' : 'default';
75
- return ` ${row.name} = ${row.value} (${source}) — ${row.description}`;
76
- });
77
- }
78
-
79
- /** `/pier-config` catalog rows: name, effective value and where it came from. */
80
76
  export function pierOptionRows(env: NodeJS.ProcessEnv = process.env): Array<{
81
77
  name: string;
82
78
  value: string;
@@ -85,12 +81,20 @@ export function pierOptionRows(env: NodeJS.ProcessEnv = process.env): Array<{
85
81
  }> {
86
82
  return PIER_OPTIONS.map((spec) => {
87
83
  const own = typeof env[spec.name] === 'string' && env[spec.name]!.trim() !== '';
88
- if (own) return { name: spec.name, value: String(env[spec.name]), source: 'env' as const, description: spec.description };
89
84
  const legacyRaw = spec.legacy ? env[spec.legacy] : undefined;
90
85
  const viaLegacy = typeof legacyRaw === 'string' && legacyRaw.trim() !== '';
91
- if (viaLegacy) {
92
- return { name: spec.name, value: String(legacyRaw), source: 'legacy-env' as const, description: spec.description };
86
+ if (own || viaLegacy) {
87
+ return { name: spec.name, value: String(own ? env[spec.name] : legacyRaw), source: own ? 'env' : 'legacy-env', description: spec.description };
93
88
  }
94
89
  return { name: spec.name, value: spec.fallback, source: 'default' as const, description: spec.description };
95
90
  });
96
91
  }
92
+
93
+ /** `/pier-config doctor` table, one line per option: `NAME = value (source) — description`; legacy env
94
+ * names are called out so a stale export is visible. */
95
+ export function formatOptionRows(env: NodeJS.ProcessEnv = process.env): string[] {
96
+ return pierOptionRows(env).map((row) => {
97
+ const source = row.source === 'default' ? 'default' : row.source === 'env' ? 'env' : 'env (legacy name)';
98
+ return ` ${row.name} = ${row.value} (${source}) — ${row.description}`;
99
+ });
100
+ }
@@ -1,15 +1,14 @@
1
1
  /**
2
- * M11 separates the human/machine channels (D45–D47) by having the pi extension
3
- * create its own Windows named pipe.
2
+ * M11 separates the human/machine channels (D45–D47) by having the pi extension create its own
3
+ * Windows named pipe.
4
4
  *
5
5
  * - Controller (client): one connection per request, matching the herdr client;
6
- * - Child pane (server): when PI_HERDR_SUBAGENT=1, the extension starts a server at
7
- * session_start and injects prompt/follow_up through pi.sendUserMessage (visible in TUI);
8
- * - Completion data is not sent back; authoritative state lives in herdr, session JSONL,
9
- * and history (D47).
6
+ * - Child pane (server): when PI_HERDR_SUBAGENT=1, the extension starts a server at session_start
7
+ * and injects prompt/follow_up through pi.sendUserMessage (visible in TUI);
8
+ * - Completion data is not sent back; authoritative state lives in herdr, session JSONL, and
9
+ * history (D47).
10
10
  *
11
- * This file contains only pure transport-layer logic for isolated tests; index.ts
12
- * performs pi injection in the server callback.
11
+ * Only pure transport-layer logic here; index.ts performs pi injection in the server callback.
13
12
  */
14
13
  import * as net from 'node:net';
15
14
  import * as fs from 'node:fs';
@@ -52,6 +51,13 @@ export type PipeRequest =
52
51
  steer?: boolean;
53
52
  }
54
53
  | { type: 'interrupt'; id: string }
54
+ | {
55
+ /** P0 (RFC §4.4): master switches a worker's role; the worker applies the manifest swap and
56
+ * replies ok/error. Mixed-version peers answer `unknown type role`, which the master surfaces. */
57
+ type: 'role';
58
+ id: string;
59
+ role: string;
60
+ }
55
61
  | {
56
62
  /** D50: fast-path settlement reply from child to requester, with summary and session path for progressive disclosure. */
57
63
  type: 'reply';
@@ -65,31 +71,25 @@ export type PipeResponse =
65
71
  | { type: 'ok'; id: string; detail?: string }
66
72
  | { type: 'error'; id: string; message: string };
67
73
 
68
- /**
69
- * Response side of the protocol (`ok` / `error`). Requests never use these tags, so this is a sound
70
- * discriminator for narrowing a parsed line at the answering end.
71
- */
72
- export function isPipeResponse(value: PipeRequest | PipeResponse): value is PipeResponse {
74
+ /** Response side of the protocol (`ok` / `error`): requests never use these tags, so this is a sound
75
+ * discriminator when narrowing a parsed line at the answering end. */
76
+ function isPipeResponse(value: PipeRequest | PipeResponse): value is PipeResponse {
73
77
  return value.type === 'ok' || value.type === 'error';
74
78
  }
75
79
 
76
- /** Parse one JSON line, returning null for malformed input. */
77
- export function parsePipeLine(line: string): PipeRequest | PipeResponse | null {
80
+ function parsePipeLine(line: string): PipeRequest | PipeResponse | null {
78
81
  const t = (line ?? '').trim();
79
82
  if (!t) return null;
80
83
  try {
81
84
  const obj = JSON.parse(t);
82
85
  if (obj && typeof obj === 'object' && typeof obj.type === 'string') return obj as PipeRequest | PipeResponse;
83
86
  } catch {
84
- /* Malformed line. */
85
87
  }
86
88
  return null;
87
89
  }
88
90
 
89
- /**
90
- * One connection, one request: connect → write JSON line → wait for JSON line → close.
91
- * Timeout / connect failure / bad frame throw (caller retries).
92
- */
91
+ /** One connection, one request: connect → write JSON line → wait for JSON line → close. Timeout /
92
+ * connect failure / bad frame throw (caller retries). */
93
93
  export function pipeRequest(
94
94
  pipeName: string,
95
95
  payload: PipeRequest,
@@ -142,9 +142,6 @@ export function pipeRequest(
142
142
  });
143
143
  }
144
144
 
145
- /**
146
- * Try new then legacy pipe names so a new client can still reach an old server.
147
- */
148
145
  export async function pipeRequestTo(
149
146
  cwd: string,
150
147
  paneId: string,
@@ -163,45 +160,18 @@ export async function pipeRequestTo(
163
160
  throw lastErr instanceof Error ? lastErr : new Error(`pipe ${names[0] ?? paneId}: unreachable`);
164
161
  }
165
162
 
166
- /** Ping with retry (readiness probe). Accepts one name or new+legacy candidates. */
167
- export async function pingUntilReady(
168
- pipeName: string | readonly string[],
169
- timeoutMs: number,
170
- intervalMs = 1000,
171
- ): Promise<boolean> {
172
- const names = typeof pipeName === 'string' ? [pipeName] : pipeName;
173
- const deadline = Date.now() + timeoutMs;
174
- while (Date.now() < deadline) {
175
- for (const name of names) {
176
- try {
177
- const res = await pipeRequest(name, { type: 'ping', id: `ping-${Date.now()}` }, 3000);
178
- if (res.type === 'ok') return true;
179
- } catch {
180
- /* not ready on this name */
181
- }
182
- }
183
- await new Promise((r) => setTimeout(r, intervalMs));
184
- }
185
- return false;
186
- }
187
-
188
- export type PipeMessageHandler = (req: PipeRequest) => Promise<PipeResponse>;
163
+ type PipeMessageHandler = (req: PipeRequest) => Promise<PipeResponse>;
189
164
 
190
- /**
191
- * Server: one line in → handler → one line out → close.
192
- * Returns the server (caller closes it).
193
- */
165
+ /** Server: one line in → handler → one line out → close. Returns the server (caller closes it). */
194
166
  export function startPipeServer(
195
167
  pipeName: string,
196
168
  handler: PipeMessageHandler,
197
169
  onError?: (err: Error) => void,
198
170
  ): net.Server {
199
171
  const socketPath = pipePathFor(pipeName);
200
- // Why: On POSIX, a crash or ungraceful shutdown leaves the UNIX domain socket file behind.
201
- // Subsequent listen calls fail with EADDRINUSE unless the stale socket is unlinked before bind.
202
- // Windows named pipes live in kernel namespace (\\.\pipe\...) and require no unlinking.
203
- // Best-effort on purpose: if the path cannot be removed (EACCES/EISDIR/ENAMETOOLONG) the
204
- // subsequent listen reports the real failure through onError instead of a synchronous throw here.
172
+ // POSIX leaves the UNIX domain socket behind after a crash, and the next listen fails with
173
+ // EADDRINUSE unless it is unlinked before bind (Windows named pipes need no unlink). Best-effort:
174
+ // if unlink fails, listen reports the real error through onError.
205
175
  if (process.platform !== 'win32') {
206
176
  try {
207
177
  fs.unlinkSync(socketPath);
@@ -243,9 +213,8 @@ export function startPipeServer(
243
213
  if (onError) {
244
214
  server.on('error', onError);
245
215
  } else {
246
- // Always attach a listener: an EventEmitter 'error' event with no listener throws and would
247
- // take the whole extension host down on a transient listen failure (EADDRINUSE/ENOTDIR).
248
- // Callers that care pass onError to make the failure observable (F04).
216
+ // Always attach a listener: an EventEmitter 'error' with no listener throws and would take the
217
+ // whole extension host down on a transient listen failure; pass onError to observe it (F04).
249
218
  server.on('error', () => { /* swallowed by design; pass onError to observe */ });
250
219
  }
251
220
  if (process.platform !== 'win32') {
@@ -253,7 +222,6 @@ export function startPipeServer(
253
222
  try {
254
223
  fs.unlinkSync(socketPath);
255
224
  } catch {
256
- /* best effort */
257
225
  }
258
226
  });
259
227
  }
@@ -2,71 +2,38 @@ import { homedir } from 'node:os'
2
2
  import { join } from 'node:path'
3
3
 
4
4
  /**
5
- * Platform-aware storage and workspace path resolution.
6
- *
7
- * Why: Centralizes platform-specific path logic to support Windows (LOCALAPPDATA),
8
- * Unix XDG conventions, and testability through injection.
5
+ * Windows uses LOCALAPPDATA, Linux XDG, macOS keeps `~/.pi` (with XDG override); herdr keeps its
6
+ * worktrees next to the data dir on Windows/Linux but under `~/.herdr` on macOS. Injectable for tests.
9
7
  */
10
-
11
8
  export interface PlatformPaths {
12
- /** Base directory for agent sessions and state */
13
9
  readonly agentDataDir: string
14
- /** Base directory for worktrees */
15
10
  readonly worktreeBaseDir: string
16
- /** Sessions subdirectory under agent data */
17
11
  readonly sessionsDir: string
18
12
  }
19
13
 
20
- function getDefaultDataDir(): string {
21
- const platform = process.platform
22
-
23
- if (platform === 'win32') {
24
- // Windows: use LOCALAPPDATA or fallback to homedir
25
- return process.env.LOCALAPPDATA || join(homedir(), 'AppData', 'Local')
26
- }
27
-
28
- // Unix: XDG_DATA_HOME or ~/.local/share on Linux, ~/.local/share on macOS
29
- if (platform === 'linux') {
30
- return process.env.XDG_DATA_HOME || join(homedir(), '.local', 'share')
31
- }
32
-
33
- // macOS: use ~/.local/share for consistency, but .pi is acceptable legacy
34
- return process.env.XDG_DATA_HOME || join(homedir(), '.pi')
35
- }
36
-
37
- function getDefaultWorktreeDir(): string {
38
- const platform = process.platform
39
-
40
- if (platform === 'win32') {
41
- const localAppData = process.env.LOCALAPPDATA || join(homedir(), 'AppData', 'Local')
42
- return join(localAppData, 'herdr', 'worktrees')
43
- }
44
-
45
- // Unix: use XDG_DATA_HOME or ~/.local/share
46
- if (platform === 'linux') {
47
- const dataHome = process.env.XDG_DATA_HOME || join(homedir(), '.local', 'share')
48
- return join(dataHome, 'herdr', 'worktrees')
49
- }
50
-
51
- // macOS legacy: ~/.herdr/worktrees
52
- return join(homedir(), '.herdr', 'worktrees')
14
+ const PLATFORM_DEFAULTS: Record<string, {
15
+ readonly envVar: string
16
+ readonly homeFallback: readonly string[]
17
+ /** macOS keeps worktrees in ~/.herdr instead of under the data dir. */
18
+ readonly worktreesUnderData: boolean
19
+ }> = {
20
+ win32: { envVar: 'LOCALAPPDATA', homeFallback: ['AppData', 'Local'], worktreesUnderData: true },
21
+ linux: { envVar: 'XDG_DATA_HOME', homeFallback: ['.local', 'share'], worktreesUnderData: true },
22
+ darwin: { envVar: 'XDG_DATA_HOME', homeFallback: ['.pi'], worktreesUnderData: false },
53
23
  }
54
24
 
55
25
  export function createPlatformPaths(overrides?: Partial<PlatformPaths>): PlatformPaths {
56
- const defaultAgentDataDir = join(getDefaultDataDir(), 'agent')
57
- const agentDataDir = overrides?.agentDataDir || defaultAgentDataDir
58
- const sessionsDir = overrides?.sessionsDir || join(agentDataDir, 'sessions')
59
- const worktreeBaseDir = overrides?.worktreeBaseDir || getDefaultWorktreeDir()
60
-
26
+ const layout = PLATFORM_DEFAULTS[process.platform] ?? PLATFORM_DEFAULTS.darwin!
27
+ const dataDir = process.env[layout.envVar] || join(homedir(), ...layout.homeFallback)
28
+ const agentDataDir = overrides?.agentDataDir || join(dataDir, 'agent')
29
+
61
30
  return {
62
31
  agentDataDir,
63
- sessionsDir,
64
- worktreeBaseDir,
32
+ sessionsDir: overrides?.sessionsDir || join(agentDataDir, 'sessions'),
33
+ worktreeBaseDir: overrides?.worktreeBaseDir
34
+ || (layout.worktreesUnderData ? join(dataDir, 'herdr', 'worktrees') : join(homedir(), '.herdr', 'worktrees')),
65
35
  }
66
36
  }
67
37
 
68
- /**
69
- * Singleton platform paths for production use.
70
- * Tests should inject via createPlatformPaths().
71
- */
38
+ /** Singleton for production; tests inject via createPlatformPaths(). */
72
39
  export const platformPaths = createPlatformPaths()
@@ -1,14 +1,13 @@
1
1
  /**
2
2
  * D97: Always split the largest cell downward so spawned panes remain full-width strips.
3
3
  *
4
- * Heat reflow narrows unfocused cells, but full-width strips keep the slim-frame title
5
- * readable on one line. The previous grid topology produced narrow vertical cells whose
6
- * titles wrapped into unreadable columns. The overlay threshold complements both layouts:
7
- * new strips cross the row limit, while existing columns cross the column limit.
4
+ * Heat reflow narrows unfocused cells, but full-width strips keep the slim-frame title readable on
5
+ * one line. The overlay threshold complements both layouts: new strips cross the row limit, while
6
+ * existing columns cross the column limit.
8
7
  *
9
- * herdr 0.8.2 defines ratio as the first child's share. Because layout.apply creates a
10
- * new shell rather than attaching an existing pane, spawn-time incremental splits must
11
- * establish this topology; heat reflow owns only later sizing.
8
+ * herdr 0.8.2 defines ratio as the first child's share. Because layout.apply creates a new shell
9
+ * rather than attaching an existing pane, spawn-time incremental splits must establish this
10
+ * topology; heat reflow owns only later sizing.
12
11
  */
13
12
 
14
13
  export type ShapeNode =
@@ -71,9 +70,8 @@ export function paneCells(root: ShapeNode, width = 200, height = 50): PaneCell[]
71
70
  }
72
71
 
73
72
  /**
74
- * Split the largest eligible cell downward; preorder tie-breaking keeps the choice stable.
75
- * At D97's 200x50 model and ten-pane cap, strips eventually become slim title frames by design.
76
- * Exclusions protect resident non-agent panes such as the board from worker splits.
73
+ * Split the largest eligible cell downward; preorder tie-breaking keeps the choice stable, and exclusions
74
+ * protect resident non-agent panes such as the board from worker splits.
77
75
  */
78
76
  export function pickGridSplit(
79
77
  root: ShapeNode,
@@ -1,35 +1,33 @@
1
1
  /**
2
- * Controlled mirror of workbench heat-layout for spawn-time pane.split ratio.
2
+ * Controlled mirror of workbench heat-layout for spawn-time pane.split ratio — do NOT merge with
3
+ * pier-workbench/src/heat-layout.ts (npm pi-pier has no workbench, and the workbench checkout has no
4
+ * pi-pier). Only planSpawnSplitRatio / simulateSplit / SPAWN_PLACEHOLDER_ID / fromShapeTree / LayoutNode
5
+ * are the interface; the weights stay in lockstep with packages/pier-workbench/test/heat-grid.test.ts.
3
6
  *
4
- * Cannot import pier-workbench (npm pi-pier has no workbench; workbench user-mode
5
- * checkout has no pi-pier). Canonical tree shape matches workbench LayoutNode.
6
- * Lockstep: packages/pier-workbench/test/heat-grid.test.ts.
7
- *
8
- * herdr v0.9.1 src/layout.rs split_at: first = original, second = new, ratio = first share.
7
+ * herdr src/layout.rs split_at: first = original, second = new, ratio = first share.
9
8
  */
10
9
  import { type ShapeNode } from './grid-shape.ts';
11
10
 
12
11
  export const SPAWN_PLACEHOLDER_ID = '__pier_new__';
13
- export const RATIO_FLOOR = 0.10;
14
- export const FOCUS_SHARE = 0.72;
15
- export const FOCUS_SHARE_BLOCKED = 0.60;
16
- export const MAX_AUTO_LAYOUT_PANES = 10;
17
- export const BLOCKED_WEIGHT = 3;
18
- export const ASK_WEIGHT = 2.5;
19
- export const WORKING_WEIGHT = 1.4;
20
- export const IDLE_WEIGHT = 1;
21
- export const SLIM_THRESHOLD = 4;
22
- export const SLIM_FACTOR = 0.6;
12
+ const RATIO_FLOOR = 0.10;
13
+ const FOCUS_SHARE = 0.72;
14
+ const FOCUS_SHARE_BLOCKED = 0.60;
15
+ const MAX_AUTO_LAYOUT_PANES = 10;
16
+ const BLOCKED_WEIGHT = 3;
17
+ const ASK_WEIGHT = 2.5;
18
+ const WORKING_WEIGHT = 1.4;
19
+ const IDLE_WEIGHT = 1;
20
+ const SLIM_THRESHOLD = 4;
21
+ const SLIM_FACTOR = 0.6;
23
22
 
24
23
  /** Same shape as pier-workbench heat-layout LayoutNode. */
25
24
  export type LayoutNode =
26
25
  | { type: 'pane'; pane_id: string }
27
26
  | { type: 'split'; direction: 'right' | 'down'; ratio: number; first: LayoutNode; second: LayoutNode };
28
27
 
29
- export type AgentStatusMap = Record<string, string>;
30
- export type AskFlagMap = Record<string, boolean>;
28
+ type AgentStatusMap = Record<string, string>;
29
+ type AskFlagMap = Record<string, boolean>;
31
30
 
32
- /** parseShapeTree (paneId) → LayoutNode (pane_id). */
33
31
  export function fromShapeTree(node: ShapeNode): LayoutNode {
34
32
  if (node.type === 'pane') return { type: 'pane', pane_id: node.paneId };
35
33
  const direction = node.direction === 'down' || node.direction === 'vertical' ? 'down' : 'right';
@@ -42,15 +40,15 @@ export function fromShapeTree(node: ShapeNode): LayoutNode {
42
40
  };
43
41
  }
44
42
 
45
- export function countPanes(node: LayoutNode): number {
43
+ function countPanes(node: LayoutNode): number {
46
44
  return node.type === 'pane' ? 1 : countPanes(node.first) + countPanes(node.second);
47
45
  }
48
46
 
49
- export function flattenPanes(node: LayoutNode): string[] {
47
+ function flattenPanes(node: LayoutNode): string[] {
50
48
  return node.type === 'pane' ? [node.pane_id] : [...flattenPanes(node.first), ...flattenPanes(node.second)];
51
49
  }
52
50
 
53
- export function containsPane(node: LayoutNode, paneId: string): boolean {
51
+ function containsPane(node: LayoutNode, paneId: string): boolean {
54
52
  if (node.type === 'pane') return node.pane_id === paneId;
55
53
  return containsPane(node.first, paneId) || containsPane(node.second, paneId);
56
54
  }