pi-pier 0.4.1 → 0.4.3

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 (45) hide show
  1. package/README.md +1 -2
  2. package/package.json +1 -1
  3. package/schemas/efficiency-config.schema.json +25 -19
  4. package/schemas/role-manifest.schema.json +9 -9
  5. package/src/ask-dialog.ts +9 -2
  6. package/src/ask-user.ts +142 -74
  7. package/src/compact-coordinator.ts +204 -33
  8. package/src/compact-economics-core.ts +62 -29
  9. package/src/config-catalog-core.ts +1 -0
  10. package/src/delegation-core.ts +1 -1
  11. package/src/efficiency-config-core.ts +34 -20
  12. package/src/efficiency-store.ts +42 -5
  13. package/src/herdr-client.ts +15 -5
  14. package/src/index-notices.ts +1 -1
  15. package/src/index-pipe.ts +0 -8
  16. package/src/index.ts +71 -87
  17. package/src/jev-client.ts +32 -7
  18. package/src/jev-core.ts +70 -33
  19. package/src/manifest-compose.ts +4 -4
  20. package/src/observation-core.ts +13 -1
  21. package/src/pipe-channel.ts +0 -8
  22. package/src/plugins/heat-plan.ts +18 -3
  23. package/src/plugins/observation.ts +118 -46
  24. package/src/plugins/subagent.ts +53 -40
  25. package/src/plugins/terminal.ts +192 -47
  26. package/src/plugins/todo.ts +44 -22
  27. package/src/reducer-invoker.ts +28 -14
  28. package/src/renderers.ts +1 -13
  29. package/src/role-loader.ts +8 -8
  30. package/src/role-manifest.ts +22 -22
  31. package/src/roles/master.json +1 -1
  32. package/src/roles/worker-default.json +3 -3
  33. package/src/settle-wake-core.ts +8 -2
  34. package/src/subagent-core.ts +41 -3
  35. package/src/subagent-poller.ts +52 -18
  36. package/src/subagent-session.ts +67 -4
  37. package/src/subagent-spawn.ts +6 -5
  38. package/src/terminal-core.ts +111 -17
  39. package/src/todo-core.ts +47 -2
  40. package/src/todo-read-hook.ts +31 -17
  41. package/src/todo-reminder-core.ts +9 -5
  42. package/src/todo-window.ts +8 -4
  43. package/src/todos-service.ts +12 -0
  44. package/src/tree-view.ts +10 -10
  45. package/src/vocab.ts +7 -1
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.3",
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",
@@ -8,23 +8,29 @@
8
8
  "onlineContextCompact": {
9
9
  "type": "object",
10
10
  "properties": {
11
- "enabled": { "type": "boolean", "default": false, "description": "是否启用待办驱动的在线上下文压缩" },
12
- "logEnabled": { "type": "boolean", "default": false, "description": "是否记录在线压缩的决策与节省量日志" },
11
+ "enabled": { "type": "boolean", "default": false, "description": "Enable todo-driven online context compaction" },
12
+ "logEnabled": { "type": "boolean", "default": false, "description": "Log online compaction decisions and savings" },
13
13
  "cacheWriteReadRatio": {
14
14
  "oneOf": [
15
15
  { "type": "number", "minimum": 0 },
16
16
  { "type": "string", "enum": ["auto"] }
17
17
  ],
18
18
  "default": "auto",
19
- "description": "KV 缓存写入与读取成本比。auto 表示从 ctx.model.cost 自动推导"
19
+ "description": "KV cache write-to-read cost ratio. auto derives it from ctx.model.cost"
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 level cap for compaction summary requests (only lowers, never raises; the session level is restored afterwards). inherit keeps the session level"
28
+ },
23
29
  "keepRecentTokens": {
24
30
  "type": "integer",
25
31
  "default": 20000,
26
32
  "minimum": 1000,
27
- "description": "原生压缩保留区大小(同时用于 archiveTokens 估算与 nativeCompactionFeasible 预检)。缺省自动继承 pi 的 compaction.keepRecentTokens 设置(默认 20000)。"
33
+ "description": "Native compaction keep-recent window size (also used for archiveTokens estimation and the nativeCompactionFeasible precheck). Defaults to inheriting pi's compaction.keepRecentTokens setting (20000 when unset)."
28
34
  }
29
35
  },
30
36
  "additionalProperties": false
@@ -32,8 +38,8 @@
32
38
  "observationPack": {
33
39
  "type": "object",
34
40
  "properties": {
35
- "enabled": { "type": "boolean", "default": false, "description": "是否启用大工具输出冷热分级打包" },
36
- "logEnabled": { "type": "boolean", "default": false, "description": "是否记录观察结果打包与召回日志" },
41
+ "enabled": { "type": "boolean", "default": false, "description": "Enable hot/cold-tiered packing of large tool outputs" },
42
+ "logEnabled": { "type": "boolean", "default": false, "description": "Log observation packing and recall" },
37
43
  "thresholdBytes": { "type": "integer", "default": 10240, "minimum": 1024 },
38
44
  "fullSends": { "type": "integer", "default": 2, "minimum": 1 },
39
45
  "recallChunkBytes": { "type": "integer", "default": 16384, "minimum": 1024 },
@@ -44,31 +50,31 @@
44
50
  "evidencePreservingReducer": {
45
51
  "type": "object",
46
52
  "properties": {
47
- "enabled": { "type": "boolean", "default": false, "description": "是否启用长测试日志保真提炼" },
48
- "logEnabled": { "type": "boolean", "default": false, "description": "是否记录提炼比对、耗时与回退日志" },
53
+ "enabled": { "type": "boolean", "default": false, "description": "Enable fidelity-preserving refinement of long test logs" },
54
+ "logEnabled": { "type": "boolean", "default": false, "description": "Log refinement comparisons, durations and fallbacks" },
49
55
  "model": {
50
56
  "type": "string",
51
- "description": "专用的轻量 Reducer 模型 (provider/model)。缺省使用当前模型并在日志中提示经济性"
57
+ "description": "Dedicated lightweight Reducer model (provider/model). Defaults to the current model and logs an economy hint"
52
58
  },
53
59
  "minBytes": { "type": "integer", "default": 4096, "minimum": 512 },
54
60
  "maxChars": { "type": "integer", "default": 600000 },
55
61
  "maxOutputTokens": { "type": "integer", "default": 2048 },
56
- "timeoutMs": { "type": "integer", "default": 5000, "description": "同步拦截超时上限,默认 5 秒避免阻塞" },
57
- "localOnly": { "type": "boolean", "default": false, "description": "仅本地归档长日志并跳过模型提炼" }
62
+ "timeoutMs": { "type": "integer", "default": 5000, "description": "Upper bound for the synchronous interception timeout; defaults to 5s to avoid blocking" },
63
+ "localOnly": { "type": "boolean", "default": false, "description": "Archive long logs locally only and skip model refinement" }
58
64
  },
59
65
  "additionalProperties": false
60
66
  },
61
67
  "jev": {
62
68
  "type": "object",
63
- "description": "jev 决策层(RFC docs/rfc-jev-integration.md):可选的 System One 分类调用,逐点 fail-open",
69
+ "description": "jev decision layer (RFC docs/rfc-jev-integration.md): optional System One classification calls, fail-open at each point",
64
70
  "properties": {
65
- "enabled": { "type": "boolean", "default": false, "description": "是否启用 jev 决策层(还需配置 apiKey 才会真正发起调用)" },
66
- "logEnabled": { "type": "boolean", "default": false, "description": "是否写 efficiency-logs/jev.jsonl(不记录请求/响应 body)" },
67
- "baseUrl": { "type": "string", "description": "API 根地址覆盖(中转/网关);缺省 https://api.typesafe.ai" },
68
- "model": { "type": "string", "default": "jev-1.13.0", "description": "钉住的版本化模型 id;别名会静默漂移导致校准阈值失真" },
69
- "timeoutMs": { "type": "integer", "default": 2000, "minimum": 500, "description": "单次调用总预算(AbortController 硬杀,SDK 无总预算模式)" },
70
- "minConfidence": { "type": "number", "default": 0.6, "minimum": 0, "maximum": 1, "description": "Choice/Score 答案被采纳的最低置信度,低于则按未回答处理" },
71
- "apiKey": { "type": "string", "description": "API key。解析优先级:PIER_JEV_API_KEY 环境变量 > 此处 > TYPESAFE_API_KEY 环境变量" }
71
+ "enabled": { "type": "boolean", "default": false, "description": "Enable the jev decision layer (calls are only made once apiKey is configured)" },
72
+ "logEnabled": { "type": "boolean", "default": false, "description": "Write efficiency-logs/jev.jsonl (no request/response bodies)" },
73
+ "baseUrl": { "type": "string", "description": "API base URL override (proxy/gateway); defaults to https://api.typesafe.ai" },
74
+ "model": { "type": "string", "default": "jev-1.13.0", "description": "Pinned versioned model id; aliases drift silently and skew calibration thresholds" },
75
+ "timeoutMs": { "type": "integer", "default": 2000, "minimum": 500, "description": "Total budget per call (hard kill via AbortController; the SDK has no total-budget mode)" },
76
+ "minConfidence": { "type": "number", "default": 0.6, "minimum": 0, "maximum": 1, "description": "Minimum confidence for accepting a Choice/Score answer; below it the answer is treated as unanswered" },
77
+ "apiKey": { "type": "string", "description": "API key. Resolution order: PIER_JEV_API_KEY env var > this field > TYPESAFE_API_KEY env var" }
72
78
  },
73
79
  "additionalProperties": false
74
80
  }
@@ -1,19 +1,19 @@
1
1
  {
2
2
  "$schema": "https://json-schema.org/draft/2020-12/schema",
3
3
  "$id": "https://herdr-pi.local/schemas/role-manifest.schema.json",
4
- "title": "herdr-pi role manifest(D76 v2)",
5
- "description": "档2 role 档案契约:version(P2 语义版本)+ roleType(P3 planner/executor/hybrid)+ 三态 rules(P1 allow/ask/deny)。运行时校验为 src/role-manifest.ts 手写零依赖实现,本文件是合同文档。",
4
+ "title": "herdr-pi role manifest (D76 v2)",
5
+ "description": "Tier-2 role manifest contract: version (P2 semantic version) + roleType (P3 planner/executor/hybrid) + tri-state rules (P1 allow/ask/deny). Runtime validation is the hand-written zero-dependency implementation in src/role-manifest.ts; this file is the contract document.",
6
6
  "type": "object",
7
7
  "properties": {
8
- "role": { "type": "string", "pattern": "^[a-z0-9-]+$", "description": "角色名,须与文件名一致" },
9
- "version": { "type": "string", "pattern": "^\\d+\\.\\d+\\.\\d+$", "description": "语义版本 x.y.z" },
10
- "model": { "type": "string", "pattern": "^[A-Za-z0-9._-]+/[A-Za-z0-9._-]+$", "description": "按角色路由模型 provider/model(WS-D10;可省略 = 进程默认)" },
8
+ "role": { "type": "string", "pattern": "^[a-z0-9-]+$", "description": "Role name; must match the file name" },
9
+ "version": { "type": "string", "pattern": "^\\d+\\.\\d+\\.\\d+$", "description": "Semantic version x.y.z" },
10
+ "model": { "type": "string", "pattern": "^[A-Za-z0-9._-]+/[A-Za-z0-9._-]+$", "description": "Per-role model routing, provider/model (WS-D10; optional = process default)" },
11
11
  "guidelines": {
12
12
  "type": "array",
13
13
  "minItems": 1,
14
14
  "maxItems": 20,
15
15
  "items": { "type": "string", "minLength": 1 },
16
- "description": "P0 per-role 行为约束(RFC §4.6):工具集表达不了的“怎么用”约束,每轮作为 pier-role prompt section 注入;空数组请省略"
16
+ "description": "P0 per-role behavior constraints (RFC §4.6): constraints on \"how to use\" tools that the tool set cannot express, injected each turn as the pier-role prompt section; omit instead of passing an empty array"
17
17
  },
18
18
  "manifest": {
19
19
  "type": "object",
@@ -23,17 +23,17 @@
23
23
  "minItems": 1,
24
24
  "items": { "type": "string", "pattern": "^[a-z0-9_-]+$" },
25
25
  "uniqueItems": true,
26
- "description": "基线工具;必含 todo_write 与 ask_user_question(协调工具,运行时校验强制)"
26
+ "description": "Baseline tools; must include todo_write and ask_user_question (coordination tools, enforced by runtime validation)"
27
27
  },
28
28
  "rules": {
29
29
  "type": "object",
30
30
  "propertyNames": { "pattern": "^[a-z0-9_*-]+$" },
31
31
  "additionalProperties": { "enum": ["allow", "ask", "deny"] },
32
- "description": "三态权限;* 为通配符默认;可省略 = {\"*\":\"allow\"}"
32
+ "description": "Tri-state permissions; * is the wildcard default; optional = {\"*\":\"allow\"}"
33
33
  },
34
34
  "unknownTools": {
35
35
  "enum": ["allow", "deny"],
36
- "description": "D82 未知工具姿态(缺省 deny):allow = 用户安装的扩展默认可见可用(master/worker);deny = 清单外一律不可见。与 rules 的 * 通配分属两轴(可见性 vs 强制力)"
36
+ "description": "D82 unknown-tool stance (default deny): allow = user-installed extensions are visible and usable by default (master/worker); deny = anything outside the list is invisible. Orthogonal to the rules * wildcard (visibility vs enforcement)"
37
37
  }
38
38
  },
39
39
  "required": ["tools"],
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> {