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/slim-frame.ts CHANGED
@@ -1,39 +1,25 @@
1
1
  /**
2
- * D97 slim frame: when a pane is too narrow or short for the minimum usable TUI,
3
- * cover it with a non-capturing overlay. Content is three-tier: full TUI (overlay
4
- * hidden), an activity-anchored todo window when ≥3 rows fit, else the pane title.
2
+ * D97 slim frame: a pane too narrow/short for the minimum usable TUI gets a non-capturing overlay
3
+ * instead of a continuously repainting one — full TUI when it fits, an activity-anchored todo
4
+ * window when ≥3 rows fit, else the pane title. Content updates only on todo/status reports, so
5
+ * streaming thinking tokens can never reach it and the alt-screen diff produces no PTY output.
5
6
  *
6
- * Why (observed by the user): after the heat view compresses an unfocused pane, streaming worker thinking
7
- * continuously repaints the entire screen in the narrow pane, causing flicker. The static frame updates only on
8
- * todo_write/status reports (sharing arguments and source with formatPaneTitle), so thinking tokens cannot reach it;
9
- * alt-screen row-level diffing then produces zero PTY output and physically eliminates the flicker.
7
+ * Requires fullscreen TUI mode (buildLaunchParts injects it): the composite layer sits above the
8
+ * document, so only the alt-screen path can be covered. Registered only inside a herdr pane
9
+ * (PIER_SLIM_FRAME=0 opts out), as a process-local singleton whose overlay stays resident —
10
+ * `done()` is never called, so the pending promise is swallowed and never awaited.
10
11
  *
11
- * Prerequisite: worker/master run with --tui-mode fullscreen (injected by default by buildLaunchParts).
12
- * The regular main-screen full dump path (tui-main-screen firstChanged < viewportTop → full redraw) cannot be
13
- * covered by the overlay: composite is above the document, while the dump is inside the document.
14
- *
15
- * Registration gate: only inside a herdr pane (index.ts checks the environment), because covering an interactive
16
- * normal terminal window would be hazardous. Clicking a narrow herdr pane focuses it and the heat view expands it;
17
- * the visible predicate re-evaluates on every frame as SIGWINCH arrives, so the frame disappears automatically and
18
- * the real TUI remains underneath (agent/session/widget execution never stops).
19
- *
20
- * Lifecycle: one process-local singleton, registered once on session_start (not resume); the overlay stays resident
21
- * (done() is never called, leaving the Promise pending, swallowing rejection, and never awaiting it). If switching
22
- * pi sessions removes the overlay (resetExtensionUI), the static frame naturally exits without affecting the main
23
- * flow. PI_HERDR_SLIM_FRAME=0 is the escape hatch.
24
- * Resize watchdog (D98): the component owns a SIGWINCH listener plus a 1s size poll that call tui.requestRender() on
25
- * any PTY size change. Why: long-running sessions stop re-rendering on resize (observed in herdr — an idle master
26
- * shrunk by heat reflow froze on a stale clipped frame: no todo, no title), so pi's own resize wiring cannot be relied
27
- * on; columns/rows are read live per render, so one render is the whole fix. Stopped in dispose().
12
+ * Resize watchdog (D98): a SIGWINCH listener plus a 1 s poll, since long-running sessions stop
13
+ * re-rendering on resize; sizes are read live per render, both stop in dispose().
28
14
  */
29
15
 
30
16
  import { isArchived } from './stale-core.ts';
31
17
  import type { TodoItem } from './vocab.ts';
32
18
  import { anchorTodoRange, formatTodoSummary, renderTodoGroups } from './todo-window.ts';
33
19
  import { pierOption } from './pier-options.ts';
20
+ import { styledWidth, wrapStyled } from './ansi-text.ts';
34
21
 
