pi-pier 0.3.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.
Files changed (54) hide show
  1. package/README.md +7 -5
  2. package/package.json +3 -3
  3. package/schemas/efficiency-config.schema.json +6 -0
  4. package/src/ask-dialog.ts +9 -2
  5. package/src/ask-user.ts +142 -74
  6. package/src/bootstrap.ts +2 -1
  7. package/src/compact-coordinator.ts +207 -35
  8. package/src/compact-economics-core.ts +62 -29
  9. package/src/config-catalog-core.ts +1 -0
  10. package/src/cordis-services.ts +64 -0
  11. package/src/dashboard-command.ts +40 -15
  12. package/src/delegation-core.ts +115 -0
  13. package/src/efficiency-config-core.ts +17 -6
  14. package/src/efficiency-store.ts +43 -6
  15. package/src/guards.ts +6 -0
  16. package/src/herdr-client.ts +141 -36
  17. package/src/history-store.ts +2 -0
  18. package/src/index-gates.ts +6 -12
  19. package/src/index-master.ts +12 -16
  20. package/src/index-pipe.ts +6 -0
  21. package/src/index-runtime.ts +14 -0
  22. package/src/index-worker.ts +4 -10
  23. package/src/index.ts +310 -76
  24. package/src/jev-client.ts +32 -7
  25. package/src/jev-core.ts +71 -36
  26. package/src/observation-core.ts +13 -1
  27. package/src/panel-open.ts +47 -0
  28. package/src/pier-options.ts +1 -2
  29. package/src/pipe-channel.ts +9 -0
  30. package/src/plugins/heat-plan.ts +18 -3
  31. package/src/plugins/observation.ts +118 -46
  32. package/src/plugins/subagent.ts +119 -68
  33. package/src/plugins/terminal.ts +221 -57
  34. package/src/plugins/todo.ts +63 -63
  35. package/src/reducer-core.ts +3 -5
  36. package/src/reducer-invoker.ts +27 -13
  37. package/src/renderers.ts +25 -2
  38. package/src/role-manifest.ts +2 -3
  39. package/src/roles/worker-default.json +2 -2
  40. package/src/settle-wake-core.ts +17 -3
  41. package/src/subagent-core.ts +209 -3
  42. package/src/subagent-poller.ts +69 -19
  43. package/src/subagent-session.ts +67 -4
  44. package/src/subagent-spawn.ts +55 -7
  45. package/src/terminal-core.ts +178 -50
  46. package/src/todo-core.ts +47 -2
  47. package/src/todo-read-hook.ts +54 -17
  48. package/src/todo-reminder-core.ts +9 -5
  49. package/src/todo-window.ts +8 -4
  50. package/src/todos-service.ts +12 -0
  51. package/src/tree-view.ts +162 -0
  52. package/src/vocab.ts +7 -1
  53. package/src/worker-marker.ts +76 -0
  54. package/src/focus-poller.ts +0 -223
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 0.86 records the toolset change as a transcript delta, so it applies on the worker's next request and survives resume) |
47
- | `ask_user_question` | Human gate: 2-5 authored options plus a trailing free-text row (`allowOther: false` for pure choice); multiple related questions per call. TUI renders single- and multi-select through one picker dialog (multi adds checkboxes: space toggles, `a` all, live count; `recommended` seeds the cursor and pre-checks in multi); pane shows blocked in herdr while waiting |
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
@@ -52,13 +51,16 @@ The installer also verifies node / pi / herdr versions.
52
51
  | Command | Purpose |
53
52
  |---|---|
54
53
  | `/todos` | Show the todo list, or edit it: `/todos done|drop|rm|unblock <fuzzy match>` |
54
+ | `/dashboard [overlay]` | Ops dashboard. Default opens the shared Pier **popup** on its `Dashboard` tab (90% × 80%, singleton); `overlay` opens the same view as an independent full-window pane. Non-Herdr falls back to an in-pane text view. |
55
+ | `/pier-tree [overlay]` | Master only: the full delegation tree — your complete todo list plus every running subagent's full list (live-pushed over the pipe) and settled nodes with ages. Default opens the same shared popup on its `Tree` tab (the snapshot refreshes on every todo/worker push; `1`/`2`/`Tab` switch views inside the popup); `overlay` opens an independent full-window pane; bare pi / a missing plugin falls back to the in-pane notify view. The resident widget stays one line per agent and hides entirely once the self list is fully completed (D113/D114) |
55
56
  | `/locks` | Show write locks held by this pane and by live panes — the **human/operator view** of the write-lock beacons (herdr only). Agents see the holders inside their own write warnings; the raw tokens are readable via `herdr agent list` |
