pi-pier 0.4.1 → 0.4.2

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 CHANGED
@@ -43,8 +43,7 @@ The installer also verifies node / pi / herdr versions.
43
43
  | Tool | Purpose |
44
44
  |---|---|
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
- | `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`, `output` (incremental preview of a running subagent's pane output), `role` (switch that worker's role profile mid-session — pi records the toolset change as a transcript delta, so it applies on the worker's next request and survives resume). Every spawned task gets a stable slug: outputs print `slug@paneId`, and `agentId` accepts the slug, the pane id, or the combined form |
47
- | `report_progress` | Worker panes only: send the delegating master a short checkpoint update without ending the task. Arrives in the master's context as a display-less custom message (no turn, no chat flood), rendered as a `⤴ progress` card |
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`, `output` (incremental preview of a running subagent's pane output), `role` (switch that worker's role profile mid-session — pi records the toolset change as a transcript delta, so it applies on the worker's next request and survives resume), `help` (the full manual — placement, isolate, roles, ledger — kept out of the per-request description). Every spawned task gets a stable slug: outputs print `slug@paneId`, and `agentId` accepts the slug, the pane id, or the combined form |
48
47
  | `terminal` | Persistent interactive shells in dedicated herdr panes. Actions: `open`, `send`, `read`, `signal`, `close`, `list` |
49
48
 
50
49
  ### Commands
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-pier",
3
- "version": "0.4.1",
3
+ "version": "0.4.2",
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",
@@ -20,6 +20,12 @@
20
20
  },
21
21
  "firstCompactionRequestScale": { "type": "number", "default": 2.0, "minimum": 1.0 },
22
22
  "subsequentCompactionMargin": { "type": "number", "default": 1.5, "minimum": 1.0 },
23
+ "summaryThinkingLevel": {
24
+ "type": "string",
25
+ "enum": ["inherit", "off", "minimal", "low", "medium", "high", "xhigh", "max"],
26
+ "default": "low",
27
+ "description": "压缩摘要请求的 thinking 等级上限(只降不升,结束后恢复会话等级)。inherit 表示沿用会话等级"
28
+ },
23
29
  "keepRecentTokens": {
24
30
  "type": "integer",
25
31
  "default": 20000,
package/src/ask-dialog.ts CHANGED
@@ -25,6 +25,8 @@ export interface DialogConfig {
25
25
  recommended?: number;
26
26
  /** true = toggle list (space toggles, enter confirms the set); false = pick-one (enter/space takes the cursor row). */
27
27
  multi: boolean;
28
+ /** Multi mode only: toggles to restore when the dialog reopens (an empty Other answer does not lose them). */
29
+ initialSelected?: readonly boolean[];
28
30
  }
29
31
 
30
32
  export interface DialogState {
@@ -38,7 +40,8 @@ type DialogKey = 'up' | 'down' | 'toggle' | 'confirm' | 'cancel' | 'all' | 'igno
38
40
 
39
41
  export type DialogOutcome =
40
42
  | { kind: 'selected'; labels: string[] }
41
- | { kind: 'other' }
43
+ /** `labels` are the toggled rows at the moment Other was taken, so a custom answer keeps them. */
44
+ | { kind: 'other'; labels: string[] }
42
45
  | { kind: 'cancel' };
43
46
 
44
47
  export interface DialogStep {
@@ -94,7 +97,7 @@ export function stepDialog(state: DialogState, key: DialogKey, config: DialogCon
94
97
  }
95
98
  case 'toggle':
96
99
  case 'confirm': {
97
- if (isOtherRow(state, config)) return { state, outcome: { kind: 'other' } };
100
+ if (isOtherRow(state, config)) return { state, outcome: { kind: 'other', labels: selectedLabels(state, config) } };
98
101
  if (!config.multi) {
99
102
  // Pick-one: enter and space both take the cursor row.
100
103
  return { state, outcome: { kind: 'selected', labels: [config.options[state.cursor]!.label] } };
@@ -234,7 +237,11 @@ export function dialogFooter(
234
237
  }
235
238
 
236
239
  export function createDialogComponent(opts: DialogUiOptions): DialogComponent {
240
+ const initial = opts.config.initialSelected;
237
241
  let state = createDialogState(opts.config);
242
+ if (initial && initial.length === opts.config.options.length) {
243
+ state = { ...state, selected: opts.config.options.map((_, i) => initial[i] === true) };
244
+ }
238
245
  let finished = false;
239
246
  const onAbort = (): void => finish({ kind: 'cancel' });
240
247
  function finish(outcome: DialogOutcome): void {
package/src/ask-user.ts CHANGED
@@ -37,49 +37,38 @@ const RESERVED_LOWER: Record<string, true> = {
37
37
  };
38
38
 
39
39
  const OptionSchema = Type.Object({
40
- label: Type.String({ description: 'Short display label (1-5 words)' }),
41
- description: Type.Optional(Type.String({ description: 'Tradeoff / meaning of this choice' })),
40
+ label: Type.String({ description: 'Short label (1-5 words)' }),
41
+ description: Type.Optional(Type.String({ description: 'Tradeoff of this choice' })),
42
42
  });
43
43
 
44
44
  export const ASK_PARAMETERS = Type.Object({
45
- question: Type.Optional(Type.String({ description: 'The question to ask the human. Required unless questions is set.' })),
46
- options: Type.Optional(Type.Array(OptionSchema, {
47
- description: '2-5 authored choices. Do NOT include Other; the UI appends it.',
48
- })),
49
- multi: Type.Optional(Type.Boolean({ description: 'Allow multiple selections (default false). In the TUI this opens an interactive toggle list (space toggles, enter confirms, esc declines).' })),
50
- allowOther: Type.Optional(Type.Boolean({ description: 'Set false to offer the authored choices only, with no free-text row (default true).' })),
51
- recommended: Type.Optional(Type.Number({ description: '0-based index of the recommended option; UI adds (Recommended)' })),
45
+ question: Type.Optional(Type.String({ description: 'Flat form: the question. Required unless questions is set.' })),
46
+ options: Type.Optional(Type.Array(OptionSchema, { description: 'Flat form: 2-5 choices' })),
47
+ multi: Type.Optional(Type.Boolean({ description: 'Allow several selections (default false)' })),
48
+ allowOther: Type.Optional(Type.Boolean({ description: 'false = authored choices only, no free-text row (default true)' })),
49
+ recommended: Type.Optional(Type.Number({ description: '0-based default option; the UI adds "(Recommended)", never write it in the label' })),
52
50
  questions: Type.Optional(Type.Array(
53
51
  Type.Object({
54
52
  id: Type.Optional(Type.String({ description: 'Stable id for mapping answers' })),
55
- question: Type.String({ description: 'Question text' }),
56
- options: Type.Array(OptionSchema, {
57
- description: '2-5 authored choices. Do NOT include Other; the UI appends it unless allowOther is false.',
58
- }),
59
- multi: Type.Optional(Type.Boolean({ description: 'Allow multiple selections (default false). In the TUI this opens an interactive toggle list (space toggles, enter confirms).' })),
60
- allowOther: Type.Optional(Type.Boolean({ description: 'Set false to offer the authored choices only, with no free-text row (default true).' })),
61
- recommended: Type.Optional(Type.Number({ description: '0-based recommended option index' })),
53
+ question: Type.String(),
54
+ options: Type.Array(OptionSchema, { description: '2-5 choices' }),
55
+ multi: Type.Optional(Type.Boolean()),
56
+ allowOther: Type.Optional(Type.Boolean()),
57
+ recommended: Type.Optional(Type.Number()),
62
58
  }),
63
- { description: '1-4 related questions in one call. Wins over top-level question/options when set.' },
59
+ { description: 'Batched form: 1-4 questions; wins over top-level question/options' },
64
60
  )),
65
61
  });
66
62
 
67
63
  export const ASK_TOOL_DESCRIPTION = [
68
- 'Ask the human a question and wait for their answer.',
69
- 'Use this when you genuinely need a human decision (approval, direction, trade-off) — not for information you can find yourself.',
70
- 'While waiting, the pane shows as blocked in herdr (the human sees it and can step in).',
71
- 'Prefer 2-5 options with short labels; put tradeoffs in description.',
72
- 'Do NOT include an "Other" option — the UI appends "Other (type your own)" automatically.',
73
- 'Use recommended (0-based) to mark the default; "(Recommended)" is added automatically — do NOT write it in the label.',
74
- 'Use multi: true when several options can apply — the TUI opens an interactive toggle list (space toggles a row, a toggles all, enter confirms, esc declines) instead of making the human type numbers.',
75
- 'Set allowOther: false when the authored choices are exhaustive and a free-text answer would be misleading.',
64
+ 'Ask the human and wait for the answer — only for a real decision (approval, direction, trade-off), not for information you can find yourself. While waiting, the pane shows as blocked in herdr.',
65
+ 'Two shapes. Flat form (no questions key; do not close with ]): {"question":"Store?","options":[{"label":"Redis","description":"In-memory"},{"label":"Postgres"}]}. Batched form: {"questions":[{"question":"Store?","options":[{"label":"Redis"},{"label":"Postgres"}]},{"question":"Region?","options":[{"label":"EU"},{"label":"US"}],"multi":true}]}.',
76
66
  ].join(' ');
77
67
 
78
68
  export const ASK_PROMPT_GUIDELINES = [
79
- 'Default to action. Ask only when options have materially different tradeoffs the user must decide.',
80
- 'Do NOT include "Other" in options; the UI appends it unless allowOther is false. Group related questions in questions (max 4) rather than calling this tool repeatedly.',
81
- 'For multi questions the human toggles rows in a list and confirms with enter — never instruct them to type indices.',
82
- 'Short option labels; explanatory tradeoffs in description.',
69
+ 'Default to action. Ask only when options have materially different tradeoffs the user must decide; group related questions in one call (max 4).',
70
+ 'Do NOT include "Other" in options: the UI appends it unless allowOther is false. Set allowOther: false when the choices are exhaustive.',
71
+ 'Use multi: true when several options can apply; the human toggles rows and confirms — never ask them to type indices.',
83
72
  ];
84
73
 
85
74
  export type AskOption = { label: string; description?: string };
@@ -114,6 +103,8 @@ export type AskDetails = {
114
103
  export type AskToolResult = {
115
104
  content: Array<{ type: 'text'; text: string }>;
116
105
  details: AskDetails;
106
+ /** Set on failures so pi/UI do not count a rejected ask as a successful tool call. */
107
+ isError?: boolean;
117
108
  };
118
109
 
119
110
  export type AskUi = {
@@ -144,6 +135,7 @@ function errorResult(text: string, code: string): AskToolResult {
144
135
  return {
145
136
  content: [{ type: 'text', text }],
146
137
  details: { answers: [], cancelled: true, error: code },
138
+ isError: true,
147
139
  };
148
140
  }
149
141
 
@@ -233,6 +225,42 @@ function parseQuestion(raw: unknown): { ok: true; question: AskQuestion } | { ok
233
225
  };
234
226
  }
235
227
 
228
+ type AskParams = Type.Static<typeof ASK_PARAMETERS>;
229
+
230
+ /** Repair the argument spellings models have sent historically, before pi validates them: `options` or
231
+ * `questions` as a JSON string instead of an array. Only strings that parse to arrays are replaced, so
232
+ * anything genuinely malformed still reaches prepareAsk and gets its normal error text. */
233
+ export function prepareAskArguments(args: unknown): AskParams {
234
+ const rec = asRecord(args);
235
+ if (!rec) return {};
236
+ const parseArray = (value: unknown): unknown[] | undefined => {
237
+ if (typeof value !== 'string') return undefined;
238
+ try {
239
+ const parsed: unknown = JSON.parse(value);
240
+ return Array.isArray(parsed) ? parsed : undefined;
241
+ } catch {
242
+ return undefined;
243
+ }
244
+ };
245
+ let changed = false;
246
+ const next: Record<string, unknown> = { ...rec };
247
+ const options = parseArray(rec.options);
248
+ if (options) {
249
+ next.options = options;
250
+ changed = true;
251
+ }
252
+ const questions = parseArray(rec.questions);
253
+ if (questions) {
254
+ next.questions = questions.map((item) => {
255
+ const question = asRecord(item);
256
+ const questionOptions = question ? parseArray(question.options) : undefined;
257
+ return question && questionOptions ? { ...question, options: questionOptions } : item;
258
+ });
259
+ changed = true;
260
+ }
261
+ return (changed ? next : rec) as AskParams;
262
+ }
263
+
236
264
  export function prepareAsk(params: unknown): PrepareAskResult {
237
265
  const rec = asRecord(params);
238
266
  if (!rec) return fail('Error: `question` must be a non-empty string', 'empty_question');
@@ -312,15 +340,33 @@ function withId(question: AskQuestion, answer: AskAnswer): AskAnswer {
312
340
  return question.id ? { ...answer, id: question.id } : answer;
313
341
  }
314
342
 
315
- /** Free-text answer behind the Other row; shared by the dialog and the typed-prompt fallback. */
316
- async function askOtherText(ui: AskUi, question: AskQuestion, signal?: AbortSignal): Promise<AskAnswer | undefined> {
343
+ /** Free-text answer behind the Other row; shared by the dialog and the typed-prompt fallback. `labels`
344
+ * are the rows toggled when the dialog opened Other — a multi-select custom answer keeps them.
345
+ * Returns 'empty' for a blank answer so the caller can go back to the choices. */
346
+ async function askOtherText(
347
+ ui: AskUi,
348
+ question: AskQuestion,
349
+ labels: readonly string[],
350
+ signal?: AbortSignal,
351
+ ): Promise<AskAnswer | 'empty' | undefined> {
317
352
  const typed = await ui.input(`${question.question}\n\n${CUSTOM_ANSWER_TITLE}`, '', signal ? { signal } : undefined);
318
- if (typed == null || !typed.trim()) return undefined;
353
+ if (typed == null) return undefined;
354
+ const customInput = typed.trim();
355
+ if (!customInput) return 'empty';
356
+ if (question.multi && labels.length > 0) {
357
+ return withId(question, {
358
+ question: question.question,
359
+ kind: 'multi',
360
+ answer: null,
361
+ selected: [...labels],
362
+ customInput,
363
+ });
364
+ }
319
365
  return withId(question, {
320
366
  question: question.question,
321
367
  kind: 'custom',
322
368
  answer: OTHER_ANSWER,
323
- customInput: typed.trim(),
369
+ customInput,
324
370
  });
325
371
  }
326
372
 
@@ -376,53 +422,71 @@ function parseTypedPicks(
376
422
  }
377
423
 
378
424
  /** Ask one question, both modes: the pier dialog, then the host's select dialog (single only), then
379
- * the typed prompt. `ui.custom` is absent (or resolves undefined) in RPC mode, hence every rung. */
425
+ * the typed prompt. `ui.custom` is absent (or resolves undefined) in RPC mode, hence every rung.
426
+ * A blank Other answer goes back to the choices instead of declining the question. */
380
427
  async function askQuestion(ui: AskUi, question: AskQuestion, signal?: AbortSignal): Promise<AskAnswer | undefined> {
381
428
  const { multi } = question;
382
- if (typeof ui.custom === 'function') {
383
- const toggleToolsExpanded = ui.getToolsExpanded && ui.setToolsExpanded
384
- ? (): void => ui.setToolsExpanded!(!ui.getToolsExpanded!())
385
- : undefined;
386
- const outcome = await runSelectDialog(
387
- ui.custom,
388
- question.question,
389
- {
390
- options: question.options,
391
- allowOther: question.allowOther,
392
- multi,
393
- ...(question.recommended !== undefined ? { recommended: question.recommended } : {}),
394
- },
395
- { ...(signal ? { signal } : {}), ...(toggleToolsExpanded ? { toggleToolsExpanded } : {}) },
396
- );
397
- if (outcome?.kind === 'cancel') return undefined;
398
- if (outcome?.kind === 'other') return askOtherText(ui, question, signal);
399
- if (outcome?.kind === 'selected') {
400
- return withId(
401
- question,
402
- multi
403
- ? { question: question.question, kind: 'multi', answer: null, selected: outcome.labels }
404
- : { question: question.question, kind: 'option', answer: outcome.labels[0]! },
429
+ const toggleToolsExpanded = ui.getToolsExpanded && ui.setToolsExpanded
430
+ ? (): void => ui.setToolsExpanded!(!ui.getToolsExpanded!())
431
+ : undefined;
432
+ let custom = typeof ui.custom === 'function' ? ui.custom : undefined;
433
+ let toggled: string[] = [];
434
+ for (;;) {
435
+ const dialog = custom;
436
+ if (dialog) {
437
+ const outcome = await runSelectDialog(
438
+ dialog,
439
+ question.question,
440
+ {
441
+ options: question.options,
442
+ allowOther: question.allowOther,
443
+ multi,
444
+ ...(question.recommended !== undefined ? { recommended: question.recommended } : {}),
445
+ ...(toggled.length > 0 ? { initialSelected: question.options.map((option) => toggled.includes(option.label)) } : {}),
446
+ },
447
+ { ...(signal ? { signal } : {}), ...(toggleToolsExpanded ? { toggleToolsExpanded } : {}) },
405
448
  );
449
+ if (outcome?.kind === 'cancel') return undefined;
450
+ if (outcome?.kind === 'other') {
451
+ const answer = await askOtherText(ui, question, outcome.labels, signal);
452
+ if (answer !== 'empty') return answer;
453
+ toggled = outcome.labels;
454
+ continue;
455
+ }
456
+ if (outcome?.kind === 'selected') {
457
+ return withId(
458
+ question,
459
+ multi
460
+ ? { question: question.question, kind: 'multi', answer: null, selected: outcome.labels }
461
+ : { question: question.question, kind: 'option', answer: outcome.labels[0]! },
462
+ );
463
+ }
464
+ // The host cannot render custom components (RPC mode): fall through to the typed prompt.
465
+ custom = undefined;
406
466
  }
467
+ const lines = optionLines(question);
468
+ const opts = signal ? { signal } : undefined;
469
+ // A multi question has no single-pick host dialog; its typed prompt is always the fallback.
470
+ const select = multi ? undefined : ui.select;
471
+ const hint = multi
472
+ ? (question.allowOther ? MULTI_INSTRUCTIONS : MULTI_NO_OTHER_INSTRUCTIONS)
473
+ : NUMBERED_HINT;
474
+ const raw = select
475
+ ? await select(question.question, lines, opts)
476
+ : await ui.input(`${question.question}\n\n${lines.join('\n')}\n\n${hint}`, multi ? '1,3' : '1', opts);
477
+ if (raw == null) return undefined;
478
+ const picks = parseTypedPicks(question, lines, raw, select === undefined);
479
+ if (picks === 'other') {
480
+ const answer = await askOtherText(ui, question, [], signal);
481
+ if (answer !== 'empty') return answer;
482
+ continue;
483
+ }
484
+ return picks?.[0];
407
485
  }
408
- const lines = optionLines(question);
409
- const opts = signal ? { signal } : undefined;
410
- // A multi question has no single-pick host dialog; its typed prompt is always the fallback.
411
- const select = multi ? undefined : ui.select;
412
- const hint = multi
413
- ? (question.allowOther ? MULTI_INSTRUCTIONS : MULTI_NO_OTHER_INSTRUCTIONS)
414
- : NUMBERED_HINT;
415
- const raw = select
416
- ? await select(question.question, lines, opts)
417
- : await ui.input(`${question.question}\n\n${lines.join('\n')}\n\n${hint}`, multi ? '1,3' : '1', opts);
418
- if (raw == null) return undefined;
419
- const picks = parseTypedPicks(question, lines, raw, select === undefined);
420
- if (picks === 'other') return askOtherText(ui, question, signal);
421
- return picks?.[0];
422
486
  }
423
487
 
424
488
  export function formatAskContent(details: AskDetails): string {
425
- if (details.cancelled || details.answers.length === 0) return DECLINE_TEXT;
489
+ if (details.answers.length === 0) return DECLINE_TEXT;
426
490
  const entries = details.answers.map((answer) => {
427
491
  if (answer.kind === 'custom' && answer.answer === OTHER_ANSWER) {
428
492
  const notes = answer.customInput ? ` user notes: ${answer.customInput}` : '';
@@ -430,11 +494,15 @@ export function formatAskContent(details: AskDetails): string {
430
494
  }
431
495
  if (answer.kind === 'multi') {
432
496
  const labels = answer.selected?.length ? answer.selected.join(', ') : '(none)';
433
- return `"${answer.question}"="${labels}"`;
497
+ const notes = answer.customInput ? ` user notes: ${answer.customInput}` : '';
498
+ return `"${answer.question}"="${labels}"${notes}`;
434
499
  }
435
500
  return `"${answer.question}"="${answer.answer ?? ''}"`;
436
501
  });
437
- return `User has answered your questions: ${entries.join(', ')}. You can now continue with the user's answers in mind.`;
502
+ const answered = `User has answered your questions: ${entries.join(', ')}.`;
503
+ return details.cancelled
504
+ ? `${answered} The user declined the remaining questions — continue with these answers and your best judgment.`
505
+ : `${answered} You can now continue with the user's answers in mind.`;
438
506
  }
439
507
 
440
508
  export async function runAsk(spec: AskSpec, ui: AskUi, signal?: AbortSignal): Promise<AskToolResult> {