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.
- package/README.md +12 -11
- package/package.json +2 -2
- package/schemas/role-manifest.schema.json +7 -1
- package/skills/pi-herdr-workflow/SKILL.md +4 -5
- package/src/ansi-text.ts +62 -11
- package/src/ask-dialog.ts +308 -0
- package/src/ask-user.ts +113 -112
- package/src/bootstrap.ts +8 -12
- package/src/compact-coordinator.ts +278 -261
- package/src/compact-economics-core.ts +77 -145
- package/src/config-catalog-core.ts +77 -197
- package/src/config-command.ts +271 -64
- package/src/dashboard-command.ts +38 -19
- package/src/efficiency-config-core.ts +287 -484
- package/src/efficiency-store.ts +16 -29
- package/src/focus-poller.ts +14 -35
- package/src/gc-core.ts +10 -36
- package/src/herdr-client.ts +82 -208
- package/src/history-store.ts +36 -23
- package/src/index-gates.ts +124 -0
- package/src/index-locks.ts +2 -7
- package/src/index-master.ts +24 -18
- package/src/index-notices.ts +28 -15
- package/src/index-pipe.ts +17 -12
- package/src/index-roles.ts +306 -0
- package/src/index-runtime.ts +2 -6
- package/src/index-worker.ts +4 -8
- package/src/index.ts +305 -564
- package/src/jev-client.ts +12 -17
- package/src/jev-core.ts +137 -87
- package/src/ledger.ts +13 -21
- package/src/lock-core.ts +28 -56
- package/src/manifest-compose.ts +35 -20
- package/src/observation-core.ts +52 -100
- package/src/pane-title.ts +19 -38
- package/src/pi-surface.ts +34 -22
- package/src/pier-options.ts +54 -50
- package/src/pipe-channel.ts +27 -59
- package/src/platform-paths.ts +19 -52
- package/src/{core → plugins}/grid-shape.ts +8 -10
- package/src/{core → plugins}/heat-plan.ts +20 -22
- package/src/plugins/observation.ts +475 -0
- package/src/plugins/subagent.ts +728 -0
- package/src/{core → plugins}/terminal.ts +167 -127
- package/src/{core → plugins}/todo.ts +61 -100
- package/src/progress-core.ts +16 -53
- package/src/reconcile-core.ts +24 -33
- package/src/reducer-core.ts +24 -60
- package/src/reducer-invoker.ts +266 -239
- package/src/renderers.ts +76 -87
- package/src/role-loader.ts +33 -28
- package/src/role-manifest.ts +23 -28
- package/src/role-state.ts +119 -0
- package/src/routing-telemetry.ts +136 -0
- package/src/runtime-policy.ts +25 -40
- package/src/session-tail.ts +65 -41
- package/src/settle-wake-core.ts +52 -21
- package/src/slim-frame.ts +25 -122
- package/src/stale-core.ts +17 -24
- package/src/storage-layout.ts +14 -43
- package/src/subagent-core.ts +253 -118
- package/src/subagent-gc.ts +109 -67
- package/src/subagent-poller.ts +367 -24
- package/src/subagent-session.ts +516 -0
- package/src/subagent-spawn.ts +530 -52
- package/src/swallow.ts +7 -17
- package/src/terminal-core.ts +125 -106
- package/src/todo-core.ts +57 -109
- package/src/todo-read-hook.ts +23 -39
- package/src/todo-reminder-core.ts +26 -51
- package/src/todo-window.ts +11 -12
- package/src/todos-service.ts +15 -35
- package/src/tool-error.ts +6 -13
- package/src/tool-gate.ts +38 -21
- package/src/vocab.ts +52 -25
- package/src/ask-multi.ts +0 -260
- package/src/config-guide.ts +0 -361
- package/src/core/observation.ts +0 -555
- package/src/core/subagent.ts +0 -633
- package/src/efficiency-config-io.ts +0 -53
- package/src/git-adapter.ts +0 -113
- package/src/notice-buffer.ts +0 -23
- package/src/subagent-execute.ts +0 -328
- package/src/subagent-git-io.ts +0 -80
- package/src/subagent-launch.ts +0 -114
- package/src/subagent-list-action.ts +0 -69
- package/src/subagent-output-action.ts +0 -104
- package/src/subagent-output-adapter.ts +0 -27
- package/src/subagent-output-core.ts +0 -263
- package/src/subagent-poll-loop.ts +0 -305
- package/src/subagent-port.ts +0 -27
- package/src/subagent-ready.ts +0 -89
- package/src/subagent-registry.ts +0 -70
- package/src/subagent-resolution.ts +0 -41
- package/src/subagent-scope.ts +0 -41
- package/src/subagent-session-io.ts +0 -230
package/src/swallow.ts
CHANGED
|
@@ -1,16 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
*
|
|
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
|
-
/**
|
|
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
|
-
*
|
|
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) => {
|
package/src/terminal-core.ts
CHANGED
|
@@ -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
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
53
|
-
|
|
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
|
-
|
|
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',
|
|
71
|
-
'\x1b[?47h',
|
|
72
|
-
'\x1b[2J\x1b[H',
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
-
/**
|
|
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, '')
|
|
215
|
-
.replace(/\x1b\][^\x07\x1b]*(\x07|\x1b\\)/g, '')
|
|
216
|
-
.replace(/\x1b[@-Z\\-_]/g, '')
|
|
217
|
-
.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, '');
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
395
|
+
return { shouldInit: true, command: 'set +H' };
|
|
371
396
|
}
|
|
372
397
|
|
|
373
|
-
export function foldTerminalsRegistry(
|
|
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
|
-
*
|
|
404
|
-
*
|
|
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;
|
|
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 (
|
|
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
|
|
451
|
-
*
|
|
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 },
|