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.
- package/README.md +16 -12
- package/package.json +3 -3
- 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 +10 -13
- package/src/compact-coordinator.ts +279 -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/cordis-services.ts +64 -0
- package/src/dashboard-command.ts +77 -33
- package/src/delegation-core.ts +115 -0
- package/src/efficiency-config-core.ts +286 -486
- package/src/efficiency-store.ts +16 -29
- package/src/gc-core.ts +10 -36
- package/src/guards.ts +6 -0
- package/src/herdr-client.ts +202 -233
- package/src/history-store.ts +38 -23
- package/src/index-gates.ts +118 -0
- package/src/index-locks.ts +2 -7
- package/src/index-master.ts +31 -29
- package/src/index-notices.ts +28 -15
- package/src/index-pipe.ts +31 -12
- package/src/index-roles.ts +306 -0
- package/src/index-runtime.ts +16 -6
- package/src/index-worker.ts +8 -18
- package/src/index.ts +561 -570
- package/src/jev-client.ts +12 -17
- package/src/jev-core.ts +138 -90
- 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/panel-open.ts +47 -0
- package/src/pi-surface.ts +34 -22
- package/src/pier-options.ts +53 -50
- package/src/pipe-channel.ts +44 -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 +766 -0
- package/src/{core → plugins}/terminal.ts +198 -139
- package/src/{core → plugins}/todo.ts +73 -134
- package/src/progress-core.ts +16 -53
- package/src/reconcile-core.ts +24 -33
- package/src/reducer-core.ts +26 -64
- package/src/reducer-invoker.ts +266 -239
- package/src/renderers.ts +108 -84
- package/src/role-loader.ts +33 -28
- package/src/role-manifest.ts +25 -31
- package/src/role-state.ts +119 -0
- package/src/roles/worker-default.json +3 -3
- 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 +61 -22
- 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 +423 -120
- package/src/subagent-gc.ts +109 -67
- package/src/subagent-poller.ts +383 -24
- package/src/subagent-session.ts +516 -0
- package/src/subagent-spawn.ts +577 -52
- package/src/swallow.ts +7 -17
- package/src/terminal-core.ts +187 -134
- package/src/todo-core.ts +57 -109
- package/src/todo-read-hook.ts +46 -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/tree-view.ts +162 -0
- package/src/vocab.ts +52 -25
- package/src/worker-marker.ts +76 -0
- 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/focus-poller.ts +0 -244
- 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;
|
|
@@ -85,7 +69,9 @@ export interface TerminalEntry {
|
|
|
85
69
|
label: string | null;
|
|
86
70
|
createdAt: number;
|
|
87
71
|
lastActivityAt: number;
|
|
88
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
-
/**
|
|
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, '')
|
|
215
|
-
.replace(/\x1b\][^\x07\x1b]*(\x07|\x1b\\)/g, '')
|
|
216
|
-
.replace(/\x1b[@-Z\\-_]/g, '')
|
|
217
|
-
.replace(/[\x00-\x08\x0b-\x1f\x7f]/g, '');
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
398
|
+
return { shouldInit: true, command: 'set +H' };
|
|
371
399
|
}
|
|
372
400
|
|
|
373
|
-
export function foldTerminalsRegistry(
|
|
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
|
-
/* ──
|
|
403
|
-
*
|
|
404
|
-
*
|
|
405
|
-
*
|
|
406
|
-
*
|
|
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
|
-
/**
|
|
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
|
|
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') ??
|
|
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
|
|
458
|
+
export interface TerminalNudgeInput {
|
|
426
459
|
terminalId: string;
|
|
427
460
|
cwd: string;
|
|
428
461
|
label: string | null;
|
|
429
462
|
lastActivityAt: number;
|
|
430
|
-
/**
|
|
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
|
|
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
|
-
/**
|
|
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
|
|
446
|
-
return { due: false, content: null,
|
|
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
|
-
*
|
|
451
|
-
*
|
|
452
|
-
*
|
|
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
|
|
457
|
-
input: { open: readonly
|
|
458
|
-
):
|
|
498
|
+
export function planTerminalReminder(
|
|
499
|
+
input: { open: readonly TerminalNudgeInput[]; now: number; turnStartedAt?: number | null },
|
|
500
|
+
): TerminalReminderPlan {
|
|
459
501
|
const idleMs = terminalIdleMs();
|
|
460
|
-
const
|
|
461
|
-
|
|
462
|
-
)
|
|
463
|
-
|
|
464
|
-
|
|
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
|
-
|
|
524
|
+
if (ids.length === 0) return noTerminalInject();
|
|
467
525
|
const content = [
|
|
468
|
-
|
|
469
|
-
|
|
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
|
}
|