@zhuxixi/pi-agent-board 0.3.1 → 0.4.1

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.
@@ -0,0 +1,211 @@
1
+ # Question/Questionnaire Tool Grouping Fix Implementation Plan
2
+
3
+ > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
4
+
5
+ **Goal:** Make pi sessions blocked on the `question`/`questionnaire` tools group under "Needs answer" instead of "Running".
6
+
7
+ **Architecture:** Two minimal changes to `src/core/events.mjs`: (1) extend the private `questionFromArgs` helper to read pi's arg shapes; (2) replace the hardcoded `=== "ask_questions"` name checks with a `QUESTION_TOOL_NAMES` set. All downstream behavior (needs_input state, grouping, summary) already exists via `preservePendingQuestion` and `deriveSummary` — verified in spec section 3.3, nothing else changes.
8
+
9
+ **Tech Stack:** Node.js (`.mjs` ESM, no deps), `node:test` + `node:assert/strict` for tests.
10
+
11
+ ## Global Constraints
12
+
13
+ - All code changes confined to `src/core/events.mjs` and `test/events.test.mjs`.
14
+ - No store/schema/type changes; no changes to `finalizeRun`, `projectViewState`, `deriveSummary`, `rows.mjs`, `service.mjs`.
15
+ - Non-interactive (detached) reduction path keeps its current behavior — the `opts.interactive` gate stays.
16
+ - Static name set, no config surface.
17
+ - Commit messages in conventional commits format; stage files individually (`git add <file>`), never `git add -A`.
18
+ - Test command: `node --test test/events.test.mjs`; project gate: `npm run verify`.
19
+ - Work in worktree `/home/elling/git-repo/github/pi-agent-board/.pi/worktrees/issue-26-question-tool-grouping`; never touch main.
20
+
21
+ ---
22
+
23
+ ### Task 1: Extend `questionFromArgs` for pi arg shapes
24
+
25
+ **Files:**
26
+ - Modify: `src/core/events.mjs:229-235`
27
+ - Test: `test/events.test.mjs` (append new test near the ask_questions tests, after line 160)
28
+
29
+ **Interfaces:**
30
+ - Consumes: nothing new.
31
+ - Produces: `questionFromArgs(args)` (private) returns the first non-empty question text from: `args.question` (string), or `args.questions[]` items' `question` or `prompt` fields; falls back to `"Answer the pending question"`. Task 2's name-recognition relies on this extraction.
32
+
33
+ - [ ] **Step 1: Write the failing test**
34
+
35
+ Append to `test/events.test.mjs` (after the "interactive questions remain visible..." test):
36
+
37
+ ```js
38
+ test("questionFromArgs extracts pi question/questionnaire arg shapes", () => {
39
+ // pi `question` tool shape: args.question is a plain string.
40
+ const s1 = createRunStatus(cfg(), 1, 1000);
41
+ reduceEvent(s1, {
42
+ type: "tool_execution_start",
43
+ toolCallId: "q1",
44
+ toolName: "ask_questions",
45
+ args: { question: "Approve the plan?" },
46
+ }, 2000, { interactive: true });
47
+ assert.equal(s1.question, "Approve the plan?");
48
+
49
+ // pi `questionnaire` tool shape: args.questions[].prompt.
50
+ const s2 = createRunStatus(cfg(), 1, 1000);
51
+ reduceEvent(s2, {
52
+ type: "tool_execution_start",
53
+ toolCallId: "q1",
54
+ toolName: "ask_questions",
55
+ args: { questions: [{ prompt: "Pick the scope?" }] },
56
+ }, 2000, { interactive: true });
57
+ assert.equal(s2.question, "Pick the scope?");
58
+ });
59
+ ```
60
+
61
+ Note: the tests route through the reducer with the already-recognized `ask_questions` name because `questionFromArgs` is private and the reducer is the public surface; name recognition for the pi tools lands in Task 2.
62
+
63
+ - [ ] **Step 2: Run test to verify it fails**
64
+
65
+ Run: `node --test test/events.test.mjs`
66
+ Expected: FAIL — both assertions get `"Answer the pending question"` (the current extractor only reads `questions[].question`).
67
+
68
+ - [ ] **Step 3: Implement the extractor**
69
+
70
+ Replace the body of `questionFromArgs` in `src/core/events.mjs` (L229-235):
71
+
72
+ ```js
73
+ function questionFromArgs(args) {
74
+ if (typeof args?.question === "string" && args.question.trim()) return args.question.trim();
75
+ for (const item of Array.isArray(args?.questions) ? args.questions : []) {
76
+ const question = String(item?.question ?? item?.prompt ?? "").trim();
77
+ if (question) return question;
78
+ }
79
+ return "Answer the pending question";
80
+ }
81
+ ```
82
+
83
+ - [ ] **Step 4: Run tests to verify they pass**
84
+
85
+ Run: `node --test test/events.test.mjs`
86
+ Expected: PASS (all 17 existing + 1 new test).
87
+
88
+ - [ ] **Step 5: Commit**
89
+
90
+ ```bash
91
+ git add src/core/events.mjs test/events.test.mjs
92
+ git commit -m "fix: support pi question arg shapes in questionFromArgs (issue #26)"
93
+ ```
94
+
95
+ ---
96
+
97
+ ### Task 2: Recognize `question`/`questionnaire` as pending-question tools
98
+
99
+ **Files:**
100
+ - Modify: `src/core/events.mjs:25-27` (insert set), `src/core/events.mjs:83`, `src/core/events.mjs:97`
101
+ - Test: `test/events.test.mjs` (append three tests after the Task 1 test)
102
+
103
+ **Interfaces:**
104
+ - Consumes: `questionFromArgs` extraction from Task 1.
105
+ - Produces: `QUESTION_TOOL_NAMES` (module-private `Set<string>`). `reduceEvent` treats any interactive tool_execution_start/end whose tool name is in the set as a pending-question event — `upsertPendingQuestion`/`removePendingQuestion` plus the existing `preservePendingQuestion` flow (needs_input state, `currentTool = null`).
106
+
107
+ - [ ] **Step 1: Write the failing tests**
108
+
109
+ Append to `test/events.test.mjs`:
110
+
111
+ ```js
112
+ test("interactive pi question tool is treated as a pending question", () => {
113
+ const s = createRunStatus(cfg(), 1, 1000);
114
+ reduceEvent(s, {
115
+ type: "tool_execution_start",
116
+ toolCallId: "q1",
117
+ toolName: "question",
118
+ args: { question: "Approve the plan?", options: [{ label: "Yes" }, { label: "No" }] },
119
+ }, 2000, { interactive: true });
120
+ assert.equal(s.semanticState, "needs_input");
121
+ assert.equal(s.question, "Approve the plan?");
122
+ assert.equal(s.currentTool, null);
123
+ assert.equal(s.summary, "Approve the plan?");
124
+ assert.deepEqual(s.pendingQuestions, [{ toolCallId: "q1", question: "Approve the plan?" }]);
125
+ assert.equal(projectViewState(s, 2100).needsInput, true);
126
+
127
+ reduceEvent(s, { type: "tool_execution_end", toolCallId: "q1", toolName: "question", isError: false }, 2400, { interactive: true });
128
+ assert.equal(s.semanticState, "working");
129
+ assert.equal(s.question, null);
130
+ assert.deepEqual(s.pendingQuestions, []);
131
+ });
132
+
133
+ test("interactive questionnaire tool extracts prompt and clears on end", () => {
134
+ const s = createRunStatus(cfg(), 1, 1000);
135
+ reduceEvent(s, {
136
+ type: "tool_execution_start",
137
+ toolCallId: "q1",
138
+ toolName: "questionnaire",
139
+ args: { questions: [{ prompt: "Pick the scope?" }] },
140
+ }, 2000, { interactive: true });
141
+ assert.equal(s.semanticState, "needs_input");
142
+ assert.equal(s.question, "Pick the scope?");
143
+ reduceEvent(s, { type: "tool_execution_end", toolCallId: "q1", toolName: "questionnaire", isError: false }, 2400, { interactive: true });
144
+ assert.equal(s.semanticState, "working");
145
+ });
146
+
147
+ test("detached question tool keeps legacy currentTool behavior", () => {
148
+ const s = createRunStatus(cfg(), 1, 1000);
149
+ reduceEvent(s, { type: "tool_execution_start", toolCallId: "q1", toolName: "question", args: { question: "Approve?" } }, 2000);
150
+ assert.equal(s.semanticState, "working");
151
+ assert.equal(s.currentTool.name, "question");
152
+ assert.deepEqual(s.pendingQuestions, []);
153
+ });
154
+ ```
155
+
156
+ - [ ] **Step 2: Run tests to verify they fail**
157
+
158
+ Run: `node --test test/events.test.mjs`
159
+ Expected: FAIL — question/questionnaire names are not recognized: first test asserts `needs_input` but gets `working` with `currentTool.name === "question"`.
160
+
161
+ - [ ] **Step 3: Add the name set and wire the two checks**
162
+
163
+ Insert after the imports at the top of `src/core/events.mjs` (before `createRunStatus`, ~L25):
164
+
165
+ ```js
166
+ /** Tool names whose interactive execution blocks on a user answer. */
167
+ const QUESTION_TOOL_NAMES = new Set(["ask_questions", "question", "questionnaire"]);
168
+ ```
169
+
170
+ Replace L83:
171
+
172
+ ```js
173
+ if (opts.interactive && QUESTION_TOOL_NAMES.has(name)) {
174
+ ```
175
+
176
+ Replace L97:
177
+
178
+ ```js
179
+ if (opts.interactive && QUESTION_TOOL_NAMES.has(event.toolName ?? "")) removePendingQuestion(status, event.toolCallId);
180
+ ```
181
+
182
+ - [ ] **Step 4: Run tests to verify they pass**
183
+
184
+ Run: `node --test test/events.test.mjs`
185
+ Expected: PASS (21 tests total: 17 existing + 1 Task 1 + 3 new). Existing `ask_questions` tests must pass unchanged.
186
+
187
+ - [ ] **Step 5: Full project gate**
188
+
189
+ Run: `npm run verify`
190
+ Expected: typecheck, all tests, and pack dry-run pass.
191
+
192
+ - [ ] **Step 6: Commit**
193
+
194
+ ```bash
195
+ git add src/core/events.mjs test/events.test.mjs
196
+ git commit -m "fix: recognize question/questionnaire tools as pending questions (issue #26)"
197
+ ```
198
+
199
+ ---
200
+
201
+ ## Self-Review
202
+
203
+ **Spec coverage:**
204
+ - Spec 3.1 (QUESTION_TOOL_NAMES replacing both hardcoded checks) → Task 2 steps 3. ✓
205
+ - Spec 3.2 (questionFromArgs pi shapes: `args.question`, `items[].prompt`, fallback `item.question`) → Task 1 step 3. ✓
206
+ - Spec 3.4 tests 1-4 (question start→needs_input, questionnaire extraction, end→working, detached unchanged) → Task 2 step 1 + Task 1 step 1. ✓
207
+ - Spec 3.3 downstream flow unchanged → asserted via `summary`/`needsInput` in Task 2 test 1; no production code touched outside `events.mjs`. ✓
208
+
209
+ **Placeholder scan:** every step carries concrete code or exact commands; no TBD/TODO/"similar to". ✓
210
+
211
+ **Type consistency:** `QUESTION_TOOL_NAMES` used identically in both replace steps; arg fields (`question`, `questions[].prompt`) match the pi extension schemas (`~/.pi/agent/extensions/question.ts`, `questionnaire.ts`). ✓
@@ -0,0 +1,131 @@
1
+ # Spec: Launch 对话框 cwd 常用目录榜(频率排名 + 模糊补全)
2
+
3
+ - Date: 2026-08-22
4
+ - Status: draft(待 review)
5
+
6
+ ## Background
7
+
8
+ Ctrl+N 打开 launch 对话框后,`cwd` 字段进入目录选择器,目前只能从 `~` 开始逐层
9
+ 浏览 + 输入过滤(`src/core/launch-options.mjs` 的 `listDirectorySuggestions`,
10
+ `src/ui/dashboard.ts` 的 launch picker)。launch-prefs.json 只记忆上一次的 cwd,
11
+ 没有「常用目录」概念。
12
+
13
+ 本机现状(2026-08-22 实测):agent-board 共 177 个 view,cwd 分布高度集中——
14
+ `/home/elling` 107 次、`zima-blue-cli` 20、`jfox` 17、`.pi/agent/extensions` 10、
15
+ `pi-agent-board` 10,其余个位数。用户每次起 session 都要手动浏览目录,重复劳动。
16
+
17
+ 用户需求:按**真实使用频率自动排名**的候选目录榜,用得越多排越前;纯键盘快速
18
+ 选择;并且输入时能做**路径补全**(输入 `jfox` → 出现完整路径 → tab/回车补全),
19
+ 类似 zsh 的 cd 补全体验。
20
+
21
+ ## Goals
22
+
23
+ 1. 持久化的 cwd 使用统计:每次成功发起 session 时对 cwd 计数 +1;统计独立于
24
+ session 生命周期(删 view 不减计数)。
25
+ 2. 首次使用时从现有 view 的 meta.json 一次性导入计数,立即有榜单数据。
26
+ 3. cwd 选择器交互升级:
27
+ - 打开选择器(未输入)时显示频率榜 Top 8,↑↓ 选 + 回车直接确认;
28
+ - 输入时先对候选榜做大小写不敏感子串匹配(路径任意部分);
29
+ - 有匹配 → 候选模式显示匹配项;无匹配 → 回落现有文件系统浏览(旧能力完整保留);
30
+ - tab 把高亮项的完整路径补全进输入框(picker 保持打开,可继续微调),
31
+ 回车最终确认;输入框内容本身就是有效路径时回车直接生效。
32
+ 4. 候选行显示 `~` 简写路径 + 使用次数(如 `107×`)。
33
+
34
+ ## Non-goals
35
+
36
+ - 不做手动收藏/置顶编辑(无配置文件 UI)。
37
+ - 不做模糊 subsequence 匹配(先做子串,不够再升级)。
38
+ - 不改 launch-prefs.json 结构。
39
+ - 不改 launch 对话框的其他字段(model/thinking/action)。
40
+
41
+ ## Design
42
+
43
+ ### 新模块:`src/core/cwd-stats.mjs`
44
+
45
+ - 新文件 `~/.pi/agent/agent-board/cwd-stats.json`(root 由现有
46
+ `paths.mjs` 的 `defaultRoot()` 派生,尊重 `$AGENT_BOARD_ROOT`):
47
+
48
+ ```json
49
+ {
50
+ "version": 1,
51
+ "entries": {
52
+ "/home/elling": { "count": 107, "lastUsed": 1756332000000 }
53
+ }
54
+ }
55
+ ```
56
+
57
+ - `paths.mjs` 增加 `cwdStatsPath(root)`,风格与 `launchPrefsPath` 一致。
58
+ - 函数:
59
+ - `readCwdStats(root)`:缺失/损坏 → 返回空 entries(沿用 store.mjs 的容错读风格)。
60
+ - `seedCwdStatsFromViews(root)`:仅当 cwd-stats.json 不存在时执行;遍历
61
+ roster 全部 view 的 meta.json,按 cwd 聚合计数,lastUsed 取该 cwd 下 view 的
62
+ updatedAt 最大值(缺失则用当前时间);原子写(`atomicWriteJson`)。
63
+ - `recordCwdLaunch(root, cwd)`:count +1、lastUsed 更新,原子写;cwd 非法
64
+ (空/不存在)直接忽略。
65
+ - `rankedCwdCandidates(root, limit)`:count 降序、lastUsed 降序;末尾始终补
66
+ home 目录兜底(若不在榜内则 count 0 排最后),保证「用户根目录」永远可一键选。
67
+ - 写入用 `src/core/atomic.mjs` 的 `atomicWriteJson`(temp + rename,防并发写坏),
68
+ 与 board 现有 meta.json 写入同款。
69
+
70
+ ### 埋点
71
+
72
+ - `src/ui/dashboard.ts` 的 `submitDispatch`:`res.ok` 分支内
73
+ `recordCwdLaunch(root, launchCwd)`,try/catch 尽力而为,失败不影响派发。
74
+ - lazy seed:launch 对话框首次打开(`openLaunchDialog`)时若 cwd-stats.json
75
+ 不存在则调用 `seedCwdStatsFromViews`(同步、一次性、容错)。
76
+
77
+ ### 选择器交互
78
+
79
+ `LaunchState` 增加字段:
80
+ - `cwdRanked: {path, count}[]`:打开 picker 时由 `rankedCwdCandidates(root, 8)` 生成;
81
+ - `cwdPickerMode: "favorites" | "browse"`:候选模式 / 文件系统浏览模式。
82
+
83
+ 行为规则(`openLaunchPicker("cwd")` 与 `handleLaunchPickerKey`):
84
+
85
+ | 输入(cwdQuery) | 模式 | 建议列表 |
86
+ | --- | --- | --- |
87
+ | 空 | favorites | 频率榜 Top 8,首项高亮 |
88
+ | 非空,候选榜有子串匹配(大小写不敏感、路径任意部分) | favorites | 匹配项(保持排名序) |
89
+ | 非空,无匹配 | browse | 现有 `listDirectorySuggestions` 文件系统浏览 |
90
+
91
+ - 打开 picker 时 cwdQuery 置空(不再 seed 成 `~`);在 launch 主对话框 cwd 字段上
92
+ 直接打字进入 picker 时,query = 已输入字符(现有 type-to-jump 行为保留)。
93
+ 输入 `~` 等无候选匹配时自然进入 browse 模式,旧浏览能力保留。
94
+ - tab(favorites 模式):`cwdQuery = 高亮项完整路径`,picker 保持打开;
95
+ 此时输入框即完整路径,回车经现有 `resolveDirectoryValue` 直接生效。
96
+ - 回车(favorites 模式):选中高亮项。
97
+ - esc:关闭 picker(现有行为)。
98
+ - ↑↓:移动高亮(现有逻辑复用)。
99
+
100
+ ### 渲染
101
+
102
+ - favorites 模式候选行:`displayPath(value)`(`~` 简写)+ 右侧 dim 次数
103
+ (`{count}×`);browse 模式渲染不变。
104
+ - 底部提示区分文案:
105
+ - favorites:`常用目录 · type to search · tab complete · enter choose · esc back`
106
+ - browse:现有 `type to filter folders · enter choose · esc back`
107
+
108
+ ## Error handling
109
+
110
+ - 统计读写全部容错:损坏的 cwd-stats.json 视为空表,永不抛到 UI。
111
+ - seed 与 record 的失败静默忽略(console 调试输出可选)。
112
+ - 榜单为空(无 stats、无 home)时 picker 直接进入 browse 模式,行为与现状一致。
113
+
114
+ ## Testing
115
+
116
+ - `test/cwd-stats.test.mjs`(新):seed 从 views 导入、record 累加与 lastUsed、
117
+ 排序(count 优先、lastUsed tie-break)、损坏文件容错、home 兜底、空表行为。
118
+ - 候选匹配逻辑(新函数,放 launch-options.mjs 或 cwd-stats.mjs):大小写不敏感、
119
+ 路径任意部分匹配、无匹配判定。
120
+ - `test/dashboard-render.test.mjs`:repo 现状无 DashboardComponent 单测 harness,picker
121
+ 渲染断言由 Task 5 手工验收覆盖(路径 + 次数、browse 模式渲染不变)。
122
+ - 所有测试用 tmp dir 作 root(沿 paths.mjs「显式 root 可测」约定)。
123
+
124
+ ## Verification
125
+
126
+ - 启动后打开 launch 对话框 cwd 选择器:Top 榜应为 `~`(107×)、zima-blue-cli 等,
127
+ 与现有 view 统计一致。
128
+ - 输入 `jfox`:`~/git-repo/github/jfox` 出现在榜中;tab 补全完整路径;回车发起
129
+ session;再次打开选择器 jfox 计数 +1。
130
+ - 输入 `~`:回落文件系统浏览,旧行为不变。
131
+ - 删除某 view 后计数不变(stats 独立于 view 生命周期)。
@@ -0,0 +1,114 @@
1
+ # Spec: Recognize pi `question`/`questionnaire` tools as pending-question sources
2
+
3
+ - **Issue**: zhuxixi/pi-agent-board#26
4
+ - **Date**: 2026-08-22
5
+ - **Status**: draft — awaiting user confirmation before implementation
6
+ - **Type**: bug fix (event reducer + arg extraction), no data-model change
7
+
8
+ ## 1. Problem
9
+
10
+ A pi session blocked on the built-in `question` (or `questionnaire`) tool shows a
11
+ "Question" summary but stays in the **RUNNING** group instead of **NEEDS ANSWER**.
12
+
13
+ Observed 2026-08-22 01:26 on view `view_e809b1a3a2` ("jfox moc密度PR审查与监控"):
14
+ row summary "Question", `semanticState=working`, `pendingQuestions=[]`.
15
+
16
+ ## 2. Root cause
17
+
18
+ The reducer only recognizes ONE question-tool name — `ask_questions`
19
+ (`src/core/events.mjs` L83/L97). Pi's actual question tools are named
20
+ `question` and `questionnaire` (user extensions:
21
+ `~/.pi/agent/extensions/question.ts`, `questionnaire.ts`).
22
+
23
+ When `tool_execution_start` arrives with `toolName: "question"`:
24
+ 1. Name check fails → `else if (pendingQuestions.length === 0)` branch runs →
25
+ `semanticState = "working"`, `currentTool.summary = toolSummary("question")` =
26
+ `capitalize("question")` = `"Question"` (heuristics.mjs default branch).
27
+ 2. `preservePendingQuestion` finds no pending question → no-op.
28
+ 3. Result: row grouped by `semanticState` = "working" → RUNNING, summary "Question".
29
+
30
+ Correction to issue body: the "design gap B" claim (pending questions don't affect
31
+ grouping) is **wrong** — `preservePendingQuestion` already sets
32
+ `semanticState = "needs_input"` when `pendingQuestions` is non-empty. The ONLY gap is
33
+ tool-name recognition (plus arg-shape extraction). See research/root-cause.md.
34
+
35
+ ## 3. Design
36
+
37
+ All changes in `src/core/events.mjs` + tests. No store/schema/type changes.
38
+
39
+ ### 3.1 Question-tool name set
40
+
41
+ ```js
42
+ const QUESTION_TOOL_NAMES = new Set(["ask_questions", "question", "questionnaire"]);
43
+ ```
44
+
45
+ Replace both `name === "ask_questions"` checks (tool_execution_start L83,
46
+ tool_execution_end L97) with `QUESTION_TOOL_NAMES.has(name)` /
47
+ `QUESTION_TOOL_NAMES.has(event.toolName ?? "")`.
48
+
49
+ Rationale: static set matches the existing hardcoded-name precedent; all three names
50
+ are in the family of pi/agent question tools. A configurable list is deferred (4.2).
51
+
52
+ ### 3.2 `questionFromArgs` — support pi arg shapes
53
+
54
+ Current: reads `args.questions[].question` (ask_questions shape).
55
+
56
+ Extend to:
57
+
58
+ | Tool | Args shape | Extract from |
59
+ |---|---|---|
60
+ | `ask_questions` | `{ questions: [{ question }] }` | `item.question` |
61
+ | `question` (pi) | `{ question: string, options: [...] }` | `args.question` |
62
+ | `questionnaire` (pi) | `{ questions: [{ prompt, options }] }` | `item.prompt`, fallback `item.question` |
63
+
64
+ Priority in `questions[]` items: `item.question ?? item.prompt`. Keep the
65
+ "Answer the pending question" fallback.
66
+
67
+ ### 3.3 Downstream flow (already correct — verify, don't change)
68
+
69
+ - `tool_execution_start` (interactive, question tool): `upsertPendingQuestion` →
70
+ `preservePendingQuestion` → `semanticState = "needs_input"`, `currentTool = null`,
71
+ `question = first.question`. Row moves to NEEDS ANSWER group; summary shows the
72
+ question text (deriveSummary: needs_input + question).
73
+ - `tool_execution_end` (interactive, question tool): `removePendingQuestion`,
74
+ `semanticState = "working"`, `question = null` — back to RUNNING after the answer.
75
+ - Non-interactive (detached worker) path: `opts.interactive` gate unchanged →
76
+ question tools keep the old generic behavior (no pending-question tracking).
77
+
78
+ ### 3.4 Tests (`test/events.test.mjs`)
79
+
80
+ Mirror the existing `ask_questions` coverage (L109-160):
81
+
82
+ 1. `tool_execution_start` with `toolName: "question"` (args `{ question: "Approve?" }`),
83
+ interactive → `pendingQuestions = [{ toolCallId, question: "Approve?" }]`,
84
+ `semanticState === "needs_input"`, `currentTool === null`, summary = question text.
85
+ 2. Same for `toolName: "questionnaire"` with args `{ questions: [{ prompt: "Pick scope" }] }`
86
+ → extracted question "Pick scope".
87
+ 3. `tool_execution_end` with `toolName: "question"` → pendingQuestions empty,
88
+ `semanticState === "working"`.
89
+ 4. Detached (no `interactive`) `question` start → unchanged legacy behavior
90
+ (`currentTool.name === "question"`, `pendingQuestions = []`).
91
+
92
+ ## 4. Non-goals / deferred
93
+
94
+ 1. No configurable question-tool list (env/config escape hatch). Add later only if a
95
+ harness with a different question-tool name appears. Keeping the set static avoids
96
+ new config surface.
97
+ 2. No changes to `finalizeRun`, `projectViewState`, `deriveSummary`, or grouping logic.
98
+ 3. Non-interactive path behavior unchanged.
99
+ 4. No i18n / label changes.
100
+
101
+ ## 5. Edge cases
102
+
103
+ - Two concurrent question tool calls: keyed by `toolCallId`; first pending question wins
104
+ display. Unchanged from `ask_questions` behavior.
105
+ - Non-TUI pi question tool returns an error result immediately → start/end pair clears
106
+ the pending question within the same cycle; transient needs_input is harmless.
107
+ - `agent_start` / `input` events already clear `pendingQuestions` in the foreground
108
+ path (service.mjs L524-535) — no interaction.
109
+
110
+ ## 6. Verification
111
+
112
+ - `npm run verify` (project gate) with the new events tests.
113
+ - Manual: start a pi session under the board, have it call the `question` tool →
114
+ row shows the question text in NEEDS ANSWER group; answer it → row returns to RUNNING.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zhuxixi/pi-agent-board",
3
- "version": "0.3.1",
3
+ "version": "0.4.1",
4
4
  "description": "Agent-board dashboard for Pi: dispatch, monitor, peek/reply, and attach to background Pi sessions.",
