pi-pier 0.3.0 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (103) hide show
  1. package/README.md +16 -12
  2. package/package.json +3 -3
  3. package/schemas/role-manifest.schema.json +7 -1
  4. package/skills/pi-herdr-workflow/SKILL.md +4 -5
  5. package/src/ansi-text.ts +62 -11
  6. package/src/ask-dialog.ts +308 -0
  7. package/src/ask-user.ts +113 -112
  8. package/src/bootstrap.ts +10 -13
  9. package/src/compact-coordinator.ts +279 -261
  10. package/src/compact-economics-core.ts +77 -145
  11. package/src/config-catalog-core.ts +77 -197
  12. package/src/config-command.ts +271 -64
  13. package/src/cordis-services.ts +64 -0
  14. package/src/dashboard-command.ts +77 -33
  15. package/src/delegation-core.ts +115 -0
  16. package/src/efficiency-config-core.ts +286 -486
  17. package/src/efficiency-store.ts +16 -29
  18. package/src/gc-core.ts +10 -36
  19. package/src/guards.ts +6 -0
  20. package/src/herdr-client.ts +202 -233
  21. package/src/history-store.ts +38 -23
  22. package/src/index-gates.ts +118 -0
  23. package/src/index-locks.ts +2 -7
  24. package/src/index-master.ts +31 -29
  25. package/src/index-notices.ts +28 -15
  26. package/src/index-pipe.ts +31 -12
  27. package/src/index-roles.ts +306 -0
  28. package/src/index-runtime.ts +16 -6
  29. package/src/index-worker.ts +8 -18
  30. package/src/index.ts +561 -570
  31. package/src/jev-client.ts +12 -17
  32. package/src/jev-core.ts +138 -90
  33. package/src/ledger.ts +13 -21
  34. package/src/lock-core.ts +28 -56
  35. package/src/manifest-compose.ts +35 -20
  36. package/src/observation-core.ts +52 -100
  37. package/src/pane-title.ts +19 -38
  38. package/src/panel-open.ts +47 -0
  39. package/src/pi-surface.ts +34 -22
  40. package/src/pier-options.ts +53 -50
  41. package/src/pipe-channel.ts +44 -59
  42. package/src/platform-paths.ts +19 -52
  43. package/src/{core → plugins}/grid-shape.ts +8 -10
  44. package/src/{core → plugins}/heat-plan.ts +20 -22
  45. package/src/plugins/observation.ts +475 -0
  46. package/src/plugins/subagent.ts +766 -0
  47. package/src/{core → plugins}/terminal.ts +198 -139
  48. package/src/{core → plugins}/todo.ts +73 -134
  49. package/src/progress-core.ts +16 -53
  50. package/src/reconcile-core.ts +24 -33
  51. package/src/reducer-core.ts +26 -64
  52. package/src/reducer-invoker.ts +266 -239
  53. package/src/renderers.ts +108 -84
  54. package/src/role-loader.ts +33 -28
  55. package/src/role-manifest.ts +25 -31
  56. package/src/role-state.ts +119 -0
  57. package/src/roles/worker-default.json +3 -3
  58. package/src/routing-telemetry.ts +136 -0
  59. package/src/runtime-policy.ts +25 -40
  60. package/src/session-tail.ts +65 -41
  61. package/src/settle-wake-core.ts +61 -22
  62. package/src/slim-frame.ts +25 -122
  63. package/src/stale-core.ts +17 -24
  64. package/src/storage-layout.ts +14 -43
  65. package/src/subagent-core.ts +423 -120
  66. package/src/subagent-gc.ts +109 -67
  67. package/src/subagent-poller.ts +383 -24
  68. package/src/subagent-session.ts +516 -0
  69. package/src/subagent-spawn.ts +577 -52
  70. package/src/swallow.ts +7 -17
  71. package/src/terminal-core.ts +187 -134
  72. package/src/todo-core.ts +57 -109
  73. package/src/todo-read-hook.ts +46 -39
  74. package/src/todo-reminder-core.ts +26 -51
  75. package/src/todo-window.ts +11 -12
  76. package/src/todos-service.ts +15 -35
  77. package/src/tool-error.ts +6 -13
  78. package/src/tool-gate.ts +38 -21
  79. package/src/tree-view.ts +162 -0
  80. package/src/vocab.ts +52 -25
  81. package/src/worker-marker.ts +76 -0
  82. package/src/ask-multi.ts +0 -260
  83. package/src/config-guide.ts +0 -361
  84. package/src/core/observation.ts +0 -555
  85. package/src/core/subagent.ts +0 -633
  86. package/src/efficiency-config-io.ts +0 -53
  87. package/src/focus-poller.ts +0 -244
  88. package/src/git-adapter.ts +0 -113
  89. package/src/notice-buffer.ts +0 -23
  90. package/src/subagent-execute.ts +0 -328
  91. package/src/subagent-git-io.ts +0 -80
  92. package/src/subagent-launch.ts +0 -114
  93. package/src/subagent-list-action.ts +0 -69
  94. package/src/subagent-output-action.ts +0 -104
  95. package/src/subagent-output-adapter.ts +0 -27
  96. package/src/subagent-output-core.ts +0 -263
  97. package/src/subagent-poll-loop.ts +0 -305
  98. package/src/subagent-port.ts +0 -27
  99. package/src/subagent-ready.ts +0 -89
  100. package/src/subagent-registry.ts +0 -70
  101. package/src/subagent-resolution.ts +0 -41
  102. package/src/subagent-scope.ts +0 -41
  103. package/src/subagent-session-io.ts +0 -230
