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.
- package/README.md +26 -12
- package/package.json +8 -3
- package/schemas/efficiency-config.schema.json +77 -0
- package/schemas/role-manifest.schema.json +7 -1
- package/skills/pi-herdr-workflow/SKILL.md +11 -0
- package/src/ansi-text.ts +134 -0
- package/src/ask-dialog.ts +308 -0
- package/src/ask-user.ts +139 -88
- package/src/bootstrap.ts +13 -14
- package/src/compact-coordinator.ts +471 -0
- package/src/compact-economics-core.ts +328 -0
- package/src/config-catalog-core.ts +332 -0
- package/src/config-command.ts +388 -0
- package/src/dashboard-command.ts +108 -0
- package/src/efficiency-config-core.ts +464 -0
- package/src/efficiency-store.ts +299 -0
- package/src/focus-poller.ts +223 -0
- package/src/gc-core.ts +77 -18
- package/src/herdr-client.ts +284 -154
- package/src/history-store.ts +36 -23
- package/src/index-gates.ts +124 -0
- package/src/index-locks.ts +29 -16
- package/src/index-master.ts +29 -19
- package/src/index-notices.ts +35 -11
- package/src/index-pipe.ts +17 -7
- package/src/index-roles.ts +306 -0
- package/src/index-runtime.ts +2 -6
- package/src/index-worker.ts +7 -9
- package/src/index.ts +425 -388
- package/src/jev-client.ts +141 -0
- package/src/jev-core.ts +431 -0
- package/src/ledger.ts +13 -21
- package/src/lock-core.ts +98 -37
- package/src/manifest-compose.ts +35 -20
- package/src/observation-core.ts +200 -0
- package/src/pane-title.ts +19 -38
- package/src/pi-surface.ts +34 -22
- package/src/pier-options.ts +100 -0
- package/src/pipe-channel.ts +65 -52
- package/src/platform-paths.ts +19 -52
- package/src/{core → plugins}/grid-shape.ts +12 -12
- package/src/plugins/heat-plan.ts +158 -0
- package/src/plugins/observation.ts +475 -0
- package/src/plugins/subagent.ts +728 -0
- package/src/{core → plugins}/terminal.ts +237 -123
- package/src/{core → plugins}/todo.ts +75 -100
- package/src/progress-core.ts +16 -53
- package/src/reconcile-core.ts +24 -33
- package/src/reducer-core.ts +322 -0
- package/src/reducer-invoker.ts +419 -0
- package/src/renderers.ts +187 -0
- package/src/role-loader.ts +35 -29
- package/src/role-manifest.ts +23 -32
- package/src/role-state.ts +119 -0
- package/src/routing-telemetry.ts +136 -0
- package/src/runtime-policy.ts +25 -43
- package/src/session-tail.ts +102 -23
- package/src/settle-wake-core.ts +55 -22
- package/src/slim-frame.ts +29 -124
- package/src/stale-core.ts +17 -24
- package/src/storage-layout.ts +24 -30
- package/src/subagent-core.ts +256 -119
- package/src/subagent-gc.ts +131 -69
- package/src/subagent-poller.ts +401 -12
- package/src/subagent-session.ts +516 -0
- package/src/subagent-spawn.ts +651 -28
- package/src/swallow.ts +65 -0
- package/src/terminal-core.ts +160 -69
- package/src/todo-core.ts +62 -113
- package/src/todo-read-hook.ts +23 -39
- package/src/todo-reminder-core.ts +30 -48
- package/src/todo-window.ts +11 -12
- package/src/todos-service.ts +27 -35
- package/src/tool-error.ts +19 -0
- package/src/tool-gate.ts +38 -21
- package/src/vocab.ts +55 -25
- package/src/core/subagent.ts +0 -839
- package/src/git-adapter.ts +0 -104
- package/src/notice-buffer.ts +0 -23
- package/src/subagent-git-io.ts +0 -80
- package/src/subagent-launch.ts +0 -110
- package/src/subagent-poll-loop.ts +0 -285
- package/src/subagent-port.ts +0 -27
- package/src/subagent-scope.ts +0 -39
- 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
|
+
}
|
package/src/terminal-core.ts
CHANGED
|
@@ -1,17 +1,17 @@
|
|
|
1
|
-
|
|
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.
|
|
14
|
-
|
|
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: '[
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
-
|
|
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',
|
|
43
|
-
'\x1b[?47h',
|
|
44
|
-
'\x1b[2J\x1b[H',
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
-
/**
|
|
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, '')
|
|
182
|
-
.replace(/\x1b\][^\x07\x1b]*(\x07|\x1b\\)/g, '')
|
|
183
|
-
.replace(/\x1b[@-Z\\-_]/g, '')
|
|
184
|
-
.replace(/[\x00-\x08\x0b-\x1f\x7f]/g, '');
|
|
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
|
-
|
|
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
|
-
|
|
369
|
+
export function makeTerminalsRegistry(terminals: TerminalEntry[] = []): TerminalsRegistry {
|
|
370
|
+
return { version: 1, terminals };
|
|
371
|
+
}
|
|
292
372
|
|
|
293
|
-
export interface
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
data?: unknown;
|
|
373
|
+
export interface ShellInitPlan {
|
|
374
|
+
readonly shouldInit: boolean;
|
|
375
|
+
readonly command: string | null;
|
|
297
376
|
}
|
|
298
377
|
|
|
299
|
-
|
|
300
|
-
|
|
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(
|
|
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
|
-
|
|
324
|
-
|
|
325
|
-
|
|
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
|
-
*
|
|
333
|
-
*
|
|
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;
|
|
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
|
-
|
|
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(
|
|
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 (
|
|
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
|
|
379
|
-
*
|
|
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 },
|