pi-pier 0.1.2 → 0.1.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.
package/README.md CHANGED
@@ -1,22 +1,40 @@
1
1
  # pi-pier
2
2
 
3
+ > [!IMPORTANT]
4
+ > **This npm package is only the pi-extension half of pier.** pier is a two-half system — this extension plus a **herdr plugin** (`pier.workbench`). Without the herdr side you still get `todo_write` / `ask_user_question`, but subagents, pane integration, notifications, and the workspace bootstrap all require herdr. See [Install the herdr half](#install-the-herdr-half-companion-plugin) below.
5
+
3
6
  **pier** = **pi** × h**erdr** — a [pi](https://pi.dev/) extension that fuses coding agent sessions with the [herdr](https://herdr.dev/) pane/tab orchestrator.
4
7
 
5
- `pi-pier` is the **pi extension half** of the [pier monorepo](https://github.com/July24/pier). It fills two gaps pi deliberately leaves open — the **todo list loop** and **interactive subagents** — using herdr panes/tabs as the visual and interaction substrate. It degrades gracefully outside herdr.
8
+ `pi-pier` fills two gaps pi deliberately leaves open — the **todo list loop** and **interactive subagents** — using herdr panes/tabs as the visual and interaction substrate. It degrades gracefully outside herdr.
6
9
 
7
- ## Install
10
+ ## Install (this pi half)
8
11
 
9
12
  ```sh
10
13
  pi install npm:pi-pier
11
14
  ```
12
15
 
13
- Or from git:
16
+ Or from git (monorepo root, same content):
14
17
 
15
18
  ```sh
16
19
  pi install git:github.com/July24/pier
17
20
  ```
18
21
 
19
- > The companion **herdr plugin half** (`pier.workbench`: main-tab bootstrap, blocked-gate notifications, focus-heat layout) is installed separately via `herdr plugin install July24/pier/packages/pier-workbench`, or all at once with the one-shot installer in the [repo](https://github.com/July24/pier): `node install.mjs`.
22
+ ## Install the herdr half (companion plugin)
23
+
24
+ The herdr plugin `pier.workbench` provides the other half: main-tab bootstrap (auto-start a pi master session per workspace), blocked-gate notifications (system notifications when a subagent waits for a human), and the focus-heat layout (focused pane grows in place). Install it from the same repo:
25
+
26
+ ```sh
27
+ herdr plugin install July24/pier/packages/pier-workbench --yes
28
+ ```
29
+
30
+ Or install **both halves at once** with the one-shot installer from the monorepo:
31
+
32
+ ```sh
33
+ git clone https://github.com/July24/pier && cd pier
34
+ node install.mjs # user mode: installs both halves + generates local bootstrap config
35
+ ```
36
+
37
+ The installer also verifies node / pi / herdr versions, probes local paths, and writes the workbench bootstrap config (`boot-config.json`) into herdr's plugin config dir.
20
38
 
21
39
  ## What you get
22
40
 
@@ -45,7 +63,7 @@ Outside a herdr environment the extension auto-degrades: herdr reporting costs n
45
63
 
46
64
  - Node ≥ 22
47
65
  - pi ≥ 0.84 (`@earendil-works/pi-coding-agent`)
48
- - herdr ≥ 0.8.0 for the full pane experience (optional)
66
+ - **herdr ≥ 0.8.0** — required for subagents / pane integration / notifications (see the IMPORTANT note above); without it only `todo_write` / `ask_user_question` work
49
67
 
50
68
  ## Development
51
69
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-pier",
3
- "version": "0.1.2",
3
+ "version": "0.1.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",
@@ -21,7 +21,7 @@ import type { PiSurface } from '../pi-surface.ts';
21
21
  import type { HerdrClientLike, HerdrAgentState } from '../herdr-client.ts';
22
22
  import type { TodosService } from '../todos-service.ts';
23
23
  import { mountSubagentScope } from '../subagent-scope.ts';
24
- import { Semaphore, SUBS_CUSTOM_TYPE, buildLaunchLine, classifyWorktreeZone, foldSubsRegistry, formatSubagentResult, isPathUnder, makeProgressUpdate, makeRegistry, planTabPlacement, tabNameForTask, type SubEntry, type SubagentSpec, type TabPlacementPlan, type WorktreeZone } from '../subagent-core.ts';
24
+ import { Semaphore, SUBS_CUSTOM_TYPE, agoText, buildAliveNotice, buildBlockedGateNotice, buildLaunchLine, classifyWorktreeZone, foldSubsRegistry, formatSubagentResult, isAlive, isPathUnder, makeProgressUpdate, makeRegistry, planTabPlacement, psQuote, tabNameForTask, type AliveProbe, type SubEntry, type SubagentSpec, type TabPlacementPlan, type WorktreeZone } from '../subagent-core.ts';
25
25
  import { hasAssistantAfter, hasPendingToolCall, lastAssistantText, listSessionFiles, readSessionFile, sessionFileById } from '../session-tail.ts';
26
26
  import { appendHistory, applyReportedSessionFile, historyFilePath, latestGeneration, normalizeEntryKind, readHistory, type HistoryEntry } from '../history-store.ts';
27
27
  import { execFile } from 'node:child_process';
@@ -130,6 +130,9 @@ export default function subagentPlugin(ctx: Context): void {
130
130
  }
131
131
  }
132
132
 
133
+ /** E2:已发过闸门通知的 pane(blocked 期间去重;解除后移除,二次 ask 可再通知)。 */
134
+ const blockedGateNotified = new Set<string>();
135
+
133
136
  // slots 回填:common 段 pipe reply 消费
134
137
  slots.applyReplySession = (paneId, sessionFile) => {
135
138
  const entry = subs.get(paneId);
@@ -169,6 +172,35 @@ export default function subagentPlugin(ctx: Context): void {
169
172
  rebuildSubs(eventCtx);
170
173
  });
171
174
 
175
+ /* ── B1(用户实证修复):subagent isError 结果的探活改写 ──────────────
176
+ * 实测事故:前台误判 no-output → 模型读裸错误文案 → "failed, 我自己干" 抢活。
177
+ * pi 的 tool_result 事件官方语义 "Can modify result"——在文本进模型上下文前,
178
+ * 当场探活(agent.list + 会话 mtime);判活则改写为统一存活通知(buildAliveNotice
179
+ * 与 A2 转后台同一出口)。hook 层硬保障,纯 prompt 防不住的自作主张在此物理拦截。 */
180
+ scoped.on('tool_result', async (event: { toolName?: string; toolCallId?: string; isError?: boolean; content?: Array<{ type: string; text?: string }> }) => {
181
+ if (event?.toolName !== 'subagent' || !event.isError) return;
182
+ // 从错误文本里取 pane id(我们自己的错误文案都带 pane);取不到则不改写
183
+ const errText = (event.content ?? []).map((c) => c.text ?? '').join(' ');
184
+ const paneId = [...subs.keys()].find((id) => errText.includes(id))
185
+ ?? (errText.match(/\bw[A-Za-z0-9]+:p\d+\b/) ?? [])[0];
186
+ if (!paneId) return;
187
+ const entry = subs.get(paneId);
188
+ if (!entry || entry.status === 'settled' || entry.status === 'consumed') return;
189
+ const probe = await probeAlive(paneId, entry.cwd);
190
+ if (!isAlive(probe, Date.now())) return; // 真死了 → 保留原错误,模型可重做
191
+ // 活着 → 转 poller(若尚未在跑)并改写结果文本
192
+ if (!pollers.has(paneId)) {
193
+ entry.background = true;
194
+ startPoller(paneId, entry.cwd, entry.createdAt, entry.createdAt, entry.description, lastRequestIdByPane.get(paneId) ?? `probe-${paneId}`);
195
+ persistSubs();
196
+ }
197
+ const notice = buildAliveNotice(
198
+ { paneId, description: entry.description, scenario: 'error-alive', probe },
199
+ Date.now(),
200
+ );
201
+ return { content: [{ type: 'text', text: notice }] };
202
+ });
203
+
172
204
  /* ── D41:stop 未完成提醒(仅主控;子代理不注册本块) ── */
173
205
 
174
206
  let todoReminders = 0;
@@ -253,6 +285,39 @@ export default function subagentPlugin(ctx: Context): void {
253
285
  return null;
254
286
  }
255
287
 
288
+ /** E1/E2:读子 pane 的人类闸门问题(tokens['pi-ask'];子扩展 reportAskFlag 上报)。 */
289
+ async function readAskFlag(paneId: string): Promise<string | null> {
290
+ try {
291
+ const a = (await client.listAgents()).find((x) => x.paneId === paneId);
292
+ const v = a?.tokens?.['pi-ask'];
293
+ return typeof v === 'string' && v ? v : null;
294
+ } catch {
295
+ return null;
296
+ }
297
+ }
298
+
299
+ /** A2/B1 探活:pane 实时状态 + 会话候选最新 mtime(毫秒级,失败字段为 null)。 */
300
+ async function probeAlive(paneId: string, cwd: string): Promise<AliveProbe> {
301
+ const probe: AliveProbe = { paneExists: false, agentStatus: null, lastActivityMs: null };
302
+ try {
303
+ const agents = await client.listAgents();
304
+ const a = agents.find((x) => x.paneId === paneId);
305
+ probe.paneExists = a != null;
306
+ probe.agentStatus = a?.status ?? null;
307
+ } catch {
308
+ /* agent.list 失败 → 状态未知,靠会话活动判定 */
309
+ }
310
+ for (const file of await resolveSessionFileCandidates(paneId, cwd)) {
311
+ try {
312
+ const mtime = (await statAsync(file)).mtimeMs;
313
+ if (probe.lastActivityMs == null || mtime > probe.lastActivityMs) probe.lastActivityMs = mtime;
314
+ } catch {
315
+ /* 文件消失跳过 */
316
+ }
317
+ }
318
+ return probe;
319
+ }
320
+
256
321
  /**
257
322
  * 子会话结算状态(v1.3 M8 结算竞态修复):
258
323
  * herdr 的 idle/done 在"注入瞬间"也是真,不能当结算信号;
@@ -275,21 +340,6 @@ export default function subagentPlugin(ctx: Context): void {
275
340
  return { text: null, pendingTool: false, activity: false };
276
341
  }
277
342
 
278
- /** 等待结算:idle/done=settled、blocked=人类闸门、超时切片循环。 */
279
- async function waitSubSettled(
280
- paneId: string,
281
- timeoutMs: number,
282
- ): Promise<'settled' | 'blocked' | 'timeout'> {
283
- const deadline = Date.now() + timeoutMs;
284
- while (Date.now() < deadline) {
285
- const state = await client.waitAgent(paneId, ['idle', 'done', 'blocked'], 30000);
286
- if (state === 'idle' || state === 'done') return 'settled';
287
- if (state === 'blocked') return 'blocked';
288
- // null = 30s 切片超时(working 中或异常),继续
289
- }
290
- return 'timeout';
291
- }
292
-
293
343
  /** D92:结算通知统一出口——经 deps 缓冲(忙时 turn_end 折叠注入),缺省回退旧 followUp 直投。 */
294
344
  const injectNotice = (content: string): Promise<void> =>
295
345
  d.deliverNotice ? d.deliverNotice(content)
@@ -360,7 +410,22 @@ export default function subagentPlugin(ctx: Context): void {
360
410
  } catch {
361
411
  state = null;
362
412
  }
363
- if (state === 'blocked') continue; // 人类闸门:保持 running,通知钩子已触发
413
+ if (state === 'blocked') {
414
+ // E2(人类闸门):blocked 时 master 对话流里也要知道(此前只有 herdr 系统
415
+ // 通知发给人类,master 毫不知情 → 可能自作主张揽活)。一次性 gate 通知注入;
416
+ // 解除(idle)后重置,二次 ask 新问题可再通知。
417
+ if (!blockedGateNotified.has(paneId)) {
418
+ blockedGateNotified.add(paneId);
419
+ const question = await readAskFlag(paneId);
420
+ try {
421
+ await injectNotice(buildBlockedGateNotice({ paneId, description, question }));
422
+ } catch {
423
+ /* 注入失败静默(下次 turn 可 list_agents 自查) */
424
+ }
425
+ }
426
+ continue; // 人类闸门:保持 running
427
+ }
428
+ if (state !== null) blockedGateNotified.delete(paneId); // 闸门解除(working/idle)
364
429
  if (state === 'idle' || state === 'done') {
365
430
  const s = await subSessionState(paneId, cwd, injectTs);
366
431
  if (s.text || (!s.pendingTool && s.activity)) {
@@ -961,52 +1026,69 @@ export default function subagentPlugin(ctx: Context): void {
961
1026
  entry.sessionFile = await resolveSessionFile(paneId, cwd);
962
1027
  subs.set(paneId, entry);
963
1028
  persistSubs();
964
- writeHistory(entry);
965
- onUpdate?.(makeProgressUpdate(`subagent ready in pane ${paneId}; injecting prompt via pipe…`));
966
1029
  // M11(D45/D46):注入走扩展管道 → 子扩展 sendUserMessage(followUp);
967
- // PTY 键盘通道 100% 归人(无软锁窗口、无混行)。
968
- const injectTs = Date.now();
969
- const injected = await pipeRequest(pipeNameFor(cwd, paneId), {
970
- type: 'prompt',
971
- id: `prompt-${taskId}`,
972
- text: spec.prompt,
973
- from: pipeNameFor(cwd, env?.paneId ?? ''),
974
- push: background,
975
- });
976
- if (injected.type !== 'ok') {
977
- throw new Error(`pipe prompt rejected: ${injected.type === 'error' ? injected.message : 'unknown response'}`);
978
- }
979
-
980
- if (background) {
981
- lastRequestIdByPane.set(paneId, `prompt-${taskId}`);
982
- startPoller(paneId, cwd, spawnedAt, injectTs, spec.description, `prompt-${taskId}`);
983
- return {
984
- content: [{ type: 'text', text: `started subagent ${paneId} (task ${taskId})` }],
985
- details: { paneId, taskId, background: true, role: kind },
986
- };
987
- }
988
-
989
- let settled = await waitSubSettled(paneId, SUBAGENT_TIMEOUT_MS);
990
- let text = await collectFinalText(paneId, cwd, injectTs);
991
- // v1.3 M8:早结算竞态(注入瞬间 herdr 仍是 idle)→ 无文本时再等真实结算(最多 90s)
992
- if (settled === 'settled' && !text) {
993
- const reDeadline = Date.now() + 90000;
994
- while (Date.now() < reDeadline) {
995
- await new Promise((r) => setTimeout(r, 3000));
996
- const st = await client.waitAgent(paneId, ['idle', 'done', 'blocked'], 15000);
997
- if (st === 'blocked') {
998
- settled = 'blocked';
999
- break;
1000
- }
1001
- if (st === 'idle' || st === 'done') {
1002
- text = await collectFinalText(paneId, cwd, injectTs);
1003
- if (text) break;
1030
+ // A1+A2(用户实证修复):前台等待 = 内容闸 + 耐心阈值转后台。
1031
+ // 旧实现的 idle 即结算 + 90s 硬窗口在真实任务(working 期 4-6 分钟)上必然误判
1032
+ // no-output(实测:3 个健康子代理 101s 时被同时 consumed,成果 4 分钟后才产出)。
1033
+ const PATIENCE_MS = Number(process.env.PI_HERDR_FRONTIER_PATIENCE_MS ?? 300000) || 300000;
1034
+ const patienceDeadline = Date.now() + PATIENCE_MS;
1035
+ let text: string | null = null;
1036
+ let settledKind: 'settled' | 'timeout' = 'timeout';
1037
+ while (Date.now() < Math.min(patienceDeadline, spawnedAt + SUBAGENT_TIMEOUT_MS)) {
1038
+ const state = await client.waitAgent(paneId, ['idle', 'done', 'blocked'], 30_000);
1039
+ if (state === 'blocked') {
1040
+ // E1(人类闸门):blocked 不是终态——转后台 poller(人类答后续跑、结算自动通知),
1041
+ // 返回 gate 通知:不揽活、提醒用户去 pane 答题、授权例外经 send_message 转达。
1042
+ const question = await readAskFlag(paneId);
1043
+ entry.background = true;
1044
+ lastRequestIdByPane.set(paneId, `prompt-${taskId}`);
1045
+ startPoller(paneId, cwd, spawnedAt, injectTs, spec.description, `prompt-${taskId}`);
1046
+ persistSubs();
1047
+ writeHistory(entry);
1048
+ return {
1049
+ content: [{ type: 'text', text: buildBlockedGateNotice({ paneId, description: spec.description, question }) }],
1050
+ details: { paneId, taskId, background: true, blocked: true, role: kind },
1051
+ };
1052
+ }
1053
+ if (state === 'idle' || state === 'done') {
1054
+ // A1 内容闸:herdr idle 只是"可能是结算"——以会话内容定夺
1055
+ const s = await subSessionState(paneId, cwd, injectTs);
1056
+ if (s.text) { text = s.text; settledKind = 'settled'; break; }
1057
+ if (s.pendingTool || (!s.text && !s.activity)) {
1058
+ // 挂起 toolCall(等人类)或注入前瞬间的 idle → 不当结算,继续观察
1059
+ await new Promise((r) => setTimeout(r, 2000));
1060
+ continue;
1004
1061
  }
1062
+ // 有活动无文本(活动已停止)→ 再给 collectFinalText 的重试窗口
1063
+ text = await collectFinalText(paneId, cwd, injectTs, 6);
1064
+ if (text) { settledKind = 'settled'; break; }
1065
+ await new Promise((r) => setTimeout(r, 2000));
1066
+ continue;
1005
1067
  }
1068
+ // null = 30s 切片超时(working 中)→ 继续循环
1069
+ }
1070
+ // A2 耐心阈值到点仍在跑 → 探活;活着 → 转后台并返回统一存活通知
1071
+ if (!text && settledKind !== 'blocked') {
1072
+ const probe = await probeAlive(paneId, cwd);
1073
+ if (isAlive(probe, Date.now())) {
1074
+ entry.background = true;
1075
+ lastRequestIdByPane.set(paneId, `prompt-${taskId}`);
1076
+ startPoller(paneId, cwd, spawnedAt, injectTs, spec.description, `prompt-${taskId}`);
1077
+ persistSubs();
1078
+ const notice = buildAliveNotice(
1079
+ { paneId, description: spec.description, scenario: 'moved-to-bg', probe },
1080
+ Date.now(),
1081
+ );
1082
+ return {
1083
+ content: [{ type: 'text', text: notice }],
1084
+ details: { paneId, taskId, background: true, movedToBackground: true, role: kind },
1085
+ };
1086
+ }
1087
+ // 探活失败(pane 消失且会话沉寂)→ 保留原 timeout 语义
1088
+ settledKind = 'timeout';
1006
1089
  }
1007
1090
  const outcome =
1008
- settled === 'blocked' ? { kind: 'blocked' as const, text: text ?? '' }
1009
- : settled === 'timeout' ? { kind: 'timeout' as const, text: text ?? '' }
1091
+ settledKind === 'timeout' ? { kind: 'timeout' as const, text: text ?? '' }
1010
1092
  : text ? { kind: 'completed' as const, text }
1011
1093
  : { kind: 'no-output' as const, text: '' };
1012
1094
  entry.status = 'consumed';
@@ -1138,7 +1220,7 @@ export default function subagentPlugin(ctx: Context): void {
1138
1220
  name: 'list_agents',
1139
1221
  label: 'List Subagents',
1140
1222
  description:
1141
- 'List the background subagents you started, with their live state: running (working or blocked), idle (settled), plus pane ids, role (task or a role label), and descriptions. Foreground one-shot panes are transient and not listed. Covers only this session branch — for tasks from earlier sessions or collected panes, read the delegation ledger (path and format in resume_subagent) and resume by taskId.',
1223
+ 'List the background subagents you started, with their live state: running (working or blocked), idle (settled), plus pane ids, live agent status, last session activity time, role (task or a role label), and descriptions. Foreground one-shot panes are transient and not listed. Covers only this session branch — for tasks from earlier sessions or collected panes, read the delegation ledger (path and format in resume_subagent) and resume by taskId.',
1142
1224
  parameters: Type.Object({}),
1143
1225
  async execute() {
1144
1226
  const listed = [...subs.values()].filter((s) => s.background);
@@ -1151,6 +1233,12 @@ export default function subagentPlugin(ctx: Context): void {
1151
1233
  const prev = byTask.get(sub.taskId);
1152
1234
  if (!prev || sub.createdAt >= prev.createdAt) byTask.set(sub.taskId, sub);
1153
1235
  }
1236
+ // C1:实时探活(pane 状态 + 会话活动时间)——master 能区分"在干活"vs"真卡死"
1237
+ const probes = new Map<string, AliveProbe>();
1238
+ await Promise.all([...byTask.values()].map(async (sub) => {
1239
+ if (sub.status === 'closed') return;
1240
+ try { probes.set(sub.paneId, await probeAlive(sub.paneId, sub.cwd)); } catch { /* 显示层尽力 */ }
1241
+ }));
1154
1242
  const lines: string[] = [];
1155
1243
  for (const sub of byTask.values()) {
1156
1244
  const tabTag = sub.tabName ? ` [tab: ${sub.tabName}]` : '';
@@ -1162,7 +1250,16 @@ export default function subagentPlugin(ctx: Context): void {
1162
1250
  const state = sub.status === 'settled' ? 'idle' : 'running';
1163
1251
  // D94:用户接管标记
1164
1252
  const takeoverMark = sub.userTakeover ? ', user-controlled' : '';
1165
- lines.push(`${sub.paneId} [${state}${takeoverMark}] (${sub.kind})${tabTag} ${sub.description}`);
1253
+ const probe = probes.get(sub.paneId);
1254
+ const statusTag = probe?.agentStatus ? ` ${probe.agentStatus}` : '';
1255
+ const activityTag = probe?.lastActivityMs != null ? `, active ${agoText(probe.lastActivityMs, Date.now())}` : '';
1256
+ // E:blocked 时附上闸门问题摘要(提示 master 该叫用户去 pane,不是自己接手)
1257
+ let gateTag = '';
1258
+ if (probe?.agentStatus === 'blocked') {
1259
+ const q = await readAskFlag(sub.paneId);
1260
+ gateTag = q ? ` — AWAITING HUMAN: "${q}"` : ' — AWAITING HUMAN decision';
1261
+ }
1262
+ lines.push(`${sub.paneId} [${state}${takeoverMark}${statusTag}${activityTag}${gateTag}] (${sub.kind})${tabTag} ${sub.description}`);
1166
1263
  }
1167
1264
  return { content: [{ type: 'text', text: lines.join('\n') }], details: {} };
1168
1265
  },
package/src/core/todo.ts CHANGED
@@ -22,8 +22,8 @@ import { makeProgressUpdate } from '../subagent-core.ts';
22
22
  import { TODO_DETAILS_KEY, TODO_TOOL_NAME, formatTodoConfirmation, type TodoItem, type TodoStatus } from '../vocab.ts';
23
23
  import {
24
24
  TODO_EDIT_CUSTOM_TYPE,
25
- boundedView,
26
25
  completionTransitions,
26
+ countTodos,
27
27
  fuzzyFind,
28
28
  listsEqual,
29
29
  makeSnapshot,
@@ -65,17 +65,11 @@ const TODO_MARKS: Record<TodoStatus, string> = {
65
65
  abandoned: '✗',
66
66
  };
67
67
 
68
- function widgetLines(items: TodoItem[]): string[] {
69
- if (items.length === 0) return [];
70
- const counts = { pending: 0, inProgress: 0, completed: 0, blocked: 0 };
71
- for (const it of items) {
72
- if (it.status === 'pending') counts.pending++;
73
- else if (it.status === 'in_progress') counts.inProgress++;
74
- else if (it.status === 'completed') counts.completed++;
75
- else if (it.status === 'blocked') counts.blocked++;
76
- }
77
- const lines = [`todo: ${counts.inProgress}▶ ${counts.pending}○ ${counts.blocked}■ ${counts.completed}✓`];
78
- // D43 phase 分组 + D39 有界(每组预算 10 行)
68
+ /** pi TUI widget 硬上限(interactive-mode MAX_WIDGET_LINES=10,头部截断;我们自己控制在限内)。 */
69
+ export const WIDGET_MAX_LINES = 10;
70
+
71
+ /** 分组渲染(phase 头 + 条目行;不设预算,widget 与 /todos 共用)。 */
72
+ function renderGroups(items: readonly TodoItem[]): string[] {
79
73
  const groups = new Map<string, TodoItem[]>();
80
74
  for (const it of items) {
81
75
  const key = it.phase ?? '';
@@ -83,16 +77,44 @@ function widgetLines(items: TodoItem[]): string[] {
83
77
  arr.push(it);
84
78
  groups.set(key, arr);
85
79
  }
80
+ const lines: string[] = [];
86
81
  for (const [phase, list] of groups) {
87
82
  if (phase) lines.push(` [${phase}]`);
88
- const view = boundedView(list, 10);
89
- for (const it of view.visible) {
83
+ for (const it of list) {
90
84
  const suffix = it.status === 'blocked' && it.blocker ? ` — ${it.blocker}` : '';
91
85
  lines.push(` ${TODO_MARKS[it.status]} ${it.content}${suffix}`);
92
86
  }
93
- if (view.hiddenCompleted > 0 || view.hiddenOpen > 0) {
94
- lines.push(` +${view.hiddenCompleted + view.hiddenOpen} more (${view.hiddenCompleted} completed, ${view.hiddenOpen} open)`);
95
- }
87
+ }
88
+ return lines;
89
+ }
90
+
91
+ /**
92
+ * widget 行(全局预算版,用户实证修复):
93
+ * 旧实现按分组插入序逐组渲染(每组预算 10),pi 头部截 10 行 → 老 phase 吃光窗口、
94
+ * 最新任务组(in_progress 所在)整组不可见。新规则:
95
+ * - 全局预算 = WIDGET_MAX_LINES(含摘要行与 +N 行);
96
+ * - 超额丢弃顺序 = 最老 completed → 最老 open(尾部/当前工作永远存活);
97
+ * - +N 行指路 /todos(全量视图)。
98
+ */
99
+ export function widgetLines(items: readonly TodoItem[]): string[] {
100
+ if (items.length === 0) return [];
101
+ const c = countTodos(items);
102
+ const summary = `todo: ${c.inProgress}▶ ${c.pending}○ ${c.blocked}■ ${c.completed}✓`;
103
+
104
+ const kept = [...items];
105
+ while (
106
+ kept.length > 0
107
+ && 1 + renderGroups(kept).length + (items.length > kept.length ? 1 : 0) > WIDGET_MAX_LINES
108
+ ) {
109
+ const oldestCompleted = kept.findIndex((it) => it.status === 'completed');
110
+ kept.splice(oldestCompleted === -1 ? 0 : oldestCompleted, 1);
111
+ }
112
+
113
+ const hidden = items.filter((it) => !kept.includes(it));
114
+ const lines = [summary, ...renderGroups(kept)];
115
+ if (hidden.length > 0) {
116
+ const hiddenCompleted = hidden.filter((it) => it.status === 'completed').length;
117
+ lines.push(` +${hidden.length} hidden (${hiddenCompleted}✓) · /todos 全量`);
96
118
  }
97
119
  return lines;
98
120
  }
@@ -181,8 +203,8 @@ export default function todoPlugin(ctx: Context): void {
181
203
  Type.Literal('blocked'),
182
204
  Type.Literal('abandoned'),
183
205
  ], { description: 'pending | in_progress | completed | blocked | abandoned' }),
184
- blocker: Type.Optional(Type.String({ description: 'Only when status is "blocked": what it is waiting for' })),
185
- phase: Type.Optional(Type.String({ description: 'Optional group name (≤30 chars); omit for a flat list' })),
206
+ blocker: Type.Optional(Type.String({ description: 'Only when status is "blocked": what it is waiting for. Omit the field entirely (never an empty string) for other statuses' })),
207
+ phase: Type.Optional(Type.String({ description: 'Optional group name (≤30 chars); omit for a flat list (never an empty string)' })),
186
208
  },
187
209
  { additionalProperties: false },
188
210
  ),
@@ -290,9 +312,14 @@ export default function todoPlugin(ctx: Context): void {
290
312
  ui?.notify?.(`"${content}" ${verb}`, 'info');
291
313
  return;
292
314
  }
293
- const lines = widgetLines(todos.items);
294
- const text = lines.length ? lines.join('\n') : 'todo list is empty';
295
- ui?.notify?.(text, 'info');
315
+ // 全量视图(/todos 不受 widget 10 行预算限制——"看整个列表"的正式入口)
316
+ const body = renderGroups(todos.items);
317
+ if (body.length === 0) {
318
+ ui?.notify?.('todo list is empty', 'info');
319
+ return;
320
+ }
321
+ const c = countTodos(todos.items);
322
+ ui?.notify?.([`todo: ${c.inProgress}▶ ${c.pending}○ ${c.blocked}■ ${c.completed}✓`, ...body].join('\n'), 'info');
296
323
  },
297
324
  });
298
325
 
@@ -98,6 +98,76 @@ export function formatSubagentResult(outcome: SubagentOutcome, description: stri
98
98
  }
99
99
  }
100
100
 
101
+ /* ── 探活 + 存活通知(A2/B1 统一出口;纯逻辑,IO 在 adapter) ────── */
102
+
103
+ /** 探活快照:pane 实时状态 + 会话活动时间(毫秒 epoch;null = 未知)。 */
104
+ export interface AliveProbe {
105
+ paneExists: boolean;
106
+ agentStatus: string | null;
107
+ /** 会话文件 mtime(最新候选);无文件/不可读 = null。 */
108
+ lastActivityMs: number | null;
109
+ }
110
+
111
+ /** 探活判活:pane 在 +(working/blocked 或近期有会话活动)→ 视为还在干活。 */
112
+ export function isAlive(probe: AliveProbe, nowMs: number, staleAfterMs = 120_000): boolean {
113
+ if (!probe.paneExists) return false;
114
+ if (probe.agentStatus === 'working' || probe.agentStatus === 'blocked') return true;
115
+ // idle/unknown:会话 2 分钟内有活动仍算活(写作间隙/刚结算未采集)
116
+ return probe.lastActivityMs != null && nowMs - probe.lastActivityMs < staleAfterMs;
117
+ }
118
+
119
+ /** 人话相对时间("12s ago" / "3m ago")。 */
120
+ export function agoText(ms: number, nowMs: number): string {
121
+ const s = Math.max(0, Math.round((nowMs - ms) / 1000));
122
+ if (s < 60) return `${s}s ago`;
123
+ if (s < 3600) return `${Math.floor(s / 60)}m ago`;
124
+ return `${Math.floor(s / 3600)}h ago`;
125
+ }
126
+
127
+ /**
128
+ * 存活通知(A2 转后台返回值 与 B1 tool_result 改写 的统一构造出口):
129
+ * 两场景同一话术——"它在干活 / 别重做 / 转后台了 / 结算会通知 / list_agents 可查"。
130
+ * scenario: 'moved-to-bg'(耐心阈值转后台)| 'error-alive'(工具报错但探活判活,兜底改写)。
131
+ */
132
+ export function buildAliveNotice(
133
+ opts: { paneId: string; description: string; scenario: 'moved-to-bg' | 'error-alive'; probe: AliveProbe },
134
+ nowMs: number,
135
+ ): string {
136
+ const { paneId, description, scenario, probe } = opts;
137
+ const status = probe.agentStatus ?? 'unknown';
138
+ const activity = probe.lastActivityMs != null ? `, last session activity ${agoText(probe.lastActivityMs, nowMs)}` : '';
139
+ if (scenario === 'moved-to-bg') {
140
+ return [
141
+ `Subagent "${description}" is still running in pane ${paneId} (agent_status=${status}${activity}).`,
142
+ 'The foreground wait has moved it to background so this call returns now.',
143
+ 'Do NOT redo its work. When it settles you will receive a notification with its closing output.',
144
+ 'Use list_agents to check its live state; send_message to give it follow-up work.',
145
+ ].join(' ');
146
+ }
147
+ return [
148
+ `The subagent "${description}" is ALIVE in pane ${paneId} (agent_status=${status}${activity}) — this "no readable output" result only means its final answer could not be read yet, NOT that the task failed.`,
149
+ 'Do NOT redo its work. It is being moved to background; you will be notified when it settles.',
150
+ 'Use list_agents to check its live state.',
151
+ ].join(' ');
152
+ }
153
+
154
+ /**
155
+ * 人类闸门通知(E1/E2 统一出口):subagent blocked(ask_user_question 等人类)时
156
+ * 给 master 的话术——不揽活、引导用户去 pane、授权例外经 send_message 转达。
157
+ * question 来自 herdr tokens['pi-ask'](子扩展 reportAskFlag 上报)。
158
+ */
159
+ export function buildBlockedGateNotice(opts: { paneId: string; description: string; question: string | null }): string {
160
+ const { paneId, description, question } = opts;
161
+ const q = question ? ` (question: "${question}")` : '';
162
+ return [
163
+ `Subagent "${description}" is BLOCKED waiting for a HUMAN decision in pane ${paneId}${q}.`,
164
+ 'It keeps running in background; once the human answers it resumes, and you will be notified when it settles.',
165
+ 'Do NOT take over its work and do NOT answer on the human\'s behalf.',
166
+ 'Tell the user to open that pane in herdr and answer directly.',
167
+ 'Only if the user explicitly authorizes you: relay their decision to the subagent via send_message — do not redo the work yourself.',
168
+ ].join(' ');
169
+ }
170
+
101
171
  /* ── 子代理注册表(custom 条目持久化) ────────────────────────────── */
102
172
 
103
173
  export const SUBS_CUSTOM_TYPE = 'pi-herdr.subs';
package/src/todo-core.ts CHANGED
@@ -93,20 +93,31 @@ export function validateTodos(
93
93
  const item: TodoItem = { content, status: status as TodoStatus };
94
94
  if ('blocker' in entry) {
95
95
  const blocker = entry.blocker;
96
- if (status !== 'blocked') {
97
- return { ok: false, error: `invalid todo: \`blocker\` is only allowed when status is "blocked" (got "${status}")` };
98
- }
99
- if (typeof blocker !== 'string' || blocker.trim() === '') {
96
+ // 容忍归一(用户实证修复):Optional 字段用空串/null 填充是常见模型行为
97
+ //(grok 实测 9 连拒死循环);status 是权威——非 blocked 态的 blocker 一律视为
98
+ // 缺省丢弃(无论是否有值),空值同样丢弃。仅类型畸形仍硬拒。
99
+ if (blocker != null && typeof blocker !== 'string') {
100
100
  return { ok: false, error: 'invalid todo: `blocker` must be a non-empty string' };
101
101
  }
102
- item.blocker = blocker;
102
+ if (status === 'blocked') {
103
+ if (typeof blocker !== 'string' || blocker.trim() === '') {
104
+ return { ok: false, error: 'invalid todo: `blocker` must be a non-empty string' };
105
+ }
106
+ item.blocker = blocker;
107
+ }
103
108
  }
104
109
  if ('phase' in entry) {
105
110
  const phase = entry.phase;
106
- if (typeof phase !== 'string' || phase.trim() === '' || phase.length > PHASE_MAX_LEN) {
111
+ if (phase != null && typeof phase !== 'string') {
107
112
  return { ok: false, error: `invalid todo: \`phase\` must be a non-empty string ≤ ${PHASE_MAX_LEN} chars` };
108
113
  }
109
- item.phase = phase;
114
+ // 空串/纯空白 = 缺省(实证循环里 phase:"" 同样是被掩盖的雷);超长是真实约束仍硬拒
115
+ if (typeof phase === 'string') {
116
+ if (phase.length > PHASE_MAX_LEN) {
117
+ return { ok: false, error: `invalid todo: \`phase\` must be a non-empty string ≤ ${PHASE_MAX_LEN} chars` };
118
+ }
119
+ if (phase.trim() !== '') item.phase = phase;
120
+ }
110
121
  }
111
122
  if (status === 'in_progress') {
112
123
  inProgress++;
@@ -298,7 +309,7 @@ export function fuzzyFind(items: readonly TodoItem[], query: string): string[] {
298
309
  return substr.map((it) => it.content);
299
310
  }
300
311
 
301
- /** D39:有界视图选择——先丢 completed、再截断尾部;返回可见条目与摘要。 */
312
+ /** D39:有界视图选择——先丢 completed、再截 open;两端都超额时保最新(尾部切片)。 */
302
313
  export interface BoundedView {
303
314
  visible: TodoItem[];
304
315
  /** 被隐藏的数量按状态计数(completed 单独计,其余合并)。 */
@@ -312,15 +323,18 @@ export function boundedView(items: readonly TodoItem[], budget: number): Bounded
312
323
  }
313
324
  const completed = items.filter((it) => it.status === 'completed');
314
325
  const open = items.filter((it) => it.status !== 'completed');
315
- let visible = open.slice(0, budget);
326
+ // 修正(用户实证):widget 是头部可见窗口——超预算必须隐最老(丢头部),保最新可见;
327
+ // 旧实现 slice(0, budget) 保最老,最新任务永远落在窗口外。
328
+ const visible = open.length > budget ? open.slice(-budget) : open;
316
329
  const hiddenOpen = open.length - visible.length;
317
330
  let hiddenCompleted = completed.length;
331
+ let final = visible;
318
332
  if (hiddenOpen === 0 && visible.length < budget) {
319
333
  const room = budget - visible.length;
320
- visible = [...visible, ...completed.slice(0, room)];
334
+ final = [...visible, ...completed.slice(-room)];
321
335
  hiddenCompleted = completed.length - room;
322
336
  }
323
- return { visible, hiddenCompleted, hiddenOpen };
337
+ return { visible: final, hiddenCompleted, hiddenOpen };
324
338
  }
325
339
 
326
340
  /** 当前 in_progress 中的第一条(上报 agent 状态时作为 message,供侧边栏显示"正在做什么")。 */