35
- /** Minimum usable TUI dimensions (pi interactive-mode layout: editor 3 rows + footer 1 + transcript ≥3 + spacing).
36
- * Below either threshold the pane cannot display a readable TUI, so the overlay covers it. */
22
+ /** Minimum usable TUI dimensions (pi interactive-mode layout: editor 3 rows + footer 1 + transcript ≥3 + spacing). */
37
23
 
38
24
  export const SLIM_MIN_COLS = 24;
39
25
  export const SLIM_MIN_ROWS = 12;
@@ -51,107 +37,37 @@ export function isSlimFrame(cols: number, rows: number): boolean {
51
37
  return cols < SLIM_MIN_COLS || rows < SLIM_MIN_ROWS;
52
38
  }
53
39
 
54
- /* ── Width-aware wrapping (conservative East Asian width; ambiguous glyphs such as ▶○■✓ count as 1, affecting only break positions) ── */
55
-
56
- function isWide(cp: number): boolean {
57
- return (
58
- (cp >= 0x1100 && cp <= 0x115f) // Hangul compatibility jamo
59
- || (cp >= 0x2e80 && cp <= 0xa4cf) // CJK radicals through Yi (including kana)
60
- || (cp >= 0xac00 && cp <= 0xd7a3) // Hangul syllables
61
- || (cp >= 0xf900 && cp <= 0xfaff) // CJK compatibility ideographs
62
- || (cp >= 0xfe30 && cp <= 0xfe4f) // CJK compatibility forms
63
- || (cp >= 0xff00 && cp <= 0xff60) // Fullwidth forms
64
- || (cp >= 0xffe0 && cp <= 0xffe6)
65
- );
66
- }
67
-
68
- export function displayWidth(s: string): number {
69
- let w = 0;
70
- for (const ch of s) w += isWide(ch.codePointAt(0) ?? 0) ? 2 : 1;
71
- return w;
72
- }
73
-
74
- /**
75
- * Wrap at visible width: prefer word breaks (retreat to the last space instead of cutting an English phrase);
76
- * CJK text without spaces hard-wraps naturally. Consume the space at the break point.
77
- */
78
- export function wrapByWidth(text: string, width: number): string[] {
79
- const w = Math.max(1, Math.floor(width));
80
- const lines: string[] = [];
81
- let line = '';
82
- let lineW = 0;
83
- const push = () => {
84
- lines.push(line);
85
- line = '';
86
- lineW = 0;
87
- };
88
- for (const ch of text) {
89
- const cw = isWide(ch.codePointAt(0) ?? 0) ? 2 : 1;
90
- if (lineW + cw > w) {
91
- const lastSpace = line.lastIndexOf(' ');
92
- if (lastSpace > 0) {
93
- lines.push(line.slice(0, lastSpace));
94
- line = line.slice(lastSpace + 1) + ch;
95
- lineW = displayWidth(line);
96
- } else {
97
- push();
98
- line = ch;
99
- lineW = cw;
100
- }
101
- } else {
102
- line += ch;
103
- lineW += cw;
104
- }
105
- }
106
- if (line !== '' || lines.length === 0) lines.push(line);
107
- return lines;
108
- }
109
-
110
- /**
111
- * Frame lines: wrap the title, center it vertically, and clamp to rows. Pad every line to full visible width:
112
- * row-wise overlay compositing would let the TUI underneath show through blank lines (leaking flicker),
113
- * while full-width spaces make the freeze genuinely opaque. Without a title, center one `·` (alive, no plan).
114
- */
40
+ /** Frame lines: wrap the title, center it vertically, clamp to rows. Every line is padded to the
41
+ * full visible width — row-wise compositing would otherwise let the TUI underneath show through
42
+ * blank lines and leak the flicker this frame exists to hide. No title → one centered `·`. */
115
43
  export function frameLines(
116
44
  text: string,
117
45
  opts: { width: number; rows: number; colorize?: (s: string) => string },
118
46
  ): string[] {
119
47
  const width = Math.max(1, Math.floor(opts.width));
120
48
  const rows = Math.max(1, Math.floor(opts.rows));
121
- const colorize = opts.colorize ?? ((s: string) => s);
122
- const pad = (s: string) => s + ' '.repeat(Math.max(0, width - displayWidth(s)));
123
-
124
- const body = text.trim()
125
- ? wrapByWidth(text.trim(), width).slice(0, rows)
126
- : ['·'];
127
- const top = Math.max(0, Math.floor((rows - body.length) / 2));
128
- const out: string[] = [];
129
- for (let i = 0; i < rows; i++) {
130
- const idx = i - top;
131
- out.push(idx >= 0 && idx < body.length ? pad(colorize(body[idx])) : ' '.repeat(width));
132
- }
133
- return out;
49
+ const body = text.trim() ? wrapStyled(text.trim(), width).slice(0, rows) : ['·'];
50
+ return padFrame(body, { width, rows, colorize: opts.colorize ?? ((s: string) => s), vAlign: 'center' });
134
51
  }
