pi-pier 0.2.6 → 0.2.7
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 +2 -2
- package/package.json +1 -1
- package/skills/pi-herdr-workflow/SKILL.md +1 -1
- package/src/ask-user.ts +408 -0
- package/src/core/terminal.ts +165 -16
- package/src/core/todo.ts +24 -3
- package/src/index-master.ts +2 -1
- package/src/index-worker.ts +4 -1
- package/src/index.ts +31 -27
- package/src/slim-frame.ts +56 -1
- package/src/terminal-core.ts +86 -7
package/README.md
CHANGED
|
@@ -45,7 +45,7 @@ The installer also verifies node / pi / herdr versions, probes local paths, and
|
|
|
45
45
|
| `todo_write` | Full-replacement todo list. Session JSONL is the single source of truth; correct rollback on branch switches. Projected live onto the pane title (`▶i ○p ■b ✓c (N/M) · current task`). `/todos` command to view/edit/unblock |
|
|
46
46
|
| `subagent` | Delegate self-contained subtasks to an isolated pi session in its own herdr pane. Actions: `spawn` (default; foreground / parallel / background), `list`, `send`, `interrupt`, `resume` |
|
|
47
47
|
| `terminal` | Persistent interactive shells in dedicated herdr panes. Actions: `open`, `send`, `read`, `signal`, `close`, `list` |
|
|
48
|
-
| `ask_user_question` | Human gate: pane shows blocked in herdr while waiting |
|
|
48
|
+
| `ask_user_question` | Human gate: structured options plus Other; pane shows blocked in herdr while waiting |
|
|
49
49
|
|
|
50
50
|
### Behaviors
|
|
51
51
|
|
|
@@ -61,7 +61,7 @@ This extension mutates the pi session even outside herdr. After `pi install npm:
|
|
|
61
61
|
| Surface | Bare `pi` (no `HERDR_ENV`) | Inside herdr |
|
|
62
62
|
|---|---|---|
|
|
63
63
|
| `todo_write`, `/todos`, widget, anti-freeze, stop reminder | live | live |
|
|
64
|
-
| `ask_user_question` | live (TUI input) | live + blocked marker |
|
|
64
|
+
| `ask_user_question` | live (TUI select/input) | live + blocked marker |
|
|
65
65
|
| Hidden inject (`before_agent_start` todo-read; settle reminder) | live | live |
|
|
66
66
|
| `subagent`, `terminal` | **not registered** | live |
|
|
67
67
|
| `/locks`, write-lock on `write`/`edit` | not installed | live |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-pier",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.7",
|
|
4
4
|
"description": "pier: pi extension that fuses pi agent sessions with the herdr pane/tab orchestrator — todo_write loop closure + interactive subagents in isolated panes",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -90,7 +90,7 @@ Call: `subagent(action: "resume", taskId: "<uuid>")`
|
|
|
90
90
|
|
|
91
91
|
## 3. Human gate
|
|
92
92
|
|
|
93
|
-
- For a human decision (approval, tradeoff, direction), use `ask_user_question` (this plugin). While waiting, your pane is **blocked** in herdr so the human can see and intervene. The answer comes back as the tool result, then you continue.
|
|
93
|
+
- For a human decision (approval, tradeoff, direction), use `ask_user_question` (this plugin). Prefer 2-5 options with short labels and tradeoffs in `description`. Do **not** author an "Other" option — the UI appends "Other (type your own)". Use `recommended` (0-based) for the default and `questions` to batch related decisions (max 4). While waiting, your pane is **blocked** in herdr so the human can see and intervene. The answer comes back as the tool result, then you continue.
|
|
94
94
|
- A human can enter a subagent pane directly. Write the delegation `description` (pane label) so a human understands it at a glance.
|
|
95
95
|
|
|
96
96
|
## 4. Multi-agent engineering rules
|
package/src/ask-user.ts
ADDED
|
@@ -0,0 +1,408 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ask_user_question I/O: normalize params, select+Other dialog, result envelope.
|
|
3
|
+
*
|
|
4
|
+
* Why: the herdr gate (blocked / pi-ask / widget collapse) stays in index.ts.
|
|
5
|
+
* This module is the questionnaire — authored options plus a runtime Other row —
|
|
6
|
+
* so the model cannot forget a free-text escape and Esc is a real decline.
|
|
7
|
+
*/
|
|
8
|
+
import { Type } from 'typebox';
|
|
9
|
+
|
|
10
|
+
export const ASK_TOOL_NAME = 'ask_user_question';
|
|
11
|
+
export const OTHER_OPTION = 'Other (type your own)';
|
|
12
|
+
export const OTHER_ANSWER = 'Other';
|
|
13
|
+
export const RECOMMENDED_SUFFIX = ' (Recommended)';
|
|
14
|
+
export const MIN_OPTIONS = 2;
|
|
15
|
+
export const MAX_OPTIONS = 5;
|
|
16
|
+
export const MAX_QUESTIONS = 4;
|
|
17
|
+
export const MAX_LABEL_CHARS = 60;
|
|
18
|
+
|
|
19
|
+
export const DECLINE_TEXT =
|
|
20
|
+
'User declined to answer the questions. Continue with your best judgment, or ask different questions.';
|
|
21
|
+
export const NO_UI_TEXT =
|
|
22
|
+
'Error: UI not available (running in non-interactive mode). The user never saw the questions — do NOT treat this as a decline. Ask the questions as plain chat text instead, without using this tool.';
|
|
23
|
+
|
|
24
|
+
const CUSTOM_ANSWER_TITLE = 'Type your answer:';
|
|
25
|
+
const MULTI_INSTRUCTIONS =
|
|
26
|
+
'Enter the numbers of all that apply, comma-separated (e.g. "1,3"), or type a custom answer as plain text.';
|
|
27
|
+
const NUMBERED_HINT = 'Enter a number, or type a custom answer.';
|
|
28
|
+
|
|
29
|
+
const RESERVED_LOWER: Record<string, true> = {
|
|
30
|
+
[OTHER_OPTION.toLowerCase()]: true,
|
|
31
|
+
other: true,
|
|
32
|
+
'type something.': true,
|
|
33
|
+
next: true,
|
|
34
|
+
'next →': true,
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const OptionSchema = Type.Object({
|
|
38
|
+
label: Type.String({ description: 'Short display label (1-5 words)' }),
|
|
39
|
+
description: Type.Optional(Type.String({ description: 'Tradeoff / meaning of this choice' })),
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
export const ASK_PARAMETERS = Type.Object({
|
|
43
|
+
question: Type.Optional(Type.String({ description: 'The question to ask the human. Required unless questions is set.' })),
|
|
44
|
+
options: Type.Optional(Type.Array(OptionSchema, {
|
|
45
|
+
description: '2-5 authored choices. Do NOT include Other; the UI appends it.',
|
|
46
|
+
})),
|
|
47
|
+
multi: Type.Optional(Type.Boolean({ description: 'Allow multiple selections (default false)' })),
|
|
48
|
+
recommended: Type.Optional(Type.Number({ description: '0-based index of the recommended option; UI adds (Recommended)' })),
|
|
49
|
+
questions: Type.Optional(Type.Array(
|
|
50
|
+
Type.Object({
|
|
51
|
+
id: Type.Optional(Type.String({ description: 'Stable id for mapping answers' })),
|
|
52
|
+
question: Type.String({ description: 'Question text' }),
|
|
53
|
+
options: Type.Array(OptionSchema, {
|
|
54
|
+
description: '2-5 authored choices. Do NOT include Other; the UI appends it.',
|
|
55
|
+
}),
|
|
56
|
+
multi: Type.Optional(Type.Boolean({ description: 'Allow multiple selections (default false)' })),
|
|
57
|
+
recommended: Type.Optional(Type.Number({ description: '0-based recommended option index' })),
|
|
58
|
+
}),
|
|
59
|
+
{ description: '1-4 related questions in one call. Wins over top-level question/options when set.' },
|
|
60
|
+
)),
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
export const ASK_TOOL_DESCRIPTION = [
|
|
64
|
+
'Ask the human a question and wait for their answer.',
|
|
65
|
+
'Use this when you genuinely need a human decision (approval, direction, trade-off) — not for information you can find yourself.',
|
|
66
|
+
'While waiting, the pane shows as blocked in herdr (the human sees it and can step in).',
|
|
67
|
+
'Prefer 2-5 options with short labels; put tradeoffs in description.',
|
|
68
|
+
'Do NOT include an "Other" option — the UI appends "Other (type your own)" automatically.',
|
|
69
|
+
'Use recommended (0-based) to mark the default; "(Recommended)" is added automatically.',
|
|
70
|
+
'Use multi: true when several options can apply. Group related questions in questions (max 4) rather than calling this tool repeatedly.',
|
|
71
|
+
'The answer comes back as the tool result; then continue your work.',
|
|
72
|
+
].join(' ');
|
|
73
|
+
|
|
74
|
+
export const ASK_PROMPT_GUIDELINES = [
|
|
75
|
+
'Default to action. Ask only when options have materially different tradeoffs the user must decide.',
|
|
76
|
+
'Do NOT include "Other" in options; the UI appends it.',
|
|
77
|
+
'Short option labels; explanatory tradeoffs in description.',
|
|
78
|
+
];
|
|
79
|
+
|
|
80
|
+
export type AskOption = { label: string; description?: string };
|
|
81
|
+
export type AskQuestion = {
|
|
82
|
+
id?: string;
|
|
83
|
+
question: string;
|
|
84
|
+
options: AskOption[];
|
|
85
|
+
multi: boolean;
|
|
86
|
+
recommended?: number;
|
|
87
|
+
};
|
|
88
|
+
export type AskSpec =
|
|
89
|
+
| { mode: 'freeform'; question: string }
|
|
90
|
+
| { mode: 'questionnaire'; questions: AskQuestion[] };
|
|
91
|
+
|
|
92
|
+
export type AskAnswer = {
|
|
93
|
+
question: string;
|
|
94
|
+
id?: string;
|
|
95
|
+
kind: 'option' | 'custom' | 'multi';
|
|
96
|
+
answer: string | null;
|
|
97
|
+
selected?: string[];
|
|
98
|
+
customInput?: string;
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
export type AskDetails = {
|
|
102
|
+
answers: AskAnswer[];
|
|
103
|
+
cancelled: boolean;
|
|
104
|
+
error?: string;
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
export type AskToolResult = {
|
|
108
|
+
content: Array<{ type: 'text'; text: string }>;
|
|
109
|
+
details: AskDetails;
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
export type AskUi = {
|
|
113
|
+
select?: (title: string, options: string[], opts?: { signal?: AbortSignal }) => Promise<string | undefined>;
|
|
114
|
+
input: (title: string, placeholder?: string, opts?: { signal?: AbortSignal }) => Promise<string | undefined>;
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
export type PrepareAskResult =
|
|
118
|
+
| { ok: true; spec: AskSpec }
|
|
119
|
+
| { ok: false; result: AskToolResult };
|
|
120
|
+
|
|
121
|
+
function asRecord(value: unknown): Record<string, unknown> | null {
|
|
122
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
|
123
|
+
? value as Record<string, unknown>
|
|
124
|
+
: null;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function fail(text: string, code: string): { ok: false; result: AskToolResult } {
|
|
128
|
+
return { ok: false, result: errorResult(text, code) };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function errorResult(text: string, code: string): AskToolResult {
|
|
132
|
+
return {
|
|
133
|
+
content: [{ type: 'text', text }],
|
|
134
|
+
details: { answers: [], cancelled: true, error: code },
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function okResult(details: AskDetails): AskToolResult {
|
|
139
|
+
return {
|
|
140
|
+
content: [{ type: 'text', text: formatAskContent(details) }],
|
|
141
|
+
details,
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export function noUiResult(): AskToolResult {
|
|
146
|
+
return errorResult(NO_UI_TEXT, 'no_ui');
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export function hasAskUi(ui: unknown): ui is AskUi {
|
|
150
|
+
return typeof (ui as { input?: unknown } | null | undefined)?.input === 'function';
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function parseRecommended(raw: unknown, count: number): number | undefined {
|
|
154
|
+
if (typeof raw !== 'number' || !Number.isInteger(raw) || raw < 0 || raw >= count) return undefined;
|
|
155
|
+
return raw;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function parseOptions(raw: unknown): { ok: true; options: AskOption[] } | { ok: false; result: AskToolResult } {
|
|
159
|
+
if (!Array.isArray(raw)) {
|
|
160
|
+
return fail('Error: `options` must be an array of 2-5 choices', 'empty_options');
|
|
161
|
+
}
|
|
162
|
+
if (raw.length < MIN_OPTIONS) {
|
|
163
|
+
return fail(`Error: each question needs ${MIN_OPTIONS}-${MAX_OPTIONS} options`, 'empty_options');
|
|
164
|
+
}
|
|
165
|
+
if (raw.length > MAX_OPTIONS) {
|
|
166
|
+
return fail(`Error: each question needs ${MIN_OPTIONS}-${MAX_OPTIONS} options`, 'too_many_options');
|
|
167
|
+
}
|
|
168
|
+
const options: AskOption[] = [];
|
|
169
|
+
const seen = new Set<string>();
|
|
170
|
+
for (const item of raw) {
|
|
171
|
+
const rec = asRecord(item);
|
|
172
|
+
const label = typeof rec?.label === 'string' ? rec.label.trim() : '';
|
|
173
|
+
if (!label) return fail('Error: each option needs a non-empty label', 'empty_options');
|
|
174
|
+
if (label.length > MAX_LABEL_CHARS) {
|
|
175
|
+
return fail(`Error: option label exceeds ${MAX_LABEL_CHARS} characters`, 'label_too_long');
|
|
176
|
+
}
|
|
177
|
+
if (RESERVED_LOWER[label.toLowerCase()]) {
|
|
178
|
+
return fail(
|
|
179
|
+
`Error: option label "${label}" is reserved — the UI appends "${OTHER_OPTION}" automatically`,
|
|
180
|
+
'reserved_label',
|
|
181
|
+
);
|
|
182
|
+
}
|
|
183
|
+
if (seen.has(label)) {
|
|
184
|
+
return fail(`Error: duplicate option label "${label}"`, 'duplicate_option_label');
|
|
185
|
+
}
|
|
186
|
+
seen.add(label);
|
|
187
|
+
const description = typeof rec?.description === 'string' ? rec.description.replace(/\s+/g, ' ').trim() : '';
|
|
188
|
+
options.push(description ? { label, description } : { label });
|
|
189
|
+
}
|
|
190
|
+
return { ok: true, options };
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function parseQuestion(raw: unknown): { ok: true; question: AskQuestion } | { ok: false; result: AskToolResult } {
|
|
194
|
+
const rec = asRecord(raw);
|
|
195
|
+
if (!rec) return fail('Error: each questions[] entry must be an object', 'empty_question');
|
|
196
|
+
const question = typeof rec.question === 'string' ? rec.question.trim() : '';
|
|
197
|
+
if (!question) return fail('Error: `question` must be a non-empty string', 'empty_question');
|
|
198
|
+
const parsed = parseOptions(rec.options);
|
|
199
|
+
if (!parsed.ok) return parsed;
|
|
200
|
+
const recommended = parseRecommended(rec.recommended, parsed.options.length);
|
|
201
|
+
const id = typeof rec.id === 'string' && rec.id.trim() ? rec.id.trim() : undefined;
|
|
202
|
+
return {
|
|
203
|
+
ok: true,
|
|
204
|
+
question: {
|
|
205
|
+
question,
|
|
206
|
+
options: parsed.options,
|
|
207
|
+
multi: rec.multi === true,
|
|
208
|
+
...(id ? { id } : {}),
|
|
209
|
+
...(recommended !== undefined ? { recommended } : {}),
|
|
210
|
+
},
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
export function prepareAsk(params: unknown): PrepareAskResult {
|
|
215
|
+
const rec = asRecord(params);
|
|
216
|
+
if (!rec) return fail('Error: `question` must be a non-empty string', 'empty_question');
|
|
217
|
+
|
|
218
|
+
if ('questions' in rec && rec.questions !== undefined) {
|
|
219
|
+
if (!Array.isArray(rec.questions)) {
|
|
220
|
+
return fail('Error: `questions` must be an array of 1-4 questions', 'no_questions');
|
|
221
|
+
}
|
|
222
|
+
if (rec.questions.length < 1) {
|
|
223
|
+
return fail('Error: `questions` must contain 1-4 entries', 'no_questions');
|
|
224
|
+
}
|
|
225
|
+
if (rec.questions.length > MAX_QUESTIONS) {
|
|
226
|
+
return fail('Error: `questions` must contain 1-4 entries', 'too_many_questions');
|
|
227
|
+
}
|
|
228
|
+
const questions: AskQuestion[] = [];
|
|
229
|
+
const seen = new Set<string>();
|
|
230
|
+
for (const item of rec.questions) {
|
|
231
|
+
const parsed = parseQuestion(item);
|
|
232
|
+
if (!parsed.ok) return parsed;
|
|
233
|
+
if (seen.has(parsed.question.question)) {
|
|
234
|
+
return fail(`Error: duplicate question "${parsed.question.question}"`, 'duplicate_question');
|
|
235
|
+
}
|
|
236
|
+
seen.add(parsed.question.question);
|
|
237
|
+
questions.push(parsed.question);
|
|
238
|
+
}
|
|
239
|
+
return { ok: true, spec: { mode: 'questionnaire', questions } };
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const question = typeof rec.question === 'string' ? rec.question.trim() : '';
|
|
243
|
+
if (!question) return fail('Error: `question` must be a non-empty string', 'empty_question');
|
|
244
|
+
if (rec.options === undefined) {
|
|
245
|
+
return { ok: true, spec: { mode: 'freeform', question } };
|
|
246
|
+
}
|
|
247
|
+
const parsed = parseOptions(rec.options);
|
|
248
|
+
if (!parsed.ok) return parsed;
|
|
249
|
+
const recommended = parseRecommended(rec.recommended, parsed.options.length);
|
|
250
|
+
return {
|
|
251
|
+
ok: true,
|
|
252
|
+
spec: {
|
|
253
|
+
mode: 'questionnaire',
|
|
254
|
+
questions: [{
|
|
255
|
+
question,
|
|
256
|
+
options: parsed.options,
|
|
257
|
+
multi: rec.multi === true,
|
|
258
|
+
...(recommended !== undefined ? { recommended } : {}),
|
|
259
|
+
}],
|
|
260
|
+
},
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
export function gateLabel(spec: AskSpec): string {
|
|
265
|
+
if (spec.mode === 'freeform') return spec.question;
|
|
266
|
+
const first = spec.questions[0]?.question ?? '';
|
|
267
|
+
const extra = spec.questions.length - 1;
|
|
268
|
+
return extra > 0 ? `${first} (+${extra})` : first;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
export function formatAuthoredLine(option: AskOption, index: number, recommended?: number): string {
|
|
272
|
+
const rec = recommended === index ? RECOMMENDED_SUFFIX : '';
|
|
273
|
+
const desc = option.description ? ` — ${option.description}` : '';
|
|
274
|
+
return `${index + 1}. ${option.label}${rec}${desc}`;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
export function optionLines(question: AskQuestion): string[] {
|
|
278
|
+
const lines = question.options.map((option, i) => formatAuthoredLine(option, i, question.recommended));
|
|
279
|
+
lines.push(`${question.options.length + 1}. ${OTHER_OPTION}`);
|
|
280
|
+
return lines;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
export function parseChoice(chosen: string, lines: readonly string[]): number | null {
|
|
284
|
+
const trimmed = chosen.trim();
|
|
285
|
+
const exact = lines.indexOf(trimmed);
|
|
286
|
+
if (exact >= 0) return exact;
|
|
287
|
+
const parsed = Number.parseInt(trimmed, 10);
|
|
288
|
+
if (!Number.isFinite(parsed)) return null;
|
|
289
|
+
const idx = parsed - 1;
|
|
290
|
+
return idx >= 0 && idx < lines.length ? idx : null;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function withId(question: AskQuestion, answer: AskAnswer): AskAnswer {
|
|
294
|
+
return question.id ? { ...answer, id: question.id } : answer;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
async function askSingle(ui: AskUi, question: AskQuestion, signal?: AbortSignal): Promise<AskAnswer | undefined> {
|
|
298
|
+
const lines = optionLines(question);
|
|
299
|
+
const opts = signal ? { signal } : undefined;
|
|
300
|
+
const chosen = ui.select
|
|
301
|
+
? await ui.select(question.question, lines, opts)
|
|
302
|
+
: await ui.input(`${question.question}\n\n${lines.join('\n')}\n\n${NUMBERED_HINT}`, '1', opts);
|
|
303
|
+
if (chosen == null) return undefined;
|
|
304
|
+
const trimmed = chosen.trim();
|
|
305
|
+
if (!trimmed) return undefined;
|
|
306
|
+
const idx = parseChoice(trimmed, lines);
|
|
307
|
+
if (idx != null && idx < question.options.length) {
|
|
308
|
+
return withId(question, {
|
|
309
|
+
question: question.question,
|
|
310
|
+
kind: 'option',
|
|
311
|
+
answer: question.options[idx]!.label,
|
|
312
|
+
});
|
|
313
|
+
}
|
|
314
|
+
if (idx === question.options.length) {
|
|
315
|
+
const typed = await ui.input(`${question.question}\n\n${CUSTOM_ANSWER_TITLE}`, '', opts);
|
|
316
|
+
if (typed == null || !typed.trim()) return undefined;
|
|
317
|
+
return withId(question, {
|
|
318
|
+
question: question.question,
|
|
319
|
+
kind: 'custom',
|
|
320
|
+
answer: OTHER_ANSWER,
|
|
321
|
+
customInput: typed.trim(),
|
|
322
|
+
});
|
|
323
|
+
}
|
|
324
|
+
if (!ui.select) {
|
|
325
|
+
return withId(question, {
|
|
326
|
+
question: question.question,
|
|
327
|
+
kind: 'custom',
|
|
328
|
+
answer: OTHER_ANSWER,
|
|
329
|
+
customInput: trimmed,
|
|
330
|
+
});
|
|
331
|
+
}
|
|
332
|
+
return undefined;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
async function askMulti(ui: AskUi, question: AskQuestion, signal?: AbortSignal): Promise<AskAnswer | undefined> {
|
|
336
|
+
const list = question.options
|
|
337
|
+
.map((option, i) => formatAuthoredLine(option, i, question.recommended))
|
|
338
|
+
.join('\n');
|
|
339
|
+
const value = await ui.input(
|
|
340
|
+
`${question.question}\n\n${list}\n\n${MULTI_INSTRUCTIONS}`,
|
|
341
|
+
'1,3',
|
|
342
|
+
signal ? { signal } : undefined,
|
|
343
|
+
);
|
|
344
|
+
if (value == null) return undefined;
|
|
345
|
+
const trimmed = value.trim();
|
|
346
|
+
if (!trimmed) {
|
|
347
|
+
return withId(question, { question: question.question, kind: 'multi', answer: null, selected: [] });
|
|
348
|
+
}
|
|
349
|
+
const tokens = trimmed.split(/[,\s]+/).filter((tok) => tok.length > 0);
|
|
350
|
+
const indices = tokens.map((tok) => {
|
|
351
|
+
if (!/^\d+\.?$/.test(tok)) return null;
|
|
352
|
+
const idx = Number.parseInt(tok, 10) - 1;
|
|
353
|
+
return idx >= 0 && idx < question.options.length ? idx : null;
|
|
354
|
+
});
|
|
355
|
+
if (indices.every((i): i is number => i != null)) {
|
|
356
|
+
const selected: string[] = [];
|
|
357
|
+
for (const i of indices) {
|
|
358
|
+
const label = question.options[i]!.label;
|
|
359
|
+
if (!selected.includes(label)) selected.push(label);
|
|
360
|
+
}
|
|
361
|
+
return withId(question, { question: question.question, kind: 'multi', answer: null, selected });
|
|
362
|
+
}
|
|
363
|
+
return withId(question, {
|
|
364
|
+
question: question.question,
|
|
365
|
+
kind: 'custom',
|
|
366
|
+
answer: OTHER_ANSWER,
|
|
367
|
+
customInput: trimmed,
|
|
368
|
+
});
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
export function formatAskContent(details: AskDetails): string {
|
|
372
|
+
if (details.cancelled || details.answers.length === 0) return DECLINE_TEXT;
|
|
373
|
+
const entries = details.answers.map((answer) => {
|
|
374
|
+
if (answer.kind === 'custom' && answer.answer === OTHER_ANSWER) {
|
|
375
|
+
const notes = answer.customInput ? ` user notes: ${answer.customInput}` : '';
|
|
376
|
+
return `"${answer.question}"="${OTHER_ANSWER}"${notes}`;
|
|
377
|
+
}
|
|
378
|
+
if (answer.kind === 'multi') {
|
|
379
|
+
const labels = answer.selected?.length ? answer.selected.join(', ') : '(none)';
|
|
380
|
+
return `"${answer.question}"="${labels}"`;
|
|
381
|
+
}
|
|
382
|
+
return `"${answer.question}"="${answer.answer ?? ''}"`;
|
|
383
|
+
});
|
|
384
|
+
return `User has answered your questions: ${entries.join(', ')}. You can now continue with the user's answers in mind.`;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
export async function runAsk(spec: AskSpec, ui: AskUi, signal?: AbortSignal): Promise<AskToolResult> {
|
|
388
|
+
if (spec.mode === 'freeform') {
|
|
389
|
+
const answer = await ui.input(spec.question, 'your answer', signal ? { signal } : undefined);
|
|
390
|
+
if (answer == null || !answer.trim()) {
|
|
391
|
+
return okResult({ answers: [], cancelled: true });
|
|
392
|
+
}
|
|
393
|
+
const trimmed = answer.trim();
|
|
394
|
+
return okResult({
|
|
395
|
+
answers: [{ question: spec.question, kind: 'custom', answer: trimmed, customInput: trimmed }],
|
|
396
|
+
cancelled: false,
|
|
397
|
+
});
|
|
398
|
+
}
|
|
399
|
+
const answers: AskAnswer[] = [];
|
|
400
|
+
for (const question of spec.questions) {
|
|
401
|
+
const answer = question.multi
|
|
402
|
+
? await askMulti(ui, question, signal)
|
|
403
|
+
: await askSingle(ui, question, signal);
|
|
404
|
+
if (!answer) return okResult({ answers, cancelled: true });
|
|
405
|
+
answers.push(answer);
|
|
406
|
+
}
|
|
407
|
+
return okResult({ answers, cancelled: false });
|
|
408
|
+
}
|
package/src/core/terminal.ts
CHANGED
|
@@ -13,6 +13,7 @@ import type { HerdrClientLike } from '../herdr-client.ts';
|
|
|
13
13
|
import {
|
|
14
14
|
READINESS_TIMEOUT_MS,
|
|
15
15
|
READ_MAX_CHARS,
|
|
16
|
+
TERM_REMINDER_CUSTOM_TYPE,
|
|
16
17
|
TERMINALS_CUSTOM_TYPE,
|
|
17
18
|
activeTerminalPaneIds,
|
|
18
19
|
classifyReadiness,
|
|
@@ -21,10 +22,12 @@ import {
|
|
|
21
22
|
detectFullscreenTUI,
|
|
22
23
|
foldTerminalsRegistry,
|
|
23
24
|
makeTerminalsRegistry,
|
|
25
|
+
planIdleTerminalReminder,
|
|
24
26
|
promptStrategyFor,
|
|
25
27
|
registerTerminal,
|
|
26
28
|
stripAnsi,
|
|
27
29
|
summarizeSessions,
|
|
30
|
+
terminalReminderGraceMs,
|
|
28
31
|
validateSendText,
|
|
29
32
|
validateSignal,
|
|
30
33
|
type ReadCursor,
|
|
@@ -95,7 +98,85 @@ export default function terminalPlugin(ctx: Context): void {
|
|
|
95
98
|
rebuildTerminals(eventCtx);
|
|
96
99
|
});
|
|
97
100
|
|
|
101
|
+
// Session teardown (quit / Ctrl+D / SIGTERM) is the last chance to reclaim resident shells:
|
|
102
|
+
// terminals are persistent by design, so a master that exits without closing them leaks live
|
|
103
|
+
// zsh panes. Session SWITCH never fires this event (only session_start/session_tree), so
|
|
104
|
+
// switching branches cannot kill shells that the registry replays on the next session_start.
|
|
105
|
+
scoped.on('session_shutdown', async () => {
|
|
106
|
+
idleReminderTimer && clearTimeout(idleReminderTimer);
|
|
107
|
+
idleReminderTimer = null;
|
|
108
|
+
const open = terminals.filter((t) => t.status === 'open');
|
|
109
|
+
if (open.length === 0) return;
|
|
110
|
+
await Promise.allSettled(open.map((t) => client.closePane(t.paneId)));
|
|
111
|
+
const now = Date.now();
|
|
112
|
+
terminals = terminals.map((t) =>
|
|
113
|
+
t.status === 'open' ? { ...t, status: 'closed' as const, closedAt: now } : t,
|
|
114
|
+
);
|
|
115
|
+
persistTerminals();
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
// Turn-end nudge: remind the model to close terminals idle past the threshold (wF:p7 orphan —
|
|
119
|
+
// a one-shot background compile left a dead split in the main tab forever). Same delivery
|
|
120
|
+
// shape as the todo stop reminder: grace window, cancel on agent_start, capped followUp.
|
|
121
|
+
const piSend = surface.raw as {
|
|
122
|
+
sendMessage?: (
|
|
123
|
+
message: { customType: string; content: string; display?: boolean },
|
|
124
|
+
opts?: { deliverAs?: string; triggerTurn?: boolean },
|
|
125
|
+
) => Promise<void>;
|
|
126
|
+
};
|
|
127
|
+
let idleReminders = 0;
|
|
128
|
+
let idleReminderTimer: NodeJS.Timeout | null = null;
|
|
129
|
+
scoped.on('agent_start', () => {
|
|
130
|
+
if (idleReminderTimer !== null) {
|
|
131
|
+
clearTimeout(idleReminderTimer);
|
|
132
|
+
idleReminderTimer = null;
|
|
133
|
+
}
|
|
134
|
+
});
|
|
135
|
+
scoped.on('agent_settled', async () => {
|
|
136
|
+
if (idleReminderTimer !== null) {
|
|
137
|
+
clearTimeout(idleReminderTimer);
|
|
138
|
+
idleReminderTimer = null;
|
|
139
|
+
}
|
|
140
|
+
const plan = planIdleTerminalReminder({
|
|
141
|
+
open: terminals.filter((t) => t.status === 'open'),
|
|
142
|
+
now: Date.now(),
|
|
143
|
+
reminders: idleReminders,
|
|
144
|
+
});
|
|
145
|
+
if (!plan.due || plan.content == null) return;
|
|
146
|
+
// 01a06ae3 goodbye-loop: mark the covered terminals nudged NOW (persisted), and deliver the
|
|
147
|
+
// notice as a queued followUp WITHOUT triggerTurn — a reminder alone must never wake the
|
|
148
|
+
// agent, or a farewell exchange loops: wake → polite goodbye → settle → nudge → wake …
|
|
149
|
+
const now = Date.now();
|
|
150
|
+
const ids = new Set(plan.ids);
|
|
151
|
+
terminals = terminals.map((t) => (ids.has(t.terminalId) ? { ...t, nudgedAt: now } : t));
|
|
152
|
+
persistTerminals();
|
|
153
|
+
idleReminders += 1;
|
|
154
|
+
const content = plan.content;
|
|
155
|
+
idleReminderTimer = setTimeout(() => {
|
|
156
|
+
idleReminderTimer = null;
|
|
157
|
+
void (async () => {
|
|
158
|
+
const send = piSend.sendMessage;
|
|
159
|
+
if (typeof send !== 'function') return;
|
|
160
|
+
try {
|
|
161
|
+
await send(
|
|
162
|
+
{ customType: TERM_REMINDER_CUSTOM_TYPE, content, display: true },
|
|
163
|
+
{ deliverAs: 'followUp' },
|
|
164
|
+
);
|
|
165
|
+
} catch {
|
|
166
|
+
/* Delivery failure is non-fatal; the cap already bounds retries. */
|
|
167
|
+
}
|
|
168
|
+
})();
|
|
169
|
+
}, terminalReminderGraceMs());
|
|
170
|
+
idleReminderTimer.unref?.();
|
|
171
|
+
});
|
|
172
|
+
|
|
98
173
|
const TERM_READ_MAX = Number(process.env.PI_HERDR_TERM_READ_MAX ?? READ_MAX_CHARS) || READ_MAX_CHARS;
|
|
174
|
+
/** wait action: default and ceiling for the blocking output wait (an LLM call must not hang forever). */
|
|
175
|
+
const WAIT_DEFAULT_MS = 120_000;
|
|
176
|
+
const WAIT_MAX_MS = 600_000;
|
|
177
|
+
/** wait action: recent-tail size handed back on timeout so the model can decide the next move. */
|
|
178
|
+
const WAIT_TAIL_CHARS = 2_000;
|
|
179
|
+
|
|
99
180
|
const errText = (msg: string) => ({ content: [{ type: 'text' as const, text: `Error: ${msg}` }], details: {} });
|
|
100
181
|
|
|
101
182
|
scoped.registerTool({
|
|
@@ -103,22 +184,30 @@ export default function terminalPlugin(ctx: Context): void {
|
|
|
103
184
|
label: 'Terminal',
|
|
104
185
|
description: [
|
|
105
186
|
'Manage persistent interactive terminals (resident shells in dedicated herdr panes).',
|
|
106
|
-
'Operations: open (create session), send (type commands), read (capture output), signal (ctrl+c/ctrl+d/ctrl+z/esc/enter), close (kill shell), list (show all).',
|
|
187
|
+
'Operations: open (create session), send (type commands), wait (block until output matches a pattern), read (capture output), signal (ctrl+c/ctrl+d/ctrl+z/esc/enter), close (kill shell), list (show all).',
|
|
107
188
|
'The shell keeps cwd, environment variables, and background processes across calls.',
|
|
108
189
|
'Use for dev servers, REPLs, or multi-step shell work — not one-shot bash calls.',
|
|
190
|
+
'Close a terminal with action: "close" as soon as the work in it is done — finished terminals are never auto-reclaimed and keep occupying a pane.',
|
|
191
|
+
'Long job pattern: send "cmd; echo TERM_DONE_$?" then wait with pattern "TERM_DONE_" — the sentinel also carries the exit code; redirect verbose output to a log file and read the file, because output between two reads is lost.',
|
|
192
|
+
'After send, use read to confirm the command actually started; multi-line text executes line-by-line. send(wait_prompt: true) refuses to queue text while a previous command is still running.',
|
|
109
193
|
].join(' '),
|
|
110
194
|
parameters: Type.Object({
|
|
111
195
|
action: Type.Union([
|
|
112
196
|
Type.Literal('open'),
|
|
113
197
|
Type.Literal('send'),
|
|
198
|
+
Type.Literal('wait'),
|
|
114
199
|
Type.Literal('read'),
|
|
115
200
|
Type.Literal('signal'),
|
|
116
201
|
Type.Literal('close'),
|
|
117
202
|
Type.Literal('list'),
|
|
118
203
|
], { description: 'Terminal operation to perform' }),
|
|
119
204
|
cwd: Type.Optional(Type.String({ description: '[open] Working directory for the shell (defaults to session cwd)' })),
|
|
120
|
-
terminal_id: Type.Optional(Type.String({ description: '[send|read|signal|close] Terminal id returned by open action' })),
|
|
205
|
+
terminal_id: Type.Optional(Type.String({ description: '[send|wait|read|signal|close] Terminal id returned by open action' })),
|
|
121
206
|
text: Type.Optional(Type.String({ description: '[send] Command text to type and run (Enter appended automatically)' })),
|
|
207
|
+
wait_prompt: Type.Optional(Type.Boolean({ description: '[send] Verify the shell is back at a prompt before sending; refuses with the detected readiness instead of queueing text behind a running command' })),
|
|
208
|
+
pattern: Type.Optional(Type.String({ description: '[wait] Literal text or regular expression to wait for in pane output' })),
|
|
209
|
+
regex: Type.Optional(Type.Boolean({ description: '[wait] Treat pattern as a regular expression (default: literal substring)' })),
|
|
210
|
+
timeout_ms: Type.Optional(Type.Number({ description: '[wait] Give up after this many ms (default 120000, max 600000)' })),
|
|
122
211
|
pane_id: Type.Optional(Type.String({ description: '[read] Direct pane read (limited to own tab panes only)' })),
|
|
123
212
|
max_chars: Type.Optional(Type.Number({ description: '[read] Output cap for this read (default 10000)' })),
|
|
124
213
|
key: Type.Optional(Type.String({ description: '[signal] Control key: ctrl+c | ctrl+d | ctrl+z | esc | enter' })),
|
|
@@ -128,18 +217,33 @@ export default function terminalPlugin(ctx: Context): void {
|
|
|
128
217
|
switch (action) {
|
|
129
218
|
case 'open': return executeTerminalOpen(params, ctx);
|
|
130
219
|
case 'send': return executeTerminalSend(params);
|
|
220
|
+
case 'wait': return executeTerminalWait(params);
|
|
131
221
|
case 'read': return executeTerminalRead(params);
|
|
132
222
|
case 'signal': return executeTerminalSignal(params);
|
|
133
223
|
case 'close': return executeTerminalClose(params);
|
|
134
224
|
case 'list': return executeTerminalList();
|
|
135
225
|
default:
|
|
136
226
|
return errText(action
|
|
137
|
-
? `unknown action "${action}" (valid: open, send, read, signal, close, list)`
|
|
138
|
-
: 'action is required (open, send, read, signal, close, list)');
|
|
227
|
+
? `unknown action "${action}" (valid: open, send, wait, read, signal, close, list)`
|
|
228
|
+
: 'action is required (open, send, wait, read, signal, close, list)');
|
|
139
229
|
}
|
|
140
230
|
},
|
|
141
231
|
});
|
|
142
232
|
|
|
233
|
+
/** Probe whether the shell sits at its interactive prompt (advisory: probe failures degrade to 'busy'). */
|
|
234
|
+
async function probeReadiness(paneId: string): Promise<'prompt' | 'silent' | 'busy'> {
|
|
235
|
+
const prompt = promptStrategyFor();
|
|
236
|
+
try {
|
|
237
|
+
const matched = await client.waitForOutput(paneId, { type: 'regex', value: prompt.waitPattern }, READINESS_TIMEOUT_MS);
|
|
238
|
+
if (matched) return 'prompt';
|
|
239
|
+
const read = await client.readPane(paneId, { stripAnsi: false });
|
|
240
|
+
return classifyReadiness(stripAnsi(read.text), { silentMs: 0, prompt });
|
|
241
|
+
} catch {
|
|
242
|
+
/* Readiness is advisory, so probe failures still leave a usable terminal. */
|
|
243
|
+
return 'busy';
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
143
247
|
async function executeTerminalOpen(params, ctx) {
|
|
144
248
|
if (!client.available || !env) return errText('terminal tools require a herdr-managed pane');
|
|
145
249
|
const cwd = typeof params?.cwd === 'string' && params.cwd ? params.cwd
|
|
@@ -160,18 +264,7 @@ export default function terminalPlugin(ctx: Context): void {
|
|
|
160
264
|
r.entry.paneId = paneId;
|
|
161
265
|
terminals = r.entries;
|
|
162
266
|
persistTerminals();
|
|
163
|
-
const
|
|
164
|
-
let readiness: 'prompt' | 'silent' | 'busy' = 'busy';
|
|
165
|
-
try {
|
|
166
|
-
const matched = await client.waitForOutput(paneId, { type: 'regex', value: prompt.waitPattern }, READINESS_TIMEOUT_MS);
|
|
167
|
-
if (matched) readiness = 'prompt';
|
|
168
|
-
else {
|
|
169
|
-
const read = await client.readPane(paneId, { stripAnsi: false });
|
|
170
|
-
readiness = classifyReadiness(stripAnsi(read.text), { silentMs: 0, prompt });
|
|
171
|
-
}
|
|
172
|
-
} catch {
|
|
173
|
-
/* Readiness is advisory, so probe failures still leave a usable terminal. */
|
|
174
|
-
}
|
|
267
|
+
const readiness = await probeReadiness(paneId);
|
|
175
268
|
const text = [
|
|
176
269
|
`terminal ${r.entry.terminalId} open (pane ${paneId})`,
|
|
177
270
|
`cwd: ${cwd}`,
|
|
@@ -185,6 +278,18 @@ export default function terminalPlugin(ctx: Context): void {
|
|
|
185
278
|
if (!entry) return errText(`unknown or closed terminal "${String(params?.terminal_id)}" (see action list)`);
|
|
186
279
|
const v = validateSendText(typeof params?.text === 'string' ? params.text : '');
|
|
187
280
|
if (!v.ok) return errText(v.error);
|
|
281
|
+
if (params?.wait_prompt === true) {
|
|
282
|
+
// Orchestration convention: read before writing. Text typed while a previous command still
|
|
283
|
+
// owns the foreground gets queued and fires later, which reads as "the shell ignored me".
|
|
284
|
+
// Refuse with the observed readiness instead of guessing.
|
|
285
|
+
const readiness = await probeReadiness(entry.paneId);
|
|
286
|
+
if (readiness !== 'prompt') {
|
|
287
|
+
return errText(
|
|
288
|
+
`shell is ${readiness === 'busy' ? 'busy (a previous command may still be running)' : 'silent (no prompt detected)'} — text was NOT sent. `
|
|
289
|
+
+ 'Use action read to inspect the pane, or omit wait_prompt to send anyway.',
|
|
290
|
+
);
|
|
291
|
+
}
|
|
292
|
+
}
|
|
188
293
|
try {
|
|
189
294
|
await client.sendPaneText(entry.paneId, v.text);
|
|
190
295
|
} catch (e) {
|
|
@@ -194,6 +299,50 @@ export default function terminalPlugin(ctx: Context): void {
|
|
|
194
299
|
return { content: [{ type: 'text', text: `sent to ${entry.terminalId} (${v.text.length} chars)` }], details: { terminal_id: entry.terminalId } };
|
|
195
300
|
}
|
|
196
301
|
|
|
302
|
+
async function executeTerminalWait(params) {
|
|
303
|
+
const entry = findOpenTerminal(params?.terminal_id);
|
|
304
|
+
if (!entry) return errText(`unknown or closed terminal "${String(params?.terminal_id)}" (see action list)`);
|
|
305
|
+
const raw = typeof params?.pattern === 'string' ? params.pattern : '';
|
|
306
|
+
if (!raw) return errText('pattern is required (literal text, or a regular expression with regex: true)');
|
|
307
|
+
const useRegex = params?.regex === true;
|
|
308
|
+
if (useRegex) {
|
|
309
|
+
try {
|
|
310
|
+
new RegExp(raw);
|
|
311
|
+
} catch (e) {
|
|
312
|
+
return errText(`invalid regex: ${(e as Error).message}`);
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
const requested = typeof params?.timeout_ms === 'number' && params.timeout_ms > 0 ? params.timeout_ms : WAIT_DEFAULT_MS;
|
|
316
|
+
const timeoutMs = Math.min(Math.max(requested, 1000), WAIT_MAX_MS);
|
|
317
|
+
const matcher = useRegex ? { type: 'regex' as const, value: raw } : { type: 'substring' as const, value: raw };
|
|
318
|
+
let matched: boolean | null;
|
|
319
|
+
try {
|
|
320
|
+
matched = await client.waitForOutput(entry.paneId, matcher, timeoutMs);
|
|
321
|
+
} catch (e) {
|
|
322
|
+
return errText(`wait failed (pane may be closed): ${(e as Error).message}`);
|
|
323
|
+
}
|
|
324
|
+
if (matched !== true) {
|
|
325
|
+
// wait-for-text convention: on timeout, hand back the recent tail so the model can decide
|
|
326
|
+
// the next move instead of re-polling blind.
|
|
327
|
+
let tail = '';
|
|
328
|
+
try {
|
|
329
|
+
const read = await client.readPane(entry.paneId, { stripAnsi: false });
|
|
330
|
+
tail = stripAnsi(read.text).slice(-WAIT_TAIL_CHARS);
|
|
331
|
+
} catch {
|
|
332
|
+
/* The pane is gone; the timeout text alone still tells the model it is stuck. */
|
|
333
|
+
}
|
|
334
|
+
const reason = matched === false ? 'no match' : 'wait unavailable';
|
|
335
|
+
return {
|
|
336
|
+
content: [{ type: 'text', text: `no match within ${timeoutMs}ms (${reason}).${tail ? `\nrecent output tail:\n${tail}` : ''}` }],
|
|
337
|
+
details: { terminal_id: entry.terminalId, matched: false, pattern: raw },
|
|
338
|
+
};
|
|
339
|
+
}
|
|
340
|
+
return {
|
|
341
|
+
content: [{ type: 'text', text: `matched in ${entry.terminalId} — output is ready, use action read to inspect it` }],
|
|
342
|
+
details: { terminal_id: entry.terminalId, matched: true, pattern: raw },
|
|
343
|
+
};
|
|
344
|
+
}
|
|
345
|
+
|
|
197
346
|
async function executeTerminalRead(params) {
|
|
198
347
|
let paneId: string | null = null;
|
|
199
348
|
let entry: TerminalEntry | null = null;
|
package/src/core/todo.ts
CHANGED
|
@@ -31,6 +31,8 @@ import { anchorTodoRange, formatTodoSummary, renderTodoGroups } from '../todo-wi
|
|
|
31
31
|
export interface TodoUiSlot {
|
|
32
32
|
/** Lets index lifecycle events render through the currently mounted plugin. */
|
|
33
33
|
renderWidget: (ctx: unknown) => void;
|
|
34
|
+
/** Re-render from the last seen context without a fresh event (human-gate open/close). */
|
|
35
|
+
rerenderWidget?: () => void;
|
|
34
36
|
}
|
|
35
37
|
|
|
36
38
|
interface TodoDeps {
|
|
@@ -41,6 +43,8 @@ interface TodoDeps {
|
|
|
41
43
|
mirrorTodos: () => void;
|
|
42
44
|
appendEntry: (customType: string, data: unknown) => void;
|
|
43
45
|
state: TodoUiSlot;
|
|
46
|
+
/** Human-gate depth (ask_user_question / herdr:blocked); >0 collapses the widget to one line. */
|
|
47
|
+
getBlockedDepth?: () => number;
|
|
44
48
|
/** Master-only unfinished-todo reminder; omitted on worker panes. */
|
|
45
49
|
stopReminder?: {
|
|
46
50
|
getBlockedDepth: () => number;
|
|
@@ -67,8 +71,18 @@ export const WIDGET_MAX_LINES = 10;
|
|
|
67
71
|
* completions. A +N line points to /todos when the full plan cannot fit. This fixes 01a03c0d,
|
|
68
72
|
* where active work scrolled out while only the final entries remained visible.
|
|
69
73
|
*/
|
|
70
|
-
export function widgetLines(
|
|
74
|
+
export function widgetLines(
|
|
75
|
+
items: readonly TodoItem[],
|
|
76
|
+
opts?: { archivedAgeMs?: number | null; blockedDepth?: number | null },
|
|
77
|
+
): string[] {
|
|
71
78
|
if (items.length === 0) return [];
|
|
79
|
+
// Human gate open (ask_user_question waiting): the question + editor own the fixed area, so the
|
|
80
|
+
// widget collapses to the one-line summary — /todos still reaches the full list. Without this,
|
|
81
|
+
// a 10-line widget plus a multi-line question leaves almost no scrollable transcript
|
|
82
|
+
// (user-reported: both blocks visible ⇒ history range too small).
|
|
83
|
+
if ((opts?.blockedDepth ?? 0) > 0) {
|
|
84
|
+
return [`${formatTodoSummary(items)} · /todos 全量`];
|
|
85
|
+
}
|
|
72
86
|
// Collapse archived plans to two lines so stale work cannot monopolize the widget.
|
|
73
87
|
if (opts?.archivedAgeMs != null) {
|
|
74
88
|
return [
|
|
@@ -95,7 +109,7 @@ export function widgetLines(items: readonly TodoItem[], opts?: { archivedAgeMs?:
|
|
|
95
109
|
|
|
96
110
|
export default function todoPlugin(ctx: Context): void {
|
|
97
111
|
const surface = ctx.get('pi-herdr.surface') as PiSurface<object>;
|
|
98
|
-
const { todos, allowParallelInProgress, maxItems, mirrorTodos, appendEntry, state, stopReminder } =
|
|
112
|
+
const { todos, allowParallelInProgress, maxItems, mirrorTodos, appendEntry, state, stopReminder, getBlockedDepth } =
|
|
99
113
|
ctx.get('pi-herdr.todo-deps') as TodoDeps;
|
|
100
114
|
const scoped = surface.forModule(import.meta.url);
|
|
101
115
|
|
|
@@ -115,9 +129,15 @@ export default function todoPlugin(ctx: Context): void {
|
|
|
115
129
|
: undefined;
|
|
116
130
|
}
|
|
117
131
|
|
|
132
|
+
/** Gate transitions fire outside lifecycle events; remember one ctx so rerenderWidget can target the live ui. */
|
|
133
|
+
let lastEventCtx: unknown = null;
|
|
118
134
|
function renderWidget(eventCtx: unknown): void {
|
|
135
|
+
if (eventCtx !== null && eventCtx !== undefined) lastEventCtx = eventCtx;
|
|
119
136
|
try {
|
|
120
|
-
widgetUi(eventCtx)?.setWidget?.('todos', widgetLines(todos.items, {
|
|
137
|
+
widgetUi(eventCtx)?.setWidget?.('todos', widgetLines(todos.items, {
|
|
138
|
+
archivedAgeMs: archivedAgeMs(),
|
|
139
|
+
blockedDepth: getBlockedDepth?.() ?? 0,
|
|
140
|
+
}));
|
|
121
141
|
} catch {
|
|
122
142
|
/* Older pi versions may omit widget support without disabling todo tracking. */
|
|
123
143
|
}
|
|
@@ -125,6 +145,7 @@ export default function todoPlugin(ctx: Context): void {
|
|
|
125
145
|
|
|
126
146
|
// Let index lifecycle hooks call the current plugin implementation.
|
|
127
147
|
state.renderWidget = renderWidget;
|
|
148
|
+
state.rerenderWidget = () => renderWidget(lastEventCtx);
|
|
128
149
|
|
|
129
150
|
/* ── Read hook: D39 reminders and stale-plan thawing ────────────── */
|
|
130
151
|
let todoReadTurn = 0;
|
package/src/index-master.ts
CHANGED
|
@@ -86,9 +86,10 @@ export async function mountMasterPlugins(m: MasterPluginMount): Promise<void> {
|
|
|
86
86
|
maxItems: 15,
|
|
87
87
|
mirrorTodos: m.mirrorTodos,
|
|
88
88
|
appendEntry: (customType: string, data: unknown) => {
|
|
89
|
-
(m.pi as { appendEntry?: (t: string, d: unknown) => void }).appendEntry?.(customType,
|
|
89
|
+
(m.pi as { appendEntry?: (t: string, d: unknown) => void }).appendEntry?.(customType, d);
|
|
90
90
|
},
|
|
91
91
|
state: m.todoUi,
|
|
92
|
+
getBlockedDepth: m.getBlockedDepth,
|
|
92
93
|
stopReminder: {
|
|
93
94
|
getBlockedDepth: m.getBlockedDepth,
|
|
94
95
|
getRunningSubs: () => m.port.current?.listRunningSubs().length ?? 0,
|
package/src/index-worker.ts
CHANGED
|
@@ -16,6 +16,8 @@ export interface TodoOnlyMount {
|
|
|
16
16
|
todos: TodosService;
|
|
17
17
|
todoUi: TodoUiSlot;
|
|
18
18
|
mirrorTodos: () => void;
|
|
19
|
+
/** Human-gate depth source; collapses the todo widget to one line while a gate is open. */
|
|
20
|
+
getBlockedDepth?: () => number;
|
|
19
21
|
/** Master-only stop reminder; omit on worker panes. */
|
|
20
22
|
stopReminder?: {
|
|
21
23
|
getBlockedDepth: () => number;
|
|
@@ -33,9 +35,10 @@ export async function mountTodoOnly(m: TodoOnlyMount): Promise<void> {
|
|
|
33
35
|
maxItems: 15,
|
|
34
36
|
mirrorTodos: m.mirrorTodos,
|
|
35
37
|
appendEntry: (customType: string, data: unknown) => {
|
|
36
|
-
(m.pi as { appendEntry?: (t: string, d: unknown) => void }).appendEntry?.(customType,
|
|
38
|
+
(m.pi as { appendEntry?: (t: string, d: unknown) => void }).appendEntry?.(customType, d);
|
|
37
39
|
},
|
|
38
40
|
state: m.todoUi,
|
|
41
|
+
...(m.getBlockedDepth ? { getBlockedDepth: m.getBlockedDepth } : {}),
|
|
39
42
|
...(m.stopReminder ? { stopReminder: m.stopReminder } : {}),
|
|
40
43
|
});
|
|
41
44
|
await workerRoot.plugin(todoPlugin);
|
package/src/index.ts
CHANGED
|
@@ -15,7 +15,17 @@
|
|
|
15
15
|
* - tool-result details persist in session JSONL, and getBranch() replay implements branch rollback.
|
|
16
16
|
*/
|
|
17
17
|
import type { ExtensionAPI } from '@earendil-works/pi-coding-agent';
|
|
18
|
-
import {
|
|
18
|
+
import {
|
|
19
|
+
ASK_PARAMETERS,
|
|
20
|
+
ASK_PROMPT_GUIDELINES,
|
|
21
|
+
ASK_TOOL_DESCRIPTION,
|
|
22
|
+
ASK_TOOL_NAME,
|
|
23
|
+
gateLabel,
|
|
24
|
+
hasAskUi,
|
|
25
|
+
noUiResult,
|
|
26
|
+
prepareAsk,
|
|
27
|
+
runAsk,
|
|
28
|
+
} from './ask-user.ts';
|
|
19
29
|
import { pipeNameFor, pipeRequest, startPipeServer } from './pipe-channel.ts';
|
|
20
30
|
import {
|
|
21
31
|
TODO_EDIT_CUSTOM_TYPE,
|
|
@@ -32,6 +42,7 @@ import { lastAssistantText, readSessionFile } from './session-tail.ts';
|
|
|
32
42
|
import { fileURLToPath } from 'node:url';
|
|
33
43
|
import { TodosService } from './todos-service.ts';
|
|
34
44
|
import { reconcileTodos } from './reconcile-core.ts';
|
|
45
|
+
import type { TodoUiSlot } from './core/todo.ts';
|
|
35
46
|
import {
|
|
36
47
|
estimateEta,
|
|
37
48
|
formatProgressSuffix,
|
|
@@ -237,7 +248,7 @@ export default async function (pi: ExtensionAPI) {
|
|
|
237
248
|
}
|
|
238
249
|
|
|
239
250
|
/* ── todo family slot (core/todo.ts fills the plugin hook; widget rendering moved with the family) ── */
|
|
240
|
-
const todoUi:
|
|
251
|
+
const todoUi: TodoUiSlot = {
|
|
241
252
|
renderWidget: () => { /* No-op before plugin mounting; filled after mounting. */ },
|
|
242
253
|
};
|
|
243
254
|
|
|
@@ -388,6 +399,8 @@ export default async function (pi: ExtensionAPI) {
|
|
|
388
399
|
reportAgent('blocked', label);
|
|
389
400
|
// D95: human-gate marker lets the workbench heatmap distinguish ask from block.
|
|
390
401
|
if (label) void client.reportAskFlag(label).catch(() => {});
|
|
402
|
+
// Gate open collapses the todo widget to one line — the question owns the fixed area.
|
|
403
|
+
todoUi.rerenderWidget?.();
|
|
391
404
|
}
|
|
392
405
|
function exitBlocked(): void {
|
|
393
406
|
blockedDepth = Math.max(0, blockedDepth - 1);
|
|
@@ -396,6 +409,8 @@ export default async function (pi: ExtensionAPI) {
|
|
|
396
409
|
// D95: gate released → clear marker.
|
|
397
410
|
void client.reportAskFlag(null).catch(() => {});
|
|
398
411
|
}
|
|
412
|
+
// Gate closed → restore the full widget window.
|
|
413
|
+
todoUi.rerenderWidget?.();
|
|
399
414
|
}
|
|
400
415
|
|
|
401
416
|
// emit() is synchronous; this flag stops our own listener from double-counting depth.
|
|
@@ -429,36 +444,24 @@ export default async function (pi: ExtensionAPI) {
|
|
|
429
444
|
/* ── Tool: ask_user_question (v1.3 M8 human gate, available to master and subagents) ── */
|
|
430
445
|
|
|
431
446
|
pi.registerTool({
|
|
432
|
-
name:
|
|
447
|
+
name: ASK_TOOL_NAME,
|
|
433
448
|
label: 'Ask User',
|
|
434
|
-
description:
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
const question = String(params?.question ?? '').trim();
|
|
445
|
-
if (!question) {
|
|
446
|
-
return { content: [{ type: 'text', text: 'Error: `question` must be a non-empty string' }], details: {} };
|
|
447
|
-
}
|
|
448
|
-
const ui = (ctx as {
|
|
449
|
-
ui?: { input?: (title: string, placeholder?: string) => Promise<string | undefined> };
|
|
450
|
-
}).ui;
|
|
451
|
-
publishHerdrBlocked(true, question);
|
|
449
|
+
description: ASK_TOOL_DESCRIPTION,
|
|
450
|
+
promptGuidelines: ASK_PROMPT_GUIDELINES,
|
|
451
|
+
parameters: ASK_PARAMETERS,
|
|
452
|
+
async execute(_tc, params, signal, _upd, ctx) {
|
|
453
|
+
const prepared = prepareAsk(params);
|
|
454
|
+
if (!prepared.ok) return prepared.result;
|
|
455
|
+
const ui = ctx && typeof ctx === 'object' && 'ui' in ctx ? ctx.ui : undefined;
|
|
456
|
+
if (!hasAskUi(ui)) return noUiResult();
|
|
457
|
+
const label = gateLabel(prepared.spec);
|
|
458
|
+
publishHerdrBlocked(true, label);
|
|
452
459
|
// Fallback when herdr:pi is absent: a one-shot pi-herdr blocked report can lose
|
|
453
460
|
// to later working reports; refresh while the gate is open. Do not re-emit
|
|
454
461
|
// herdr:blocked here — official blockedCount is edge-triggered.
|
|
455
|
-
const hb = setInterval(() => { reportAgent('blocked',
|
|
462
|
+
const hb = setInterval(() => { reportAgent('blocked', label); }, 5000);
|
|
456
463
|
try {
|
|
457
|
-
|
|
458
|
-
return {
|
|
459
|
-
content: [{ type: 'text', text: `The human answered: ${answer ?? '(no answer given)'}` }],
|
|
460
|
-
details: { question, answer: answer ?? null },
|
|
461
|
-
};
|
|
464
|
+
return await runAsk(prepared.spec, ui, signal instanceof AbortSignal ? signal : undefined);
|
|
462
465
|
} finally {
|
|
463
466
|
clearInterval(hb);
|
|
464
467
|
publishHerdrBlocked(false, null);
|
|
@@ -589,6 +592,7 @@ export default async function (pi: ExtensionAPI) {
|
|
|
589
592
|
todos,
|
|
590
593
|
todoUi,
|
|
591
594
|
mirrorTodos,
|
|
595
|
+
getBlockedDepth: () => blockedDepth,
|
|
592
596
|
...(isSubagent
|
|
593
597
|
? {}
|
|
594
598
|
: {
|
package/src/slim-frame.ts
CHANGED
|
@@ -21,6 +21,10 @@
|
|
|
21
21
|
* (done() is never called, leaving the Promise pending, swallowing rejection, and never awaiting it). If switching
|
|
22
22
|
* pi sessions removes the overlay (resetExtensionUI), the static frame naturally exits without affecting the main
|
|
23
23
|
* flow. PI_HERDR_SLIM_FRAME=0 is the escape hatch.
|
|
24
|
+
* Resize watchdog (D98): the component owns a SIGWINCH listener plus a 1s size poll that call tui.requestRender() on
|
|
25
|
+
* any PTY size change. Why: long-running sessions stop re-rendering on resize (observed in herdr — an idle master
|
|
26
|
+
* shrunk by heat reflow froze on a stale clipped frame: no todo, no title), so pi's own resize wiring cannot be relied
|
|
27
|
+
* on; columns/rows are read live per render, so one render is the whole fix. Stopped in dispose().
|
|
24
28
|
*/
|
|
25
29
|
|
|
26
30
|
import { isArchived } from './stale-core.ts';
|
|
@@ -38,6 +42,8 @@ export const SLIM_TODO_MIN_ROWS = 3;
|
|
|
38
42
|
export const SLIM_TODO_MIN_COLS = 16;
|
|
39
43
|
/** Empty-list copy: the pane is alive but has no plan yet (replaces a lone ·). */
|
|
40
44
|
export const SLIM_EMPTY_COPY = 'no todos yet';
|
|
45
|
+
/** D98: watchdog poll period — SIGWINCH covers the delivered signal; the poll covers lost ones (e.g. after suspend). */
|
|
46
|
+
export const RESIZE_WATCHDOG_MS = 1_000;
|
|
41
47
|
|
|
42
48
|
/** Visibility predicate (overlayOptions.visible invokes it on every render frame). */
|
|
43
49
|
export function isSlimFrame(cols: number, rows: number): boolean {
|
|
@@ -227,6 +233,11 @@ class SlimFrameComponent {
|
|
|
227
233
|
private cache: { width: number; lines: string[] } | null = null;
|
|
228
234
|
private readonly tui: FrameTui;
|
|
229
235
|
private readonly colorize: (s: string) => string;
|
|
236
|
+
/** D98 watchdog: last PTY size seen by onMaybeResized (re-seeded at watchdog start). */
|
|
237
|
+
private seenCols = 0;
|
|
238
|
+
private seenRows = 0;
|
|
239
|
+
private sigwinch: (() => void) | null = null;
|
|
240
|
+
private poll: NodeJS.Timeout | null = null;
|
|
230
241
|
|
|
231
242
|
constructor(tui: FrameTui, colorize: (s: string) => string) {
|
|
232
243
|
this.tui = tui;
|
|
@@ -271,13 +282,56 @@ class SlimFrameComponent {
|
|
|
271
282
|
|
|
272
283
|
/** Reclaim the module singleton when pi tears down the overlay (session switch/exit), allowing re-registration. */
|
|
273
284
|
dispose(): void {
|
|
285
|
+
this.stopResizeWatchdog();
|
|
274
286
|
if (active === this) active = null;
|
|
275
287
|
}
|
|
288
|
+
|
|
289
|
+
/** Live PTY size (pi-tui reads the same source on every render; an instance method so tests can stub it). */
|
|
290
|
+
viewportSize(): { cols: number; rows: number } {
|
|
291
|
+
return { cols: process.stdout.columns ?? 0, rows: process.stdout.rows ?? 0 };
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/** D98: SIGWINCH handler and poll tick — exactly one requestRender per actual size change; the next render reads live sizes. */
|
|
295
|
+
readonly onMaybeResized = (): void => {
|
|
296
|
+
const { cols, rows } = this.viewportSize();
|
|
297
|
+
if (cols === this.seenCols && rows === this.seenRows) return;
|
|
298
|
+
this.seenCols = cols;
|
|
299
|
+
this.seenRows = rows;
|
|
300
|
+
this.tui.requestRender();
|
|
301
|
+
};
|
|
302
|
+
|
|
303
|
+
/** Start the D98 watchdog (idempotent). SIGWINCH covers the delivered signal; the poll covers lost ones. */
|
|
304
|
+
startResizeWatchdog(): void {
|
|
305
|
+
if (this.poll) return;
|
|
306
|
+
const size = this.viewportSize();
|
|
307
|
+
this.seenCols = size.cols;
|
|
308
|
+
this.seenRows = size.rows;
|
|
309
|
+
try {
|
|
310
|
+
process.on('SIGWINCH', this.onMaybeResized);
|
|
311
|
+
this.sigwinch = this.onMaybeResized;
|
|
312
|
+
} catch {
|
|
313
|
+
/* Platforms without a SIGWINCH mapping keep the poll only. */
|
|
314
|
+
}
|
|
315
|
+
this.poll = setInterval(this.onMaybeResized, RESIZE_WATCHDOG_MS);
|
|
316
|
+
this.poll.unref?.();
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
private stopResizeWatchdog(): void {
|
|
320
|
+
if (this.sigwinch) {
|
|
321
|
+
process.removeListener('SIGWINCH', this.sigwinch);
|
|
322
|
+
this.sigwinch = null;
|
|
323
|
+
}
|
|
324
|
+
if (this.poll) {
|
|
325
|
+
clearInterval(this.poll);
|
|
326
|
+
this.poll = null;
|
|
327
|
+
}
|
|
328
|
+
}
|
|
276
329
|
}
|
|
277
330
|
|
|
278
331
|
|
|
279
|
-
/** Test injection point (reset module state). */
|
|
332
|
+
/** Test injection point (reset module state); dispose first so an undisposed component's watchdog cannot leak. */
|
|
280
333
|
export function resetForTest(): void {
|
|
334
|
+
active?.dispose();
|
|
281
335
|
active = null;
|
|
282
336
|
}
|
|
283
337
|
|
|
@@ -302,6 +356,7 @@ export function registerSlimFrame(eventCtx: unknown): void {
|
|
|
302
356
|
const pending = custom(
|
|
303
357
|
(tui, theme) => {
|
|
304
358
|
const comp = new SlimFrameComponent(tui, (s) => theme.fg('accent', s));
|
|
359
|
+
comp.startResizeWatchdog();
|
|
305
360
|
active = comp;
|
|
306
361
|
return comp;
|
|
307
362
|
},
|
package/src/terminal-core.ts
CHANGED
|
@@ -57,13 +57,10 @@ export interface TerminalEntry {
|
|
|
57
57
|
label: string | null;
|
|
58
58
|
createdAt: number;
|
|
59
59
|
lastActivityAt: number;
|
|
60
|
+
/** Idle-nudge bookkeeping: set when this terminal has been nudged once (per-entry, persisted). */
|
|
61
|
+
nudgedAt: number | null;
|
|
60
62
|
status: 'open' | 'closed';
|
|
61
63
|
closedAt: number | null;
|
|
62
|
-
/** Why: Preserve the established compatibility and safety behavior. */
|
|
63
|
-
readRevision: number | null;
|
|
64
|
-
readLen: number;
|
|
65
|
-
readTail: string;
|
|
66
|
-
readEoTail: string;
|
|
67
64
|
}
|
|
68
65
|
|
|
69
66
|
export interface TerminalsRegistry {
|
|
@@ -107,6 +104,7 @@ export function registerTerminal(
|
|
|
107
104
|
label: opts.label ?? (opts.cwd ? basename(opts.cwd.replace(/\\/g, '/')) || opts.cwd : null),
|
|
108
105
|
createdAt: opts.createdAt,
|
|
109
106
|
lastActivityAt: opts.createdAt,
|
|
107
|
+
nudgedAt: null,
|
|
110
108
|
status: 'open',
|
|
111
109
|
closedAt: null,
|
|
112
110
|
readRevision: null,
|
|
@@ -322,9 +320,90 @@ export function foldTerminalsRegistry(entries: readonly BranchEntryLike3[]): Ter
|
|
|
322
320
|
closedAt: typeof t.closedAt === 'number' ? t.closedAt : null,
|
|
323
321
|
readRevision: typeof t.readRevision === 'number' ? t.readRevision : null,
|
|
324
322
|
readLen: typeof t.readLen === 'number' ? t.readLen : 0,
|
|
325
|
-
|
|
326
|
-
|
|
323
|
+
lastActivityAt: typeof t.lastActivityAt === 'number' ? t.lastActivityAt : 0,
|
|
324
|
+
nudgedAt: typeof t.nudgedAt === 'number' ? t.nudgedAt : null,
|
|
325
|
+
status: t.status === 'closed' ? ('closed' as const) : ('open' as const),
|
|
327
326
|
}));
|
|
328
327
|
}
|
|
329
328
|
return found;
|
|
330
329
|
}
|
|
330
|
+
|
|
331
|
+
/* ── Idle-terminal nudge: turn-end self-cleanup for shells the model forgot to close ──
|
|
332
|
+
* Observed (wF:p7 orphan): a master opened a resident terminal for a one-shot background
|
|
333
|
+
* compile, the job finished, and nothing ever closed the pane — terminals are persistent by
|
|
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. */
|
|
336
|
+
|
|
337
|
+
/** Cap idle-terminal nudges for the lifetime of the process (same shape as TODO_REMINDERS_MAX). */
|
|
338
|
+
export const TERM_REMINDERS_MAX = 2;
|
|
339
|
+
|
|
340
|
+
/** Idle threshold before a nudge is due; PI_HERDR_TERM_IDLE_MS is read per call so tests can use small values. */
|
|
341
|
+
export function terminalIdleMs(): number {
|
|
342
|
+
return Number(process.env.PI_HERDR_TERM_IDLE_MS ?? 30 * 60_000) || 30 * 60_000;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/** Grace from agent_settled to injection, letting the user read the answer and intervene; cancel on agent_start. */
|
|
346
|
+
export function terminalReminderGraceMs(): number {
|
|
347
|
+
return Number(process.env.PI_HERDR_TERM_GRACE_MS ?? 30_000) || 30_000;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/** Custom message type for the idle-terminal nudge (registerMessageRenderer may restyle it). */
|
|
351
|
+
export const TERM_REMINDER_CUSTOM_TYPE = 'pi-herdr.term-reminder';
|
|
352
|
+
|
|
353
|
+
export interface IdleTerminalInput {
|
|
354
|
+
terminalId: string;
|
|
355
|
+
cwd: string;
|
|
356
|
+
label: string | null;
|
|
357
|
+
lastActivityAt: number;
|
|
358
|
+
/** Set once this terminal has been nudged; a nudged terminal is never nudged again (01a06ae3 goodbye-loop guard). */
|
|
359
|
+
nudgedAt?: number | null;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
export interface IdleTerminalReminderPlan {
|
|
363
|
+
/** Whether a nudge should be injected. */
|
|
364
|
+
due: boolean;
|
|
365
|
+
/** Complete injected content, or null when due is false. */
|
|
366
|
+
content: string | null;
|
|
367
|
+
/** Nudge count after successful injection, or the original count when due is false. */
|
|
368
|
+
nextReminders: number;
|
|
369
|
+
/** Terminal ids this nudge covers; the caller marks them nudged at schedule time. */
|
|
370
|
+
ids: string[];
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
function noIdleInject(reminders: number): IdleTerminalReminderPlan {
|
|
374
|
+
return { due: false, content: null, nextReminders: reminders, ids: [] };
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/**
|
|
378
|
+
* Due only when an OPEN terminal is idle past the threshold AND has never been nudged
|
|
379
|
+
* (01a06ae3: a per-process counter alone let nudges resume after restarts and looped a
|
|
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.
|
|
383
|
+
*/
|
|
384
|
+
export function planIdleTerminalReminder(
|
|
385
|
+
input: { open: readonly IdleTerminalInput[]; now: number; reminders: number },
|
|
386
|
+
): IdleTerminalReminderPlan {
|
|
387
|
+
const idleMs = terminalIdleMs();
|
|
388
|
+
const idle = input.open.filter(
|
|
389
|
+
(t) => !t.nudgedAt && input.now - t.lastActivityAt >= idleMs,
|
|
390
|
+
);
|
|
391
|
+
if (idle.length === 0 || input.reminders >= TERM_REMINDERS_MAX) {
|
|
392
|
+
return noIdleInject(input.reminders);
|
|
393
|
+
}
|
|
394
|
+
const list = idle.map((t) => `- ${t.terminalId} (${t.label ?? t.cwd})`).join('\n');
|
|
395
|
+
const content = [
|
|
396
|
+
`<system-reminder>Open terminal(s) idle for a while (nudge ${input.reminders + 1}/${TERM_REMINDERS_MAX}):`,
|
|
397
|
+
list,
|
|
398
|
+
'This is an internal housekeeping notice — do NOT reply to the user and do NOT send any farewell on this notice.',
|
|
399
|
+
'If the work in them is done, close them now with terminal(action: "close", terminal_id: "…").',
|
|
400
|
+
'Keep a terminal only if a long-running process still needs that shell.',
|
|
401
|
+
'</system-reminder>',
|
|
402
|
+
].join('\n');
|
|
403
|
+
return {
|
|
404
|
+
due: true,
|
|
405
|
+
content,
|
|
406
|
+
nextReminders: input.reminders + 1,
|
|
407
|
+
ids: idle.map((t) => t.terminalId),
|
|
408
|
+
};
|
|
409
|
+
}
|