5
5
  "type": "module",
6
6
  "main": "./index.ts",
@@ -88,6 +88,7 @@ export async function openDashboard(
88
88
  };
89
89
  const comp = new DashboardComponent(tui, theme as never, keybindings, wrappedDone, {
90
90
  service,
91
+ root: service.getRoot(),
91
92
  defaultCwd: ctx.cwd,
92
93
  initialSelectedId: options.initialSelectedId,
93
94
  availableModels,
@@ -0,0 +1,112 @@
1
+ /**
2
+ * Persistent cwd usage stats for the launch dialog's directory favorites.
3
+ *
4
+ * Counts every successfully dispatched session per cwd, independent of the
5
+ * view lifecycle (deleting a view does not decrement). The dashboard reads
6
+ * the ranked list for the cwd picker's favorites mode. Pure node, no Pi imports.
7
+ */
8
+ import { existsSync } from "node:fs";
9
+ import * as os from "node:os";
10
+ import { atomicWriteJson, readJson } from "./atomic.mjs";
11
+ import * as P from "./paths.mjs";
12
+ import { readMeta, readRoster } from "./store.mjs";
13
+
14
+ /**
15
+ * @typedef {Object} CwdStatsEntry
16
+ * @property {number} count
17
+ * @property {number} lastUsed epoch ms, like meta.updatedAt
18
+ */
19
+
20
+ /** @returns {{version: number, entries: Record<string, CwdStatsEntry>}} */
21
+ function emptyStats() {
22
+ return { version: 1, entries: {} };
23
+ }
24
+
25
+ /** @param {string} root @returns {{version: number, entries: Record<string, CwdStatsEntry>}} */
26
+ export function readCwdStats(root) {
27
+ const raw = readJson(P.cwdStatsPath(root), null);
28
+ if (!raw || typeof raw !== "object" || typeof raw.entries !== "object" || raw.entries === null) return emptyStats();
29
+ /** @type {Record<string, CwdStatsEntry>} */
30
+ const entries = {};
31
+ for (const [dir, entry] of Object.entries(raw.entries)) {
32
+ if (!entry || typeof entry.count !== "number") continue;
33
+ entries[dir] = {
34
+ count: Math.max(0, Math.floor(entry.count)),
35
+ lastUsed: typeof entry.lastUsed === "number" ? entry.lastUsed : 0,
36
+ };
37
+ }
38
+ return { version: 1, entries };
39
+ }
40
+
41
+ /**
42
+ * One-time seed: aggregate cwd counts from every roster view's meta.json.
43
+ * No-op when cwd-stats.json already exists.
44
+ * @param {string} root
45
+ */
46
+ export function seedCwdStatsFromViews(root) {
47
+ if (existsSync(P.cwdStatsPath(root))) return;
48
+ /** @type {Record<string, CwdStatsEntry>} */
49
+ const entries = {};
50
+ for (const viewId of readRoster(root).views ?? []) {
51
+ const meta = readMeta(root, viewId);
52
+ const cwd = meta?.cwd;
53
+ if (!cwd) continue;
54
+ const lastUsed = typeof meta.updatedAt === "number" ? meta.updatedAt : Date.now();
55
+ const existing = entries[cwd];
56
+ if (existing) {
57
+ existing.count += 1;
58
+ existing.lastUsed = Math.max(existing.lastUsed, lastUsed);
59
+ } else {
60
+ entries[cwd] = { count: 1, lastUsed };
61
+ }
62
+ }
63
+ atomicWriteJson(P.cwdStatsPath(root), { version: 1, entries });
64
+ }
65
+
66
+ /**
67
+ * Seed when the stats file is missing; tolerate every failure (dashboard UX
68
+ * must never break because of stats bookkeeping).
69
+ * @param {string} root
70
+ */
71
+ export function ensureCwdStatsSeeded(root) {
72
+ if (existsSync(P.cwdStatsPath(root))) return;
73
+ try {
74
+ seedCwdStatsFromViews(root);
75
+ } catch {
76
+ /* best effort */
77
+ }
78
+ }
79
+
80
+ /**
81
+ * Record one successful dispatch for `cwd`. Invalid dirs are ignored.
82
+ * @param {string} root @param {string} cwd
83
+ */
84
+ export function recordCwdLaunch(root, cwd) {
85
+ if (!cwd || !existsSync(cwd)) return;
86
+ const stats = readCwdStats(root);
87
+ const existing = stats.entries[cwd] ?? { count: 0, lastUsed: 0 };
88
+ stats.entries[cwd] = { count: existing.count + 1, lastUsed: Date.now() };
89
+ atomicWriteJson(P.cwdStatsPath(root), stats);
90
+ }
91
+
92
+ /**
93
+ * Ranked candidates: count desc, then lastUsed desc; home appended at the
94
+ * end when absent so the user's home dir is always one keystroke away.
95
+ * @param {string} root @param {number=} limit
96
+ * @returns {Array<{path: string, count: number}>}
97
+ */
98
+ export function rankedCwdCandidates(root, limit = 8) {
99
+ const stats = readCwdStats(root);
100
+ const rows = Object.entries(stats.entries).map(([dir, entry]) => ({
101
+ path: dir,
102
+ count: entry.count,
103
+ lastUsed: entry.lastUsed,
104
+ }));
105
+ rows.sort((a, b) => b.count - a.count || b.lastUsed - a.lastUsed);
106
+ const out = rows.slice(0, Math.max(1, limit)).map(({ path, count }) => ({ path, count }));
107
+ const home = os.homedir();
108
+ if (out.some((entry) => entry.path === home)) return out;
109
+ const homeRow = rows.find((entry) => entry.path === home);
110
+ out.push(homeRow ? { path: home, count: homeRow.count } : { path: home, count: 0 });
111
+ return out;
112
+ }
@@ -17,6 +17,9 @@ import { assistantText, detectNeedsInput, toolPath, toolSummary, truncate } from
17
17
 
18
18
  const PREVIEW_MAX = 240;
19
19
 
20
+ /** Tool names whose interactive execution blocks on a user answer. */
21
+ const QUESTION_TOOL_NAMES = new Set(["ask_questions", "question", "questionnaire"]);
22
+
20
23
  /**
21
24
  * Build the initial status for a freshly-launched run.
22
25
  * @param {RunConfig} config
@@ -80,7 +83,7 @@ export function reduceEvent(status, event, now, opts = {}) {
80
83
  const name = event.toolName ?? event.args?.name ?? "tool";
81
84
  const args = event.args ?? {};
82
85
  status.toolCount += 1;
83
- if (opts.interactive && name === "ask_questions") {
86
+ if (opts.interactive && QUESTION_TOOL_NAMES.has(name)) {
84
87
  upsertPendingQuestion(status, event.toolCallId, questionFromArgs(args));
85
88
  } else if (pendingQuestions(status).length === 0) {
86
89
  status.currentTool = { name, path: toolPath(args), summary: toolSummary(name, args) };
@@ -94,7 +97,7 @@ export function reduceEvent(status, event, now, opts = {}) {
94
97
  }
95
98
  case "tool_execution_end": {
96
99
  if (event.isError) status.error = `Tool ${event.toolName ?? ""} failed`.trim();
97
- if (opts.interactive && event.toolName === "ask_questions") removePendingQuestion(status, event.toolCallId);
100
+ if (opts.interactive && QUESTION_TOOL_NAMES.has(event.toolName ?? "")) removePendingQuestion(status, event.toolCallId);
98
101
  status.currentTool = null;
99
102
  status.semanticState = "working";
100
103
  status.question = null;
@@ -227,8 +230,9 @@ function pendingQuestions(status) {
227
230
  }
228
231
 
229
232
  function questionFromArgs(args) {
233
+ if (typeof args?.question === "string" && args.question.trim()) return args.question.trim();
230
234
  for (const item of Array.isArray(args?.questions) ? args.questions : []) {
231
- const question = String(item?.question ?? "").trim();
235
+ const question = String(item?.question ?? item?.prompt ?? "").trim();
232
236
  if (question) return question;
233
237
  }
234
238
  return "Answer the pending question";
@@ -315,3 +315,50 @@ function existsDir(dir) {
315
315
  return false;
316
316
  }
317
317
  }
318
+
319
+ /**
320
+ * Keep only candidates whose path is an existing directory.
321
+ * @param {CwdCandidate[]} candidates
322
+ * @returns {CwdCandidate[]}
323
+ */
324
+ export function existingCwdCandidates(candidates) {
325
+ return candidates.filter((entry) => existsDir(entry.path));
326
+ }
327
+
328
+ /**
329
+ * @typedef {Object} CwdCandidate
330
+ * @property {string} path
331
+ * @property {number} count
332
+ */
333
+
334
+ /**
335
+ * Filter ranked cwd candidates by case-insensitive substring match anywhere
336
+ * in the path (empty query keeps the full ranked list).
337
+ * @param {CwdCandidate[]} candidates
338
+ * @param {string} query
339
+ * @returns {CwdCandidate[]}
340
+ */
341
+ export function filterCwdCandidates(candidates, query) {
342
+ const q = String(query ?? "").trim().toLowerCase();
343
+ if (!q) return candidates;
344
+ return candidates.filter((entry) => entry.path.toLowerCase().includes(q));
345
+ }
346
+
347
+ /**
348
+ * Decide cwd picker mode + suggestions for a query: favorites when the query
349
+ * is empty or matches ranked candidates, filesystem browse otherwise.
350
+ * @param {string} query
351
+ * @param {CwdCandidate[]} ranked
352
+ * @param {string} baseCwd
353
+ * @returns {{mode: "favorites"|"browse", suggestions: string[]}}
354
+ */
355
+ export function nextCwdPickerState(query, ranked, baseCwd) {
356
+ if (!ranked || ranked.length === 0) {
357
+ return { mode: "browse", suggestions: listDirectorySuggestions(query, baseCwd) };
358
+ }
359
+ const matches = filterCwdCandidates(ranked, query);
360
+ if (String(query ?? "").trim() === "" || matches.length > 0) {
361
+ return { mode: "favorites", suggestions: matches.map((entry) => entry.path) };
362
+ }
363
+ return { mode: "browse", suggestions: listDirectorySuggestions(query, baseCwd) };
364
+ }