pi-pier 0.1.2 → 0.1.4

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.
@@ -21,9 +21,9 @@ 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, buildLaunchParts, 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
- import { appendHistory, applyReportedSessionFile, historyFilePath, latestGeneration, normalizeEntryKind, readHistory, type HistoryEntry } from '../history-store.ts';
26
+ import { appendHistory, applyReportedSessionFile, historyFilePath, inheritOutcome, latestGeneration, normalizeEntryKind, readHistory, type HistoryEntry } from '../history-store.ts';
27
27
  import { execFile } from 'node:child_process';
28
28
  import { stat as statCb } from 'node:fs';
29
29
  import { resolve as pathResolve } from 'node:path';
@@ -33,7 +33,7 @@ import { pingUntilReady, pipeNameFor, pipeRequest } from '../pipe-channel.ts';
33
33
  import { shouldClosePane, shouldCloseTaskTab } from '../gc-core.ts';
34
34
  import { composeForRole } from '../manifest-compose.ts';
35
35
  import { formatSettlementNotice } from '../vocab.ts';
36
- import { parseShapeTree, pickGridSplit } from './grid-shape.ts';
36
+ import { ABORT_STOP_REASON } from '../settle-wake-core.ts';
37
37
 
38
38
  export interface SubagentEnv {
39
39
  paneId: string;
@@ -66,9 +66,10 @@ export interface SubagentDeps {
66
66
  * D92 结算通知注入器(index.ts 提供):忙时入缓冲、turn_end 折叠 steer 批量注入。
67
67
  * 缺省回退 pi.sendUserMessage(followUp)(旧路径——整个 run 结束才回填,测试桩用)。
68
68
  */
69
- deliverNotice?: (content: string) => Promise<void>;
69
+ deliverNotice?: (content: string, paneId?: string) => Promise<void>;
70
+ /** B2:结算通知尚在缓冲未送达的 pane 集合(GC 豁免——先送达再回收)。 */
71
+ noticePending?: () => ReadonlySet<string>;
70
72
  /** terminal 族 GC 豁免槽。 */
71
- terminalState: { activePaneIds: () => Set<string> };
72
73
  todos: TodosService;
73
74
  }
74
75
 
@@ -84,10 +85,18 @@ const SUBAGENT_DESCRIPTION = [
84
85
 
85
86
  /** 子代理并发上限(D10)。 */
86
87
  const SUBAGENT_CONCURRENCY = 4;
87
- /** 单子代理等待上限(内部 marker PI_HERDR_SUBAGENT_TIMEOUT_MS 可调,默认 10 分钟)。 */
88
+ /** B1:观察超时 = 无活动时长预算(非总墙钟)——working 切片持续续命;marker
89
+ * PI_HERDR_SUBAGENT_TIMEOUT_MS 沿用旧名,语义已从「poller 起算总墙钟」改为
90
+ * 「距最近一次活动」——>10min 的健康长任务不再被误杀(session 01a03c0d 实证)。 */
88
91
  const SUBAGENT_TIMEOUT_MS = Number(process.env.PI_HERDR_SUBAGENT_TIMEOUT_MS ?? 600000) || 600000;
89
92
  /** 就绪等待上限(TUI 启动 + herdr 检测)。 */
90
93
  const SUB_READY_TIMEOUT_MS = 30000;
94
+ /** D94/B4:结算观察窗(此期内 working 默认视为用户接管)。 */
95
+ const OBSERVE_WINDOW_MS = Number(process.env.PI_HERDR_OBSERVE_MS ?? 30000) || 30000;
96
+ /** B4:机器注入宽限——观察窗内的 working 若由自己的 prompt/follow_up 触发,不算接管。 */
97
+ const MACHINE_INJECT_GRACE_MS = Number(process.env.PI_HERDR_INJECT_GRACE_MS ?? 30000) || 30000;
98
+ /** D94:接管态归还阈值(持续 idle 即归还)。 */
99
+ const TAKEOVER_IDLE_MS = Number(process.env.PI_HERDR_TAKEOVER_IDLE_MS ?? 60000) || 60000;
91
100
 
92
101
  function defaultAgentSessionsDir(): string {
93
102
  const base = process.env.PI_CODING_AGENT_DIR || join(homedir(), '.pi', 'agent');
@@ -114,7 +123,9 @@ export default function subagentPlugin(ctx: Context): void {
114
123
  extPath: d.extPath,
115
124
  };
116
125
  const subSemaphore = new Semaphore(SUBAGENT_CONCURRENCY);
117
-
126
+ /** B4:每 pane 最近一次机器注入(prompt/follow_up pipe 投递成功)时间——观察窗内的
127
+ * working 若发生在宽限期内,是自己的注入在被处理,不是用户接管。 */
128
+ const lastMachineInjectAt = new Map<string, number>();
118
129
  /* 子代理注册表(custom 条目持久化,分支正确;parent 重启后可从分支重建) */
119
130
  const subs = new Map<string, SubEntry>();
120
131
  const pollers = new Set<string>();
@@ -122,13 +133,22 @@ export default function subagentPlugin(ctx: Context): void {
122
133
  /** D50:每 pane 最近一次机器请求 id(interrupt 按轮次占位、pollLoop 去重用)。 */
123
134
  const lastRequestIdByPane = new Map<string, string>();
124
135
 
136
+ /** O1:上次落盘快照(内容哈希门控——01a03c0d 实证 48% session 行是心跳期重复快照)。 */
137
+ let lastSubsSnapshot = '';
138
+
125
139
  function persistSubs(): void {
126
140
  try {
127
- pi.appendEntry?.(SUBS_CUSTOM_TYPE, makeRegistry([...subs.values()]));
141
+ const reg = makeRegistry([...subs.values()]);
142
+ const snap = JSON.stringify(reg);
143
+ if (snap === lastSubsSnapshot) return; // 内容未变不落(1s 观察窗/5s 接管循环的重复心跳)
144
+ lastSubsSnapshot = snap;
145
+ pi.appendEntry?.(SUBS_CUSTOM_TYPE, reg);
128
146
  } catch {
129
147
  /* 持久化尽力而为 */
130
148
  }
131
149
  }
150
+ /** E2:已发过闸门通知的 pane(blocked 期间去重;解除后移除,二次 ask 可再通知)。 */
151
+ const blockedGateNotified = new Set<string>();
132
152
 
133
153
  // slots 回填:common 段 pipe reply 消费
134
154
  slots.applyReplySession = (paneId, sessionFile) => {
@@ -138,7 +158,7 @@ export default function subagentPlugin(ctx: Context): void {
138
158
  if (next === entry.sessionFile) return;
139
159
  entry.sessionFile = next;
140
160
  persistSubs();
141
- writeHistory(entry);
161
+ writeHistory(entry, undefined, 'session-report');
142
162
  };
143
163
  slots.reconcileOnReply = (paneId) => {
144
164
  const entry = subs.get(paneId);
@@ -157,23 +177,88 @@ export default function subagentPlugin(ctx: Context): void {
157
177
  ?.sessionManager?.getBranch?.() ?? [];
158
178
  const reg = foldSubsRegistry(entries as Parameters<typeof foldSubsRegistry>[0]);
159
179
  for (const sub of reg.subs) subs.set(sub.paneId, sub);
180
+ lastSubsSnapshot = ''; // 分支回放改变了状态 → 下次 persistSubs 强制落盘
160
181
  } catch {
161
182
  /* 重建失败不影响主流程 */
162
183
  }
163
184
  }
164
185
 
186
+ /** B6:master 崩溃遗留的 running 行——按 herdr 实况补 closed(pane 没了才关,
187
+ * pane 还在的真在跑则保留)。01a03c0d 台账实证:p6/p7 永久 running 无回收。 */
188
+ async function sweepZombieRunning(): Promise<void> {
189
+ if (!client.available || subs.size === 0) return;
190
+ let livePaneIds: ReadonlySet<string>;
191
+ try {
192
+ livePaneIds = new Set((await client.listPanes()).map((p) => p.paneId));
193
+ } catch {
194
+ return; // 查询失败不误关
195
+ }
196
+ let changed = false;
197
+ for (const [paneId, e] of subs) {
198
+ if (e.status !== 'running' || livePaneIds.has(paneId)) continue;
199
+ e.status = 'closed';
200
+ writeHistory(e, { status: 'closed', closedAt: Date.now() }, 'zombie-sweep');
201
+ changed = true;
202
+ }
203
+ if (changed) persistSubs();
204
+ }
205
+
165
206
  scoped.on('session_start', async (_event: unknown, eventCtx: unknown) => {
166
207
  rebuildSubs(eventCtx);
208
+ await sweepZombieRunning();
167
209
  });
168
210
  scoped.on('session_tree', async (_event: unknown, eventCtx: unknown) => {
169
211
  rebuildSubs(eventCtx);
212
+ await sweepZombieRunning();
213
+ });
214
+
215
+ /* ── B1(用户实证修复):subagent isError 结果的探活改写 ──────────────
216
+ * 实测事故:前台误判 no-output → 模型读裸错误文案 → "failed, 我自己干" 抢活。
217
+ * pi 的 tool_result 事件官方语义 "Can modify result"——在文本进模型上下文前,
218
+ * 当场探活(agent.list + 会话 mtime);判活则改写为统一存活通知(buildAliveNotice
219
+ * 与 A2 转后台同一出口)。hook 层硬保障,纯 prompt 防不住的自作主张在此物理拦截。 */
220
+ scoped.on('tool_result', async (event: { toolName?: string; toolCallId?: string; isError?: boolean; content?: Array<{ type: string; text?: string }> }) => {
221
+ if (event?.toolName !== 'subagent' || !event.isError) return;
222
+ // 从错误文本里取 pane id(我们自己的错误文案都带 pane);取不到则不改写
223
+ const errText = (event.content ?? []).map((c) => c.text ?? '').join(' ');
224
+ const paneId = [...subs.keys()].find((id) => errText.includes(id))
225
+ ?? (errText.match(/\bw[A-Za-z0-9]+:p\d+\b/) ?? [])[0];
226
+ if (!paneId) return;
227
+ const entry = subs.get(paneId);
228
+ if (!entry || entry.status === 'settled' || entry.status === 'consumed') return;
229
+ const probe = await probeAlive(paneId, entry.cwd);
230
+ if (!isAlive(probe, Date.now())) return; // 真死了 → 保留原错误,模型可重做
231
+ // 活着 → 转 poller(若尚未在跑)并改写结果文本
232
+ if (!pollers.has(paneId)) {
233
+ entry.background = true;
234
+ startPoller(paneId, entry.cwd, entry.createdAt, entry.createdAt, entry.description, lastRequestIdByPane.get(paneId) ?? `probe-${paneId}`);
235
+ persistSubs();
236
+ }
237
+ const notice = buildAliveNotice(
238
+ { paneId, description: entry.description, scenario: 'error-alive', probe },
239
+ Date.now(),
240
+ );
241
+ return { content: [{ type: 'text', text: notice }] };
170
242
  });
171
243
 
172
244
  /* ── D41:stop 未完成提醒(仅主控;子代理不注册本块) ── */
173
245
 
174
246
  let todoReminders = 0;
175
247
  const TODO_REMINDERS_MAX = 3;
248
+ // 反唤醒风暴:用户 ESC 中止后的 settled 不催(否则 abort → 提醒 → 新 run → 再 abort
249
+ // 死循环;与 index 的 D96/结算缓冲同款守卫,session 01a03bf0 实证)。
250
+ let lastAssistantStopReason: string | null = null;
251
+ scoped.on('turn_end', async (event: unknown) => {
252
+ if (event === null || typeof event !== 'object' || !('message' in event)) return;
253
+ const msg = (event as { message: unknown }).message; // 'message' in 已守卫
254
+ if (msg === null || typeof msg !== 'object') return;
255
+ const { role, stopReason } = msg as { role?: unknown; stopReason?: unknown };
256
+ if (role === 'assistant' && typeof stopReason === 'string') {
257
+ lastAssistantStopReason = stopReason;
258
+ }
259
+ });
176
260
  scoped.on('agent_settled', async () => {
261
+ if (lastAssistantStopReason === ABORT_STOP_REASON) return;
177
262
  if (todoReminders >= TODO_REMINDERS_MAX) return;
178
263
  if (pollers.size > 0) return; // 有在途子代理 → 主控本来就在等,不催
179
264
  if (d.getBlockedDepth() > 0) return; // 等人类回答 → 不催
@@ -253,6 +338,39 @@ export default function subagentPlugin(ctx: Context): void {
253
338
  return null;
254
339
  }
255
340
 
341
+ /** E1/E2:读子 pane 的人类闸门问题(tokens['pi-ask'];子扩展 reportAskFlag 上报)。 */
342
+ async function readAskFlag(paneId: string): Promise<string | null> {
343
+ try {
344
+ const a = (await client.listAgents()).find((x) => x.paneId === paneId);
345
+ const v = a?.tokens?.['pi-ask'];
346
+ return typeof v === 'string' && v ? v : null;
347
+ } catch {
348
+ return null;
349
+ }
350
+ }
351
+
352
+ /** A2/B1 探活:pane 实时状态 + 会话候选最新 mtime(毫秒级,失败字段为 null)。 */
353
+ async function probeAlive(paneId: string, cwd: string): Promise<AliveProbe> {
354
+ const probe: AliveProbe = { paneExists: false, agentStatus: null, lastActivityMs: null };
355
+ try {
356
+ const agents = await client.listAgents();
357
+ const a = agents.find((x) => x.paneId === paneId);
358
+ probe.paneExists = a != null;
359
+ probe.agentStatus = a?.status ?? null;
360
+ } catch {
361
+ /* agent.list 失败 → 状态未知,靠会话活动判定 */
362
+ }
363
+ for (const file of await resolveSessionFileCandidates(paneId, cwd)) {
364
+ try {
365
+ const mtime = (await statAsync(file)).mtimeMs;
366
+ if (probe.lastActivityMs == null || mtime > probe.lastActivityMs) probe.lastActivityMs = mtime;
367
+ } catch {
368
+ /* 文件消失跳过 */
369
+ }
370
+ }
371
+ return probe;
372
+ }
373
+
256
374
  /**
257
375
  * 子会话结算状态(v1.3 M8 结算竞态修复):
258
376
  * herdr 的 idle/done 在"注入瞬间"也是真,不能当结算信号;
@@ -266,8 +384,8 @@ export default function subagentPlugin(ctx: Context): void {
266
384
  ): Promise<{ text: string | null; pendingTool: boolean; activity: boolean }> {
267
385
  for (const file of await resolveSessionFileCandidates(paneId, cwd)) {
268
386
  const entries = readSessionFile(file);
269
- if (!entries) continue;
270
- const r = lastAssistantText(entries, { sinceTs });
387
+ if (entries.length === 0) continue;
388
+ const r = lastAssistantText(entries, sinceTs);
271
389
  if (r?.text) return { text: r.text, pendingTool: false, activity: true };
272
390
  if (hasPendingToolCall(entries, sinceTs)) return { text: null, pendingTool: true, activity: true };
273
391
  if (hasAssistantAfter(entries, sinceTs)) return { text: null, pendingTool: false, activity: true };
@@ -275,21 +393,6 @@ export default function subagentPlugin(ctx: Context): void {
275
393
  return { text: null, pendingTool: false, activity: false };
276
394
  }
277
395
 
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
396
  /** D92:结算通知统一出口——经 deps 缓冲(忙时 turn_end 折叠注入),缺省回退旧 followUp 直投。 */
294
397
  const injectNotice = (content: string): Promise<void> =>
295
398
  d.deliverNotice ? d.deliverNotice(content)
@@ -305,7 +408,11 @@ export default function subagentPlugin(ctx: Context): void {
305
408
  requestId: string,
306
409
  ): Promise<void> {
307
410
  void spawnedAt;
411
+ // B1:无活动预算锚点——working 切片(waitAgent 超时 null)即心跳(O3):每次续命;
412
+ // 只有「不 working 也无结算进展」的真空期才消耗预算(01a03c0d:27min 健康长任务
413
+ // 被旧总墙钟 10min 误杀两次)。startedAt 保留供通知文案诊断。
308
414
  const startedAt = Date.now();
415
+ let lastActivityAt = Date.now();
309
416
  try {
310
417
  while (true) {
311
418
  const entry = subs.get(paneId);
@@ -360,7 +467,22 @@ export default function subagentPlugin(ctx: Context): void {
360
467
  } catch {
361
468
  state = null;
362
469
  }
363
- if (state === 'blocked') continue; // 人类闸门:保持 running,通知钩子已触发
470
+ if (state === 'blocked') {
471
+ // E2(人类闸门):blocked 时 master 对话流里也要知道(此前只有 herdr 系统
472
+ // 通知发给人类,master 毫不知情 → 可能自作主张揽活)。一次性 gate 通知注入;
473
+ // 解除(idle)后重置,二次 ask 新问题可再通知。
474
+ if (!blockedGateNotified.has(paneId)) {
475
+ blockedGateNotified.add(paneId);
476
+ const question = await readAskFlag(paneId);
477
+ try {
478
+ await injectNotice(buildBlockedGateNotice({ paneId, description, question }));
479
+ } catch {
480
+ /* 注入失败静默(下次 turn 可 list_agents 自查) */
481
+ }
482
+ }
483
+ continue; // 人类闸门:保持 running
484
+ }
485
+ if (state !== null) blockedGateNotified.delete(paneId); // 闸门解除(working/idle)
364
486
  if (state === 'idle' || state === 'done') {
365
487
  const s = await subSessionState(paneId, cwd, injectTs);
366
488
  if (s.text || (!s.pendingTool && s.activity)) {
@@ -380,17 +502,29 @@ export default function subagentPlugin(ctx: Context): void {
380
502
  // 已在观察期
381
503
  const elapsed = Date.now() - entry.observationStartedAt;
382
504
 
383
- // 检测用户介入:查询当前状态是否变为 working
505
+ // 检测用户介入:查询当前状态是否变为 working。
506
+ // B4:观察窗内 working ≠ 必然用户接管——自己的 follow_up 注入也呈现
507
+ // working。距最近机器注入 < MACHINE_INJECT_GRACE_MS 的 working 视为
508
+ // 注入被处理(取消结算继续观察),否则才判接管(01a03c0d:契约消息
509
+ // 20min 后投递,恰好撞进观察窗)。
384
510
  try {
385
511
  const agents = await client.listAgents();
386
512
  const agent = agents.find((a) => a.paneId === paneId);
387
513
  if (agent && agent.status === 'working') {
388
- // 用户介入!标记接管,取消结算
389
- entry.userTakeover = true;
514
+ const injectedAgo = Date.now() - (lastMachineInjectAt.get(paneId) ?? 0);
515
+ if (injectedAgo > MACHINE_INJECT_GRACE_MS) {
516
+ // 用户介入!标记接管,取消结算
517
+ entry.userTakeover = true;
518
+ entry.observationStartedAt = Date.now();
519
+ entry.lastAgentStatus = 'working';
520
+ persistSubs();
521
+ continue; // 跳过结算,进入用户接管模式
522
+ }
523
+ // 机器注入在处理中 → 归零观察窗,等这轮 run 收尾再判结算
390
524
  entry.observationStartedAt = Date.now();
391
- entry.lastAgentStatus = 'working';
392
525
  persistSubs();
393
- continue; // 跳过结算,进入用户接管模式
526
+ await new Promise((resolve) => setTimeout(resolve, 1000));
527
+ continue;
394
528
  }
395
529
  } catch {
396
530
  // listAgents 失败静默
@@ -405,7 +539,6 @@ export default function subagentPlugin(ctx: Context): void {
405
539
  // 观察期结束(30s 无用户介入),正常结算
406
540
  entry.observationStartedAt = null; // 清除观察期标记
407
541
  }
408
-
409
542
  // 正常结算逻辑
410
543
  // O6:reply 已写的 sessionFile 是权威;poll 只在缺失时用扫描补,绝不反向覆盖
411
544
  if (!entry.sessionFile) {
@@ -416,15 +549,13 @@ export default function subagentPlugin(ctx: Context): void {
416
549
  }
417
550
  entry.status = 'consumed';
418
551
  entry.consumedAt = Date.now();
419
- persistSubs();
420
- writeHistory(entry, { outcome: closing });
552
+ writeHistory(entry, { outcome: closing }, 'poll-settle');
421
553
  // M17:结算自动对账(先于通知;与 reply 快路径幂等双跑)
422
554
  const notes = d.reconcileOnSettlement(description, 'settled');
423
555
  const notice = d.withReconcileNotes(
424
556
  formatSettlementNotice(`${paneId} (${description})`, closing),
425
557
  notes,
426
558
  );
427
- // D50:push 快路径可能已发过通知 → 按轮次(paneId+请求id)去重,只发一条
428
559
  if (d.claimSettleNotice(`${paneId}:${requestId}`)) {
429
560
  try {
430
561
  await injectNotice(notice);
@@ -436,6 +567,8 @@ export default function subagentPlugin(ctx: Context): void {
436
567
  }
437
568
  // 挂起 toolCall(等人类输入)或还没开工 → 继续等
438
569
  }
570
+ // B1/O3:state=null(30s 切片超时)= working 中的心跳 → 续命无活动预算。
571
+ if (state === null) lastActivityAt = Date.now();
439
572
  // 切片超时:检查 pane 存活与总预算
440
573
  let alive = false;
441
574
  try {
@@ -447,7 +580,7 @@ export default function subagentPlugin(ctx: Context): void {
447
580
  entry.status = 'consumed';
448
581
  entry.consumedAt = Date.now(); // O8:提前退出路径必须置消费时间,否则 tab TTL 被卡住
449
582
  persistSubs();
450
- writeHistory(entry, { outcome: 'pane closed before settling' });
583
+ writeHistory(entry, { outcome: 'pane closed before settling' }, 'poll-pane-closed');
451
584
  const notes = d.reconcileOnSettlement(description, 'failed');
452
585
  const notice = d.withReconcileNotes(
453
586
  `Background subagent ${paneId} (${description}) stopped before settling (its pane closed).`,
@@ -460,14 +593,19 @@ export default function subagentPlugin(ctx: Context): void {
460
593
  }
461
594
  return;
462
595
  }
463
- if (Date.now() - startedAt > SUBAGENT_TIMEOUT_MS) {
596
+ // B1:无活动预算超时——working 心跳持续续命,此处只在「pane 活着但
597
+ // 既不 working、也无结算进展」真空期超预算时触发(旧总墙钟 10min 误杀
598
+ // 27min 健康长任务 ×2,01a03c0d 实证)。
599
+ if (Date.now() - lastActivityAt > SUBAGENT_TIMEOUT_MS) {
464
600
  entry.status = 'consumed';
465
601
  entry.consumedAt = Date.now(); // O8:observation timeout 同样置消费时间
466
602
  persistSubs();
467
- writeHistory(entry, { outcome: 'observation timeout' });
603
+ writeHistory(entry, { outcome: 'observation timeout' }, 'poll-timeout');
468
604
  const notes = d.reconcileOnSettlement(description, 'failed');
605
+ // O2:给行动钩子——list_agents 看活态;真在跑就让它跑完(结算会自动通知),
606
+ // 别 sleep 盲等(01a03c0d 实证 master 收到旧文案后连 sleep 90/120/180)。
469
607
  const notice = d.withReconcileNotes(
470
- `Background subagent ${paneId} (${description}) stopped being observed before settling; check its pane.`,
608
+ `Background subagent ${paneId} (${description}) has shown no progress for ${Math.round((Date.now() - lastActivityAt) / 1000)}s (observed since ${new Date(startedAt).toISOString()}). Run list_agents to check its live state; if it is working, let it run — its settlement notice will arrive automatically. Do not sleep-wait.`,
471
609
  notes,
472
610
  );
473
611
  try {
@@ -509,7 +647,7 @@ export default function subagentPlugin(ctx: Context): void {
509
647
  return historyFilePath(agentRootDir(), cwd);
510
648
  }
511
649
 
512
- function toHistory(e: SubEntry): HistoryEntry {
650
+ function toHistory(e: SubEntry, outcome: string | null): HistoryEntry {
513
651
  return {
514
652
  taskId: e.taskId,
515
653
  kind: e.kind,
@@ -522,7 +660,7 @@ export default function subagentPlugin(ctx: Context): void {
522
660
  sessionFile: e.sessionFile,
523
661
  launchCommand: e.launchCommand,
524
662
  status: e.status,
525
- outcome: null,
663
+ outcome,
526
664
  createdAt: e.createdAt,
527
665
  consumedAt: e.consumedAt ?? null,
528
666
  closedAt: null,
@@ -530,8 +668,14 @@ export default function subagentPlugin(ctx: Context): void {
530
668
  };
531
669
  }
532
670
 
533
- function writeHistory(e: SubEntry, patch?: Partial<HistoryEntry>): void {
534
- appendHistory(histFile(e.cwd), { ...toHistory(e), ...(patch ?? {}) });
671
+ /** B5:每 taskId 最近非空 outcome(closed 补记行继承——latestGeneration 取最新行,
672
+ * 旧实现 closed 行恒 outcome:null → 结算成果在「最新行」语义下丢失)。 */
673
+ const lastOutcomeByTask = new Map<string, string>();
674
+
675
+ function writeHistory(e: SubEntry, patch?: Partial<HistoryEntry>, via?: string): void {
676
+ const outcome = inheritOutcome(lastOutcomeByTask.get(e.taskId), patch?.outcome);
677
+ if (typeof outcome === 'string' && outcome.length > 0) lastOutcomeByTask.set(e.taskId, outcome);
678
+ appendHistory(histFile(e.cwd), { ...toHistory(e, outcome), ...(patch ?? {}), ...(via ? { via } : {}) });
535
679
  }
536
680
 
537
681
  /** 存活任务 tab(本 workspace;herdr 为权威,tab.rename/自动关后自动纠正)。 */
@@ -600,7 +744,7 @@ export default function subagentPlugin(ctx: Context): void {
600
744
  mainTabId,
601
745
  });
602
746
  if (plan.mode === 'append' && plan.tabId) {
603
- // D91 网格形态:目标格 = tab 内面积最大的格子(沿长轴分裂,自然铺成方格);
747
+ // D97 网格形态:目标格 = tab 内面积最大的格子,一律 down(全宽横条,title 静帧横读);
604
748
  // board 等无 agent 常驻 shell(agentStatus=unknown)不入候选,避免被 worker 蚕食。
605
749
  const exclude = new Set(
606
750
  allPanes.filter((p) => p.tabId === plan.tabId && p.agentStatus === 'unknown').map((p) => p.paneId),
@@ -617,7 +761,7 @@ export default function subagentPlugin(ctx: Context): void {
617
761
  ?? allPanes.find((p) => p.tabId === plan.tabId)?.paneId;
618
762
  if (anchorPaneId) {
619
763
  const paneId = await client.splitPane({
620
- direction: pick?.direction ?? 'right',
764
+ direction: pick?.direction ?? 'down',
621
765
  cwd,
622
766
  env: envOver,
623
767
  targetPaneId: anchorPaneId,
@@ -643,12 +787,8 @@ export default function subagentPlugin(ctx: Context): void {
643
787
 
644
788
  function launchLine(resumeFile?: string | null, roleModel?: string | null, approve = false): string {
645
789
  // 裸 argv → 平台 shell 语法(win32=PowerShell `&`,POSIX=sh);引号转义在 buildLaunchLine
646
- const parts = [runtime.nodePath, runtime.cliPath];
647
- if (approve) parts.push('-a');
648
- parts.push('-e', runtime.extPath);
649
- if (roleModel) parts.push('--provider', roleModel.split('/')[0], '--model', roleModel.split('/')[1] ?? roleModel);
650
- if (resumeFile) parts.push('--session', resumeFile);
651
- return buildLaunchLine(parts);
790
+ // argv 构造在 buildLaunchParts(D97:默认 --tui-mode fullscreen,PI_HERDR_TUI=regular 逃生)
791
+ return buildLaunchLine(buildLaunchParts(runtime, { resumeFile, roleModel, approve }));
652
792
  }
653
793
 
654
794
  /**
@@ -677,9 +817,10 @@ export default function subagentPlugin(ctx: Context): void {
677
817
  } catch {
678
818
  return;
679
819
  }
680
- const statuses = new Map(panesList.map((p) => [p.paneId, p.agentStatus]));
681
820
  // D71:有活跃终端会话的 pane 豁免回收(tab 级与 pane 级两处)
682
821
  const termPaneIds = terminalState.activePaneIds();
822
+ // B2:结算通知未送达的 pane 豁免(先送达再回收)——pD/pC 实证被 GC 抢关。
823
+ const pendingNoticeIds = d.noticePending?.() ?? new Set<string>();
683
824
 
684
825
  // 任务 tab 分组(含 closed 条目:closed 工作 pane 也算完成)
685
826
  const byTab = new Map<string, SubEntry[]>();
@@ -702,7 +843,7 @@ export default function subagentPlugin(ctx: Context): void {
702
843
  for (const e of entries) {
703
844
  if (e.status !== 'closed') {
704
845
  e.status = 'closed';
705
- writeHistory(e, { status: 'closed', closedAt: Date.now() });
846
+ writeHistory(e, { status: 'closed', closedAt: Date.now() }, 'gc');
706
847
  }
707
848
  }
708
849
  continue;
@@ -714,7 +855,8 @@ export default function subagentPlugin(ctx: Context): void {
714
855
  now: Date.now(),
715
856
  });
716
857
  if (!autoCloseTabs || !should) continue;
717
- if (tabPanes.some((p) => termPaneIds.has(p.paneId))) continue; // 活跃终端豁免
858
+ // B2:结算通知未送达的 pane 豁免(先送达再回收)
859
+ if (tabPanes.some((p) => termPaneIds.has(p.paneId) || pendingNoticeIds.has(p.paneId))) continue;
718
860
  try {
719
861
  await client.tabClose(tabId);
720
862
  } catch {
@@ -723,7 +865,7 @@ export default function subagentPlugin(ctx: Context): void {
723
865
  for (const e of entries) {
724
866
  if (e.status !== 'closed') {
725
867
  e.status = 'closed';
726
- writeHistory(e, { status: 'closed', closedAt: Date.now() });
868
+ writeHistory(e, { status: 'closed', closedAt: Date.now() }, 'gc');
727
869
  }
728
870
  }
729
871
  await new Promise((r) => setTimeout(r, 300)); // 串行关闭(#1358 同类风险护栏)
@@ -736,7 +878,7 @@ export default function subagentPlugin(ctx: Context): void {
736
878
  (e) => e.status === 'consumed' && !(e.tabId && closableTaskTabIds.has(e.tabId)),
737
879
  );
738
880
  for (const e of candidates) {
739
- if (termPaneIds.has(e.paneId)) continue; // 活跃终端豁免(D71)
881
+ if (termPaneIds.has(e.paneId) || pendingNoticeIds.has(e.paneId)) continue; // 活跃终端(D71)/未送达通知(B2)豁免
740
882
  if (!shouldClosePane({
741
883
  consumedAt: e.consumedAt ?? null,
742
884
  herdrStatus: statuses.get(e.paneId),
@@ -745,7 +887,7 @@ export default function subagentPlugin(ctx: Context): void {
745
887
  if (statuses.get(e.paneId) === undefined) {
746
888
  // pane 已消失(随 tab 被关)→ 补记 closed
747
889
  e.status = 'closed';
748
- writeHistory(e, { status: 'closed', closedAt: Date.now() });
890
+ writeHistory(e, { status: 'closed', closedAt: Date.now() }, 'gc');
749
891
  continue;
750
892
  }
751
893
  try {
@@ -754,7 +896,7 @@ export default function subagentPlugin(ctx: Context): void {
754
896
  /* pane 已消失 */
755
897
  }
756
898
  e.status = 'closed';
757
- writeHistory(e, { status: 'closed', closedAt: Date.now() });
899
+ writeHistory(e, { status: 'closed', closedAt: Date.now() }, 'gc');
758
900
  await new Promise((r) => setTimeout(r, 300));
759
901
  }
760
902
  persistSubs();
@@ -831,7 +973,7 @@ export default function subagentPlugin(ctx: Context): void {
831
973
  entry.revivedFrom = latest.paneId;
832
974
  entry.launchCommand = [launchLine(resumeFile, null, approve)];
833
975
  entry.createdAt = Date.now();
834
- writeHistory(entry);
976
+ writeHistory(entry, undefined, 'revive');
835
977
  return entry;
836
978
  }
837
979
 
@@ -931,7 +1073,6 @@ export default function subagentPlugin(ctx: Context): void {
931
1073
  const release = await subSemaphore.acquire();
932
1074
  let paneId = '';
933
1075
  try {
934
- onUpdate?.(makeProgressUpdate(`spawning ${kind !== 'task' ? `${kind} ` : ''}${background ? 'background ' : ''}subagent pane…`));
935
1076
  const spawnedAt = Date.now();
936
1077
  const taskId = randomUUID();
937
1078
  const spawned = await spawnPaneInTaskTab(
@@ -961,10 +1102,12 @@ export default function subagentPlugin(ctx: Context): void {
961
1102
  entry.sessionFile = await resolveSessionFile(paneId, cwd);
962
1103
  subs.set(paneId, entry);
963
1104
  persistSubs();
964
- writeHistory(entry);
965
1105
  onUpdate?.(makeProgressUpdate(`subagent ready in pane ${paneId}; injecting prompt via pipe…`));
966
1106
  // M11(D45/D46):注入走扩展管道 → 子扩展 sendUserMessage(followUp);
967
1107
  // PTY 键盘通道 100% 归人(无软锁窗口、无混行)。
1108
+ // 回归修复(session 01a03bf0 实证):ecc0bc4 重写前台等待时误删本块——
1109
+ // prompt 永不注入 + injectTs 未定义(ReferenceError → spawn-failed,
1110
+ // 台账留幽灵 running 条目)。任何后续重构不得把注入与 injectTs 拆开。
968
1111
  const injectTs = Date.now();
969
1112
  const injected = await pipeRequest(pipeNameFor(cwd, paneId), {
970
1113
  type: 'prompt',
@@ -976,48 +1119,87 @@ export default function subagentPlugin(ctx: Context): void {
976
1119
  if (injected.type !== 'ok') {
977
1120
  throw new Error(`pipe prompt rejected: ${injected.type === 'error' ? injected.message : 'unknown response'}`);
978
1121
  }
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;
1122
+ lastMachineInjectAt.set(paneId, injectTs); // B4:观察窗内 working 归因判据
1123
+ // A1+A2(用户实证修复):前台等待 = 内容闸 + 耐心阈值转后台。
1124
+ // 旧实现的 idle 即结算 + 90s 硬窗口在真实任务(working 期 4-6 分钟)上必然误判
1125
+ // no-output(实测:3 个健康子代理 101s 时被同时 consumed,成果 4 分钟后才产出)。
1126
+ const PATIENCE_MS = Number(process.env.PI_HERDR_FRONTIER_PATIENCE_MS ?? 300000) || 300000;
1127
+ const patienceDeadline = Date.now() + PATIENCE_MS;
1128
+ let text: string | null = null;
1129
+ let settledKind: 'settled' | 'timeout' = 'timeout';
1130
+ while (Date.now() < Math.min(patienceDeadline, spawnedAt + SUBAGENT_TIMEOUT_MS)) {
1131
+ const state = await client.waitAgent(paneId, ['idle', 'done', 'blocked'], 30_000);
1132
+ if (state === 'blocked') {
1133
+ // 返回 gate 通知:不揽活、提醒用户去 pane 答题、授权例外经 send_message 转达。
1134
+ const question = await readAskFlag(paneId);
1135
+ entry.background = true;
1136
+ lastRequestIdByPane.set(paneId, `prompt-${taskId}`);
1137
+ startPoller(paneId, cwd, spawnedAt, injectTs, spec.description, `prompt-${taskId}`);
1138
+ persistSubs();
1139
+ writeHistory(entry, undefined, 'to-background');
1140
+ return {
1141
+ content: [{ type: 'text', text: buildBlockedGateNotice({ paneId, description: spec.description, question }) }],
1142
+ details: { paneId, taskId, background: true, blocked: true, role: kind },
1143
+ };
1144
+ }
1145
+ if (state === 'idle' || state === 'done') {
1146
+ // A1 内容闸:herdr idle 只是"可能是结算"——以会话内容定夺
1147
+ const s = await subSessionState(paneId, cwd, injectTs);
1148
+ if (s.text) { text = s.text; settledKind = 'settled'; break; }
1149
+ if (s.pendingTool || (!s.text && !s.activity)) {
1150
+ // 挂起 toolCall(等人类)或注入前瞬间的 idle → 不当结算,继续观察
1151
+ await new Promise((r) => setTimeout(r, 2000));
1152
+ continue;
1004
1153
  }
1154
+ // 有活动无文本(活动已停止)→ 再给 collectFinalText 的重试窗口
1155
+ text = await collectFinalText(paneId, cwd, injectTs, 6);
1156
+ if (text) { settledKind = 'settled'; break; }
1157
+ await new Promise((r) => setTimeout(r, 2000));
1158
+ continue;
1005
1159
  }
1160
+ // null = 30s 切片超时(working 中)→ 继续循环
1161
+ }
1162
+ // A2 耐心阈值到点仍在跑 → 探活;活着 → 转后台并返回统一存活通知
1163
+ if (!text && settledKind !== 'blocked') {
1164
+ const probe = await probeAlive(paneId, cwd);
1165
+ if (isAlive(probe, Date.now())) {
1166
+ entry.background = true;
1167
+ lastRequestIdByPane.set(paneId, `prompt-${taskId}`);
1168
+ startPoller(paneId, cwd, spawnedAt, injectTs, spec.description, `prompt-${taskId}`);
1169
+ persistSubs();
1170
+ const notice = buildAliveNotice(
1171
+ { paneId, description: spec.description, scenario: 'moved-to-bg', probe },
1172
+ Date.now(),
1173
+ );
1174
+ return {
1175
+ content: [{ type: 'text', text: notice }],
1176
+ details: { paneId, taskId, background: true, movedToBackground: true, role: kind },
1177
+ };
1178
+ }
1179
+ // 探活失败(pane 消失且会话沉寂)→ 保留原 timeout 语义
1180
+ settledKind = 'timeout';
1006
1181
  }
1007
1182
  const outcome =
1008
- settled === 'blocked' ? { kind: 'blocked' as const, text: text ?? '' }
1009
- : settled === 'timeout' ? { kind: 'timeout' as const, text: text ?? '' }
1183
+ settledKind === 'timeout' ? { kind: 'timeout' as const, text: text ?? '' }
1010
1184
  : text ? { kind: 'completed' as const, text }
1011
1185
  : { kind: 'no-output' as const, text: '' };
1012
1186
  entry.status = 'consumed';
1013
1187
  entry.consumedAt = Date.now();
1014
1188
  persistSubs();
1015
- writeHistory(entry, { outcome: outcome.kind === 'completed' ? text : null });
1189
+ writeHistory(entry, { outcome: outcome.kind === 'completed' ? text : null }, 'fg-settle');
1016
1190
  return {
1017
1191
  content: [{ type: 'text', text: formatSubagentResult(outcome, spec.description) }],
1018
1192
  details: { paneId, taskId, background, role: kind },
1019
1193
  };
1020
1194
  } catch (err) {
1195
+ // 回归修复(session 01a03bf0):spawn 中途失败必须回收台账 + 关 pane——
1196
+ // 否则幽灵 running 条目永不结算 → D96 提醒风暴 + send_message 打到
1197
+ // 无任务上下文的空会话。pane 关闭尽力而为(board pane 例外场景由 GC 兜底)。
1198
+ if (paneId) {
1199
+ subs.delete(paneId);
1200
+ persistSubs();
1201
+ void client.closePane(paneId).catch(() => { /* 尽力而为 */ });
1202
+ }
1021
1203
  return {
1022
1204
  content: [{
1023
1205
  type: 'text',
@@ -1083,7 +1265,7 @@ export default function subagentPlugin(ctx: Context): void {
1083
1265
  };
1084
1266
  subs.set(entry.paneId, entry);
1085
1267
  persistSubs();
1086
- writeHistory(entry);
1268
+ writeHistory(entry, undefined, 'resume');
1087
1269
  return {
1088
1270
  content: [{
1089
1271
  type: 'text',
@@ -1138,7 +1320,7 @@ export default function subagentPlugin(ctx: Context): void {
1138
1320
  name: 'list_agents',
1139
1321
  label: 'List Subagents',
1140
1322
  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.',
1323
+ '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
1324
  parameters: Type.Object({}),
1143
1325
  async execute() {
1144
1326
  const listed = [...subs.values()].filter((s) => s.background);
@@ -1151,6 +1333,12 @@ export default function subagentPlugin(ctx: Context): void {
1151
1333
  const prev = byTask.get(sub.taskId);
1152
1334
  if (!prev || sub.createdAt >= prev.createdAt) byTask.set(sub.taskId, sub);
1153
1335
  }
1336
+ // C1:实时探活(pane 状态 + 会话活动时间)——master 能区分"在干活"vs"真卡死"
1337
+ const probes = new Map<string, AliveProbe>();
1338
+ await Promise.all([...byTask.values()].map(async (sub) => {
1339
+ if (sub.status === 'closed') return;
1340
+ try { probes.set(sub.paneId, await probeAlive(sub.paneId, sub.cwd)); } catch { /* 显示层尽力 */ }
1341
+ }));
1154
1342
  const lines: string[] = [];
1155
1343
  for (const sub of byTask.values()) {
1156
1344
  const tabTag = sub.tabName ? ` [tab: ${sub.tabName}]` : '';
@@ -1162,19 +1350,28 @@ export default function subagentPlugin(ctx: Context): void {
1162
1350
  const state = sub.status === 'settled' ? 'idle' : 'running';
1163
1351
  // D94:用户接管标记
1164
1352
  const takeoverMark = sub.userTakeover ? ', user-controlled' : '';
1165
- lines.push(`${sub.paneId} [${state}${takeoverMark}] (${sub.kind})${tabTag} ${sub.description}`);
1353
+ const probe = probes.get(sub.paneId);
1354
+ const statusTag = probe?.agentStatus ? ` ${probe.agentStatus}` : '';
1355
+ const activityTag = probe?.lastActivityMs != null ? `, active ${agoText(probe.lastActivityMs, Date.now())}` : '';
1356
+ // E:blocked 时附上闸门问题摘要(提示 master 该叫用户去 pane,不是自己接手)
1357
+ let gateTag = '';
1358
+ if (probe?.agentStatus === 'blocked') {
1359
+ const q = await readAskFlag(sub.paneId);
1360
+ gateTag = q ? ` — AWAITING HUMAN: "${q}"` : ' — AWAITING HUMAN decision';
1361
+ }
1362
+ lines.push(`${sub.paneId} [${state}${takeoverMark}${statusTag}${activityTag}${gateTag}] (${sub.kind})${tabTag} ${sub.description}`);
1166
1363
  }
1167
1364
  return { content: [{ type: 'text', text: lines.join('\n') }], details: {} };
1168
1365
  },
1169
1366
  });
1170
1367
 
1171
- /* ── 工具:send_message(followUp 队列语义) ── */
1368
+ /* ── 工具:send_message(steer 间隙投递) ── */
1172
1369
 
1173
1370
  scoped.registerTool({
1174
1371
  name: 'send_message',
1175
1372
  label: 'Send to Subagent',
1176
1373
  description:
1177
- 'Send a follow-up message to a background subagent. If it is working, the message is queued and delivered after it finishes (pi followUp queue); if it is idle, it wakes the subagent for a new turn. `agentId` is the id returned by the subagent tool or shown by list_agents.',
1374
+ 'Send a follow-up message to a background subagent. If it is working, the message is delivered at its next tool-call gap (steer, seconds); if it is idle, it wakes the subagent for a new turn. `agentId` is the id returned by the subagent tool or shown by list_agents.',
1178
1375
  parameters: Type.Object({
1179
1376
  agentId: Type.String({ description: 'The subagent id (herdr pane id)' }),
1180
1377
  message: Type.String({ description: 'The follow-up message' }),
@@ -1192,7 +1389,9 @@ export default function subagentPlugin(ctx: Context): void {
1192
1389
  subs.set(entry.paneId, entry);
1193
1390
  persistSubs();
1194
1391
  }
1195
- // M11(D46):follow_up 走扩展管道(sendUserMessage(followUp) 队列语义)
1392
+ // M11(D46):follow_up 走扩展管道。B3:带 steer——worker 长跑中途的补充
1393
+ // 指令在 tool-call 间隙秒级到达(旧 followUp 队列语义 = 整个 run 结束才投,
1394
+ // 01a03c0d 实证 20min 延迟致 worker 按旧契约返工)。
1196
1395
  const ready = await waitSubReady(entry.cwd, entry.paneId);
1197
1396
  if (!ready) throw new Error(`subagent pane ${entry.paneId} pipe not ready`);
1198
1397
  const fuId = `fu-${Date.now()}`;
@@ -1202,10 +1401,12 @@ export default function subagentPlugin(ctx: Context): void {
1202
1401
  text: String(params?.message ?? ''),
1203
1402
  from: pipeNameFor(entry.cwd, env?.paneId ?? ''),
1204
1403
  push: true,
1404
+ steer: true,
1205
1405
  });
1206
1406
  if (res.type !== 'ok') {
1207
1407
  throw new Error(`pipe follow_up rejected: ${res.type === 'error' ? res.message : 'unknown response'}`);
1208
1408
  }
1409
+ lastMachineInjectAt.set(entry.paneId, Date.now()); // B4:观察窗内 working 归因判据
1209
1410
  entry.status = 'running';
1210
1411
  persistSubs();
1211
1412
  lastRequestIdByPane.set(entry.paneId, fuId);