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/swallow.ts CHANGED
@@ -1,16 +1,11 @@
1
1
  /**
2
- * B9: swallowed exceptions. Cleaning up after a failure is often correct (`catch {}` around a
3
- * best-effort write), but "silently correct" and "silently broken" look identical from the outside —
4
- * this repo has twice shipped a long-lived `ReferenceError` hidden by an empty catch (pane GC, and
5
- * `todo` manual-edit persistence), discovered weeks later by accident.
6
- *
7
- * `swallow(tag, err)` keeps the cleanup behaviour, but records the event in a bounded ring buffer and
8
- * — when `PIER_TRACE`/`PI_HERDR_TRACE` is set — writes it to stderr, so `/pier-config` can show the
9
- * recent ones and a bug report can quote them.
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.
10
5
  */
11
6
 
12
7
  export interface SwallowedError {
13
- /** Short, stable site label, e.g. 'todo.persist-edit'. */
8
+ /** Stable site label, e.g. 'todo.persist-edit'. */
14
9
  readonly tag: string;
15
10
  readonly message: string;
16
11
  /** Epoch ms. */
@@ -34,15 +29,14 @@ function describe(err: unknown): string {
34
29
  }
35
30
  }
36
31
 
37
- /** Diagnostics switch shared with the rest of the extension (`PIER_TRACE`, legacy `PI_HERDR_TRACE`). */
38
32
  function traceEnabled(env: NodeJS.ProcessEnv = process.env): boolean {
39
33
  const raw = env.PIER_TRACE ?? env.PI_HERDR_TRACE;
40
34
  return typeof raw === 'string' && raw.trim() !== '' && raw !== '0';
41
35
  }
42
36
 
43
37
  /**
44
- * Record a caught error that is being intentionally ignored.
45
- * Returns nothing: call sites stay `} catch (err) { swallow('tag', err); }` and keep their cleanup.
38
+ * Record a caught error that is being intentionally ignored; call sites keep their cleanup and stay
39
+ * `} catch (err) { swallow('tag', err); }`.
46
40
  */