56
- | `/pier-role` | Show the current role + available role names, or switch mid-session: `/pier-role <name>` (pi ≥ 0.86; widening beyond the current toolset asks for confirmation; the switch lands as a transcript tool delta) |
57
+ | `/pier-role` | Show the current role + available role names, or switch mid-session: `/pier-role <name>` (widening beyond the current toolset asks for confirmation; the switch lands as a transcript tool delta) |
57
58
  | `/pier-config` | Read-only configuration guide over four planes: bare call = index + hands a guided change to the agent; `show [plane\|all]` = effective value + source (`env > workspace > user > default`); `check` = validate all planes; `doc [path]` = write a report |
58
59
 
59
60
  ### Behaviors
60
61
 
61
62
  - **Human-in-the-loop**: every subagent is a visible, interactive TUI pane — step into it anytime to talk directly (fix bugs, take over, answer its `ask_user_question`). Blocked gates raise sidebar markers + notifications
63
+ - **Delegation identity**: the worker knows who delegated it — spawn env (`PI_HERDR_DELEGATION`) replays through the worker marker and the session's own first-frame entry; its system prompt carries a `pier-delegation` section (identity + delivery contract), the transcript opens with an "⟵ delegated by master …" card, the pane title gets an `↳` prefix, and `pi-parent`/`pi-task` tokens feed the workbench dashboard tree and blocked-alert attribution
62
64
  - **Blocking dialogs block the pane**: pi's `ui_prompt_start` / `ui_prompt_end` events cover the inherently blocking `ctx.ui` dialogs (`select` / `confirm` / `input` / `editor`), so a dialog from any extension (not only pier's own ask tool) reports the pane as blocked and emits the `herdr:blocked` edge; nested gates are coalesced. `custom` is excluded because pi also routes resident overlays through it (pier's slim-frame overlay never calls `done()`, and a resident overlay keeps pi's prompt span open anyway)
63
65
  - **Role gate stops early**: a tool call denied by the role manifest returns `terminate`, so a batch whose results are all terminating ends without another model round trip
64
66
  - **Readable transcript**: pier's own session entries (todo edits, subagent/terminal registries, role manifests, soft approvals) and its reminder messages render as compact cards instead of raw JSON
@@ -105,8 +107,8 @@ pi overwrites tools/commands by name; event listeners stack.
105
107
  ## Requirements
106
108
 
107
109
  - Node ≥ 22
108
- - pi ≥ 0.86.0 (`@earendil-works/pi-coding-agent`) — dynamic toolset (transcript-backed `setActiveTools` deltas) and role switching require it
109
- - **herdr ≥ 0.9.0** — required for subagents / pane integration / notifications (see the IMPORTANT note above); without it only the todo loop and `ask_user_question` load
110
+ - pi ≥ 1.3.0 (`@earendil-works/pi-coding-agent`) — `before_agent_start.messageType` and resumed-session active tools
111
+ - **herdr ≥ 0.9.5** — required for subagents / pane integration / notifications (see the IMPORTANT note above); native `pane.focused`, self-reported resume, and shutdown-safe session saves restore subagent panes after a server restart
110
112
 
111
113
  ## Development
112
114
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-pier",
3
- "version": "0.3.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",
@@ -49,11 +49,11 @@
49
49
  "@deepseek-ai/cordis-plugin-loader": "^1.0.2"
50
50
  },
51
51
  "peerDependencies": {
52
- "@earendil-works/pi-coding-agent": "*",
52
+ "@earendil-works/pi-coding-agent": ">=1.3.0",
53
53
  "typebox": "*"
54
54
  },
55
55
  "devDependencies": {
56
- "@earendil-works/pi-coding-agent": "0.86.0",
56
+ "@earendil-works/pi-coding-agent": "1.3.0",
57
57
  "typebox": "~1.3.7",
58
58
  "typescript": "~5.9.3"
59
59
  }
@@ -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> {
package/src/bootstrap.ts CHANGED
@@ -12,6 +12,7 @@ import Group from '@deepseek-ai/cordis-plugin-group';
12
12
  import { pathToFileURL, fileURLToPath } from 'node:url';
13
13
  import * as path from 'node:path';
14
14
  import { DisposeLedger } from './ledger.ts';
15
+ import { cordisProvide } from './cordis-services.ts';
15
16
  import { pierOption } from './pier-options.ts';
16
17
 
17
18
  export interface BootstrapHooks {
@@ -37,7 +38,7 @@ export async function createCordisApp(hooks: BootstrapHooks = {}): Promise<Cordi
37
38
  const root = new Context();
38
39
  const ledger = new DisposeLedger();
39
40
  // Inject the ledger through Cordis DI because loader transforms entry config and cannot carry live instances.
40
- root.provide('pi-herdr.ledger', ledger);
41
+ cordisProvide(root, 'pi-herdr.ledger', ledger);
41
42
  if (hooks.onDispose) {
42
43
  root.effect(() => () => { hooks.onDispose?.(); }, 'session-root');
43
44
  }