pi-pier 0.1.3 → 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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-pier",
3
- "version": "0.1.3",
3
+ "version": "0.1.4",
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",
@@ -1,6 +1,10 @@
1
1
  /**
2
- * D91 网格形态:spawn 时挑「最大格子沿长轴分裂」,让 pane 自然铺成方格
3
- * (用户愿景:股票热力图式方格;横向格更易读最新输出)。
2
+ * D97 网格形态:spawn 时挑「最大格子」一律 down(上下拆),让 pane 长成全宽横条。
3
+ *
4
+ * 动因(用户实证):热力把非焦点格压窄后,横条仍保全宽——pane title 静帧
5
+ * (slim-frame.ts 同源投影)一行横读;旧「方格」拓扑的非焦点格是竖窄条,
6
+ * title 折成一竖列不可读。静帧 overlay 的可见性谓词(长宽 < TUI 下限)与
7
+ * 本拓扑配套:横条命中行数下限、存量竖条命中列数下限,两代布局都被接住。
4
8
  *
5
9
  * 依据 herdr 0.8.2 源码:ratio = split 节点 first child 份额(split_rect:
6
10
  * first_w = width*ratio);layout.apply 不能挂接既有 pane(只建新 shell),
@@ -67,8 +71,9 @@ export function paneCells(root: ShapeNode, width = 200, height = 50): PaneCell[]
67
71
  }
68
72
 
69
73
  /**
70
- * 挑分裂目标:面积最大的格子(并列取先序第一,稳定),沿长轴分裂——
71
- * 宽 ≥ 1.3×高 → right(左右分,保格子横向可读),否则 down。
74
+ * 挑分裂目标:面积最大的格子(并列取先序第一,稳定),一律 down(上下拆)。
75
+ * 模型 200×50 下 pane 数上限(热力 MAX_AUTO_LAYOUT_PANES=10)内条高最低 5 行
76
+ * ——低于 TUI 可用下限即静帧,正是设计语义(条 = title 帧,点击放大看真身)。
72
77
  * exclude = 不许动的格子(board 等无 agent 常驻 shell)。
73
78
  */
74
79
  export function pickGridSplit(
@@ -82,5 +87,5 @@ export function pickGridSplit(
82
87
  for (const c of cells) {
83
88
  if (c.w * c.h > best.w * best.h + 1e-9) best = c;
84
89
  }
85
- return { targetPaneId: best.id, direction: best.w > best.h * 1.3 ? 'right' : 'down' };
90
+ return { targetPaneId: best.id, direction: 'down' };
86
91
  }
@@ -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, 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';
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,14 +133,20 @@ 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
  }
132
-
133
150
  /** E2:已发过闸门通知的 pane(blocked 期间去重;解除后移除,二次 ask 可再通知)。 */
134
151
  const blockedGateNotified = new Set<string>();
135
152
 
@@ -141,7 +158,7 @@ export default function subagentPlugin(ctx: Context): void {
141
158
  if (next === entry.sessionFile) return;
142
159
  entry.sessionFile = next;
143
160
  persistSubs();
144
- writeHistory(entry);
161
+ writeHistory(entry, undefined, 'session-report');
145
162
  };
146
163
  slots.reconcileOnReply = (paneId) => {
147
164
  const entry = subs.get(paneId);
@@ -160,16 +177,39 @@ export default function subagentPlugin(ctx: Context): void {
160
177
  ?.sessionManager?.getBranch?.() ?? [];
161
178
  const reg = foldSubsRegistry(entries as Parameters<typeof foldSubsRegistry>[0]);
162
179
  for (const sub of reg.subs) subs.set(sub.paneId, sub);
180
+ lastSubsSnapshot = ''; // 分支回放改变了状态 → 下次 persistSubs 强制落盘
163
181
  } catch {
164
182
  /* 重建失败不影响主流程 */
165
183
  }
166
184
  }
167
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
+
168
206
  scoped.on('session_start', async (_event: unknown, eventCtx: unknown) => {
169
207
  rebuildSubs(eventCtx);
208
+ await sweepZombieRunning();
170
209
  });
171
210
  scoped.on('session_tree', async (_event: unknown, eventCtx: unknown) => {
172
211
  rebuildSubs(eventCtx);
212
+ await sweepZombieRunning();
173
213
  });
174
214
 