135
52
 
136
53
  function padFrame(
137
54
  content: readonly string[],
138
55
  opts: { width: number; rows: number; colorize: (s: string) => string; vAlign: 'center' | 'top' },
139
56
  ): string[] {
140
- const width = Math.max(1, Math.floor(opts.width));
141
- const rows = Math.max(1, Math.floor(opts.rows));
142
- const pad = (s: string) => s + ' '.repeat(Math.max(0, width - displayWidth(s)));
57
+ const { width, rows } = opts;
143
58
  const body = content.slice(0, rows);
144
59
  const top = opts.vAlign === 'center' ? Math.max(0, Math.floor((rows - body.length) / 2)) : 0;
145
60
  const out: string[] = [];
146
61
  for (let i = 0; i < rows; i++) {
147
62
  const idx = i - top;
148
- out.push(idx >= 0 && idx < body.length ? pad(opts.colorize(body[idx])) : ' '.repeat(width));
63
+ const line = idx >= 0 && idx < body.length ? opts.colorize(body[idx]) : '';
64
+ out.push(line + ' '.repeat(Math.max(0, width - styledWidth(line))));
149
65
  }
150
66
  return out;
151
67
  }
152
68
 
153
69
  function wrapTodoBody(items: readonly TodoItem[], width: number): string[] {
154
- return renderTodoGroups(items).flatMap((line) => wrapByWidth(line, width));
70
+ return renderTodoGroups(items).flatMap((line) => wrapStyled(line, width));
155
71
  }
156
72
 
