pi-pier 0.2.7 → 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 (85) hide show
  1. package/README.md +26 -12
  2. package/package.json +8 -3
  3. package/schemas/efficiency-config.schema.json +77 -0
  4. package/schemas/role-manifest.schema.json +7 -1
  5. package/skills/pi-herdr-workflow/SKILL.md +11 -0
  6. package/src/ansi-text.ts +134 -0
  7. package/src/ask-dialog.ts +308 -0
  8. package/src/ask-user.ts +139 -88
  9. package/src/bootstrap.ts +13 -14
  10. package/src/compact-coordinator.ts +471 -0
  11. package/src/compact-economics-core.ts +328 -0
  12. package/src/config-catalog-core.ts +332 -0
  13. package/src/config-command.ts +388 -0
  14. package/src/dashboard-command.ts +108 -0
  15. package/src/efficiency-config-core.ts +464 -0
  16. package/src/efficiency-store.ts +299 -0
  17. package/src/focus-poller.ts +223 -0
  18. package/src/gc-core.ts +77 -18
  19. package/src/herdr-client.ts +284 -154
  20. package/src/history-store.ts +36 -23
  21. package/src/index-gates.ts +124 -0
  22. package/src/index-locks.ts +29 -16
  23. package/src/index-master.ts +29 -19
  24. package/src/index-notices.ts +35 -11
  25. package/src/index-pipe.ts +17 -7
  26. package/src/index-roles.ts +306 -0
  27. package/src/index-runtime.ts +2 -6
  28. package/src/index-worker.ts +7 -9
  29. package/src/index.ts +425 -388
  30. package/src/jev-client.ts +141 -0
  31. package/src/jev-core.ts +431 -0
  32. package/src/ledger.ts +13 -21
  33. package/src/lock-core.ts +98 -37
  34. package/src/manifest-compose.ts +35 -20
  35. package/src/observation-core.ts +200 -0
  36. package/src/pane-title.ts +19 -38
  37. package/src/pi-surface.ts +34 -22
  38. package/src/pier-options.ts +100 -0
  39. package/src/pipe-channel.ts +65 -52
  40. package/src/platform-paths.ts +19 -52
  41. package/src/{core → plugins}/grid-shape.ts +12 -12
  42. package/src/plugins/heat-plan.ts +158 -0
  43. package/src/plugins/observation.ts +475 -0
  44. package/src/plugins/subagent.ts +728 -0
  45. package/src/{core → plugins}/terminal.ts +237 -123
  46. package/src/{core → plugins}/todo.ts +75 -100
  47. package/src/progress-core.ts +16 -53
  48. package/src/reconcile-core.ts +24 -33
  49. package/src/reducer-core.ts +322 -0
  50. package/src/reducer-invoker.ts +419 -0
  51. package/src/renderers.ts +187 -0
  52. package/src/role-loader.ts +35 -29
  53. package/src/role-manifest.ts +23 -32
  54. package/src/role-state.ts +119 -0
  55. package/src/routing-telemetry.ts +136 -0
  56. package/src/runtime-policy.ts +25 -43
  57. package/src/session-tail.ts +102 -23
  58. package/src/settle-wake-core.ts +55 -22
  59. package/src/slim-frame.ts +29 -124
  60. package/src/stale-core.ts +17 -24
  61. package/src/storage-layout.ts +24 -30
  62. package/src/subagent-core.ts +256 -119
  63. package/src/subagent-gc.ts +131 -69
  64. package/src/subagent-poller.ts +401 -12
  65. package/src/subagent-session.ts +516 -0
  66. package/src/subagent-spawn.ts +651 -28
  67. package/src/swallow.ts +65 -0
  68. package/src/terminal-core.ts +160 -69
  69. package/src/todo-core.ts +62 -113
  70. package/src/todo-read-hook.ts +23 -39
  71. package/src/todo-reminder-core.ts +30 -48
  72. package/src/todo-window.ts +11 -12
  73. package/src/todos-service.ts +27 -35
  74. package/src/tool-error.ts +19 -0
  75. package/src/tool-gate.ts +38 -21
  76. package/src/vocab.ts +55 -25
  77. package/src/core/subagent.ts +0 -839
  78. package/src/git-adapter.ts +0 -104
  79. package/src/notice-buffer.ts +0 -23
  80. package/src/subagent-git-io.ts +0 -80
  81. package/src/subagent-launch.ts +0 -110
  82. package/src/subagent-poll-loop.ts +0 -285
  83. package/src/subagent-port.ts +0 -27
  84. package/src/subagent-scope.ts +0 -39
  85. package/src/subagent-session-io.ts +0 -147
