pi-pier 0.2.5 → 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 +35 -5
- package/package.json +1 -1
- package/skills/pi-herdr-workflow/SKILL.md +1 -1
- package/src/ask-user.ts +408 -0
- package/src/core/subagent.ts +1 -71
- package/src/core/terminal.ts +165 -16
- package/src/core/todo.ts +91 -6
- package/src/index-master.ts +6 -3
- package/src/index-runtime.ts +6 -2
- package/src/index-worker.ts +15 -6
- package/src/index.ts +58 -40
- package/src/slim-frame.ts +56 -1
- package/src/terminal-core.ts +86 -7
package/README.md
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
# pi-pier
|
|
2
2
|
|
|
3
3
|
> [!IMPORTANT]
|
|
4
|
-
> **This npm package is only the pi-extension half of pier.** pier is a two-half system — this extension plus a **herdr plugin** (`pier.workbench`). Without the herdr side you still get `todo_write` / `ask_user_question
|
|
4
|
+
> **This npm package is only the pi-extension half of pier.** pier is a two-half system — this extension plus a **herdr plugin** (`pier.workbench`). Without the herdr side you still get `todo_write` / `ask_user_question`; `subagent` / `terminal` are not registered. Pane integration, notifications, and workspace bootstrap all require herdr. See [Install the herdr half](#install-the-herdr-half-companion-plugin) below.
|
|
5
5
|
|
|
6
6
|
**pier** = **pi** × h**erdr** — a [pi](https://pi.dev/) extension that fuses coding agent sessions with the [herdr](https://herdr.dev/) pane/tab orchestrator.
|
|
7
7
|
|
|
8
|
-
`pi-pier` fills two gaps pi deliberately leaves open — the **todo list loop** and **interactive subagents** — using herdr panes/tabs as the visual and interaction substrate.
|
|
8
|
+
`pi-pier` fills two gaps pi deliberately leaves open — the **todo list loop** and **interactive subagents** — using herdr panes/tabs as the visual and interaction substrate. Outside herdr it loads only the todo loop.
|
|
9
9
|
|
|
10
10
|
## Install (this pi half)
|
|
11
11
|
|
|
@@ -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
|
|
|
@@ -56,13 +56,43 @@ The installer also verifies node / pi / herdr versions, probes local paths, and
|
|
|
56
56
|
|
|
57
57
|
## Scope & degradation
|
|
58
58
|
|
|
59
|
-
|
|
59
|
+
This extension mutates the pi session even outside herdr. After `pi install npm:pi-pier`:
|
|
60
|
+
|
|
61
|
+
| Surface | Bare `pi` (no `HERDR_ENV`) | Inside herdr |
|
|
62
|
+
|---|---|---|
|
|
63
|
+
| `todo_write`, `/todos`, widget, anti-freeze, stop reminder | live | live |
|
|
64
|
+
| `ask_user_question` | live (TUI select/input) | live + blocked marker |
|
|
65
|
+
| Hidden inject (`before_agent_start` todo-read; settle reminder) | live | live |
|
|
66
|
+
| `subagent`, `terminal` | **not registered** | live |
|
|
67
|
+
| `/locks`, write-lock on `write`/`edit` | not installed | live |
|
|
68
|
+
| slim-frame overlay, pane title, pipe, isolate worktree | off | live |
|
|
69
|
+
| `setActiveTools` role visible layer | off | on (herdr master) |
|
|
70
|
+
|
|
71
|
+
Session JSONL custom types (`pi-herdr.todo-edit`, `.subs`, `.terminals`, `.todo-read`, `.todo-reminder`, …) persist across `/resume` even without herdr.
|
|
72
|
+
|
|
73
|
+
## Plugin conflicts
|
|
74
|
+
|
|
75
|
+
pi overwrites tools/commands by name; event listeners stack.
|
|
76
|
+
|
|
77
|
+
**Do not install alongside** (same-name overwrite → wrong handler, split JSONL):
|
|
78
|
+
|
|
79
|
+
- [`@nguyenquangthai/pi-todo`](https://pi.dev/packages/@nguyenquangthai/pi-todo) — `todo_write` + overlay
|
|
80
|
+
- [`@josephyoung/pi-ask-user-question`](https://pi.dev/packages/@josephyoung/pi-ask-user-question) — `ask_user_question`
|
|
81
|
+
- [`pi-herdr-subagents`](https://pi.dev/packages/pi-herdr-subagents) — `subagent` + herdr panes
|
|
82
|
+
|
|
83
|
+
**Soft conflict** (two orchestrators / two injectors — the agent “talks to itself”):
|
|
84
|
+
|
|
85
|
+
- [`@tintinweb/pi-subagents`](https://pi.dev/packages/@tintinweb/pi-subagents) (`Agent`)
|
|
86
|
+
- [`@minhduydev/pi-subagents`](https://pi.dev/packages/@minhduydev/pi-subagents) (`task`)
|
|
87
|
+
- any other extension that calls `setActiveTools`, `ui.custom` overlay, `before_agent_start`, or `sendUserMessage`/`sendMessage` followUp
|
|
88
|
+
|
|
89
|
+
**Designed coexistence:** herdr’s official `herdr:pi` reporter. Keep it. pier emits `herdr:blocked` so that plugin remains lifecycle authority.
|
|
60
90
|
|
|
61
91
|
## Requirements
|
|
62
92
|
|
|
63
93
|
- Node ≥ 22
|
|
64
94
|
- pi ≥ 0.84 (`@earendil-works/pi-coding-agent`)
|
|
65
|
-
- **herdr ≥ 0.8.0** — required for subagents / pane integration / notifications (see the IMPORTANT note above); without it only
|
|
95
|
+
- **herdr ≥ 0.8.0** — required for subagents / pane integration / notifications (see the IMPORTANT note above); without it only the todo loop and `ask_user_question` load
|
|
66
96
|
|
|
67
97
|
## Development
|
|
68
98
|
|
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/subagent.ts
CHANGED
|
@@ -11,7 +11,6 @@ import { randomUUID } from 'node:crypto';
|
|
|
11
11
|
import { basename, join, dirname } from 'node:path';
|
|
12
12
|
import type { PiSurface } from '../pi-surface.ts';
|
|
13
13
|
import type { HerdrClientLike } from '../herdr-client.ts';
|
|
14
|
-
import type { TodosService } from '../todos-service.ts';
|
|
15
14
|
import { Semaphore, SUBS_CUSTOM_TYPE, agoText, buildAliveNotice, buildBlockedGateNotice, buildIsolatePreamble, classifyWorktreeZone, foldSubsRegistry, formatSubagentResult, isAlive, isPathUnder, makeProgressUpdate, makeRegistry, planIsolateWorktree, tabNameForTask, type AliveProbe, type SubEntry } from '../subagent-core.ts';
|
|
16
15
|
import { applyReportedSessionFile, appendHistory, preferredHistoryFile, inheritOutcome, latestGeneration, readHistory, type HistoryEntry } from '../history-store.ts';
|
|
17
16
|
import { runtimePolicy } from '../runtime-policy.ts';
|
|
@@ -30,7 +29,6 @@ import { promisify } from 'node:util';
|
|
|
30
29
|
const statAsync = promisify(statCb);
|
|
31
30
|
import { pipeNameFor, pipeRequestTo } from '../pipe-channel.ts';
|
|
32
31
|
import { composeForRole } from '../manifest-compose.ts';
|
|
33
|
-
import { TODO_REMINDER_CUSTOM_TYPE, planStopTodoReminder, todoReminderGraceMs } from '../todo-reminder-core.ts';
|
|
34
32
|
import type { TerminalStateSlot } from './terminal.ts';
|
|
35
33
|
import { createGitIo } from '../subagent-git-io.ts';
|
|
36
34
|
import { createSessionIo } from '../subagent-session-io.ts';
|
|
@@ -54,7 +52,6 @@ interface SubagentDeps {
|
|
|
54
52
|
sessionRoot: Context;
|
|
55
53
|
port: SubagentPortBox;
|
|
56
54
|
getSessionId: () => string;
|
|
57
|
-
getBlockedDepth: () => number;
|
|
58
55
|
reconcileOnSettlement: (description: string, outcome: 'settled' | 'failed') => string[];
|
|
59
56
|
withReconcileNotes: (base: string, notes: readonly string[]) => string;
|
|
60
57
|
claimSettleNotice: (key: string) => boolean;
|
|
@@ -67,7 +64,6 @@ interface SubagentDeps {
|
|
|
67
64
|
noticePending?: () => ReadonlySet<string>;
|
|
68
65
|
/** Terminal-family GC exemption (live terminal panes are not collected). */
|
|
69
66
|
terminalState: TerminalStateSlot;
|
|
70
|
-
todos: TodosService;
|
|
71
67
|
}
|
|
72
68
|
|
|
73
69
|
const SUBAGENT_DESCRIPTION = [
|
|
@@ -103,14 +99,10 @@ function agentRootDir(): string {
|
|
|
103
99
|
export default function subagentPlugin(ctx: Context): void {
|
|
104
100
|
const surface = ctx.get('pi-herdr.surface') as PiSurface<object>;
|
|
105
101
|
const d = ctx.get('pi-herdr.subagent-deps') as SubagentDeps;
|
|
106
|
-
const { client, env, sessionRoot, port, terminalState
|
|
102
|
+
const { client, env, sessionRoot, port, terminalState } = d;
|
|
107
103
|
const pi = surface.raw as {
|
|
108
104
|
appendEntry?: (customType: string, data: unknown) => void;
|
|
109
105
|
sendUserMessage?: (content: string, opts?: { deliverAs?: string }) => Promise<void>;
|
|
110
|
-
sendMessage?: (
|
|
111
|
-
message: { customType: string; content: string; display?: boolean; details?: Record<string, unknown> },
|
|
112
|
-
opts?: { deliverAs?: string; triggerTurn?: boolean },
|
|
113
|
-
) => Promise<void>;
|
|
114
106
|
};
|
|
115
107
|
const scoped = surface.forModule(import.meta.url);
|
|
116
108
|
|
|
@@ -244,68 +236,6 @@ export default function subagentPlugin(ctx: Context): void {
|
|
|
244
236
|
return { content: [{ type: 'text', text: notice }] };
|
|
245
237
|
});
|
|
246
238
|
|
|
247
|
-
/* ── D41 safe reminders for unfinished todos ─────────────────────
|
|
248
|
-
* 01a040cc showed that a user-role reminder overrode model judgment and executed work reserved
|
|
249
|
-
* for the user. Use a distinguishable custom message, request reconciliation rather than action,
|
|
250
|
-
* and delay delivery so any new agent start cancels it. Preserve the ESC guard from 01a03bf0 to
|
|
251
|
-
* avoid wake-up storms after explicit interruption. */
|
|
252
|
-
|
|
253
|
-
let todoReminders = 0;
|
|
254
|
-
let lastAssistantStopReason: string | null = null;
|
|
255
|
-
let todoReminderTimer: NodeJS.Timeout | null = null;
|
|
256
|
-
|
|
257
|
-
function cancelTodoReminder(): void {
|
|
258
|
-
if (todoReminderTimer !== null) {
|
|
259
|
-
clearTimeout(todoReminderTimer);
|
|
260
|
-
todoReminderTimer = null;
|
|
261
|
-
}
|
|
262
|
-
}
|
|
263
|
-
|
|
264
|
-
scoped.on('turn_end', async (event: unknown) => {
|
|
265
|
-
if (event === null || typeof event !== 'object' || !('message' in event)) return;
|
|
266
|
-
const msg = (event as { message: unknown }).message; // Safe after the property guard above.
|
|
267
|
-
if (msg === null || typeof msg !== 'object') return;
|
|
268
|
-
const { role, stopReason } = msg as { role?: unknown; stopReason?: unknown };
|
|
269
|
-
if (role === 'assistant' && typeof stopReason === 'string') {
|
|
270
|
-
lastAssistantStopReason = stopReason;
|
|
271
|
-
}
|
|
272
|
-
});
|
|
273
|
-
|
|
274
|
-
// B3 cancels the reminder when any source resumes work during the grace window.
|
|
275
|
-
scoped.on('agent_start', () => cancelTodoReminder());
|
|
276
|
-
scoped.on('session_shutdown', () => cancelTodoReminder());
|
|
277
|
-
|
|
278
|
-
scoped.on('agent_settled', async () => {
|
|
279
|
-
cancelTodoReminder(); // Cancel any timer left from the previous settlement.
|
|
280
|
-
const plan = planStopTodoReminder({
|
|
281
|
-
lastStopReason: lastAssistantStopReason,
|
|
282
|
-
reminders: todoReminders,
|
|
283
|
-
runningSubs: pollers.size,
|
|
284
|
-
blockedDepth: d.getBlockedDepth(),
|
|
285
|
-
items: todos.items,
|
|
286
|
-
});
|
|
287
|
-
if (!plan.due || plan.content == null) return;
|
|
288
|
-
const content = plan.content;
|
|
289
|
-
todoReminderTimer = setTimeout(() => {
|
|
290
|
-
todoReminderTimer = null;
|
|
291
|
-
void (async () => {
|
|
292
|
-
// Without sendMessage, skip the reminder rather than impersonating the user channel.
|
|
293
|
-
const send = pi.sendMessage;
|
|
294
|
-
if (typeof send !== 'function') return;
|
|
295
|
-
try {
|
|
296
|
-
await send(
|
|
297
|
-
{ customType: TODO_REMINDER_CUSTOM_TYPE, content, display: true },
|
|
298
|
-
{ deliverAs: 'followUp', triggerTurn: true },
|
|
299
|
-
);
|
|
300
|
-
todoReminders += 1; // Count only delivered reminders so cancellation and failure do not consume the cap.
|
|
301
|
-
} catch {
|
|
302
|
-
/* Delivery failure is non-fatal. */
|
|
303
|
-
}
|
|
304
|
-
})();
|
|
305
|
-
}, todoReminderGraceMs());
|
|
306
|
-
todoReminderTimer.unref?.(); // Do not keep tests or headless processes alive for a reminder.
|
|
307
|
-
});
|
|
308
|
-
|
|
309
239
|
function histFile(cwd: string): string {
|
|
310
240
|
return preferredHistoryFile(agentRootDir(), cwd);
|
|
311
241
|
}
|