157
73
  function itemsEqual(a: readonly TodoItem[], b: readonly TodoItem[]): boolean {
@@ -173,10 +89,6 @@ export interface SlimFrameInput {
173
89
  now?: number;
174
90
  }
175
91
 
176
- /**
177
- * Choose overlay content: todo window when the pane can show ≥3 wrapped rows at ≥16 cols;
178
- * otherwise the pane title (or SLIM_EMPTY_COPY when the list is empty).
179
- */
180
92
  export function slimContentLines(
181
93
  input: SlimFrameInput & { width: number; rows: number; colorize?: (s: string) => string },
182
94
  ): string[] {
@@ -192,7 +104,7 @@ export function slimContentLines(
192
104
  if (items.length === 0) return titleFrame();
193
105
  if (isArchived(items, input.lastWriteAt ?? null, input.now ?? Date.now())) return titleFrame();
194
106
 
195
- const header = wrapByWidth(formatTodoSummary(items), width);
107
+ const header = wrapStyled(formatTodoSummary(items), width);
196
108
  const fullBody = wrapTodoBody(items, width);
197
109
  if (header.length + fullBody.length <= rows) {
198
110
  const packed = [...header, ...fullBody];
@@ -200,7 +112,7 @@ export function slimContentLines(
200
112
  return padFrame(packed, { width, rows, colorize, vAlign: 'top' });
201
113
  }
202
114
 
203
- const footerProbe = wrapByWidth(' +99 hidden (99✓) · /todos', width);
115
+ const footerProbe = wrapStyled(' +99 hidden (99✓) · /todos', width);
204
116
  const itemBudget = rows - header.length - footerProbe.length;
205
117
  if (itemBudget < 1) return titleFrame();
206
118
 
@@ -214,14 +126,12 @@ export function slimContentLines(
214
126
 
215
127
  const hidden = items.filter((_, i) => i < start || i >= end);
216
128
  const hiddenCompleted = hidden.filter((it) => it.status === 'completed').length;
217
- const footer = wrapByWidth(` +${hidden.length} hidden (${hiddenCompleted}✓) · /todos`, width);
129
+ const footer = wrapStyled(` +${hidden.length} hidden (${hiddenCompleted}✓) · /todos`, width);
218
130
  const packed = [...header, ...body, ...footer];
219
131
  if (packed.length < SLIM_TODO_MIN_ROWS) return titleFrame();
220
132
  return padFrame(packed, { width, rows, colorize, vAlign: 'top' });
221
133
  }
222
134
 
223
- /* ── Overlay component plus registration/update (process-local singleton) ─────────────────────────── */
224
-
225
135
  interface FrameTui {
226
136
  requestRender(): void;
227
137
  }
@@ -301,7 +211,6 @@ class SlimFrameComponent {
301
211
  this.tui.requestRender();
302
212
  };
303
213
 
304
- /** Start the D98 watchdog (idempotent). SIGWINCH covers the delivered signal; the poll covers lost ones. */
305
214
  startResizeWatchdog(): void {
306
215
  if (this.poll) return;
307
216
  const size = this.viewportSize();
@@ -329,7 +238,6 @@ class SlimFrameComponent {
329
238
  }
330
239
  }
331
240
 
332
-
333
241
  /** Test injection point (reset module state); dispose first so an undisposed component's watchdog cannot leak. */
334
242
  export function resetForTest(): void {
335
243
  active?.dispose();
@@ -338,10 +246,7 @@ export function resetForTest(): void {
338
246
 
339
247
  let active: SlimFrameComponent | null = null;
340
248
 
341
- /**
342
- * Register the static-frame overlay (idempotent: once per process). eventCtx is the pi lifecycle event context
343
- * (the session_start ctx, sourced from the same place as the todo widget's widgetUi).
344
- */
249
+ /** Register the static-frame overlay (idempotent, once per process) from the session_start ctx. */
345
250
  export function registerSlimFrame(eventCtx: unknown): void {
346
251
  if (active) return;
347
252
  if (pierOption('PIER_SLIM_FRAME') === '0') return;
@@ -373,14 +278,12 @@ export function registerSlimFrame(eventCtx: unknown): void {
373
278
  },
374
279
  },
375
280
  );
376
- // Never call done(), leaving the Promise pending so the overlay stays resident; swallow rejection and never await it.
377
281
  Promise.resolve(pending).catch(() => {});
378
282
  } catch {
379
283
  /* Silently degrade on pi versions without overlay support. */
380
284
  }
381
285
  }
382
286
 
383
- /** Update overlay snapshot. Title remains the fallback; items drive the todo window. */
384
287
  export function updateSlimFrame(input: SlimFrameInput): void {
385
288
  active?.setSnapshot(input.title ?? '', input.items ?? [], input.lastWriteAt ?? null);
386
289
  }
package/src/stale-core.ts CHANGED
@@ -1,50 +1,43 @@
1
1
  /**
2
- * Pure core for todo staleness (anti-freeze behavior, evidenced by session 01a03253).
2
+ * Anti-freeze core: a fully completed list that new work never updates must stop impersonating
3
+ * current state.
3
4
  *
4
- * Evidence: a fully completed list (3✓) froze for 16h / 37 turns while the model continued new work but made no
5
- * todo_write calls; rereading the dead list every read hook falsely endorsed "nothing to track," and the title
6
- * rendered dead counts at full weight. This module reduces whether a list still reflects reality to pure functions
7
- * consumed in three places:
8
- * - stale (A): open==0 with ≥ STALE_TURNS turns since the last write → the read hook injects a rate-limited,
9
- * capped stale warning;
10
- * - archived (B): open==0 with wall-clock age ≥ STALE_CLOCK_MS since the last write → treat it as absent
11
- * (injection/projection no longer rereads details; entries remain queryable through /todos; session JSONL stays authoritative);
12
- * - title (D): when archived, pane/sidebar renders `✓N done <age>` instead of impersonating current state.
5
+ * - stale (A): open==0 and ≥ STALE_TURNS turns since the last write → the read hook injects a
6
+ * rate-limited, capped warning;
7
+ * - archived (B): open==0 and age ≥ STALE_CLOCK_MS → the list is treated as absent;
8
+ * - title (D): an archived list renders `✓N done <age>` instead of impersonating current state.
13
9
  *
14
- * A list with open items (pending/in_progress/blocked) is never stale: agent_settled continues to cover unfinished
15
- * work. When lastWriteAt is unknown (old sessions lack timestamps), conservatively do not archive on the clock axis;
16
- * the turns axis remains unaffected.
10
+ * A list with open items is never stale — agent_settled covers unfinished work. Unknown
11
+ * lastWriteAt (old sessions) conservatively suppresses the clock axis; turns is unaffected.
17
12
  */
18
13
  import { countTodos, type TodoItem } from './vocab.ts';
19
14
 
20
- /** A: turn-based expiry threshold (user turns counted from the last todo write). */
15
+ /** A: turn-based expiry threshold, counted from the last todo write. */
21
16
  export const STALE_TURNS = 6;
22
17
 
23
- /** B: wall-clock archive threshold (age of the last write for a completed list). */
18
+ /** B: age of the last write above which a completed list is archived. */
24
19
  export const STALE_CLOCK_MS = 60 * 60 * 1000;
25
20
 
26
- /** A: maximum stale warnings injected during one stalled period (prevent spin noise; archived changes to archive-notice cadence). */
21
+ /** A: cap on stale warnings injected during one stalled period. */
27
22
  export const STALE_NOTICE_MAX = 3;
28
23
 
29
24
  export type StalenessKind = 'fresh' | 'stale' | 'archived';
30
25
 
31
26
  export interface Staleness {
32
27
  kind: StalenessKind;
33
- /** open = pending + in_progress + blocked (abandoned does not count as unfinished). */
28
+ /** open = pending + in_progress + blocked (abandoned is not unfinished). */
34
29
  open: number;
35
- /** now - lastWriteAt; unknown lastWriteAt → null. */
36
30
  ageMs: number | null;
37
31
  }
38
32
 
39
- /** Count open items: stale/archived apply only to fully completed (or fully abandoned) lists with open==0. */
40
33
  export function openTodos(items: readonly TodoItem[]): number {
41
- const c = countTodos(items as TodoItem[]);
34
+ const c = countTodos(items);
42
35
  return c.pending + c.inProgress + c.blocked;
43
36
  }
44
37
 
45
38
  /**
46
- * Determine staleness (two conditions: turns → stale; wall clock → archived, with the clock taking precedence).
47
- * Empty list → fresh (a separate guard owns empty-list handling); null lastWriteAt → never stale (conservative).
39
+ * Clock (archived) takes precedence over turns (stale). Empty list → fresh (a separate guard owns
40
+ * empty-list handling); null lastWriteAt → never stale.
48
41
  */
49
42
  export function evaluateStaleness(opts: {
50
43
  items: readonly TodoItem[];
@@ -61,7 +54,7 @@ export function evaluateStaleness(opts: {
61
54
  return st;
62
55
  }
63
56
 
64
- /** For title/mirror paths without turn information: only evaluate wall-clock archive status. */
57
+ /** Title/mirror paths have no turn information: only the wall-clock archive axis. */
65
58
  export function isArchived(
66
59
  items: readonly TodoItem[],
67
60
  lastWriteAt: number | null,
@@ -70,7 +63,7 @@ export function isArchived(
70
63
  return evaluateStaleness({ items, lastWriteAt, turnsSinceWrite: null, now }).kind === 'archived';
71
64
  }
72
65
 
73
- /** Age display: <60m → `Nm`; <48h → `Nh` (floor); otherwise `Nd`. */
66
+ /** <60m → `Nm`; <48h → `Nh` (floor); otherwise `Nd`. */
74
67
  export function formatAge(ms: number): string {
75
68
  if (ms < 60 * 60_000) return `${Math.max(1, Math.floor(ms / 60_000))}m`;
76
69
  if (ms < 48 * 3_600_000) return `${Math.floor(ms / 3_600_000)}h`;
@@ -1,32 +1,20 @@
1
1
  /**
2
- * Storage path layout (history, session dirs, role dirs).
2
+ * Storage path layout (history, session dirs, role dirs) — the single owner of the session-dir
3
+ * encoding, so history-store / session-tail / role-loader cannot drift apart.
3
4
  *
4
- * Why: sessionDirName / historyFilePath / userRolesDir were duplicated across
5
- * history-store, session-tail, and role-loader. One module owns the encoding
6
- * so path conventions cannot drift.
7
- *
8
- * Writes use a collision-resistant encoding (`a/b` ≠ `a-b`). Reads prefer the
9
- * new name, then the legacy flattened name, so existing history/session dirs
10
- * stay reachable. If only the legacy dir exists, writes keep appending there
11
- * instead of splitting the ledger.
5
+ * Writes use a collision-resistant encoding (`a/b` ≠ `a-b`); reads try the new name, then the legacy
6
+ * flattened name, and when only the legacy dir exists writes keep appending there (no split ledger).
12
7
  */
13
8
  import { existsSync } from 'node:fs';
14
9
  import { homedir } from 'node:os';
15
10
  import { join } from 'node:path';
16
11
 
17
- /**
18
- * Legacy flattening (`--F--herdr-pi--`).
19
- * Why kept: `a/b` and `a-b` collide, but on-disk dirs already use this form.
20
- */
12
+ /** Legacy flattening (`--F--herdr-pi--`): `a/b` and `a-b` collide, but on-disk dirs use this form. */
21
13
  export function sessionDirNameLegacy(cwd: string): string {
22
- const flat = cwd.replace(/[\\/]/g, '-').replace(/:/g, '-');
23
- return `--${flat}--`;
14
+ return `--${cwd.replace(/[\\/]/g, '-').replace(/:/g, '-')}--`;
24
15
  }
25
16
 
26
- /**
27
- * Collision-resistant session directory name.
28
- * `%` is escaped first so `/` `\` `:` round-trip: `a/b` → `--a%2Fb--`, `a-b` → `--a-b--`.
29
- */
17
+ /** Collision-resistant name: `%` is escaped first so `/` `\` `:` round-trip (`a/b` → `--a%2Fb--`). */
30
18
  export function sessionDirName(cwd: string): string {
31
19
  const encoded = cwd
32
20
  .replace(/%/g, '%25')
@@ -36,7 +24,6 @@ export function sessionDirName(cwd: string): string {
36
24
  return `--${encoded}--`;
37
25
  }
38
26
 
39
- /** New encoding first, then legacy when they differ. */
40
27
  export function sessionDirCandidates(cwd: string): readonly string[] {
41
28
  const next = sessionDirName(cwd);
42
29
  const prev = sessionDirNameLegacy(cwd);
@@ -44,39 +31,26 @@ export function sessionDirCandidates(cwd: string): readonly string[] {
44
31
  }
45
32
 
46
33
  /**
47
- * pi core's own session-directory encoding, byte-for-byte.
48
- *
49
- * Why: `<agentDir>/sessions/` is owned by pi core, which names it
50
- * `--${cwd.replace(/^[/\\]/, '').replace(/[/\\:]/g, '-')}--` (strip ONE leading separator,
51
- * then flatten `/` `\` `:` — note it does NOT escape `%` like `sessionDirName` does).
52
- * Verified on this machine: `/Users/yehaoyu/Documents/pier` →
53
- * `--Users-yehaoyu-Documents-pier--` (pier's own encodings produce `---Users-…--` and
54
- * `--%2FUsers…--`, neither of which exists on disk).
34
+ * pi core's own session-directory encoding, byte-for-byte: strip ONE leading separator, then flatten
35
+ * `/` `\` `:` — it does NOT escape `%` like `sessionDirName`. `<agentDir>/sessions/` is pi core's
36
+ * directory, so reading pi sessions must match it exactly.
55
37
  */
56
38
  export function piCoreSessionDirName(cwd: string): string {
57
39
  return `--${cwd.replace(/^[/\\]/, '').replace(/[/\\:]/g, '-')}--`;
58
40
  }
59
41
 
60
42
  /**
61
- * Candidates for reading pi core's session dirs: pi core's exact name first, then pier's
62
- * own historical encodings (they may hold stray dirs from earlier versions).
63
- * Read-only on purpose: writes to `<agentDir>/sessions/` are pi core's business.
43
+ * pi core's exact name first, then pier's own encodings (they may hold stray dirs from earlier
44
+ * versions). Read-only: writes to `<agentDir>/sessions/` are pi core's business.
64
45
  */
65
46
  export function piSessionDirCandidates(cwd: string): readonly string[] {
66
47
  return [...new Set([piCoreSessionDirName(cwd), ...sessionDirCandidates(cwd)])];
67
48
  }
68
49
 
69
- /**
70
- * Directory to read/write under `parent`.
71
- * Existing legacy dirs win over a missing new dir so history is not split.
72
- */
50
+ /** Directory to read/write under `parent`: an existing legacy dir wins so history is not split. */
73
51
  export function preferredSessionDir(parent: string, cwd: string): string {
74
52
  const names = sessionDirCandidates(cwd);
75
- for (const name of names) {
76
- const dir = join(parent, name);
77
- if (existsSync(dir)) return dir;
78
- }
79
- return join(parent, names[0]);
53
+ return names.map((name) => join(parent, name)).find(existsSync) ?? join(parent, names[0]!);
80
54
  }
81
55
 
82
56
  export function historyFilePath(agentRoot: string, cwd: string): string {
@@ -87,17 +61,14 @@ export function historyFilePathLegacy(agentRoot: string, cwd: string): string {
87
61
  return join(agentRoot, 'herdr-pi', 'history', sessionDirNameLegacy(cwd), 'history.jsonl');
88
62
  }
89
63
 
90
- /** Dual-read history path: existing ledger (legacy or new) else canonical new file. */
91
64
  export function preferredHistoryFile(agentRoot: string, cwd: string): string {
92
65
  return join(preferredSessionDir(join(agentRoot, 'herdr-pi', 'history'), cwd), 'history.jsonl');
93
66
  }
94
67
 
95
- /** User-global roles directory (`~/.pi/agent/herdr-pi/roles/`). */
96
68
  export function userRolesDir(): string {
97
69
  return join(homedir(), '.pi', 'agent', 'herdr-pi', 'roles');
98
70
  }
99
71
 
100
- /** Workspace-level roles directory (`<base>/.pi-herdr/roles/`). */
101
72
  export function workspaceRolesDir(baseDir: string): string {
102
73
  return join(baseDir, '.pi-herdr', 'roles');
103
74
  }