package/src/swallow.ts ADDED
@@ -0,0 +1,65 @@
1
+ /**
2
+ * `swallow(tag, err)` keeps catch-site cleanup behaviour, but records the event in a bounded ring
3
+ * buffer and — when `PIER_TRACE`/`PI_HERDR_TRACE` is set — writes it to stderr, so `/pier-config` can
4
+ * show the recent ones and a bug report can quote them.
5
+ */
6
+
7
+ export interface SwallowedError {
8
+ /** Stable site label, e.g. 'todo.persist-edit'. */
9
+ readonly tag: string;
10
+ readonly message: string;
11
+ /** Epoch ms. */
12
+ readonly at: number;
13
+ }
14
+
15
+ export const SWALLOW_BUFFER_MAX = 50;
16
+
17
+ const buffer: SwallowedError[] = [];
18
+
19
+ function describe(err: unknown): string {
20
+ if (err instanceof Error) {
21
+ const code = (err as { code?: unknown }).code;
22
+ return code !== undefined ? `${err.name}: ${err.message} (code ${String(code)})` : `${err.name}: ${err.message}`;
23
+ }
24
+ if (typeof err === 'string') return err;
25
+ try {
26
+ return JSON.stringify(err);
27
+ } catch {
28
+ return String(err);
29
+ }
30
+ }
31
+
32
+ function traceEnabled(env: NodeJS.ProcessEnv = process.env): boolean {
33
+ const raw = env.PIER_TRACE ?? env.PI_HERDR_TRACE;
34
+ return typeof raw === 'string' && raw.trim() !== '' && raw !== '0';
35
+ }
36
+
37
+ /**
38
+ * Record a caught error that is being intentionally ignored; call sites keep their cleanup and stay
39
+ * `} catch (err) { swallow('tag', err); }`.
40
+ */
41
+ export function swallow(tag: string, err: unknown, env: NodeJS.ProcessEnv = process.env): void {
42
+ const entry: SwallowedError = { tag, message: describe(err), at: Date.now() };
43
+ buffer.push(entry);
44
+ if (buffer.length > SWALLOW_BUFFER_MAX) buffer.splice(0, buffer.length - SWALLOW_BUFFER_MAX);
45
+ if (traceEnabled(env)) console.error(`[pier] swallowed ${tag}: ${entry.message}`);
46
+ }
47
+
48
+ /** Recent swallowed errors, oldest first (diagnostics only; never persisted). */
49
+ export function swallowedErrors(): readonly SwallowedError[] {
50
+ return [...buffer];
51
+ }
52
+
53
+ export function resetSwallowedErrors(): void {
54
+ buffer.length = 0;
55
+ }
56
+
57
+ /** Render for `/pier-config`; the section is never ambiguous even with no entries. */
58
+ export function formatSwallowedErrors(entries: readonly SwallowedError[] = swallowedErrors()): string {
59
+ if (entries.length === 0) return 'swallowed errors: none this session';
60
+ const lines = entries.slice(-10).map((e) => {
61
+ const hhmmss = new Date(e.at).toISOString().slice(11, 19);
62
+ return `${hhmmss} ${e.tag}: ${e.message}`;
63
+ });
64
+ return [`swallowed errors: ${entries.length} this session (last 10)`, ...lines].join('\n');
65
+ }
@@ -1,17 +1,17 @@
1
- /** Why: Preserve the established compatibility and safety behavior (D71, M14, T1–T6, T3, T6). */
1
+ import { pierOption } from './pier-options.ts';
2
2
  import { basename } from 'node:path';
3
3
 
4
- /** Why: Preserve the established compatibility and safety behavior. */
5
-
6
4
  export const TERMINALS_CUSTOM_TYPE = 'pi-herdr.terminals';