175
215
  /* ── B1(用户实证修复):subagent isError 结果的探活改写 ──────────────
@@ -205,7 +245,20 @@ export default function subagentPlugin(ctx: Context): void {
205
245
 
206
246
  let todoReminders = 0;
207
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
+ });
208
260
  scoped.on('agent_settled', async () => {
261
+ if (lastAssistantStopReason === ABORT_STOP_REASON) return;
209
262
  if (todoReminders >= TODO_REMINDERS_MAX) return;
210
263
  if (pollers.size > 0) return; // 有在途子代理 → 主控本来就在等,不催
211
264
  if (d.getBlockedDepth() > 0) return; // 等人类回答 → 不催
@@ -331,8 +384,8 @@ export default function subagentPlugin(ctx: Context): void {
331
384
  ): Promise<{ text: string | null; pendingTool: boolean; activity: boolean }> {
332
385
  for (const file of await resolveSessionFileCandidates(paneId, cwd)) {
333
386
  const entries = readSessionFile(file);
334
- if (!entries) continue;
335
- const r = lastAssistantText(entries, { sinceTs });
387
+ if (entries.length === 0) continue;
388
+ const r = lastAssistantText(entries, sinceTs);
336
389
  if (r?.text) return { text: r.text, pendingTool: false, activity: true };
337
390
  if (hasPendingToolCall(entries, sinceTs)) return { text: null, pendingTool: true, activity: true };
338
391
  if (hasAssistantAfter(entries, sinceTs)) return { text: null, pendingTool: false, activity: true };
@@ -355,7 +408,11 @@ export default function subagentPlugin(ctx: Context): void {
355
408
  requestId: string,
356
409
  ): Promise<void> {
357
410
  void spawnedAt;
411
+ // B1:无活动预算锚点——working 切片(waitAgent 超时 null)即心跳(O3):每次续命;
412
+ // 只有「不 working 也无结算进展」的真空期才消耗预算(01a03c0d:27min 健康长任务
413
+ // 被旧总墙钟 10min 误杀两次)。startedAt 保留供通知文案诊断。
358
414
  const startedAt = Date.now();
415
+ let lastActivityAt = Date.now();
359
416
  try {
360
417
  while (true) {
361
418
  const entry = subs.get(paneId);
@@ -445,17 +502,29 @@ export default function subagentPlugin(ctx: Context): void {
445
502
  // 已在观察期
446
503
  const elapsed = Date.now() - entry.observationStartedAt;
447
504
 
448
- // 检测用户介入:查询当前状态是否变为 working
505
+ // 检测用户介入:查询当前状态是否变为 working。
506
+ // B4:观察窗内 working ≠ 必然用户接管——自己的 follow_up 注入也呈现
507
+ // working。距最近机器注入 < MACHINE_INJECT_GRACE_MS 的 working 视为
508
+ // 注入被处理(取消结算继续观察),否则才判接管(01a03c0d:契约消息
509
+ // 20min 后投递,恰好撞进观察窗)。
449
510
  try {
450
511
  const agents = await client.listAgents();
451
512
  const agent = agents.find((a) => a.paneId === paneId);
452
513
  if (agent && agent.status === 'working') {
453
- // 用户介入!标记接管,取消结算
454
- 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 收尾再判结算
455
524
  entry.observationStartedAt = Date.now();
456
- entry.lastAgentStatus = 'working';
457
525
  persistSubs();
458
- continue; // 跳过结算,进入用户接管模式
526
+ await new Promise((resolve) => setTimeout(resolve, 1000));
527
+ continue;
459
528
  }
460
529
  } catch {
461
530
  // listAgents 失败静默
@@ -470,7 +539,6 @@ export default function subagentPlugin(ctx: Context): void {
470
539
  // 观察期结束(30s 无用户介入),正常结算
471
540
  entry.observationStartedAt = null; // 清除观察期标记
472
541
  }
473
-
474
542
  // 正常结算逻辑
475
543
  // O6:reply 已写的 sessionFile 是权威;poll 只在缺失时用扫描补,绝不反向覆盖
476
544
  if (!entry.sessionFile) {
@@ -481,15 +549,13 @@ export default function subagentPlugin(ctx: Context): void {
481
549
  }
482
550
  entry.status = 'consumed';
483
551
  entry.consumedAt = Date.now();
484
- persistSubs();
485
- writeHistory(entry, { outcome: closing });
552
+ writeHistory(entry, { outcome: closing }, 'poll-settle');
486
553
  // M17:结算自动对账(先于通知;与 reply 快路径幂等双跑)
487
554
  const notes = d.reconcileOnSettlement(description, 'settled');
488
555
  const notice = d.withReconcileNotes(
489
556
  formatSettlementNotice(`${paneId} (${description})`, closing),
490
557
  notes,
491
558
  );
492
- // D50:push 快路径可能已发过通知 → 按轮次(paneId+请求id)去重,只发一条
493
559
  if (d.claimSettleNotice(`${paneId}:${requestId}`)) {
494
560
  try {
495
561
  await injectNotice(notice);
@@ -501,6 +567,8 @@ export default function subagentPlugin(ctx: Context): void {
501
567
  }
502
568
  // 挂起 toolCall(等人类输入)或还没开工 → 继续等
503
569
  }
570
+ // B1/O3:state=null(30s 切片超时)= working 中的心跳 → 续命无活动预算。
571
+ if (state === null) lastActivityAt = Date.now();
504
572
  // 切片超时:检查 pane 存活与总预算
505
573
  let alive = false;
506
574
  try {
@@ -512,7 +580,7 @@ export default function subagentPlugin(ctx: Context): void {
512
580
  entry.status = 'consumed';
513
581
  entry.consumedAt = Date.now(); // O8:提前退出路径必须置消费时间,否则 tab TTL 被卡住
514
582
  persistSubs();
515
- writeHistory(entry, { outcome: 'pane closed before settling' });
583
+ writeHistory(entry, { outcome: 'pane closed before settling' }, 'poll-pane-closed');
516
584
  const notes = d.reconcileOnSettlement(description, 'failed');
517
585
  const notice = d.withReconcileNotes(
518
586
  `Background subagent ${paneId} (${description}) stopped before settling (its pane closed).`,
@@ -525,14 +593,19 @@ export default function subagentPlugin(ctx: Context): void {
525
593
  }
526
594
  return;
527
595
  }
528
- 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) {
529
600
  entry.status = 'consumed';
530
601
  entry.consumedAt = Date.now(); // O8:observation timeout 同样置消费时间
531
602
  persistSubs();
532
- writeHistory(entry, { outcome: 'observation timeout' });
603
+ writeHistory(entry, { outcome: 'observation timeout' }, 'poll-timeout');
533
604
  const notes = d.reconcileOnSettlement(description, 'failed');
605
+ // O2:给行动钩子——list_agents 看活态;真在跑就让它跑完(结算会自动通知),
606
+ // 别 sleep 盲等(01a03c0d 实证 master 收到旧文案后连 sleep 90/120/180)。
534
607
  const notice = d.withReconcileNotes(
535
- `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.`,
536
609
  notes,
537
610
  );
538
611
  try {
@@ -574,7 +647,7 @@ export default function subagentPlugin(ctx: Context): void {
574
647
  return historyFilePath(agentRootDir(), cwd);
575
648
  }
576
649
 
577
- function toHistory(e: SubEntry): HistoryEntry {
650
+ function toHistory(e: SubEntry, outcome: string | null): HistoryEntry {
578
651
  return {
579
652
  taskId: e.taskId,
580
653
  kind: e.kind,
@@ -587,7 +660,7 @@ export default function subagentPlugin(ctx: Context): void {
587
660
  sessionFile: e.sessionFile,
588
661
  launchCommand: e.launchCommand,
589
662
  status: e.status,
590
- outcome: null,
663
+ outcome,
591
664
  createdAt: e.createdAt,
592
665
  consumedAt: e.consumedAt ?? null,
593
666
  closedAt: null,
@@ -595,8 +668,14 @@ export default function subagentPlugin(ctx: Context): void {
595
668
  };
596
669
  }
597
670
 
598
- function writeHistory(e: SubEntry, patch?: Partial<HistoryEntry>): void {
599
- 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 } : {}) });
600
679
  }
601
680
 
602
681
  /** 存活任务 tab(本 workspace;herdr 为权威,tab.rename/自动关后自动纠正)。 */