47
41
  export function swallow(tag: string, err: unknown, env: NodeJS.ProcessEnv = process.env): void {
48
42
  const entry: SwallowedError = { tag, message: describe(err), at: Date.now() };
@@ -56,15 +50,11 @@ export function swallowedErrors(): readonly SwallowedError[] {
56
50
  return [...buffer];
57
51
  }
58
52
 
59
- /** Test seam: drop buffered entries. */
60
53
  export function resetSwallowedErrors(): void {
61
54
  buffer.length = 0;
62
55
  }
63
56
 
64
- /**
65
- * Render the recent swallowed errors for `/pier-config`.
66
- * Empty input returns an explicit "none" line so the section is never ambiguous.
67
- */
57
+ /** Render for `/pier-config`; the section is never ambiguous even with no entries. */
68
58
  export function formatSwallowedErrors(entries: readonly SwallowedError[] = swallowedErrors()): string {
69
59
  if (entries.length === 0) return 'swallowed errors: none this session';
70
60
  const lines = entries.slice(-10).map((e) => {
@@ -1,21 +1,15 @@
1
- /** Why: Preserve the established compatibility and safety behavior (D71, M14, T1–T6, T3, T6). */
2
1
  import { pierOption } from './pier-options.ts';
3
2
  import { basename } from 'node:path';
4
3
 
5
- /** Why: Preserve the established compatibility and safety behavior. */
6
-
7
4
  export const TERMINALS_CUSTOM_TYPE = 'pi-herdr.terminals';
8
5
  export const MAX_TERMINALS = 8;
9
6
  export const READ_MAX_CHARS = 8000;
10
- /** Why: Preserve the established compatibility and safety behavior. */
11
- export const READ_HARD_CAP_CHARS = 200;
12
7
  export const READINESS_TIMEOUT_MS = 30_000;
13
8
  export const SILENCE_IDLE_MS = 2000;
14
9
  /** POSIX / PowerShell / Nushell / Zsh prompt tail.
15
- * Why: macOS default zsh prompt ends with `%` (e.g. `user@host ~ % `), while sh/bash use `$`
16
- * (or `#` for root) and nushell/starship use `❯`. Anchoring to line tail (\s*$) prevents false
17
- * matches on percentage values in ordinary command output (e.g. `downloaded 50%`), as interactive
18
- * prompts terminate at the prompt symbol without trailing text.
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.
19
13
  */
20
14
  export const PROMPT_TAIL_RE = /[$>#❯%]\s*$/;
21
15
 
@@ -30,53 +24,43 @@ export const POSIX_PROMPT: PromptStrategy = {
30
24
  tailRe: PROMPT_TAIL_RE,
31
25
  };
32
26
 
33
- export const BASH_PROMPT: PromptStrategy = POSIX_PROMPT;
34
- export const ZSH_PROMPT: PromptStrategy = POSIX_PROMPT;
35
-
36
27
  /** cmd.exe / PowerShell: `>` still matches POSIX; this also accepts `PS C:\\>` without a trailing `$`. */
37
28
  export const POWERSHELL_PROMPT: PromptStrategy = {
38
29
  waitPattern: '(PS [^\\n>]+|>)\\s*$',
39
30
  tailRe: /(PS [^\n>]+|>)\s*$/,
40
31
  };
41
32
 
42
- export const PWSH_PROMPT: PromptStrategy = POWERSHELL_PROMPT; // same object: identity checks stay valid
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
+ };
43
42
 
44
- /**
45
- * Strategy selection covering bash, zsh, powershell/pwsh.
46
- * Explicit `PIER_TERMINAL_PROMPT` env var takes precedence; otherwise `SHELL` is inspected.
47
- * Default remains POSIX so existing Windows/Linux panes without explicit configuration do not flip.
48
- */
43
+ /** Explicit `PIER_TERMINAL_PROMPT` wins over `$SHELL`; the default stays POSIX so unconfigured panes
44
+ * never flip strategy. */
49
45
  export function promptStrategyFor(env: NodeJS.ProcessEnv = process.env): PromptStrategy {
50
46
  // B10: canonical PIER_TERMINAL_PROMPT, legacy PI_HERDR_TERMINAL_PROMPT alias.
51
47
  const prompt = pierOption('PIER_TERMINAL_PROMPT', env)?.trim().toLowerCase();
52
- if (prompt === 'powershell' || prompt === 'pwsh') return POWERSHELL_PROMPT;
53
- if (prompt === 'bash') return BASH_PROMPT;
54
- if (prompt === 'zsh') return ZSH_PROMPT;
55
- if (!prompt && env.SHELL) {
56
- const sh = basename(env.SHELL.replace(/\\/g, '/')).toLowerCase();
57
- if (sh === 'pwsh' || sh === 'powershell' || sh === 'powershell.exe' || sh === 'pwsh.exe') {
58
- return POWERSHELL_PROMPT;
59
- }
60
- if (sh === 'bash') return BASH_PROMPT;
61
- if (sh === 'zsh') return ZSH_PROMPT;
62
- }
63
- return POSIX_PROMPT;
48
+ const shell = prompt || basename((env.SHELL ?? '').replace(/\\/g, '/')).toLowerCase();
49
+ return PROMPT_STRATEGIES[shell] ?? POSIX_PROMPT;
64
50
  }
65
- /** Why: Preserve the established compatibility and safety behavior (T6). */
51
+
66
52
  export const SIGNAL_KEYS = ['ctrl+c', 'ctrl+d', 'ctrl+z', 'esc', 'enter'] as const;
67
53
  export type SignalKey = (typeof SIGNAL_KEYS)[number];
68
54
 
69
55
  const FULLSCREEN_SEQUENCES = [
70
- '\x1b[?1049h', // Why: Preserve the established compatibility and safety behavior.
71
- '\x1b[?47h', // Why: Preserve the established compatibility and safety behavior.
72
- '\x1b[2J\x1b[H', // Why: Preserve the established compatibility and safety behavior.
56
+ '\x1b[?1049h',
57
+ '\x1b[?47h',
58
+ '\x1b[2J\x1b[H',
73
59
  ] as const;
74
60
 
75
61
  export const RESET_MARKER = '…[buffer reset — earlier output scrolled away or cleared]…';
76
62
  export const TRUNCATE_MARKER = '…[truncated]…';
77
63
 
78
- /** Why: Preserve the established compatibility and safety behavior. */
79
-
80
64
  export interface TerminalEntry {
81
65
  terminalId: string;
82
66
  paneId: string;
@@ -101,8 +85,6 @@ export interface TerminalsRegistry {
101
85
  terminals: TerminalEntry[];
102
86
  }
103
87
 
104
- /** Why: Preserve the established compatibility and safety behavior. */
105
-
106
88
  export function nextTerminalId(existingIds: readonly string[]): string {
107
89
  let max = 0;
108
90
  for (const id of existingIds) {
@@ -119,6 +101,8 @@ export function registerTerminal(
119
101
  tabId: string;
120
102
  cwd: string;
121
103
  label?: string | null;
104
+ /** Caller-requested id; when omitted (or unusable per validateTerminalId) a term-N id is assigned. */
105
+ terminalId?: string | null;
122
106
  createdAt: number;
123
107
  },
124
108
  ): { ok: true; entries: TerminalEntry[]; entry: TerminalEntry } | { ok: false; error: string } {
@@ -129,8 +113,10 @@ export function registerTerminal(
129
113
  error: `terminal limit reached (max ${MAX_TERMINALS} open terminals); close one with terminal(action: "close") before opening another`,
130
114
  };
131
115
  }
116
+ const requested = validateTerminalId(opts.terminalId, entries.map((e) => e.terminalId));
117
+ if (requested != null && !requested.ok) return { ok: false, error: requested.error };
132
118
  const entry: TerminalEntry = {
133
- terminalId: nextTerminalId(entries.map((e) => e.terminalId)),
119
+ terminalId: requested != null && requested.ok ? requested.id : nextTerminalId(entries.map((e) => e.terminalId)),
134
120
  paneId: opts.paneId,
135
121
  tabId: opts.tabId,
136
122
  cwd: opts.cwd,
@@ -148,7 +134,24 @@ export function registerTerminal(
148
134
  return { ok: true, entries: [...entries, entry], entry };
149
135
  }
150
136
 
151
- /** 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
+
152
155
  export function closeTerminal(
153
156
  entries: readonly TerminalEntry[],
154
157
  terminalId: string,
@@ -163,13 +166,10 @@ export function closeTerminal(
163
166
  };
164
167
  }
165
168
 
166
- /** Why: Preserve the established compatibility and safety behavior (D71). */
167
169
  export function activeTerminalPaneIds(entries: readonly TerminalEntry[]): Set<string> {
168
170
  return new Set(entries.filter((e) => e.status === 'open').map((e) => e.paneId));
169
171
  }
170
172
 
171
- /** Why: Preserve the established compatibility and safety behavior (T6). */
172
-
173
173
  export function validateSendText(text: string): { ok: true; text: string } | { ok: false; error: string } {
174
174
  if (text.includes('\x1b')) {
175
175
  return {
@@ -177,7 +177,7 @@ export function validateSendText(text: string): { ok: true; text: string } | { o
177
177
  error: 'unsupported input: ANSI escape sequences cannot be sent as text; use terminal(action: "signal") for control keys, or plain commands only',
178
178
  };
179
179
  }
180
- // Why: Preserve the established compatibility and safety behavior (C0).
180
+
181
181
  const stripped = text.replace(/[\r\n]+$/, '');
182
182
  const bad = stripped.match(/[\x00-\x08\x0b-\x1f\x7f]/);
183
183
  if (bad) {
@@ -200,7 +200,29 @@ export function validateSignal(key: string): { ok: true; key: SignalKey } | { ok
200
200
  return { ok: true, key: k };
201
201
  }
202
202
 
203
- /** 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
+ }
204
226
 
205
227
  export function detectFullscreenTUI(raw: string): { detected: boolean; sequence: string | null } {
206
228
  for (const seq of FULLSCREEN_SEQUENCES) {
@@ -211,21 +233,16 @@ export function detectFullscreenTUI(raw: string): { detected: boolean; sequence:
211
233
 
212
234
  export function stripAnsi(raw: string): string {
213
235
  return raw
214
- .replace(/\x1b\[[0-9;?]*[ -/]*[@-~]/g, '') // Why: Preserve the established compatibility and safety behavior.
215
- .replace(/\x1b\][^\x07\x1b]*(\x07|\x1b\\)/g, '') // Why: Preserve the established compatibility and safety behavior.
216
- .replace(/\x1b[@-Z\\-_]/g, '') // Why: Preserve the established compatibility and safety behavior.
217
- .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, '');
218
240
  }
219
241
 
220
- /** Why: Preserve the established compatibility and safety behavior. */
221
-
222
242
  export interface ReadCursor {
223
243
  revision: number;
224
- /** Why: Preserve the established compatibility and safety behavior. */
225
244
  len: number;
226
- /** Why: Preserve the established compatibility and safety behavior. */
227
245
  tail: string;
228
- /** Why: Preserve the established compatibility and safety behavior. */
229
246
  eoTail: string;
230
247
  }
231
248
 
@@ -233,14 +250,11 @@ export interface ReadIncrement {
233
250
  mode: 'none' | 'append' | 'reset';
234
251
  text: string;
235
252
  cursor: ReadCursor;
236
- /** Why: Preserve the established compatibility and safety behavior. */
237
253
  hardCapped: boolean;
238
254
  }
239
255
 
240
- /** Why: Preserve the established compatibility and safety behavior. */
241
256
  const CURSOR_TAIL_CHARS = 64;
242
257
 
243
- /** Why: Preserve the established compatibility and safety behavior (M14). */
244
258
  export function computeIncrement(
245
259
  prev: ReadCursor | null,
246
260
  next: { text: string; revision: number },
@@ -265,19 +279,52 @@ export function computeIncrement(
265
279
  const bounded = boundText(suffix, maxChars);
266
280
  return { mode: 'append', text: bounded.text, cursor, hardCapped: bounded.capped };
267
281
  }
268
- // Why: Preserve the established compatibility and safety behavior.
282
+
269
283
  const bounded = boundText(next.text, maxChars);
270
284
  const text = prev ? `${RESET_MARKER}\n${bounded.text}` : bounded.text;
271
285
  return { mode: 'reset', text, cursor, hardCapped: bounded.capped };
272
286
  }
273
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
+
274
323
  function boundText(text: string, maxChars: number): { text: string; capped: boolean } {
275
324
  if (text.length <= maxChars) return { text, capped: false };
276
325
  return { text: `${text.slice(text.length - maxChars)}\n${TRUNCATE_MARKER}`, capped: true };
277
326
  }
278
327
 
279
- /** Why: Preserve the established compatibility and safety behavior (T3). */
280
-
281
328
  export type ReadinessTier = 'prompt' | 'silent' | 'busy';
282
329
 
283
330
  export function classifyReadiness(
@@ -291,8 +338,6 @@ export function classifyReadiness(
291
338
  return 'busy';
292
339
  }
293
340
 
294
- /** Why: Preserve the established compatibility and safety behavior (T6). */
295
-
296
341
  export interface TerminalSummary {
297
342
  terminalId: string;
298
343
  paneId: string;
@@ -321,56 +366,38 @@ export function summarizeSessions(
321
366
  return { terminals, stalePaneIds };
322
367
  }
323
368
 
324
- /** Why: Preserve the established compatibility and safety behavior. */
325
-
326
- export interface BranchEntryLike3 {
327
- type?: string;
328
- customType?: string;
329
- data?: unknown;
330
- }
331
-
332
369
  export function makeTerminalsRegistry(terminals: TerminalEntry[] = []): TerminalsRegistry {
333
370
  return { version: 1, terminals };
334
371
  }
335
372
 
336
- /**
337
- * Why: In interactive bash and zsh, history expansion (`!`) is active by default. Commands
338
- * containing `!` (such as `!|` or `if [ ! -f ... ]` or `!$`) trigger history expansion
339
- * (e.g. `zsh: event not found: \|`), wedging the managed shell and leaving the controller
340
- * waiting indefinitely (observed in session 01a08f37).
341
- * We disable history expansion for managed shells right after creation via `set +H` (supported
342
- * by both bash and zsh; equivalent to `unsetopt BANG_HIST` in zsh).
343
- * On Windows / PowerShell, `set +H` is unrecognized syntax and PowerShell does not use `!`
344
- * for history expansion, so init is skipped.
345
- *
346
- * Limits: `set +H` only affects the top-level persistent shell process. If a command spawns
347
- * a nested interactive subshell (e.g. `bash -i`), that subshell would re-enable its default options.
348
- */
349
- export function shellInitCommandFor(strategy: PromptStrategy = POSIX_PROMPT): string | null {
350
- return strategy === POWERSHELL_PROMPT ? null : 'set +H';
351
- }
352
-
353
373
  export interface ShellInitPlan {
354
374
  readonly shouldInit: boolean;
355
375
  readonly command: string | null;
356
376
  }
357
377
 
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
+ */
358
385
  export function planShellInit(opts: {
359
386
  strategy?: PromptStrategy;
360
387
  readiness?: ReadinessTier;
361
388
  initialized?: boolean;
362
389
  }): ShellInitPlan {
363
390
  if (opts.initialized) return { shouldInit: false, command: null };
364
- const strategy = opts.strategy ?? POSIX_PROMPT;
365
- const cmd = shellInitCommandFor(strategy);
366
- if (!cmd) return { shouldInit: false, command: null };
391
+ if ((opts.strategy ?? POSIX_PROMPT) === POWERSHELL_PROMPT) return { shouldInit: false, command: null };
367
392
  if (opts.readiness !== undefined && opts.readiness !== 'prompt') {
368
393
  return { shouldInit: false, command: null };
369
394
  }
370
- return { shouldInit: true, command: cmd };
395
+ return { shouldInit: true, command: 'set +H' };
371
396
  }
372
397
 
373
- export function foldTerminalsRegistry(entries: readonly BranchEntryLike3[]): TerminalEntry[] {
398
+ export function foldTerminalsRegistry(
399
+ entries: readonly { type?: string; customType?: string; data?: unknown }[],
400
+ ): TerminalEntry[] {
374
401
  let found: TerminalEntry[] = [];
375
402
  for (const entry of entries) {
376
403
  if (entry.type !== 'custom' || entry.customType !== TERMINALS_CUSTOM_TYPE) continue;
@@ -400,15 +427,13 @@ export function foldTerminalsRegistry(entries: readonly BranchEntryLike3[]): Ter
400
427
  }
401
428
 
402
429
  /* ── Idle-terminal nudge: turn-end self-cleanup for shells the model forgot to close ──
403
- * Observed (wF:p7 orphan): a master opened a resident terminal for a one-shot background
404
- * compile, the job finished, and nothing ever closed the pane — terminals are persistent by
405
- * design, so the shell lingered as a dead split in the main tab. Decision core mirrors the
406
- * 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. */
407
432
 
408
433
  /** Cap idle-terminal nudges for the lifetime of the process (same shape as TODO_REMINDERS_MAX). */
409
434
  export const TERM_REMINDERS_MAX = 2;
410
435
 
411
- /** 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. */
412
437
  export function terminalIdleMs(): number {
413
438
  // B10: canonical PIER_TERM_IDLE_MS, legacy PI_HERDR_TERM_IDLE_MS.
414
439
  return Number(pierOption('PIER_TERM_IDLE_MS') ?? 30 * 60_000) || 30 * 60_000;
@@ -419,7 +444,6 @@ export function terminalReminderGraceMs(): number {
419
444
  return Number(pierOption('PIER_TERM_GRACE_MS') ?? 30_000) || 30_000;
420
445
  }
421
446
 
422
- /** Custom message type for the idle-terminal nudge (registerMessageRenderer may restyle it). */
423
447
  export const TERM_REMINDER_CUSTOM_TYPE = 'pi-herdr.term-reminder';
424
448
 
425
449
  export interface IdleTerminalInput {
@@ -427,14 +451,12 @@ export interface IdleTerminalInput {
427
451
  cwd: string;
428
452
  label: string | null;
429
453
  lastActivityAt: number;
430
- /** 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). */
431
455
  nudgedAt?: number | null;
432
456
  }
433
457
 
434
458
  export interface IdleTerminalReminderPlan {
435
- /** Whether a nudge should be injected. */
436
459
  due: boolean;
437
- /** Complete injected content, or null when due is false. */
438
460
  content: string | null;
439
461
  /** Nudge count after successful injection, or the original count when due is false. */
440
462
  nextReminders: number;
@@ -447,11 +469,8 @@ function noIdleInject(reminders: number): IdleTerminalReminderPlan {
447
469
  }
448
470
 
449
471
  /**
450
- * Due only when an OPEN terminal is idle past the threshold AND has never been nudged
451
- * (01a06ae3: a per-process counter alone let nudges resume after restarts and looped a
452
- * farewell exchange 52 times — a nudged terminal must stay nudged, persisted in the ledger).
453
- * Shells still hosting long-running work were touched recently, so the idle filter is what
454
- * 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.
455
474
  */
456
475
  export function planIdleTerminalReminder(
457
476
  input: { open: readonly IdleTerminalInput[]; now: number; reminders: number },