7
5
  export const MAX_TERMINALS = 8;
8
6
  export const READ_MAX_CHARS = 8000;
9
- /** Why: Preserve the established compatibility and safety behavior. */
10
- export const READ_HARD_CAP_CHARS = 200;
11
7
  export const READINESS_TIMEOUT_MS = 30_000;
12
8
  export const SILENCE_IDLE_MS = 2000;
13
- /** POSIX / PowerShell / Nushell prompt tail. Why: herdr waitForOutput and local classify must share one pattern. */
14
- export const PROMPT_TAIL_RE = /[$>#❯]\s*$/;
9
+ /** POSIX / PowerShell / Nushell / Zsh prompt tail.
10
+ * sh/bash end with `$` (root `#`), macOS zsh with `%`, nushell/starship with `❯`; anchoring to the
11
+ * line tail (\s*$) avoids matching percentage values in ordinary output (`downloaded 50%`), since
12
+ * interactive prompts end at the prompt symbol with no trailing text.
13
+ */
14
+ export const PROMPT_TAIL_RE = /[$>#❯%]\s*$/;
15
15
 
16
16
  export interface PromptStrategy {
17
17
  /** herdr waitForOutput regex source. */
@@ -20,7 +20,7 @@ export interface PromptStrategy {
20
20
  }
21
21
 
22
22
  export const POSIX_PROMPT: PromptStrategy = {
23
- waitPattern: '[$>#❯]\\s*$',
23
+ waitPattern: '[$>#❯%]\\s*$',
24
24
  tailRe: PROMPT_TAIL_RE,
25
25
  };
26
26
 
@@ -30,25 +30,37 @@ export const POWERSHELL_PROMPT: PromptStrategy = {
30
30
  tailRe: /(PS [^\n>]+|>)\s*$/,
31
31
  };
32
32
 
33
- /** `PIER_TERMINAL_PROMPT=powershell` selects the Windows strategy; default stays POSIX so existing panes do not flip. */
33
+ /** Shell name (lowercased basename from $SHELL, or a PIER_TERMINAL_PROMPT value) → strategy. */
34
+ const PROMPT_STRATEGIES: Record<string, PromptStrategy> = {
35
+ bash: POSIX_PROMPT,
36
+ zsh: POSIX_PROMPT,
37
+ pwsh: POWERSHELL_PROMPT,
38
+ 'pwsh.exe': POWERSHELL_PROMPT,
39
+ powershell: POWERSHELL_PROMPT,
40
+ 'powershell.exe': POWERSHELL_PROMPT,
41
+ };
42
+
43
+ /** Explicit `PIER_TERMINAL_PROMPT` wins over `$SHELL`; the default stays POSIX so unconfigured panes
44
+ * never flip strategy. */
34
45
  export function promptStrategyFor(env: NodeJS.ProcessEnv = process.env): PromptStrategy {
35
- return env.PIER_TERMINAL_PROMPT === 'powershell' ? POWERSHELL_PROMPT : POSIX_PROMPT;
46
+ // B10: canonical PIER_TERMINAL_PROMPT, legacy PI_HERDR_TERMINAL_PROMPT alias.
47
+ const prompt = pierOption('PIER_TERMINAL_PROMPT', env)?.trim().toLowerCase();
48
+ const shell = prompt || basename((env.SHELL ?? '').replace(/\\/g, '/')).toLowerCase();
49
+ return PROMPT_STRATEGIES[shell] ?? POSIX_PROMPT;
36
50
  }
37
- /** Why: Preserve the established compatibility and safety behavior (T6). */
51
+
38
52
  export const SIGNAL_KEYS = ['ctrl+c', 'ctrl+d', 'ctrl+z', 'esc', 'enter'] as const;
39
53
  export type SignalKey = (typeof SIGNAL_KEYS)[number];
40
54
 
41
55
  const FULLSCREEN_SEQUENCES = [
42
- '\x1b[?1049h', // Why: Preserve the established compatibility and safety behavior.
43
- '\x1b[?47h', // Why: Preserve the established compatibility and safety behavior.
44
- '\x1b[2J\x1b[H', // Why: Preserve the established compatibility and safety behavior.
56
+ '\x1b[?1049h',
57
+ '\x1b[?47h',
58
+ '\x1b[2J\x1b[H',
45
59
  ] as const;
46
60
 
47
61
  export const RESET_MARKER = '…[buffer reset — earlier output scrolled away or cleared]…';
48
62
  export const TRUNCATE_MARKER = '…[truncated]…';
49
63
 
50
- /** Why: Preserve the established compatibility and safety behavior. */
51
-
52
64
  export interface TerminalEntry {
53
65
  terminalId: string;
54
66
  paneId: string;
@@ -61,6 +73,11 @@ export interface TerminalEntry {
61
73
  nudgedAt: number | null;
62
74
  status: 'open' | 'closed';
63
75
  closedAt: number | null;
76
+ readRevision?: number | null;
77
+ readLen?: number;
78
+ readTail?: string;
79
+ readEoTail?: string;
80
+ initialized?: boolean;
64
81
  }
65
82
 
66
83
  export interface TerminalsRegistry {
@@ -68,8 +85,6 @@ export interface TerminalsRegistry {
68
85
  terminals: TerminalEntry[];
69
86
  }
70
87
 
71
- /** Why: Preserve the established compatibility and safety behavior. */
72
-
73
88
  export function nextTerminalId(existingIds: readonly string[]): string {
74
89
  let max = 0;
75
90
  for (const id of existingIds) {
@@ -86,6 +101,8 @@ export function registerTerminal(
86
101
  tabId: string;
87
102
  cwd: string;
88
103
  label?: string | null;
104
+ /** Caller-requested id; when omitted (or unusable per validateTerminalId) a term-N id is assigned. */
105
+ terminalId?: string | null;
89
106
  createdAt: number;
90
107
  },
91
108
  ): { ok: true; entries: TerminalEntry[]; entry: TerminalEntry } | { ok: false; error: string } {
@@ -96,8 +113,10 @@ export function registerTerminal(
96
113
  error: `terminal limit reached (max ${MAX_TERMINALS} open terminals); close one with terminal(action: "close") before opening another`,
97
114
  };
98
115
  }
116
+ const requested = validateTerminalId(opts.terminalId, entries.map((e) => e.terminalId));
117
+ if (requested != null && !requested.ok) return { ok: false, error: requested.error };
99
118
  const entry: TerminalEntry = {
100
- terminalId: nextTerminalId(entries.map((e) => e.terminalId)),
119
+ terminalId: requested != null && requested.ok ? requested.id : nextTerminalId(entries.map((e) => e.terminalId)),
101
120
  paneId: opts.paneId,
102
121
  tabId: opts.tabId,
103
122
  cwd: opts.cwd,
@@ -115,7 +134,24 @@ export function registerTerminal(
115
134
  return { ok: true, entries: [...entries, entry], entry };
116
135
  }
117
136
 
118
- /** Why: Preserve the established compatibility and safety behavior. */
137
+ /** A requested terminal id must be a short single-token string that no existing terminal (open or
138
+ * closed) already uses — ids stay addressable after close for `close`/`list`, so reuse would
139
+ * retarget actions at the wrong pane. Returns null when no id was requested. */
140
+ export function validateTerminalId(
141
+ id: string | null | undefined,
142
+ existingIds: readonly string[],
143
+ ): { ok: true; id: string } | { ok: false; error: string } | null {
144
+ const trimmed = (id ?? '').trim();
145
+ if (trimmed.length === 0) return null; // blank means "not provided" → auto-assigned term-N
146
+ if (trimmed.length > 64 || /\s/.test(trimmed)) {
147
+ return { ok: false, error: `invalid terminal_id "${trimmed.slice(0, 40)}": use 1-64 characters without whitespace, or omit it to get an auto-assigned term-N id` };
148
+ }
149
+ if (existingIds.includes(trimmed)) {
150
+ return { ok: false, error: `terminal_id "${trimmed}" is already used (open or closed); ids stay reserved for addressing — pick another or omit it` };
151
+ }
152
+ return { ok: true, id: trimmed };
153
+ }
154
+
119
155
  export function closeTerminal(
120
156
  entries: readonly TerminalEntry[],
121
157
  terminalId: string,
@@ -130,13 +166,10 @@ export function closeTerminal(
130
166
  };
131
167
  }
132
168
 
133
- /** Why: Preserve the established compatibility and safety behavior (D71). */
134
169
  export function activeTerminalPaneIds(entries: readonly TerminalEntry[]): Set<string> {
135
170
  return new Set(entries.filter((e) => e.status === 'open').map((e) => e.paneId));
136
171
  }
137
172
 
138
- /** Why: Preserve the established compatibility and safety behavior (T6). */
139
-
140
173
  export function validateSendText(text: string): { ok: true; text: string } | { ok: false; error: string } {
141
174
  if (text.includes('\x1b')) {
142
175
  return {
@@ -144,7 +177,7 @@ export function validateSendText(text: string): { ok: true; text: string } | { o
144
177
  error: 'unsupported input: ANSI escape sequences cannot be sent as text; use terminal(action: "signal") for control keys, or plain commands only',
145
178
  };
146
179
  }
147
- // Why: Preserve the established compatibility and safety behavior (C0).
180
+
148
181
  const stripped = text.replace(/[\r\n]+$/, '');
149
182
  const bad = stripped.match(/[\x00-\x08\x0b-\x1f\x7f]/);
150
183
  if (bad) {
@@ -167,7 +200,29 @@ export function validateSignal(key: string): { ok: true; key: SignalKey } | { ok
167
200
  return { ok: true, key: k };
168
201
  }
169
202
 
170
- /** Why: Preserve the established compatibility and safety behavior (T6). */
203
+ /**
204
+ * Whether a wait pattern would match the terminal's own echoed input: the shell echoes typed
205
+ * commands into the pane, so a pattern contained in the last sent text (`…; echo TERM_DONE_$?`
206
+ * waited on as literal `TERM_DONE_`) matches its own echo and returns "matched" before the job
207
+ * has produced anything. Detection is per terminal and best-effort: no send in this process
208
+ * lifetime → no hazard known → the wait proceeds unchanged.
209
+ */
210
+ export function sentinelEchoHazard(opts: {
211
+ pattern: string;
212
+ regex?: boolean;
213
+ lastSentText?: string | null;
214
+ }): boolean {
215
+ const sent = opts.lastSentText;
216
+ if (!sent) return false;
217
+ if (opts.regex) {
218
+ try {
219
+ return new RegExp(opts.pattern).test(sent);
220
+ } catch {
221
+ return false; // invalid regex is rejected by the caller before this runs
222
+ }
223
+ }
224
+ return sent.includes(opts.pattern);
225
+ }
171
226
 
172
227
  export function detectFullscreenTUI(raw: string): { detected: boolean; sequence: string | null } {
173
228
  for (const seq of FULLSCREEN_SEQUENCES) {
@@ -178,21 +233,16 @@ export function detectFullscreenTUI(raw: string): { detected: boolean; sequence:
178
233
 
179
234
  export function stripAnsi(raw: string): string {
180
235
  return raw
181
- .replace(/\x1b\[[0-9;?]*[ -/]*[@-~]/g, '') // Why: Preserve the established compatibility and safety behavior.
182
- .replace(/\x1b\][^\x07\x1b]*(\x07|\x1b\\)/g, '') // Why: Preserve the established compatibility and safety behavior.
183
- .replace(/\x1b[@-Z\\-_]/g, '') // Why: Preserve the established compatibility and safety behavior.
184
- .replace(/[\x00-\x08\x0b-\x1f\x7f]/g, ''); // Why: Preserve the established compatibility and safety behavior.
236
+ .replace(/\x1b\[[0-9;?]*[ -/]*[@-~]/g, '')
237
+ .replace(/\x1b\][^\x07\x1b]*(\x07|\x1b\\)/g, '')
238
+ .replace(/\x1b[@-Z\\-_]/g, '')
239
+ .replace(/[\x00-\x08\x0b-\x1f\x7f]/g, '');
185
240
  }
186
241
 
187
- /** Why: Preserve the established compatibility and safety behavior. */
188
-
189
242
  export interface ReadCursor {
190
243
  revision: number;
191
- /** Why: Preserve the established compatibility and safety behavior. */
192
244
  len: number;
193
- /** Why: Preserve the established compatibility and safety behavior. */
194
245
  tail: string;
195
- /** Why: Preserve the established compatibility and safety behavior. */
196
246
  eoTail: string;
197
247
  }
198
248
 
@@ -200,14 +250,11 @@ export interface ReadIncrement {
200
250
  mode: 'none' | 'append' | 'reset';
201
251
  text: string;
202
252
  cursor: ReadCursor;
203
- /** Why: Preserve the established compatibility and safety behavior. */
204
253
  hardCapped: boolean;
205
254
  }
206
255
 
207
- /** Why: Preserve the established compatibility and safety behavior. */
208
256
  const CURSOR_TAIL_CHARS = 64;
209
257
 
210
- /** Why: Preserve the established compatibility and safety behavior (M14). */
211
258
  export function computeIncrement(
212
259
  prev: ReadCursor | null,
213
260
  next: { text: string; revision: number },
@@ -232,19 +279,52 @@ export function computeIncrement(
232
279
  const bounded = boundText(suffix, maxChars);
233
280
  return { mode: 'append', text: bounded.text, cursor, hardCapped: bounded.capped };
234
281
  }
235
- // Why: Preserve the established compatibility and safety behavior.
282
+
236
283
  const bounded = boundText(next.text, maxChars);
237
284
  const text = prev ? `${RESET_MARKER}\n${bounded.text}` : bounded.text;
238
285
  return { mode: 'reset', text, cursor, hardCapped: bounded.capped };
239
286
  }
240
287
 
288
+ /** Window around the first hit so a later capped read cannot hide the match behind earlier lines. */
289
+ export function excerptAroundMatch(text: string, pattern: string, regex: boolean, radius = 1500): string {
290
+ let index = -1;
291
+ let hitLen = pattern.length;
292
+ if (regex) {
293
+ const found = new RegExp(pattern).exec(text);
294
+ index = found?.index ?? -1;
295
+ hitLen = found?.[0].length ?? pattern.length;
296
+ } else {
297
+ index = text.indexOf(pattern);
298
+ }
299
+ if (index < 0) return text.slice(-radius);
300
+ const start = Math.max(0, index - radius);
301
+ const end = Math.min(text.length, index + hitLen + radius);
302
+ return `${start > 0 ? '…' : ''}${text.slice(start, end)}${end < text.length ? '…' : ''}`;
303
+ }
304
+
305
+ /**
306
+ * herdr waitForOutput matches the whole pane, including text a prior read already returned.
307
+ * A hit is fresh only when it sits in the unread increment; a hit only in consumed text is stale.
308
+ */
309
+ export function classifyWaitMatch(
310
+ prev: ReadCursor | null,
311
+ paneText: string,
312
+ pattern: string,
313
+ regex: boolean,
314
+ ): { kind: 'fresh'; excerpt: string } | { kind: 'stale' } | { kind: 'absent' } {
315
+ const match = (text: string) => regex ? new RegExp(pattern).test(text) : text.includes(pattern);
316
+ const inc = computeIncrement(prev, { text: paneText, revision: prev?.revision ?? 0 }, paneText.length + 1);
317
+ const unread = inc.mode === 'append' ? inc.text : inc.mode === 'reset' ? paneText : '';
318
+ if (match(unread)) return { kind: 'fresh', excerpt: excerptAroundMatch(unread, pattern, regex) };
319
+ if (inc.mode !== 'reset' && match(paneText)) return { kind: 'stale' };
320
+ return { kind: 'absent' };
321
+ }
322
+
241
323
  function boundText(text: string, maxChars: number): { text: string; capped: boolean } {
242
324
  if (text.length <= maxChars) return { text, capped: false };
243
325
  return { text: `${text.slice(text.length - maxChars)}\n${TRUNCATE_MARKER}`, capped: true };
244
326
  }
245
327
 
246
- /** Why: Preserve the established compatibility and safety behavior (T3). */
247
-
248
328
  export type ReadinessTier = 'prompt' | 'silent' | 'busy';
249
329
 
250
330
  export function classifyReadiness(
@@ -258,8 +338,6 @@ export function classifyReadiness(
258
338
  return 'busy';
259
339
  }
260
340
 
261
- /** Why: Preserve the established compatibility and safety behavior (T6). */
262
-
263
341
  export interface TerminalSummary {
264
342
  terminalId: string;
265
343
  paneId: string;
@@ -288,19 +366,38 @@ export function summarizeSessions(
288
366
  return { terminals, stalePaneIds };
289
367
  }
290
368
 
291
- /** Why: Preserve the established compatibility and safety behavior. */
369
+ export function makeTerminalsRegistry(terminals: TerminalEntry[] = []): TerminalsRegistry {
370
+ return { version: 1, terminals };
371
+ }
292
372
 
293
- export interface BranchEntryLike3 {
294
- type?: string;
295
- customType?: string;
296
- data?: unknown;
373
+ export interface ShellInitPlan {
374
+ readonly shouldInit: boolean;
375
+ readonly command: string | null;
297
376
  }
298
377
 
299
- export function makeTerminalsRegistry(terminals: TerminalEntry[] = []): TerminalsRegistry {
300
- return { version: 1, terminals };
378
+ /**
379
+ * Plan the shell init command: `set +H` disables history expansion in managed bash/zsh shells, where `!`
380
+ * in ordinary commands (`!|`, `if [ ! -f ... ]`, `!$`) otherwise triggers `zsh: event not found: \|` and
381
+ * wedges the shell. PowerShell rejects the syntax and has no `!` history expansion, so it is skipped.
382
+ * Limit: the top-level shell only — a nested `bash -i` re-enables its own defaults; init is only worth
383
+ * sending once the shell is at its prompt and has not been initialized yet.
384
+ */
385
+ export function planShellInit(opts: {
386
+ strategy?: PromptStrategy;
387
+ readiness?: ReadinessTier;
388
+ initialized?: boolean;
389
+ }): ShellInitPlan {
390
+ if (opts.initialized) return { shouldInit: false, command: null };
391
+ if ((opts.strategy ?? POSIX_PROMPT) === POWERSHELL_PROMPT) return { shouldInit: false, command: null };
392
+ if (opts.readiness !== undefined && opts.readiness !== 'prompt') {
393
+ return { shouldInit: false, command: null };
394
+ }
395
+ return { shouldInit: true, command: 'set +H' };
301
396
  }
302
397
 
303
- export function foldTerminalsRegistry(entries: readonly BranchEntryLike3[]): TerminalEntry[] {
398
+ export function foldTerminalsRegistry(
399
+ entries: readonly { type?: string; customType?: string; data?: unknown }[],
400
+ ): TerminalEntry[] {
304
401
  let found: TerminalEntry[] = [];
305
402
  for (const entry of entries) {
306
403
  if (entry.type !== 'custom' || entry.customType !== TERMINALS_CUSTOM_TYPE) continue;
@@ -316,38 +413,37 @@ export function foldTerminalsRegistry(entries: readonly BranchEntryLike3[]): Ter
316
413
  label: typeof t.label === 'string' ? t.label : null,
317
414
  createdAt: typeof t.createdAt === 'number' ? t.createdAt : 0,
318
415
  lastActivityAt: typeof t.lastActivityAt === 'number' ? t.lastActivityAt : 0,
416
+ nudgedAt: typeof t.nudgedAt === 'number' ? t.nudgedAt : null,
319
417
  status: t.status === 'closed' ? ('closed' as const) : ('open' as const),
320
418
  closedAt: typeof t.closedAt === 'number' ? t.closedAt : null,
321
419
  readRevision: typeof t.readRevision === 'number' ? t.readRevision : null,
322
420
  readLen: typeof t.readLen === 'number' ? t.readLen : 0,
323
- lastActivityAt: typeof t.lastActivityAt === 'number' ? t.lastActivityAt : 0,
324
- nudgedAt: typeof t.nudgedAt === 'number' ? t.nudgedAt : null,
325
- status: t.status === 'closed' ? ('closed' as const) : ('open' as const),
421
+ readTail: typeof t.readTail === 'string' ? t.readTail : '',
422
+ readEoTail: typeof t.readEoTail === 'string' ? t.readEoTail : '',
423
+ initialized: t.initialized === true,
326
424
  }));
327
425
  }
328
426
  return found;
329
427
  }
330
428
 
331
429
  /* ── Idle-terminal nudge: turn-end self-cleanup for shells the model forgot to close ──
332
- * Observed (wF:p7 orphan): a master opened a resident terminal for a one-shot background
333
- * compile, the job finished, and nothing ever closed the pane — terminals are persistent by
334
- * design, so the shell lingered as a dead split in the main tab. Decision core mirrors the
335
- * todo stop-reminder: pure plan here, delivery wiring in core/terminal. */
430
+ * Terminals persist by design, so a finished shell lingers as a dead split; delivery wiring mirrors
431
+ * the todo stop-reminder in plugins/terminal. */
336
432
 
337
433
  /** Cap idle-terminal nudges for the lifetime of the process (same shape as TODO_REMINDERS_MAX). */
338
434
  export const TERM_REMINDERS_MAX = 2;
339
435
 
340
- /** Idle threshold before a nudge is due; PI_HERDR_TERM_IDLE_MS is read per call so tests can use small values. */
436
+ /** Idle threshold before a nudge is due; the env var is read per call so tests can use small values. */
341
437
  export function terminalIdleMs(): number {
342
- return Number(process.env.PI_HERDR_TERM_IDLE_MS ?? 30 * 60_000) || 30 * 60_000;
438
+ // B10: canonical PIER_TERM_IDLE_MS, legacy PI_HERDR_TERM_IDLE_MS.
439
+ return Number(pierOption('PIER_TERM_IDLE_MS') ?? 30 * 60_000) || 30 * 60_000;
343
440
  }
344
441
 
345
442
  /** Grace from agent_settled to injection, letting the user read the answer and intervene; cancel on agent_start. */
346
443
  export function terminalReminderGraceMs(): number {
347
- return Number(process.env.PI_HERDR_TERM_GRACE_MS ?? 30_000) || 30_000;
444
+ return Number(pierOption('PIER_TERM_GRACE_MS') ?? 30_000) || 30_000;
348
445
  }
349
446
 
350
- /** Custom message type for the idle-terminal nudge (registerMessageRenderer may restyle it). */
351
447
  export const TERM_REMINDER_CUSTOM_TYPE = 'pi-herdr.term-reminder';
352
448
 
353
449
  export interface IdleTerminalInput {
@@ -355,14 +451,12 @@ export interface IdleTerminalInput {
355
451
  cwd: string;
356
452
  label: string | null;
357
453
  lastActivityAt: number;
358
- /** Set once this terminal has been nudged; a nudged terminal is never nudged again (01a06ae3 goodbye-loop guard). */
454
+ /** Set once this terminal has been nudged; a nudged terminal is never nudged again (goodbye-loop guard). */
359
455
  nudgedAt?: number | null;
360
456
  }
361
457
 
362
458
  export interface IdleTerminalReminderPlan {
363
- /** Whether a nudge should be injected. */
364
459
  due: boolean;
365
- /** Complete injected content, or null when due is false. */
366
460
  content: string | null;
367
461
  /** Nudge count after successful injection, or the original count when due is false. */
368
462
  nextReminders: number;
@@ -375,11 +469,8 @@ function noIdleInject(reminders: number): IdleTerminalReminderPlan {
375
469
  }
376
470
 
377
471
  /**
378
- * Due only when an OPEN terminal is idle past the threshold AND has never been nudged
379
- * (01a06ae3: a per-process counter alone let nudges resume after restarts and looped a
380
- * farewell exchange 52 times — a nudged terminal must stay nudged, persisted in the ledger).
381
- * Shells still hosting long-running work were touched recently, so the idle filter is what
382
- * keeps legitimate dev-server terminals out of the nudge.
472
+ * Due only when an OPEN terminal is idle past the threshold and never nudged; the idle filter also
473
+ * keeps dev-server shells touched recently out of the nudge.
383
474
  */
384
475
  export function planIdleTerminalReminder(
385
476
  input: { open: readonly IdleTerminalInput[]; now: number; reminders: number },