@@ -665,7 +744,7 @@ export default function subagentPlugin(ctx: Context): void {
665
744
  mainTabId,
666
745
  });
667
746
  if (plan.mode === 'append' && plan.tabId) {
668
- // D91 网格形态:目标格 = tab 内面积最大的格子(沿长轴分裂,自然铺成方格);
747
+ // D97 网格形态:目标格 = tab 内面积最大的格子,一律 down(全宽横条,title 静帧横读);
669
748
  // board 等无 agent 常驻 shell(agentStatus=unknown)不入候选,避免被 worker 蚕食。
670
749
  const exclude = new Set(
671
750
  allPanes.filter((p) => p.tabId === plan.tabId && p.agentStatus === 'unknown').map((p) => p.paneId),
@@ -682,7 +761,7 @@ export default function subagentPlugin(ctx: Context): void {
682
761
  ?? allPanes.find((p) => p.tabId === plan.tabId)?.paneId;
683
762
  if (anchorPaneId) {
684
763
  const paneId = await client.splitPane({
685
- direction: pick?.direction ?? 'right',
764
+ direction: pick?.direction ?? 'down',
686
765
  cwd,
687
766
  env: envOver,
688
767
  targetPaneId: anchorPaneId,
@@ -708,12 +787,8 @@ export default function subagentPlugin(ctx: Context): void {
708
787
 
709
788
  function launchLine(resumeFile?: string | null, roleModel?: string | null, approve = false): string {
710
789
  // 裸 argv → 平台 shell 语法(win32=PowerShell `&`,POSIX=sh);引号转义在 buildLaunchLine
711
- const parts = [runtime.nodePath, runtime.cliPath];
712
- if (approve) parts.push('-a');
713
- parts.push('-e', runtime.extPath);
714
- if (roleModel) parts.push('--provider', roleModel.split('/')[0], '--model', roleModel.split('/')[1] ?? roleModel);
715
- if (resumeFile) parts.push('--session', resumeFile);
716
- return buildLaunchLine(parts);
790
+ // argv 构造在 buildLaunchParts(D97:默认 --tui-mode fullscreen,PI_HERDR_TUI=regular 逃生)
791
+ return buildLaunchLine(buildLaunchParts(runtime, { resumeFile, roleModel, approve }));
717
792
  }
718
793
 
719
794
  /**
@@ -742,9 +817,10 @@ export default function subagentPlugin(ctx: Context): void {
742
817
  } catch {
743
818
  return;
744
819
  }
745
- const statuses = new Map(panesList.map((p) => [p.paneId, p.agentStatus]));
746
820
  // D71:有活跃终端会话的 pane 豁免回收(tab 级与 pane 级两处)
747
821
  const termPaneIds = terminalState.activePaneIds();
822
+ // B2:结算通知未送达的 pane 豁免(先送达再回收)——pD/pC 实证被 GC 抢关。
823
+ const pendingNoticeIds = d.noticePending?.() ?? new Set<string>();
748
824
 
749
825
  // 任务 tab 分组(含 closed 条目:closed 工作 pane 也算完成)
750
826
  const byTab = new Map<string, SubEntry[]>();
@@ -767,7 +843,7 @@ export default function subagentPlugin(ctx: Context): void {
767
843
  for (const e of entries) {
768
844
  if (e.status !== 'closed') {
769
845
  e.status = 'closed';
770
- writeHistory(e, { status: 'closed', closedAt: Date.now() });
846
+ writeHistory(e, { status: 'closed', closedAt: Date.now() }, 'gc');
771
847
  }
772
848
  }
773
849
  continue;
@@ -779,7 +855,8 @@ export default function subagentPlugin(ctx: Context): void {
779
855
  now: Date.now(),
780
856
  });
781
857
  if (!autoCloseTabs || !should) continue;
782
- 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;
783
860
  try {
784
861
  await client.tabClose(tabId);
785
862
  } catch {
@@ -788,7 +865,7 @@ export default function subagentPlugin(ctx: Context): void {
788
865
  for (const e of entries) {
789
866
  if (e.status !== 'closed') {
790
867
  e.status = 'closed';
791
- writeHistory(e, { status: 'closed', closedAt: Date.now() });
868
+ writeHistory(e, { status: 'closed', closedAt: Date.now() }, 'gc');
792
869
  }
793
870
  }
794
871
  await new Promise((r) => setTimeout(r, 300)); // 串行关闭(#1358 同类风险护栏)
@@ -801,7 +878,7 @@ export default function subagentPlugin(ctx: Context): void {
801
878
  (e) => e.status === 'consumed' && !(e.tabId && closableTaskTabIds.has(e.tabId)),
802
879
  );
803
880
  for (const e of candidates) {
804
- if (termPaneIds.has(e.paneId)) continue; // 活跃终端豁免(D71)
881
+ if (termPaneIds.has(e.paneId) || pendingNoticeIds.has(e.paneId)) continue; // 活跃终端(D71)/未送达通知(B2)豁免
805
882
  if (!shouldClosePane({
806
883
  consumedAt: e.consumedAt ?? null,
807
884
  herdrStatus: statuses.get(e.paneId),
@@ -810,7 +887,7 @@ export default function subagentPlugin(ctx: Context): void {
810
887
  if (statuses.get(e.paneId) === undefined) {
811
888
  // pane 已消失(随 tab 被关)→ 补记 closed
812
889
  e.status = 'closed';
813
- writeHistory(e, { status: 'closed', closedAt: Date.now() });
890
+ writeHistory(e, { status: 'closed', closedAt: Date.now() }, 'gc');
814
891
  continue;
815
892
  }
816
893
  try {
@@ -819,7 +896,7 @@ export default function subagentPlugin(ctx: Context): void {
819
896
  /* pane 已消失 */
820
897
  }
821
898
  e.status = 'closed';
822
- writeHistory(e, { status: 'closed', closedAt: Date.now() });
899
+ writeHistory(e, { status: 'closed', closedAt: Date.now() }, 'gc');
823
900
  await new Promise((r) => setTimeout(r, 300));
824
901
  }
825
902
  persistSubs();
@@ -896,7 +973,7 @@ export default function subagentPlugin(ctx: Context): void {
896
973
  entry.revivedFrom = latest.paneId;
897
974
  entry.launchCommand = [launchLine(resumeFile, null, approve)];
898
975
  entry.createdAt = Date.now();
899
- writeHistory(entry);
976
+ writeHistory(entry, undefined, 'revive');
900
977
  return entry;
901
978
  }
902
979
 
@@ -996,7 +1073,6 @@ export default function subagentPlugin(ctx: Context): void {
996
1073
  const release = await subSemaphore.acquire();
997
1074
  let paneId = '';
998
1075
  try {
999
- onUpdate?.(makeProgressUpdate(`spawning ${kind !== 'task' ? `${kind} ` : ''}${background ? 'background ' : ''}subagent pane…`));
1000
1076
  const spawnedAt = Date.now();
1001
1077
  const taskId = randomUUID();
1002
1078
  const spawned = await spawnPaneInTaskTab(
@@ -1026,7 +1102,24 @@ export default function subagentPlugin(ctx: Context): void {
1026
1102
  entry.sessionFile = await resolveSessionFile(paneId, cwd);
1027
1103
  subs.set(paneId, entry);
1028
1104
  persistSubs();
1105
+ onUpdate?.(makeProgressUpdate(`subagent ready in pane ${paneId}; injecting prompt via pipe…`));
1029
1106
  // M11(D45/D46):注入走扩展管道 → 子扩展 sendUserMessage(followUp);
1107
+ // PTY 键盘通道 100% 归人(无软锁窗口、无混行)。
1108
+ // 回归修复(session 01a03bf0 实证):ecc0bc4 重写前台等待时误删本块——
1109
+ // prompt 永不注入 + injectTs 未定义(ReferenceError → spawn-failed,
1110
+ // 台账留幽灵 running 条目)。任何后续重构不得把注入与 injectTs 拆开。
1111
+ const injectTs = Date.now();
1112
+ const injected = await pipeRequest(pipeNameFor(cwd, paneId), {
1113
+ type: 'prompt',
1114
+ id: `prompt-${taskId}`,
1115
+ text: spec.prompt,
1116
+ from: pipeNameFor(cwd, env?.paneId ?? ''),
1117
+ push: background,
1118
+ });
1119
+ if (injected.type !== 'ok') {
1120
+ throw new Error(`pipe prompt rejected: ${injected.type === 'error' ? injected.message : 'unknown response'}`);
1121
+ }
1122
+ lastMachineInjectAt.set(paneId, injectTs); // B4:观察窗内 working 归因判据
1030
1123
  // A1+A2(用户实证修复):前台等待 = 内容闸 + 耐心阈值转后台。
1031
1124
  // 旧实现的 idle 即结算 + 90s 硬窗口在真实任务(working 期 4-6 分钟)上必然误判
1032
1125
  // no-output(实测:3 个健康子代理 101s 时被同时 consumed,成果 4 分钟后才产出)。
@@ -1037,14 +1130,13 @@ export default function subagentPlugin(ctx: Context): void {
1037
1130
  while (Date.now() < Math.min(patienceDeadline, spawnedAt + SUBAGENT_TIMEOUT_MS)) {
1038
1131
  const state = await client.waitAgent(paneId, ['idle', 'done', 'blocked'], 30_000);
1039
1132
  if (state === 'blocked') {
1040
- // E1(人类闸门):blocked 不是终态——转后台 poller(人类答后续跑、结算自动通知),
1041
1133
  // 返回 gate 通知:不揽活、提醒用户去 pane 答题、授权例外经 send_message 转达。
1042
1134
  const question = await readAskFlag(paneId);
1043
1135
  entry.background = true;
1044
1136
  lastRequestIdByPane.set(paneId, `prompt-${taskId}`);
1045
1137
  startPoller(paneId, cwd, spawnedAt, injectTs, spec.description, `prompt-${taskId}`);
1046
1138
  persistSubs();
1047
- writeHistory(entry);
1139
+ writeHistory(entry, undefined, 'to-background');
1048
1140
  return {
1049
1141
  content: [{ type: 'text', text: buildBlockedGateNotice({ paneId, description: spec.description, question }) }],
1050
1142
  details: { paneId, taskId, background: true, blocked: true, role: kind },
@@ -1094,12 +1186,20 @@ export default function subagentPlugin(ctx: Context): void {
1094
1186
  entry.status = 'consumed';
1095
1187
  entry.consumedAt = Date.now();
1096
1188
  persistSubs();
1097
- writeHistory(entry, { outcome: outcome.kind === 'completed' ? text : null });
1189
+ writeHistory(entry, { outcome: outcome.kind === 'completed' ? text : null }, 'fg-settle');
1098
1190
  return {
1099
1191
  content: [{ type: 'text', text: formatSubagentResult(outcome, spec.description) }],
1100
1192
  details: { paneId, taskId, background, role: kind },
1101
1193
  };
1102
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
+ }
1103
1203
  return {
1104
1204
  content: [{
1105
1205
  type: 'text',
@@ -1165,7 +1265,7 @@ export default function subagentPlugin(ctx: Context): void {
1165
1265
  };
1166
1266
  subs.set(entry.paneId, entry);
1167
1267
  persistSubs();
1168
- writeHistory(entry);
1268
+ writeHistory(entry, undefined, 'resume');
1169
1269
  return {
1170
1270
  content: [{
1171
1271
  type: 'text',
@@ -1265,13 +1365,13 @@ export default function subagentPlugin(ctx: Context): void {
1265
1365
  },
1266
1366
  });
1267
1367
 
1268
- /* ── 工具:send_message(followUp 队列语义) ── */
1368
+ /* ── 工具:send_message(steer 间隙投递) ── */
1269
1369
 
1270
1370
  scoped.registerTool({
1271
1371
  name: 'send_message',
1272
1372
  label: 'Send to Subagent',
1273
1373
  description:
1274
- '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.',
1275
1375
  parameters: Type.Object({
1276
1376
  agentId: Type.String({ description: 'The subagent id (herdr pane id)' }),
1277
1377
  message: Type.String({ description: 'The follow-up message' }),
@@ -1289,7 +1389,9 @@ export default function subagentPlugin(ctx: Context): void {
1289
1389
  subs.set(entry.paneId, entry);
1290
1390
  persistSubs();
1291
1391
  }
1292
- // M11(D46):follow_up 走扩展管道(sendUserMessage(followUp) 队列语义)
1392
+ // M11(D46):follow_up 走扩展管道。B3:带 steer——worker 长跑中途的补充
1393
+ // 指令在 tool-call 间隙秒级到达(旧 followUp 队列语义 = 整个 run 结束才投,
1394
+ // 01a03c0d 实证 20min 延迟致 worker 按旧契约返工)。
1293
1395
  const ready = await waitSubReady(entry.cwd, entry.paneId);
1294
1396
  if (!ready) throw new Error(`subagent pane ${entry.paneId} pipe not ready`);
1295
1397
  const fuId = `fu-${Date.now()}`;
@@ -1299,10 +1401,12 @@ export default function subagentPlugin(ctx: Context): void {
1299
1401
  text: String(params?.message ?? ''),
1300
1402
  from: pipeNameFor(entry.cwd, env?.paneId ?? ''),
1301
1403
  push: true,
1404
+ steer: true,
1302
1405
  });
1303
1406
  if (res.type !== 'ok') {
1304
1407
  throw new Error(`pipe follow_up rejected: ${res.type === 'error' ? res.message : 'unknown response'}`);
1305
1408
  }
1409
+ lastMachineInjectAt.set(entry.paneId, Date.now()); // B4:观察窗内 working 归因判据
1306
1410
  entry.status = 'running';
1307
1411
  persistSubs();
1308
1412
  lastRequestIdByPane.set(entry.paneId, fuId);