package/src/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;
@@ -85,7 +69,9 @@ export interface TerminalEntry {
85
69
  label: string | null;
86
70
  createdAt: number;
87
71
  lastActivityAt: number;
88
- /** Idle-nudge bookkeeping: set when this terminal has been nudged once (per-entry, persisted). */
72
+ /** Nudges delivered for this terminal (0..TERM_REMINDERS_MAX), persisted. */
73
+ nudges: number;
74
+ /** Timestamp of the last nudge; kept alongside `nudges` for legacy records and debugging. */
89
75
  nudgedAt: number | null;
90
76
  status: 'open' | 'closed';
91
77
  closedAt: number | null;
@@ -101,8 +87,6 @@ export interface TerminalsRegistry {
101
87
  terminals: TerminalEntry[];
102
88
  }
103
89
 
104
- /** Why: Preserve the established compatibility and safety behavior. */
105
-
106
90
  export function nextTerminalId(existingIds: readonly string[]): string {
107
91
  let max = 0;
108
92
  for (const id of existingIds) {
@@ -119,6 +103,8 @@ export function registerTerminal(
119
103
  tabId: string;
120
104
  cwd: string;
121
105
  label?: string | null;
106
+ /** Caller-requested id; when omitted (or unusable per validateTerminalId) a term-N id is assigned. */
107
+ terminalId?: string | null;
122
108
  createdAt: number;
123
109
  },
124
110
  ): { ok: true; entries: TerminalEntry[]; entry: TerminalEntry } | { ok: false; error: string } {
@@ -129,14 +115,17 @@ export function registerTerminal(
129
115
  error: `terminal limit reached (max ${MAX_TERMINALS} open terminals); close one with terminal(action: "close") before opening another`,
130
116
  };
131
117
  }
118
+ const requested = validateTerminalId(opts.terminalId, entries.map((e) => e.terminalId));
119
+ if (requested != null && !requested.ok) return { ok: false, error: requested.error };
132
120
  const entry: TerminalEntry = {
133
- terminalId: nextTerminalId(entries.map((e) => e.terminalId)),
121
+ terminalId: requested != null && requested.ok ? requested.id : nextTerminalId(entries.map((e) => e.terminalId)),
134
122
  paneId: opts.paneId,
135
123
  tabId: opts.tabId,
136
124
  cwd: opts.cwd,
137
125
  label: opts.label ?? (opts.cwd ? basename(opts.cwd.replace(/\\/g, '/')) || opts.cwd : null),
138
126
  createdAt: opts.createdAt,
139
127
  lastActivityAt: opts.createdAt,
128
+ nudges: 0,
140
129
  nudgedAt: null,
141
130
  status: 'open',
142
131
  closedAt: null,
@@ -148,7 +137,24 @@ export function registerTerminal(
148
137
  return { ok: true, entries: [...entries, entry], entry };
149
138
  }
150
139
 
151
- /** Why: Preserve the established compatibility and safety behavior. */
140
+ /** A requested terminal id must be a short single-token string that no existing terminal (open or
141
+ * closed) already uses — ids stay addressable after close for `close`/`list`, so reuse would
142
+ * retarget actions at the wrong pane. Returns null when no id was requested. */
143
+ export function validateTerminalId(
144
+ id: string | null | undefined,
145
+ existingIds: readonly string[],
146
+ ): { ok: true; id: string } | { ok: false; error: string } | null {
147
+ const trimmed = (id ?? '').trim();
148
+ if (trimmed.length === 0) return null; // blank means "not provided" → auto-assigned term-N
149
+ if (trimmed.length > 64 || /\s/.test(trimmed)) {
150
+ 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` };
151
+ }
152
+ if (existingIds.includes(trimmed)) {
153
+ return { ok: false, error: `terminal_id "${trimmed}" is already used (open or closed); ids stay reserved for addressing — pick another or omit it` };
154
+ }
155
+ return { ok: true, id: trimmed };
156
+ }
157
+
152
158
  export function closeTerminal(
153
159
  entries: readonly TerminalEntry[],
154
160
  terminalId: string,
@@ -163,13 +169,10 @@ export function closeTerminal(
163
169
  };
164
170
  }
165
171
 
166
- /** Why: Preserve the established compatibility and safety behavior (D71). */
167
172
  export function activeTerminalPaneIds(entries: readonly TerminalEntry[]): Set<string> {
168
173
  return new Set(entries.filter((e) => e.status === 'open').map((e) => e.paneId));
169
174
  }
170
175
 
171
- /** Why: Preserve the established compatibility and safety behavior (T6). */
172
-
173
176
  export function validateSendText(text: string): { ok: true; text: string } | { ok: false; error: string } {
174
177
  if (text.includes('\x1b')) {
175
178
  return {
@@ -177,7 +180,7 @@ export function validateSendText(text: string): { ok: true; text: string } | { o
177
180
  error: 'unsupported input: ANSI escape sequences cannot be sent as text; use terminal(action: "signal") for control keys, or plain commands only',
178
181
  };
179
182
  }
180
- // Why: Preserve the established compatibility and safety behavior (C0).
183
+
181
184
  const stripped = text.replace(/[\r\n]+$/, '');
182
185
  const bad = stripped.match(/[\x00-\x08\x0b-\x1f\x7f]/);
183
186
  if (bad) {
@@ -200,7 +203,29 @@ export function validateSignal(key: string): { ok: true; key: SignalKey } | { ok
200
203
  return { ok: true, key: k };
201
204
  }
202
205
 
203
- /** Why: Preserve the established compatibility and safety behavior (T6). */
206
+ /**
207
+ * Whether a wait pattern would match the terminal's own echoed input: the shell echoes typed
208
+ * commands into the pane, so a pattern contained in the last sent text (`…; echo TERM_DONE_$?`
209
+ * waited on as literal `TERM_DONE_`) matches its own echo and returns "matched" before the job
210
+ * has produced anything. Detection is per terminal and best-effort: no send in this process
211
+ * lifetime → no hazard known → the wait proceeds unchanged.
212
+ */
213
+ export function sentinelEchoHazard(opts: {
214
+ pattern: string;
215
+ regex?: boolean;
216
+ lastSentText?: string | null;
217
+ }): boolean {
218
+ const sent = opts.lastSentText;
219
+ if (!sent) return false;
220
+ if (opts.regex) {
221
+ try {
222
+ return new RegExp(opts.pattern).test(sent);
223
+ } catch {
224
+ return false; // invalid regex is rejected by the caller before this runs
225
+ }
226
+ }
227
+ return sent.includes(opts.pattern);
228
+ }
204
229
 
205
230
  export function detectFullscreenTUI(raw: string): { detected: boolean; sequence: string | null } {
206
231
  for (const seq of FULLSCREEN_SEQUENCES) {
@@ -211,21 +236,16 @@ export function detectFullscreenTUI(raw: string): { detected: boolean; sequence:
211
236
 
212
237
  export function stripAnsi(raw: string): string {
213
238
  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.
239
+ .replace(/\x1b\[[0-9;?]*[ -/]*[@-~]/g, '')
240
+ .replace(/\x1b\][^\x07\x1b]*(\x07|\x1b\\)/g, '')
241
+ .replace(/\x1b[@-Z\\-_]/g, '')
242
+ .replace(/[\x00-\x08\x0b-\x1f\x7f]/g, '');
218
243
  }
219
244
 
220
- /** Why: Preserve the established compatibility and safety behavior. */
221
-
222
245
  export interface ReadCursor {
223
246
  revision: number;
224
- /** Why: Preserve the established compatibility and safety behavior. */
225
247
  len: number;
226
- /** Why: Preserve the established compatibility and safety behavior. */
227
248
  tail: string;
228
- /** Why: Preserve the established compatibility and safety behavior. */
229
249
  eoTail: string;
230
250
  }
231
251
 
@@ -233,14 +253,11 @@ export interface ReadIncrement {
233
253
  mode: 'none' | 'append' | 'reset';
234
254
  text: string;
235
255
  cursor: ReadCursor;
236
- /** Why: Preserve the established compatibility and safety behavior. */
237
256
  hardCapped: boolean;
238
257
  }
239
258
 
240
- /** Why: Preserve the established compatibility and safety behavior. */
241
259
  const CURSOR_TAIL_CHARS = 64;
242
260
 
243
- /** Why: Preserve the established compatibility and safety behavior (M14). */
244
261
  export function computeIncrement(
245
262
  prev: ReadCursor | null,
246
263
  next: { text: string; revision: number },
@@ -265,19 +282,52 @@ export function computeIncrement(
265
282
  const bounded = boundText(suffix, maxChars);
266
283
  return { mode: 'append', text: bounded.text, cursor, hardCapped: bounded.capped };
267
284
  }
268
- // Why: Preserve the established compatibility and safety behavior.
285
+
269
286
  const bounded = boundText(next.text, maxChars);
270
287
  const text = prev ? `${RESET_MARKER}\n${bounded.text}` : bounded.text;
271
288
  return { mode: 'reset', text, cursor, hardCapped: bounded.capped };
272
289
  }
273
290
 
291
+ /** Window around the first hit so a later capped read cannot hide the match behind earlier lines. */
292
+ export function excerptAroundMatch(text: string, pattern: string, regex: boolean, radius = 1500): string {
293
+ let index = -1;
294
+ let hitLen = pattern.length;
295
+ if (regex) {
296
+ const found = new RegExp(pattern).exec(text);
297
+ index = found?.index ?? -1;
298
+ hitLen = found?.[0].length ?? pattern.length;
299
+ } else {
300
+ index = text.indexOf(pattern);
301
+ }
302
+ if (index < 0) return text.slice(-radius);
303
+ const start = Math.max(0, index - radius);
304
+ const end = Math.min(text.length, index + hitLen + radius);
305
+ return `${start > 0 ? '…' : ''}${text.slice(start, end)}${end < text.length ? '…' : ''}`;
306
+ }
307
+
308
+ /**
309
+ * herdr waitForOutput matches the whole pane, including text a prior read already returned.
310
+ * A hit is fresh only when it sits in the unread increment; a hit only in consumed text is stale.
311
+ */
312
+ export function classifyWaitMatch(
313
+ prev: ReadCursor | null,
314
+ paneText: string,
315
+ pattern: string,
316
+ regex: boolean,
317
+ ): { kind: 'fresh'; excerpt: string } | { kind: 'stale' } | { kind: 'absent' } {
318
+ const match = (text: string) => regex ? new RegExp(pattern).test(text) : text.includes(pattern);
319
+ const inc = computeIncrement(prev, { text: paneText, revision: prev?.revision ?? 0 }, paneText.length + 1);
320
+ const unread = inc.mode === 'append' ? inc.text : inc.mode === 'reset' ? paneText : '';
321
+ if (match(unread)) return { kind: 'fresh', excerpt: excerptAroundMatch(unread, pattern, regex) };
322
+ if (inc.mode !== 'reset' && match(paneText)) return { kind: 'stale' };
323
+ return { kind: 'absent' };
324
+ }
325
+
274
326
  function boundText(text: string, maxChars: number): { text: string; capped: boolean } {
275
327
  if (text.length <= maxChars) return { text, capped: false };
276
328
  return { text: `${text.slice(text.length - maxChars)}\n${TRUNCATE_MARKER}`, capped: true };
277
329
  }
278
330
 
279
- /** Why: Preserve the established compatibility and safety behavior (T3). */
280
-
281
331
  export type ReadinessTier = 'prompt' | 'silent' | 'busy';
282
332
 
283
333
  export function classifyReadiness(
@@ -291,8 +341,6 @@ export function classifyReadiness(
291
341
  return 'busy';
292
342
  }
293
343
 
294
- /** Why: Preserve the established compatibility and safety behavior (T6). */
295
-
296
344
  export interface TerminalSummary {
297
345
  terminalId: string;
298
346
  paneId: string;
@@ -321,56 +369,38 @@ export function summarizeSessions(
321
369
  return { terminals, stalePaneIds };
322
370
  }
323
371
 
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
372
  export function makeTerminalsRegistry(terminals: TerminalEntry[] = []): TerminalsRegistry {
333
373
  return { version: 1, terminals };
334
374
  }
335
375
 
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
376
  export interface ShellInitPlan {
354
377
  readonly shouldInit: boolean;
355
378
  readonly command: string | null;
356
379
  }
357
380
 
381
+ /**
382
+ * Plan the shell init command: `set +H` disables history expansion in managed bash/zsh shells, where `!`
383
+ * in ordinary commands (`!|`, `if [ ! -f ... ]`, `!$`) otherwise triggers `zsh: event not found: \|` and
384
+ * wedges the shell. PowerShell rejects the syntax and has no `!` history expansion, so it is skipped.
385
+ * Limit: the top-level shell only — a nested `bash -i` re-enables its own defaults; init is only worth
386
+ * sending once the shell is at its prompt and has not been initialized yet.
387
+ */
358
388
  export function planShellInit(opts: {
359
389
  strategy?: PromptStrategy;
360
390
  readiness?: ReadinessTier;
361
391
  initialized?: boolean;
362
392
  }): ShellInitPlan {
363
393
  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 };
394
+ if ((opts.strategy ?? POSIX_PROMPT) === POWERSHELL_PROMPT) return { shouldInit: false, command: null };
367
395
  if (opts.readiness !== undefined && opts.readiness !== 'prompt') {
368
396
  return { shouldInit: false, command: null };
369
397
  }
370
- return { shouldInit: true, command: cmd };
398
+ return { shouldInit: true, command: 'set +H' };
371
399
  }
372
400
 
373
- export function foldTerminalsRegistry(entries: readonly BranchEntryLike3[]): TerminalEntry[] {
401
+ export function foldTerminalsRegistry(
402
+ entries: readonly { type?: string; customType?: string; data?: unknown }[],
403
+ ): TerminalEntry[] {
374
404
  let found: TerminalEntry[] = [];
375
405
  for (const entry of entries) {
376
406
  if (entry.type !== 'custom' || entry.customType !== TERMINALS_CUSTOM_TYPE) continue;
@@ -386,6 +416,9 @@ export function foldTerminalsRegistry(entries: readonly BranchEntryLike3[]): Ter
386
416
  label: typeof t.label === 'string' ? t.label : null,
387
417
  createdAt: typeof t.createdAt === 'number' ? t.createdAt : 0,
388
418
  lastActivityAt: typeof t.lastActivityAt === 'number' ? t.lastActivityAt : 0,
419
+ nudges: typeof t.nudges === 'number'
420
+ ? Math.max(0, Math.min(TERM_REMINDERS_MAX, Math.floor(t.nudges)))
421
+ : (typeof t.nudgedAt === 'number' ? 1 : 0),
389
422
  nudgedAt: typeof t.nudgedAt === 'number' ? t.nudgedAt : null,
390
423
  status: t.status === 'closed' ? ('closed' as const) : ('open' as const),
391
424
  closedAt: typeof t.closedAt === 'number' ? t.closedAt : null,
@@ -399,19 +432,20 @@ export function foldTerminalsRegistry(entries: readonly BranchEntryLike3[]): Ter
399
432
  return found;
400
433
  }
401
434
 
402
- /* ── 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. */
435
+ /* ── Terminal nudge: turn-end self-cleanup for shells the model forgot to close ──
436
+ * Terminals persist by design, so a finished shell lingers as a dead split. Two notices per terminal:
437
+ * 1. at the end of the turn that used it ("used this turn") — or the first turn that finds it idle
438
+ * past the threshold, for a shell nobody touched recently;
439
+ * 2. on a later settle once it has been idle past PIER_TERM_IDLE_MS.
440
+ * Delivery wiring mirrors the todo stop-reminder in plugins/terminal. */
407
441
 
408
- /** Cap idle-terminal nudges for the lifetime of the process (same shape as TODO_REMINDERS_MAX). */
442
+ /** Per-terminal nudge cap: one after use, one after the idle threshold (goodbye-loop guard). */
409
443
  export const TERM_REMINDERS_MAX = 2;
410
444
 
411
- /** Idle threshold before a nudge is due; PI_HERDR_TERM_IDLE_MS is read per call so tests can use small values. */
445
+ /** Idle threshold for the second nudge; the env var is read per call so tests can use small values. */
412
446
  export function terminalIdleMs(): number {
413
447
  // B10: canonical PIER_TERM_IDLE_MS, legacy PI_HERDR_TERM_IDLE_MS.
414
- return Number(pierOption('PIER_TERM_IDLE_MS') ?? 30 * 60_000) || 30 * 60_000;
448
+ return Number(pierOption('PIER_TERM_IDLE_MS') ?? 5 * 60_000) || 5 * 60_000;
415
449
  }
416
450
 
417
451
  /** Grace from agent_settled to injection, letting the user read the answer and intervene; cancel on agent_start. */
@@ -419,63 +453,82 @@ export function terminalReminderGraceMs(): number {
419
453
  return Number(pierOption('PIER_TERM_GRACE_MS') ?? 30_000) || 30_000;
420
454
  }
421
455
 
422
- /** Custom message type for the idle-terminal nudge (registerMessageRenderer may restyle it). */
423
456
  export const TERM_REMINDER_CUSTOM_TYPE = 'pi-herdr.term-reminder';
424
457
 
425
- export interface IdleTerminalInput {
458
+ export interface TerminalNudgeInput {
426
459
  terminalId: string;
427
460
  cwd: string;
428
461
  label: string | null;
429
462
  lastActivityAt: number;
430
- /** Set once this terminal has been nudged; a nudged terminal is never nudged again (01a06ae3 goodbye-loop guard). */
463
+ /** Nudges already delivered; when absent, legacy `nudgedAt` counts as one. */
464
+ nudges?: number;
431
465
  nudgedAt?: number | null;
466
+ /** A non-shell foreground process owns the pane (dev server / running job); never nudge it. */
467
+ busy?: boolean;
432
468
  }
433
469
 
434
- export interface IdleTerminalReminderPlan {
435
- /** Whether a nudge should be injected. */
470
+ export interface TerminalReminderPlan {
436
471
  due: boolean;
437
- /** Complete injected content, or null when due is false. */
438
472
  content: string | null;
439
- /** Nudge count after successful injection, or the original count when due is false. */
440
- nextReminders: number;
441
- /** Terminal ids this nudge covers; the caller marks them nudged at schedule time. */
473
+ /** Terminals covered by this notice; the caller bumps each one's `nudges` at schedule time. */
442
474
  ids: string[];
443
475
  }
444
476
 
445
- function noIdleInject(reminders: number): IdleTerminalReminderPlan {
446
- return { due: false, content: null, nextReminders: reminders, ids: [] };
477
+ function noTerminalInject(): TerminalReminderPlan {
478
+ return { due: false, content: null, ids: [] };
479
+ }
480
+
481
+ function nudgesOf(t: TerminalNudgeInput): number {
482
+ return t.nudges ?? (t.nudgedAt != null ? 1 : 0);
483
+ }
484
+
485
+ /** `72m` / `3h12m`: the reason column reads at a glance without a timestamp. */
486
+ function formatIdleAge(ms: number): string {
487
+ const minutes = Math.max(0, Math.floor(ms / 60_000));
488
+ if (minutes < 60) return `${minutes}m`;
489
+ const hours = Math.floor(minutes / 60);
490
+ return `${hours}h${String(minutes % 60).padStart(2, '0')}m`;
447
491
  }
448
492
 
449
493
  /**
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.
494
+ * First notice: the terminal was used by the agent run that just settled, or it is already idle past
495
+ * the threshold. Second notice: a terminal with one nudge is idle past the threshold again. A shell
496
+ * touched recently is never nudged, so a dev server in active use stays out.
455
497
  */
456
- export function planIdleTerminalReminder(
457
- input: { open: readonly IdleTerminalInput[]; now: number; reminders: number },
458
- ): IdleTerminalReminderPlan {
498
+ export function planTerminalReminder(
499
+ input: { open: readonly TerminalNudgeInput[]; now: number; turnStartedAt?: number | null },
500
+ ): TerminalReminderPlan {
459
501
  const idleMs = terminalIdleMs();
460
- const idle = input.open.filter(
461
- (t) => !t.nudgedAt && input.now - t.lastActivityAt >= idleMs,
462
- );
463
- if (idle.length === 0 || input.reminders >= TERM_REMINDERS_MAX) {
464
- return noIdleInject(input.reminders);
502
+ const rows: string[] = [];
503
+ const ids: string[] = [];
504
+ for (const t of input.open) {
505
+ if (t.busy) continue;
506
+ const nudges = nudgesOf(t);
507
+ if (nudges >= TERM_REMINDERS_MAX) continue;
508
+ const idleFor = input.now - t.lastActivityAt;
509
+ const sinceNudge = t.nudgedAt != null ? input.now - t.nudgedAt : Number.POSITIVE_INFINITY;
510
+ const usedThisTurn = input.turnStartedAt != null && t.lastActivityAt >= input.turnStartedAt;
511
+ // Second notice waits another full idle window after the first one, so a pane that was already
512
+ // idle for an hour at nudge 1 is not nudged twice back-to-back on the next settle.
513
+ const reason = nudges === 0 && usedThisTurn
514
+ ? 'used this turn'
515
+ : nudges === 0 && idleFor >= idleMs
516
+ ? `idle ${formatIdleAge(idleFor)}`
517
+ : nudges === 1 && idleFor >= idleMs && sinceNudge >= idleMs
518
+ ? `still idle ${formatIdleAge(idleFor)}`
519
+ : null;
520
+ if (reason == null) continue;
521
+ rows.push(`- ${t.terminalId} (${t.label ?? t.cwd}) — ${reason} · nudge ${nudges + 1}/${TERM_REMINDERS_MAX}`);
522
+ ids.push(t.terminalId);
465
523
  }
466
- const list = idle.map((t) => `- ${t.terminalId} (${t.label ?? t.cwd})`).join('\n');
524
+ if (ids.length === 0) return noTerminalInject();
467
525
  const content = [
468
- `<system-reminder>Open terminal(s) idle for a while (nudge ${input.reminders + 1}/${TERM_REMINDERS_MAX}):`,
469
- list,
526
+ '<system-reminder>Open terminal(s) that may be finished:',
527
+ ...rows,
470
528
  'This is an internal housekeeping notice — do NOT reply to the user and do NOT send any farewell on this notice.',
471
529
  'If the work in them is done, close them now with terminal(action: "close", terminal_id: "…").',
472
530
  'Keep a terminal only if a long-running process still needs that shell.',
473
531
  '</system-reminder>',
474
532
  ].join('\n');
475
- return {
476
- due: true,
477
- content,
478
- nextReminders: input.reminders + 1,
479
- ids: idle.map((t) => t.terminalId),
480
- };
533
+ return { due: true, content, ids };
481
534
  }