pi-pier 0.1.4 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/package.json +1 -1
  2. package/schemas/role-manifest.schema.json +1 -2
  3. package/src/bootstrap.ts +18 -22
  4. package/src/core/grid-shape.ts +13 -14
  5. package/src/core/subagent.ts +325 -857
  6. package/src/core/terminal.ts +25 -28
  7. package/src/core/todo.ts +47 -53
  8. package/src/gc-core.ts +15 -15
  9. package/src/git-adapter.ts +104 -0
  10. package/src/herdr-client.ts +76 -68
  11. package/src/history-store.ts +70 -36
  12. package/src/index-locks.ts +105 -0
  13. package/src/index-master.ts +113 -0
  14. package/src/index-notices.ts +43 -0
  15. package/src/index-pipe.ts +73 -0
  16. package/src/index-runtime.ts +23 -0
  17. package/src/index-worker.ts +39 -0
  18. package/src/index.ts +134 -386
  19. package/src/ledger.ts +15 -17
  20. package/src/lock-core.ts +15 -29
  21. package/src/manifest-compose.ts +9 -23
  22. package/src/notice-buffer.ts +9 -14
  23. package/src/pane-title.ts +24 -24
  24. package/src/pi-surface.ts +22 -29
  25. package/src/pipe-channel.ts +67 -34
  26. package/src/platform-paths.ts +72 -0
  27. package/src/progress-core.ts +26 -24
  28. package/src/reconcile-core.ts +25 -24
  29. package/src/role-loader.ts +32 -35
  30. package/src/role-manifest.ts +24 -28
  31. package/src/roles/master.json +2 -2
  32. package/src/roles/worker-default.json +3 -3
  33. package/src/runtime-policy.ts +66 -0
  34. package/src/session-tail.ts +46 -45
  35. package/src/settle-wake-core.ts +19 -18
  36. package/src/slim-frame.ts +45 -44
  37. package/src/stale-core.ts +23 -21
  38. package/src/storage-layout.ts +80 -0
  39. package/src/subagent-core.ts +151 -94
  40. package/src/subagent-gc.ts +237 -0
  41. package/src/subagent-git-io.ts +80 -0
  42. package/src/subagent-launch.ts +110 -0
  43. package/src/subagent-poll-loop.ts +285 -0
  44. package/src/subagent-poller.ts +90 -0
  45. package/src/subagent-port.ts +27 -0
  46. package/src/subagent-scope.ts +4 -4
  47. package/src/subagent-session-io.ts +143 -0
  48. package/src/subagent-spawn.ts +156 -0
  49. package/src/terminal-core.ts +56 -54
  50. package/src/todo-core.ts +27 -64
  51. package/src/todo-read-hook.ts +28 -24
  52. package/src/todo-reminder-core.ts +89 -0
  53. package/src/todos-service.ts +36 -25
  54. package/src/tool-gate.ts +7 -28
  55. package/src/vocab.ts +18 -19
@@ -1,75 +1,72 @@
1
1
  /**
2
- * 档1 core/subagent(subagent 族迁 loader entry —— D78 挂载树 / D81 融合核心)。
2
+ * core/subagent — master-only loader entry (tools, registry, poller, GC).
3
3
  *
4
- * 承接(master-only):五工具(subagent/resume_subagent/list_agents/send_message/
5
- * interrupt_agent)+ 注册表(subs/pollers/持久化/重建)+ 扩展管道客户端(prompt/
6
- * follow_up/interrupt 注入)+ poller(结算观察/通知去重)+ 历史(history-store
7
- * 代际)+ 复活(revive)+ 任务 tab 放置(D25/D26)+ GC(tab 级 + pane 级 + ticker)。
8
- *
9
- * 依赖全经 `pi-herdr.subagent-deps`(client/env/extPath/sessionRoot/回调槽…):
10
- * common 段的 pipe 消费者(applyReplySession/reconcileOnReply)经 slots 反向暴露。
11
- * reconcileOnSettlement(M17 对账)与 withReconcileNotes/claimSettleNotice 留在
12
- * index session 状态层,本插件回调消费(③ 的显式留守决策)。
4
+ * Inbound deps via `pi-herdr.subagent-deps`. Outbound pipe/settle queries bind
5
+ * atomically onto `port.current` (see subagent-port.ts). Settlement reconcile
6
+ * stays in index session state; this plugin consumes it through the port.
13
7
  */
14
8
  import { Context } from '@deepseek-ai/cordis';
15
9
  import { Type } from 'typebox';
16
10
  import { randomUUID } from 'node:crypto';
17
- import { fileURLToPath } from 'node:url';
18
- import { homedir } from 'node:os';
19
- import { join, dirname } from 'node:path';
11
+ import { basename, join, dirname } from 'node:path';
20
12
  import type { PiSurface } from '../pi-surface.ts';
21
- import type { HerdrClientLike, HerdrAgentState } from '../herdr-client.ts';
13
+ import type { HerdrClientLike } from '../herdr-client.ts';
22
14
  import type { TodosService } from '../todos-service.ts';
23
- import { mountSubagentScope } from '../subagent-scope.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
- import { hasAssistantAfter, hasPendingToolCall, lastAssistantText, listSessionFiles, readSessionFile, sessionFileById } from '../session-tail.ts';
26
- import { appendHistory, applyReportedSessionFile, historyFilePath, inheritOutcome, latestGeneration, normalizeEntryKind, readHistory, type HistoryEntry } from '../history-store.ts';
27
- import { execFile } from 'node:child_process';
28
- import { stat as statCb } from 'node:fs';
15
+ import { Semaphore, SUBS_CUSTOM_TYPE, agoText, buildAliveNotice, buildBlockedGateNotice, buildIsolatePreamble, classifyWorktreeZone, foldSubsRegistry, formatSubagentResult, isAlive, isPathUnder, makeProgressUpdate, makeRegistry, planIsolateWorktree, tabNameForTask, type AliveProbe, type SubEntry } from '../subagent-core.ts';
16
+ import { applyReportedSessionFile, appendHistory, preferredHistoryFile, inheritOutcome, latestGeneration, readHistory, type HistoryEntry } from '../history-store.ts';
17
+ import { runtimePolicy } from '../runtime-policy.ts';
18
+ import { platformPaths } from '../platform-paths.ts';
19
+ import type { SubagentPort, SubagentPortBox } from '../subagent-port.ts';
20
+ import {
21
+ FOREGROUND_POLL_MS,
22
+ planForegroundTick,
23
+ planIsolateRepoGuard,
24
+ planLaunchValidation,
25
+ planPatienceExpiry,
26
+ } from '../subagent-launch.ts';
27
+ import { mkdirSync, stat as statCb } from 'node:fs';
29
28
  import { resolve as pathResolve } from 'node:path';
30
29
  import { promisify } from 'node:util';
31
30
  const statAsync = promisify(statCb);
32
- import { pingUntilReady, pipeNameFor, pipeRequest } from '../pipe-channel.ts';
33
- import { shouldClosePane, shouldCloseTaskTab } from '../gc-core.ts';
31
+ import { pipeNameFor, pipeRequestTo } from '../pipe-channel.ts';
34
32
  import { composeForRole } from '../manifest-compose.ts';
35
- import { formatSettlementNotice } from '../vocab.ts';
36
- import { ABORT_STOP_REASON } from '../settle-wake-core.ts';
33
+ import { TODO_REMINDER_CUSTOM_TYPE, planStopTodoReminder, todoReminderGraceMs } from '../todo-reminder-core.ts';
34
+ import type { TerminalStateSlot } from './terminal.ts';
35
+ import { createGitIo } from '../subagent-git-io.ts';
36
+ import { createSessionIo } from '../subagent-session-io.ts';
37
+ import { createSpawner } from '../subagent-spawn.ts';
38
+ import { createPoller } from '../subagent-poll-loop.ts';
39
+ import { createGcController } from '../subagent-gc.ts';
40
+
37
41
 
38
- export interface SubagentEnv {
42
+ interface SubagentEnv {
39
43
  paneId: string;
40
44
  tabId: string;
41
45
  workspaceId: string;
42
46
  }
43
47
 
44
- /** common 段 pipe 消费者槽(插件挂载时回填)。 */
45
- export interface SubagentSlots {
46
- applyReplySession: ((paneId: string, sessionFile: string | null) => void) | null;
47
- reconcileOnReply: ((paneId: string) => string[]) | null;
48
- /** D96:running 后台 subagent 列表(master settled 时检查用;worker 侧为 null)。 */
49
- listRunningSubs: (() => Array<{ paneId: string; description: string }>) | null;
50
- }
51
-
52
- export interface SubagentDeps {
48
+ interface SubagentDeps {
53
49
  client: HerdrClientLike;
54
50
  env: SubagentEnv | null;
55
- /** 本扩展入口(index.ts)路径——launchLine 的 -e 参数。 */
51
+ /** Extension entry (index.ts) path — launchLine `-e`. */
56
52
  extPath: string;
57
- /** master 树根(scope 挂载)。 */
53
+ /** Master tree root (scope mount). */
58
54
  sessionRoot: Context;
59
- slots: SubagentSlots;
55
+ port: SubagentPortBox;
60
56
  getSessionId: () => string;
61
57
  getBlockedDepth: () => number;
62
58
  reconcileOnSettlement: (description: string, outcome: 'settled' | 'failed') => string[];
63
59
  withReconcileNotes: (base: string, notes: readonly string[]) => string;
64
60
  claimSettleNotice: (key: string) => boolean;
65
61
  /**
66
- * D92 结算通知注入器(index.ts 提供):忙时入缓冲、turn_end 折叠 steer 批量注入。
67
- * 缺省回退 pi.sendUserMessage(followUp)(旧路径——整个 run 结束才回填,测试桩用)。
62
+ * Settlement notice injector from index: buffer while busy, flush at turn_end.
63
+ * Tests may omit it and fall back to pi.sendUserMessage(followUp).
68
64
  */
69
65
  deliverNotice?: (content: string, paneId?: string) => Promise<void>;
70
- /** B2:结算通知尚在缓冲未送达的 pane 集合(GC 豁免——先送达再回收)。 */
66
+ /** Panes whose settlement notice is still buffered (GC exemption). */
71
67
  noticePending?: () => ReadonlySet<string>;
72
- /** terminal 族 GC 豁免槽。 */
68
+ /** Terminal-family GC exemption (live terminal panes are not collected). */
69
+ terminalState: TerminalStateSlot;
73
70
  todos: TodosService;
74
71
  }
75
72
 
@@ -81,25 +78,16 @@ const SUBAGENT_DESCRIPTION = [
81
78
  '`run_in_background` (default false): when true, the call returns immediately with an agentId; the subagent keeps running in its pane. Use list_agents to see its state, send_message to give it follow-up work, interrupt_agent to stop it. When it settles, you receive a notification message with its closing output.',
82
79
  '`tab` (optional): name of a task tab to place the subagent into (join if a tab with this name exists, otherwise create it). Overrides the default placement. Default placement groups by git worktree: a subagent working in your checkout shares your tab; one working in a separate worktree (pass its path via `cwd`; create worktrees with `git worktree add`) gets its own tab named after the worktree directory.',
83
80
  '`role` + `allowed_tools`: when role matches a profile (searched: workspace .pi-herdr/roles/ → user-global ~/.pi/agent/herdr-pi/roles/ → builtin), the worker toolset becomes the composed manifest — baseline ∪ allowed_tools minus role-deny tools (deny always wins). Custom roles: drop a JSON profile into .pi-herdr/roles/ (master/worker-default reserved). Unknown role names remain display labels only.',
81
+ '`isolate` (default false): creates a FRESH git worktree for this subagent and runs it there (branch pier/<slug> from your HEAD under ~/.herdr/worktrees/<repo>/). Three-way choice: heavy independent writing in parallel with your own edits or other workers, or work needing its own clean reviewable diff → isolate; read-mostly or sequential helper work → omit (shared checkout, writes guarded by the write-lock); targeting an existing directory/worktree → cwd. In isolate mode the subagent\'s writes cannot conflict with your checkout; its panes group into a tab named after the worktree; its prompt is prefixed with commit discipline (commit to its own branch, NEVER push); when it settles you get a diff summary (commits since base, files changed, uncommitted count). Review with git log/diff HEAD..<branch>, merge with git merge --no-ff <branch>; once merged and clean, the worktree auto-removes (the branch is kept for audit). Mutually exclusive with cwd.',
84
82
  ].join(' ');
85
83
 
86
- /** 子代理并发上限(D10)。 */
84
+ /** Subagent concurrency limit (max parallel delegations) */
87
85
  const SUBAGENT_CONCURRENCY = 4;
88
- /** B1:观察超时 = 无活动时长预算(非总墙钟)——working 切片持续续命;marker
89
- * PI_HERDR_SUBAGENT_TIMEOUT_MS 沿用旧名,语义已从「poller 起算总墙钟」改为
90
- * 「距最近一次活动」——>10min 的健康长任务不再被误杀(session 01a03c0d 实证)。 */
91
- const SUBAGENT_TIMEOUT_MS = Number(process.env.PI_HERDR_SUBAGENT_TIMEOUT_MS ?? 600000) || 600000;
92
- /** 就绪等待上限(TUI 启动 + herdr 检测)。 */
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;
86
+ const SUBAGENT_TIMEOUT_MS = runtimePolicy.subagentTimeoutMs;
87
+ const SUB_READY_TIMEOUT_MS = runtimePolicy.readinessTimeoutMs;
100
88
 
101
89
  function defaultAgentSessionsDir(): string {
102
- const base = process.env.PI_CODING_AGENT_DIR || join(homedir(), '.pi', 'agent');
90
+ const base = process.env.PI_CODING_AGENT_DIR || platformPaths.agentDataDir;
103
91
  return join(base, 'sessions');
104
92
  }
105
93
 
@@ -110,10 +98,14 @@ function agentRootDir(): string {
110
98
  export default function subagentPlugin(ctx: Context): void {
111
99
  const surface = ctx.get('pi-herdr.surface') as PiSurface<object>;
112
100
  const d = ctx.get('pi-herdr.subagent-deps') as SubagentDeps;
113
- const { client, env, sessionRoot, slots, terminalState, todos } = d;
101
+ const { client, env, sessionRoot, port, terminalState, todos } = d;
114
102
  const pi = surface.raw as {
115
103
  appendEntry?: (customType: string, data: unknown) => void;
116
104
  sendUserMessage?: (content: string, opts?: { deliverAs?: string }) => Promise<void>;
105
+ sendMessage?: (
106
+ message: { customType: string; content: string; display?: boolean; details?: Record<string, unknown> },
107
+ opts?: { deliverAs?: string; triggerTurn?: boolean },
108
+ ) => Promise<void>;
117
109
  };
118
110
  const scoped = surface.forModule(import.meta.url);
119
111
 
@@ -123,75 +115,82 @@ export default function subagentPlugin(ctx: Context): void {
123
115
  extPath: d.extPath,
124
116
  };
125
117
  const subSemaphore = new Semaphore(SUBAGENT_CONCURRENCY);
126
- /** B4:每 pane 最近一次机器注入(prompt/follow_up pipe 投递成功)时间——观察窗内的
127
- * working 若发生在宽限期内,是自己的注入在被处理,不是用户接管。 */
118
+ /** B4 timestamps machine injection so grace-period working events are not mistaken for takeover. */
128
119
  const lastMachineInjectAt = new Map<string, number>();
129
- /* 子代理注册表(custom 条目持久化,分支正确;parent 重启后可从分支重建) */
120
+ /* Persist the subagent registry as custom branch state so parent restarts can rebuild it. */
130
121
  const subs = new Map<string, SubEntry>();
131
- const pollers = new Set<string>();
132
- const subScopes = new Map<string, { dispose: () => Promise<void> }>();
133
- /** D50:每 pane 最近一次机器请求 id(interrupt 按轮次占位、pollLoop 去重用)。 */
122
+ /** D98 excludes branches during worktree-add-to-registry races from orphan collection. */
123
+ const pendingIsolateBranches = new Set<string>();
124
+ /** D50 tracks the latest machine request per pane for interrupt claims and poll deduplication. */
134
125
  const lastRequestIdByPane = new Map<string, string>();
135
126
 
136
- /** O1:上次落盘快照(内容哈希门控——01a03c0d 实证 48% session 行是心跳期重复快照)。 */
127
+ /** O1 suppresses duplicate snapshots because 01a03c0d found 48% of session rows were heartbeat repeats. */
137
128
  let lastSubsSnapshot = '';
138
129
 
139
130
  function persistSubs(): void {
140
131
  try {
141
132
  const reg = makeRegistry([...subs.values()]);
142
133
  const snap = JSON.stringify(reg);
143
- if (snap === lastSubsSnapshot) return; // 内容未变不落(1s 观察窗/5s 接管循环的重复心跳)
134
+ if (snap === lastSubsSnapshot) return; // Avoid duplicate writes from the 1s/5s observation loops.
144
135
  lastSubsSnapshot = snap;
145
136
  pi.appendEntry?.(SUBS_CUSTOM_TYPE, reg);
146
137
  } catch {
147
- /* 持久化尽力而为 */
138
+ /* Persistence is best-effort so session logging cannot break delegation. */
148
139
  }
149
140
  }
150
- /** E2:已发过闸门通知的 pane(blocked 期间去重;解除后移除,二次 ask 可再通知)。 */
141
+ /** E2 deduplicates gate notices while blocked but permits a later, distinct human question. */
151
142
  const blockedGateNotified = new Set<string>();
152
-
153
- // slots 回填:common 段 pipe reply 消费
154
- slots.applyReplySession = (paneId, sessionFile) => {
155
- const entry = subs.get(paneId);
156
- if (!entry) return;
157
- const next = applyReportedSessionFile(entry.sessionFile, sessionFile);
158
- if (next === entry.sessionFile) return;
159
- entry.sessionFile = next;
160
- persistSubs();
161
- writeHistory(entry, undefined, 'session-report');
162
- };
163
- slots.reconcileOnReply = (paneId) => {
164
- const entry = subs.get(paneId);
165
- return entry ? d.reconcileOnSettlement(entry.description, 'settled') : [];
166
- };
167
- // D96:master settled 时检查还有哪些后台 subagent 在 running(防过早总结)
168
- slots.listRunningSubs = () => {
169
- return [...subs.values()]
170
- .filter((s) => s.background && s.status === 'running')
171
- .map((s) => ({ paneId: s.paneId, description: s.description }));
143
+ const git = createGitIo();
144
+
145
+ const boundPort: SubagentPort = {
146
+ applyReplySession(paneId, sessionFile) {
147
+ const entry = subs.get(paneId);
148
+ if (!entry) return;
149
+ const next = applyReportedSessionFile(entry.sessionFile, sessionFile);
150
+ if (next === entry.sessionFile) return;
151
+ entry.sessionFile = next;
152
+ persistSubs();
153
+ writeHistory(entry, undefined, 'session-report');
154
+ },
155
+ reconcileOnReply(paneId) {
156
+ const entry = subs.get(paneId);
157
+ return entry ? d.reconcileOnSettlement(entry.description, 'settled') : [];
158
+ },
159
+ listRunningSubs() {
160
+ return [...subs.values()]
161
+ .filter((s) => s.background && s.status === 'running')
162
+ .map((s) => ({ paneId: s.paneId, description: s.description }));
163
+ },
164
+ async settleStatLine(paneId) {
165
+ const entry = subs.get(paneId);
166
+ return entry ? await git.worktreeStatLine(entry) : null;
167
+ },
172
168
  };
173
-
169
+ port.current = boundPort;
170
+ ctx.effect(() => () => {
171
+ if (port.current === boundPort) port.current = null;
172
+ }, 'subagent-port');
174
173
  function rebuildSubs(eventCtx: unknown): void {
175
174
  try {
176
175
  const entries = (eventCtx as { sessionManager?: { getBranch?: () => readonly unknown[] } })
177
176
  ?.sessionManager?.getBranch?.() ?? [];
178
177
  const reg = foldSubsRegistry(entries as Parameters<typeof foldSubsRegistry>[0]);
179
178
  for (const sub of reg.subs) subs.set(sub.paneId, sub);
180
- lastSubsSnapshot = ''; // 分支回放改变了状态 → 下次 persistSubs 强制落盘
179
+ lastSubsSnapshot = ''; // Force persistence because branch replay replaced live state.
181
180
  } catch {
182
- /* 重建失败不影响主流程 */
181
+ /* Registry recovery failure must not block the live session. */
183
182
  }
184
183
  }
185
184
 
186
- /** B6:master 崩溃遗留的 running 行——按 herdr 实况补 closed(pane 没了才关,
187
- * pane 还在的真在跑则保留)。01a03c0d 台账实证:p6/p7 永久 running 无回收。 */
185
+ /** B6 closes crash-stale running rows only after herdr confirms their pane is gone; 01a03c0d
186
+ * showed p6/p7 otherwise remained permanently running. */
188
187
  async function sweepZombieRunning(): Promise<void> {
189
188
  if (!client.available || subs.size === 0) return;
190
189
  let livePaneIds: ReadonlySet<string>;
191
190
  try {
192
191
  livePaneIds = new Set((await client.listPanes()).map((p) => p.paneId));
193
192
  } catch {
194
- return; // 查询失败不误关
193
+ return; // Do not close agents when liveness lookup itself failed.
195
194
  }
196
195
  let changed = false;
197
196
  for (const [paneId, e] of subs) {
@@ -212,14 +211,13 @@ export default function subagentPlugin(ctx: Context): void {
212
211
  await sweepZombieRunning();
213
212
  });
214
213
 
215
- /* ── B1(用户实证修复):subagent isError 结果的探活改写 ──────────────
216
- * 实测事故:前台误判 no-output → 模型读裸错误文案 → "failed, 我自己干" 抢活。
217
- * pi 的 tool_result 事件官方语义 "Can modify result"——在文本进模型上下文前,
218
- * 当场探活(agent.list + 会话 mtime);判活则改写为统一存活通知(buildAliveNotice
219
- * 与 A2 转后台同一出口)。hook 层硬保障,纯 prompt 防不住的自作主张在此物理拦截。 */
214
+ /* ── B1 liveness rewrite for subagent errors ─────────────────────
215
+ * A false no-output result caused the model to seize work from a healthy subagent. Probe
216
+ * agent.list and session mtime before the tool result enters model context; if alive, emit the
217
+ * same notice as A2 backgrounding. This hook enforces what prompting alone could not. */
220
218
  scoped.on('tool_result', async (event: { toolName?: string; toolCallId?: string; isError?: boolean; content?: Array<{ type: string; text?: string }> }) => {
221
219
  if (event?.toolName !== 'subagent' || !event.isError) return;
222
- // 从错误文本里取 pane id(我们自己的错误文案都带 pane);取不到则不改写
220
+ // Extract only pane ids carried by our own errors; leave unrelated errors untouched.
223
221
  const errText = (event.content ?? []).map((c) => c.text ?? '').join(' ');
224
222
  const paneId = [...subs.keys()].find((id) => errText.includes(id))
225
223
  ?? (errText.match(/\bw[A-Za-z0-9]+:p\d+\b/) ?? [])[0];
@@ -227,8 +225,8 @@ export default function subagentPlugin(ctx: Context): void {
227
225
  const entry = subs.get(paneId);
228
226
  if (!entry || entry.status === 'settled' || entry.status === 'consumed') return;
229
227
  const probe = await probeAlive(paneId, entry.cwd);
230
- if (!isAlive(probe, Date.now())) return; // 真死了 → 保留原错误,模型可重做
231
- // 活着 → 转 poller(若尚未在跑)并改写结果文本
228
+ if (!isAlive(probe, Date.now())) return; // Preserve the original error only when the agent is truly dead.
229
+ // Move a live agent to the poller so its eventual result can replace the false error.
232
230
  if (!pollers.has(paneId)) {
233
231
  entry.background = true;
234
232
  startPoller(paneId, entry.cwd, entry.createdAt, entry.createdAt, entry.description, lastRequestIdByPane.get(paneId) ?? `probe-${paneId}`);
@@ -241,410 +239,70 @@ export default function subagentPlugin(ctx: Context): void {
241
239
  return { content: [{ type: 'text', text: notice }] };
242
240
  });
243
241
 
244
- /* ── D41:stop 未完成提醒(仅主控;子代理不注册本块) ── */
242
+ /* ── D41 safe reminders for unfinished todos ─────────────────────
243
+ * 01a040cc showed that a user-role reminder overrode model judgment and executed work reserved
244
+ * for the user. Use a distinguishable custom message, request reconciliation rather than action,
245
+ * and delay delivery so any new agent start cancels it. Preserve the ESC guard from 01a03bf0 to
246
+ * avoid wake-up storms after explicit interruption. */
245
247
 
246
248
  let todoReminders = 0;
247
- const TODO_REMINDERS_MAX = 3;
248
- // 反唤醒风暴:用户 ESC 中止后的 settled 不催(否则 abort → 提醒 → 新 run → 再 abort
249
- // 死循环;与 index 的 D96/结算缓冲同款守卫,session 01a03bf0 实证)。
250
249
  let lastAssistantStopReason: string | null = null;
250
+ let todoReminderTimer: NodeJS.Timeout | null = null;
251
+
252
+ function cancelTodoReminder(): void {
253
+ if (todoReminderTimer !== null) {
254
+ clearTimeout(todoReminderTimer);
255
+ todoReminderTimer = null;
256
+ }
257
+ }
258
+
251
259
  scoped.on('turn_end', async (event: unknown) => {
252
260
  if (event === null || typeof event !== 'object' || !('message' in event)) return;
253
- const msg = (event as { message: unknown }).message; // 'message' in 已守卫
261
+ const msg = (event as { message: unknown }).message; // Safe after the property guard above.
254
262
  if (msg === null || typeof msg !== 'object') return;
255
263
  const { role, stopReason } = msg as { role?: unknown; stopReason?: unknown };
256
264
  if (role === 'assistant' && typeof stopReason === 'string') {
257
265
  lastAssistantStopReason = stopReason;
258
266
  }
259
267
  });
260
- scoped.on('agent_settled', async () => {
261
- if (lastAssistantStopReason === ABORT_STOP_REASON) return;
262
- if (todoReminders >= TODO_REMINDERS_MAX) return;
263
- if (pollers.size > 0) return; // 有在途子代理 → 主控本来就在等,不催
264
- if (d.getBlockedDepth() > 0) return; // 等人类回答 → 不催
265
- const open = todos.items.filter((it) => it.status === 'pending' || it.status === 'in_progress');
266
- if (open.length === 0) return;
267
- todoReminders += 1;
268
- const list = open
269
- .map((it) => (it.status === 'in_progress' ? `▶ ${it.content}` : `· ${it.content}`))
270
- .join('\n');
271
- const reminder = `<system-reminder>You stopped with unfinished todos (Reminder ${todoReminders}/${TODO_REMINDERS_MAX}):\n${list}\nContinue working on them before stopping, or call todo_write to update the list if they no longer apply.</system-reminder>`;
272
- try {
273
- await pi.sendUserMessage?.(reminder, { deliverAs: 'followUp' });
274
- } catch {
275
- /* 静默 */
276
- }
277
- });
278
-
279
- /* ── 通道助手(M11:就绪 = 管道握手) ── */
280
-
281
- /** 就绪等待:管道 ping(子扩展 session_start 起 server 即就绪;D47)。 */
282
- async function waitSubReady(cwd: string, paneId: string): Promise<boolean> {
283
- return pingUntilReady(pipeNameFor(cwd, paneId), SUB_READY_TIMEOUT_MS);
284
- }
285
-
286
- /**
287
- * 子代理会话文件候选(v1.3 M7 修复结算文本串线,实测):
288
- * 1. 上报路径(本扩展 report_agent_session 的 .jsonl path);
289
- * 2. 上报 session id → 按 id 还原;
290
- * 3. 目录内最新 4 个(排除主控自己的会话——父在写盘时其 mtime 常最新,
291
- * 曾实测致结算文本取到父的回复)。
292
- */
293
- async function resolveSessionFileCandidates(paneId: string, cwd: string): Promise<string[]> {
294
- const out: string[] = [];
295
- try {
296
- const reported = await client.getAgentSessionPath(paneId);
297
- if (reported) {
298
- if (/\.jsonl$/.test(reported)) out.push(reported);
299
- else {
300
- const byId = sessionFileById(cwd, defaultAgentSessionsDir(), reported);
301
- if (byId) out.push(byId);
302
- }
303
- }
304
- } catch {
305
- /* 走目录扫描 */
306
- }
307
- const ownSession = d.getSessionId();
308
- for (const f of listSessionFiles(cwd, defaultAgentSessionsDir(), 4)) {
309
- if (f !== ownSession && !out.includes(f)) out.push(f);
310
- }
311
- return out;
312
- }
313
-
314
- /** 定位可解析的子代理会话文件(spawn/结算登记用)。 */
315
- async function resolveSessionFile(paneId: string, cwd: string): Promise<string | null> {
316
- for (const file of await resolveSessionFileCandidates(paneId, cwd)) {
317
- if (readSessionFile(file)) return file;
318
- }
319
- return null;
320
- }
321
-
322
- /** 结算后(带重试)取注入时间点之后的最终回答。 */
323
- async function collectFinalText(
324
- paneId: string,
325
- cwd: string,
326
- sinceTs: number,
327
- attempts = 12,
328
- ): Promise<string | null> {
329
- for (let i = 0; i < attempts; i++) {
330
- for (const file of await resolveSessionFileCandidates(paneId, cwd)) {
331
- const entries = readSessionFile(file);
332
- if (!entries) continue;
333
- const r = lastAssistantText(entries, { sinceTs });
334
- if (r?.text) return r.text;
335
- }
336
- await new Promise((r) => setTimeout(r, 500));
337
- }
338
- return null;
339
- }
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
-
374
- /**
375
- * 子会话结算状态(v1.3 M8 结算竞态修复):
376
- * herdr 的 idle/done 在"注入瞬间"也是真,不能当结算信号;
377
- * 以会话内容为准——定稿文本 = 已结算;挂起 toolCall = 未结算;
378
- * 有 assistant 活动但无文本 = 真·无输出;无活动 = 还没开始。
379
- */
380
- async function subSessionState(
381
- paneId: string,
382
- cwd: string,
383
- sinceTs: number,
384
- ): Promise<{ text: string | null; pendingTool: boolean; activity: boolean }> {
385
- for (const file of await resolveSessionFileCandidates(paneId, cwd)) {
386
- const entries = readSessionFile(file);
387
- if (entries.length === 0) continue;
388
- const r = lastAssistantText(entries, sinceTs);
389
- if (r?.text) return { text: r.text, pendingTool: false, activity: true };
390
- if (hasPendingToolCall(entries, sinceTs)) return { text: null, pendingTool: true, activity: true };
391
- if (hasAssistantAfter(entries, sinceTs)) return { text: null, pendingTool: false, activity: true };
392
- }
393
- return { text: null, pendingTool: false, activity: false };
394
- }
395
-
396
- /** D92:结算通知统一出口——经 deps 缓冲(忙时 turn_end 折叠注入),缺省回退旧 followUp 直投。 */
397
- const injectNotice = (content: string): Promise<void> =>
398
- d.deliverNotice ? d.deliverNotice(content)
399
- : (pi.sendUserMessage?.(content, { deliverAs: 'followUp' }) ?? Promise.resolve());
400
268
 
401
- /** 后台子代理轮询器:结算→取文→followUp 通知;blocked 让位给人类;D94:用户接管检测。 */
402
- async function pollLoop(
403
- paneId: string,
404
- cwd: string,
405
- spawnedAt: number,
406
- injectTs: number,
407
- description: string,
408
- requestId: string,
409
- ): Promise<void> {
410
- void spawnedAt;
411
- // B1:无活动预算锚点——working 切片(waitAgent 超时 null)即心跳(O3):每次续命;
412
- // 只有「不 working 也无结算进展」的真空期才消耗预算(01a03c0d:27min 健康长任务
413
- // 被旧总墙钟 10min 误杀两次)。startedAt 保留供通知文案诊断。
414
- const startedAt = Date.now();
415
- let lastActivityAt = Date.now();
416
- try {
417
- while (true) {
418
- const entry = subs.get(paneId);
419
- if (!entry || entry.status === 'settled') return;
420
-
421
- // D94:归还检测——若用户已接管且 idle 超 60s,自动归还控制权
422
- if (entry.userTakeover) {
423
- try {
424
- const agents = await client.listAgents();
425
- const agent = agents.find((a) => a.paneId === paneId);
426
- if (agent) {
427
- const currentStatus = agent.status;
428
- const previousStatus = entry.lastAgentStatus;
429
-
430
- if (currentStatus === 'idle') {
431
- // 状态刚变为 idle(从 working/blocked 转来)→ 记录 idle 起点
432
- if (previousStatus !== 'idle') {
433
- entry.observationStartedAt = Date.now();
434
- entry.lastAgentStatus = 'idle';
435
- persistSubs();
436
- } else if (entry.observationStartedAt) {
437
- // 持续 idle → 检查是否超过 60s
438
- if (Date.now() - entry.observationStartedAt > 60000) {
439
- // Idle 超 60s,归还控制权
440
- entry.userTakeover = false;
441
- entry.observationStartedAt = null;
442
- entry.lastAgentStatus = null;
443
- persistSubs();
444
- // 继续正常管理流程(下一轮会走正常 waitAgent 分支)
445
- }
446
- }
447
- } else {
448
- // working/blocked → 重置 idle 计时(只记录状态,不记时间)
449
- entry.lastAgentStatus = currentStatus;
450
- entry.observationStartedAt = null; // 清除 idle 计时
451
- persistSubs();
452
- }
453
- }
454
- } catch {
455
- // listAgents 失败静默
456
- }
457
- // 已接管,跳过正常管理逻辑,5s 后再检查
458
- if (entry.userTakeover) {
459
- await new Promise((resolve) => setTimeout(resolve, 5000));
460
- continue;
461
- }
462
- }
463
-
464
- let state: HerdrAgentState | null;
465
- try {
466
- state = await client.waitAgent(paneId, ['idle', 'done', 'blocked'], 30000);
467
- } catch {
468
- state = null;
469
- }
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)
486
- if (state === 'idle' || state === 'done') {
487
- const s = await subSessionState(paneId, cwd, injectTs);
488
- if (s.text || (!s.pendingTool && s.activity)) {
489
- // Settled
490
- const closing = s.text;
269
+ // B3 cancels the reminder when any source resumes work during the grace window.
270
+ scoped.on('agent_start', () => cancelTodoReminder());
271
+ scoped.on('session_shutdown', () => cancelTodoReminder());
491
272
 
492
- // D94:观察期逻辑——settled 后 30s 内检测用户介入
493
- if (!entry.observationStartedAt) {
494
- // 首次 settled → 启动 30s 观察期
495
- entry.observationStartedAt = Date.now();
496
- entry.lastAgentStatus = 'idle';
497
- persistSubs();
498
- // 短暂等待后进入下一轮(观察期检测)
499
- await new Promise((resolve) => setTimeout(resolve, 1000));
500
- continue;
501
- } else {
502
- // 已在观察期
503
- const elapsed = Date.now() - entry.observationStartedAt;
504
-
505
- // 检测用户介入:查询当前状态是否变为 working。
506
- // B4:观察窗内 working ≠ 必然用户接管——自己的 follow_up 注入也呈现
507
- // working。距最近机器注入 < MACHINE_INJECT_GRACE_MS 的 working 视为
508
- // 注入被处理(取消结算继续观察),否则才判接管(01a03c0d:契约消息
509
- // 20min 后投递,恰好撞进观察窗)。
510
- try {
511
- const agents = await client.listAgents();
512
- const agent = agents.find((a) => a.paneId === paneId);
513
- if (agent && agent.status === 'working') {
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 收尾再判结算
524
- entry.observationStartedAt = Date.now();
525
- persistSubs();
526
- await new Promise((resolve) => setTimeout(resolve, 1000));
527
- continue;
528
- }
529
- } catch {
530
- // listAgents 失败静默
531
- }
532
-
533
- if (elapsed < 30000) {
534
- // 观察期未结束,继续等待
535
- await new Promise((resolve) => setTimeout(resolve, 1000));
536
- continue;
537
- }
538
-
539
- // 观察期结束(30s 无用户介入),正常结算
540
- entry.observationStartedAt = null; // 清除观察期标记
541
- }
542
- // 正常结算逻辑
543
- // O6:reply 已写的 sessionFile 是权威;poll 只在缺失时用扫描补,绝不反向覆盖
544
- if (!entry.sessionFile) {
545
- entry.sessionFile = applyReportedSessionFile(
546
- entry.sessionFile,
547
- await resolveSessionFile(paneId, cwd),
548
- );
549
- }
550
- entry.status = 'consumed';
551
- entry.consumedAt = Date.now();
552
- writeHistory(entry, { outcome: closing }, 'poll-settle');
553
- // M17:结算自动对账(先于通知;与 reply 快路径幂等双跑)
554
- const notes = d.reconcileOnSettlement(description, 'settled');
555
- const notice = d.withReconcileNotes(
556
- formatSettlementNotice(`${paneId} (${description})`, closing),
557
- notes,
558
- );
559
- if (d.claimSettleNotice(`${paneId}:${requestId}`)) {
560
- try {
561
- await injectNotice(notice);
562
- } catch {
563
- /* 注入失败静默(下次 turn 可 list_agents 自查) */
564
- }
565
- }
566
- return;
567
- }
568
- // 挂起 toolCall(等人类输入)或还没开工 → 继续等
569
- }
570
- // B1/O3:state=null(30s 切片超时)= working 中的心跳 → 续命无活动预算。
571
- if (state === null) lastActivityAt = Date.now();
572
- // 切片超时:检查 pane 存活与总预算
573
- let alive = false;
273
+ scoped.on('agent_settled', async () => {
274
+ cancelTodoReminder(); // Cancel any timer left from the previous settlement.
275
+ const plan = planStopTodoReminder({
276
+ lastStopReason: lastAssistantStopReason,
277
+ reminders: todoReminders,
278
+ runningSubs: pollers.size,
279
+ blockedDepth: d.getBlockedDepth(),
280
+ items: todos.items,
281
+ });
282
+ if (!plan.due || plan.content == null) return;
283
+ const content = plan.content;
284
+ todoReminderTimer = setTimeout(() => {
285
+ todoReminderTimer = null;
286
+ void (async () => {
287
+ // Without sendMessage, skip the reminder rather than impersonating the user channel.
288
+ const send = pi.sendMessage;
289
+ if (typeof send !== 'function') return;
574
290
  try {
575
- alive = (await client.listAgents()).some((a) => a.paneId === paneId);
576
- } catch {
577
- alive = true; // 查询失败不误判
578
- }
579
- if (!alive) {
580
- entry.status = 'consumed';
581
- entry.consumedAt = Date.now(); // O8:提前退出路径必须置消费时间,否则 tab TTL 被卡住
582
- persistSubs();
583
- writeHistory(entry, { outcome: 'pane closed before settling' }, 'poll-pane-closed');
584
- const notes = d.reconcileOnSettlement(description, 'failed');
585
- const notice = d.withReconcileNotes(
586
- `Background subagent ${paneId} (${description}) stopped before settling (its pane closed).`,
587
- notes,
588
- );
589
- try {
590
- await injectNotice(notice);
591
- } catch {
592
- /* 静默 */
593
- }
594
- return;
595
- }
596
- // B1:无活动预算超时——working 心跳持续续命,此处只在「pane 活着但
597
- // 既不 working、也无结算进展」真空期超预算时触发(旧总墙钟 10min 误杀
598
- // 27min 健康长任务 ×2,01a03c0d 实证)。
599
- if (Date.now() - lastActivityAt > SUBAGENT_TIMEOUT_MS) {
600
- entry.status = 'consumed';
601
- entry.consumedAt = Date.now(); // O8:observation timeout 同样置消费时间
602
- persistSubs();
603
- writeHistory(entry, { outcome: 'observation timeout' }, 'poll-timeout');
604
- const notes = d.reconcileOnSettlement(description, 'failed');
605
- // O2:给行动钩子——list_agents 看活态;真在跑就让它跑完(结算会自动通知),
606
- // 别 sleep 盲等(01a03c0d 实证 master 收到旧文案后连 sleep 90/120/180)。
607
- const notice = d.withReconcileNotes(
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.`,
609
- notes,
291
+ await send(
292
+ { customType: TODO_REMINDER_CUSTOM_TYPE, content, display: true },
293
+ { deliverAs: 'followUp', triggerTurn: true },
610
294
  );
611
- try {
612
- await injectNotice(notice);
613
- } catch {
614
- /* 静默 */
615
- }
616
- return;
295
+ todoReminders += 1; // Count only delivered reminders so cancellation and failure do not consume the cap.
296
+ } catch {
297
+ /* Delivery failure is non-fatal. */
617
298
  }
618
- }
619
- } finally {
620
- pollers.delete(paneId);
621
- }
622
- }
623
-
624
- function startPoller(paneId: string, cwd: string, spawnedAt: number, injectTs: number, description: string, requestId: string): void {
625
- if (pollers.has(paneId)) return;
626
- pollers.add(paneId);
627
- void (async () => {
628
- if (!subScopes.has(paneId)) {
629
- const fiber = await mountSubagentScope(sessionRoot, paneId, {
630
- onDispose: () => { pollers.delete(paneId); },
631
- });
632
- subScopes.set(paneId, fiber);
633
- }
634
- await pollLoop(paneId, cwd, spawnedAt, injectTs, description, requestId);
635
- const fiber = subScopes.get(paneId);
636
- subScopes.delete(paneId);
637
- try { await fiber?.dispose(); } catch { /* already gone */ }
638
- })();
639
- }
640
-
641
- /* ── 工具:subagent(v1.3:任务 tab 放置 + 短/长 pane + 历史 + GC) ── */
642
-
643
- /** 串行化 tab 创建/追加(同消息并行委派的读改写互斥;D26 放置决策在锁内做)。 */
644
- const tabMutex = new Semaphore(1);
299
+ })();
300
+ }, todoReminderGraceMs());
301
+ todoReminderTimer.unref?.(); // Do not keep tests or headless processes alive for a reminder.
302
+ });
645
303
 
646
304
  function histFile(cwd: string): string {
647
- return historyFilePath(agentRootDir(), cwd);
305
+ return preferredHistoryFile(agentRootDir(), cwd);
648
306
  }
649
307
 
650
308
  function toHistory(e: SubEntry, outcome: string | null): HistoryEntry {
@@ -668,8 +326,8 @@ export default function subagentPlugin(ctx: Context): void {
668
326
  };
669
327
  }
670
328
 
671
- /** B5:每 taskId 最近非空 outcome(closed 补记行继承——latestGeneration 取最新行,
672
- * 旧实现 closed 行恒 outcome:null → 结算成果在「最新行」语义下丢失)。 */
329
+ /** B5 retains the latest non-empty outcome because closed generations otherwise hid settled
330
+ * results behind outcome:null in latest-generation views. */
673
331
  const lastOutcomeByTask = new Map<string, string>();
674
332
 
675
333
  function writeHistory(e: SubEntry, patch?: Partial<HistoryEntry>, via?: string): void {
@@ -678,283 +336,61 @@ export default function subagentPlugin(ctx: Context): void {
678
336
  appendHistory(histFile(e.cwd), { ...toHistory(e, outcome), ...(patch ?? {}), ...(via ? { via } : {}) });
679
337
  }
680
338
 
681
- /** 存活任务 tab(本 workspace;herdr 为权威,tab.rename/自动关后自动纠正)。 */
682
- async function liveTabs(): Promise<Array<{ tabName: string; tabId: string }>> {
683
- try {
684
- const ws = env?.workspaceId ?? '';
685
- return (await client.tabList())
686
- .filter((t) => !ws || t.workspaceId === ws)
687
- .map((t) => ({ tabName: t.label, tabId: t.tabId }));
688
- } catch {
689
- return [];
690
- }
691
- }
692
-
693
- /* ── D86:git worktree 分组键 ─────────────────────────────────── */
694
-
695
- /** worktree 列表缓存(spawn 时一次查询;TTL 短——放置只需近似新鲜度)。 */
696
- let worktreesCache: { at: number; list: string[] } | null = null;
697
- const WORKTREES_CACHE_MS = 5000;
698
-
699
- /**
700
- * 列出 repo 的全部 git worktree(`git worktree list --porcelain`,主检出在前)。
701
- * 非 git 目录 / git 不可用 → 空数组(分类器自然全兜底 main,规则退化不炸)。
702
- */
703
- async function listWorktrees(cwd: string): Promise<string[]> {
704
- if (worktreesCache && Date.now() - worktreesCache.at < WORKTREES_CACHE_MS) return worktreesCache.list;
705
- const list = await new Promise<string[]>((resolve) => {
706
- execFile('git', ['-C', cwd, 'worktree', 'list', '--porcelain'], { timeout: 5000 }, (err, stdout) => {
707
- if (err) return resolve([]);
708
- const out: string[] = [];
709
- for (const line of String(stdout).split('\n')) {
710
- const m = /^worktree (.+)$/.exec(line.trim());
711
- if (m) out.push(m[1]);
712
- }
713
- resolve(out);
714
- });
715
- });
716
- worktreesCache = { at: Date.now(), list };
717
- return list;
718
- }
719
-
720
- /**
721
- * 按放置计划在任务 tab 内新建子 pane(v1.3 D25/D26 + D86 worktree 分组):
722
- * - new:tab.create{label}(focus 默认 false 不抢焦点)+ 根 pane 注入启动命令;
723
- * - append:校验既有 tab 存活 → split 追加(进程不重启);
724
- * tab 已消失 → 降级为同名的 new。放置决策在互斥锁内做(同名竞态)。
725
- * D86:placement.zone 由调用方算好传入(main → master 所在 tab;worktree → 目录名 tab)。
726
- */
727
- async function spawnPaneInTaskTab(
728
- placement: { desiredTab?: string | null; description: string; zone?: WorktreeZone },
729
- cwd: string,
730
- envOver: Record<string, string>,
731
- launch: string,
732
- ): Promise<{ tabId: string; paneId: string; tabName: string }> {
733
- const release = await tabMutex.acquire();
734
- try {
735
- // D86:main tab = master pane 所在 tab(paneId 反查;HERDR_TAB_ID 注入不可依赖,实测可为空)
736
- const allPanes = await client.listPanes();
737
- const mainTabId = allPanes.find((p) => p.paneId === env?.paneId)?.tabId
738
- ?? (env?.tabId ? env.tabId : null);
739
- let plan: TabPlacementPlan = planTabPlacement({
740
- desiredTab: placement.desiredTab,
741
- description: placement.description,
742
- knownTabs: await liveTabs(),
743
- zone: placement.zone,
744
- mainTabId,
745
- });
746
- if (plan.mode === 'append' && plan.tabId) {
747
- // D97 网格形态:目标格 = tab 内面积最大的格子,一律 down(全宽横条,title 静帧横读);
748
- // board 等无 agent 常驻 shell(agentStatus=unknown)不入候选,避免被 worker 蚕食。
749
- const exclude = new Set(
750
- allPanes.filter((p) => p.tabId === plan.tabId && p.agentStatus === 'unknown').map((p) => p.paneId),
751
- );
752
- let pick: { targetPaneId: string; direction: 'right' | 'down' } | null = null;
753
- try {
754
- const snapshot = await client.exportLayout({ tabId: plan.tabId });
755
- const tree = snapshot?.root ? parseShapeTree(snapshot.root) : null;
756
- if (tree) pick = pickGridSplit(tree, { exclude });
757
- } catch { /* 布局导出失败 → 回退锚分裂(旧行为) */ }
758
- // 锚兜底:agent 已知状态的工作 pane 优先(避开 board pane)
759
- const anchorPaneId = pick?.targetPaneId
760
- ?? allPanes.find((p) => p.tabId === plan.tabId && p.agentStatus !== 'unknown')?.paneId
761
- ?? allPanes.find((p) => p.tabId === plan.tabId)?.paneId;
762
- if (anchorPaneId) {
763
- const paneId = await client.splitPane({
764
- direction: pick?.direction ?? 'down',
765
- cwd,
766
- env: envOver,
767
- targetPaneId: anchorPaneId,
768
- });
769
- await client.sendPaneText(paneId, launch);
770
- return { tabId: plan.tabId!, paneId, tabName: plan.tabName };
771
- }
772
- // 锚 pane 缺失(tab 已空/已关)→ 同名新 tab;main tab 消失(罕见)→ 兜底新 tab
773
- plan = { mode: 'new', tabName: plan.tabName, tabId: null };
774
- }
775
- const created = await client.createTab({
776
- workspaceId: env?.workspaceId ?? '',
777
- label: plan.tabName,
778
- cwd,
779
- env: envOver,
780
- });
781
- await client.sendPaneText(created.paneId, launch);
782
- return { tabId: created.tabId, paneId: created.paneId, tabName: plan.tabName };
783
- } finally {
784
- release();
785
- }
786
- }
787
-
788
- function launchLine(resumeFile?: string | null, roleModel?: string | null, approve = false): string {
789
- // 裸 argv → 平台 shell 语法(win32=PowerShell `&`,POSIX=sh);引号转义在 buildLaunchLine
790
- // argv 构造在 buildLaunchParts(D97:默认 --tui-mode fullscreen,PI_HERDR_TUI=regular 逃生)
791
- return buildLaunchLine(buildLaunchParts(runtime, { resumeFile, roleModel, approve }));
792
- }
793
-
794
- /**
795
- * D86 信任旗标:委派即信任,但仅限 master 自己的检出与其 worktree(同 git 仓库 =
796
- * 同一批项目文件,master 本就载着它们跑)。外来目录不加 -a —— pi 的 Trust 对话框
797
- * 成为天然闸门(spawn 会在握手超时处失败,人不点头不执行未知项目扩展)。
798
- */
799
- async function approveFor(cwd: string, masterCwd: string): Promise<boolean> {
800
- if (isPathUnder(cwd, masterCwd)) return true;
801
- const zone = classifyWorktreeZone({ cwd, masterCwd, worktrees: await listWorktrees(masterCwd) });
802
- return zone.zone === 'worktree';
803
- }
804
-
805
- /* ── GC:turn_start 回收(v1.3 M8:tab 级为主 + pane 级兼容/孤儿路径) ── */
806
-
807
- let prevTurnStart = Date.now();
808
-
809
- async function gcPass(): Promise<void> {
810
- if (subs.size === 0) return;
811
- // D44:唯一用户可见开关——TTL 秒(默认 600);0 = 不自动关(只由人关)
812
- const ttlMs = Number(process.env.PI_HERDR_TASK_TAB_TTL ?? 600) * 1000;
813
- const autoCloseTabs = ttlMs > 0;
814
- let panesList: Array<{ paneId: string; tabId: string; agentStatus: string }>;
815
- try {
816
- panesList = await client.listPanes();
817
- } catch {
818
- return;
819
- }
820
- // D71:有活跃终端会话的 pane 豁免回收(tab 级与 pane 级两处)
821
- const termPaneIds = terminalState.activePaneIds();
822
- // B2:结算通知未送达的 pane 豁免(先送达再回收)——pD/pC 实证被 GC 抢关。
823
- const pendingNoticeIds = d.noticePending?.() ?? new Set<string>();
824
-
825
- // 任务 tab 分组(含 closed 条目:closed 工作 pane 也算完成)
826
- const byTab = new Map<string, SubEntry[]>();
827
- for (const e of subs.values()) {
828
- if (!e.tabId) continue;
829
- const arr = byTab.get(e.tabId) ?? [];
830
- arr.push(e);
831
- byTab.set(e.tabId, arr);
832
- }
833
- const taskTabIds = new Set(byTab.keys());
834
- // D86 R4:main tab(master 所在)永不整关——它的 consumed 子代理走 pane 级回收
835
- const mainTabId = env?.tabId ?? '';
836
-
837
- // 1) tab 级(判定规则在 gc-core.shouldCloseTaskTab,纯函数单测覆盖)
838
- for (const [tabId, entries] of byTab) {
839
- if (tabId === mainTabId) continue; // D86 R4:main tab 豁免(防 master 连坐)
840
- const tabPanes = panesList.filter((p) => p.tabId === tabId);
841
- if (tabPanes.length === 0) {
842
- // tab 已消失(人关/自动关)→ 补记 closed
843
- for (const e of entries) {
844
- if (e.status !== 'closed') {
845
- e.status = 'closed';
846
- writeHistory(e, { status: 'closed', closedAt: Date.now() }, 'gc');
847
- }
848
- }
849
- continue;
850
- }
851
- const should = shouldCloseTaskTab({
852
- entries,
853
- paneStatuses: tabPanes.map((p) => p.agentStatus),
854
- ttlMs,
855
- now: Date.now(),
856
- });
857
- if (!autoCloseTabs || !should) continue;
858
- // B2:结算通知未送达的 pane 豁免(先送达再回收)
859
- if (tabPanes.some((p) => termPaneIds.has(p.paneId) || pendingNoticeIds.has(p.paneId))) continue;
860
- try {
861
- await client.tabClose(tabId);
862
- } catch {
863
- /* tab 已消失 */
864
- }
865
- for (const e of entries) {
866
- if (e.status !== 'closed') {
867
- e.status = 'closed';
868
- writeHistory(e, { status: 'closed', closedAt: Date.now() }, 'gc');
869
- }
870
- }
871
- await new Promise((r) => setTimeout(r, 300)); // 串行关闭(#1358 同类风险护栏)
872
- }
873
-
874
- // 2) pane 级(v1.2 兼容路径 + 孤儿 + D86 main tab 回收):
875
- // 不属于任何「可整关任务 tab」的 consumed 短 pane(main tab 也算——R4)
876
- const closableTaskTabIds = new Set([...taskTabIds].filter((t) => t !== mainTabId));
877
- const candidates = [...subs.values()].filter(
878
- (e) => e.status === 'consumed' && !(e.tabId && closableTaskTabIds.has(e.tabId)),
879
- );
880
- for (const e of candidates) {
881
- if (termPaneIds.has(e.paneId) || pendingNoticeIds.has(e.paneId)) continue; // 活跃终端(D71)/未送达通知(B2)豁免
882
- if (!shouldClosePane({
883
- consumedAt: e.consumedAt ?? null,
884
- herdrStatus: statuses.get(e.paneId),
885
- prevTurnStart,
886
- })) continue;
887
- if (statuses.get(e.paneId) === undefined) {
888
- // pane 已消失(随 tab 被关)→ 补记 closed
889
- e.status = 'closed';
890
- writeHistory(e, { status: 'closed', closedAt: Date.now() }, 'gc');
891
- continue;
892
- }
893
- try {
894
- await client.closePane(e.paneId);
895
- } catch {
896
- /* pane 已消失 */
897
- }
898
- e.status = 'closed';
899
- writeHistory(e, { status: 'closed', closedAt: Date.now() }, 'gc');
900
- await new Promise((r) => setTimeout(r, 300));
901
- }
902
- persistSubs();
903
- }
904
-
905
- /** GC 互斥 + 双驱动:turn_start 与周期 ticker。 */
906
- let gcRunning = false;
907
- async function runGcSafely(): Promise<void> {
908
- if (gcRunning) return;
909
- gcRunning = true;
910
- try {
911
- await gcPass();
912
- } catch {
913
- /* GC 失败静默,下轮/tick 重试 */
914
- } finally {
915
- gcRunning = false;
916
- }
917
- }
339
+ const injectNotice = (content: string): Promise<void> =>
340
+ d.deliverNotice ? d.deliverNotice(content)
341
+ : (pi.sendUserMessage?.(content, { deliverAs: 'followUp' }) ?? Promise.resolve());
918
342
 
919
- scoped.on('turn_start', async () => {
920
- const now = Date.now();
921
- await runGcSafely();
922
- prevTurnStart = now;
343
+ const session = createSessionIo({
344
+ client,
345
+ getSessionId: d.getSessionId,
346
+ sessionsDir: defaultAgentSessionsDir,
347
+ });
348
+ const spawn = createSpawner({ client, env, runtime, git });
349
+ const poller = createPoller({
350
+ client,
351
+ sessionRoot,
352
+ subs,
353
+ persistSubs,
354
+ writeHistory,
355
+ blockedGateNotified,
356
+ lastMachineInjectAt,
357
+ session,
358
+ git,
359
+ injectNotice,
360
+ reconcileOnSettlement: d.reconcileOnSettlement,
361
+ withReconcileNotes: d.withReconcileNotes,
362
+ claimSettleNotice: d.claimSettleNotice,
923
363
  });
364
+ const gc = createGcController({
365
+ client,
366
+ env,
367
+ subs,
368
+ persistSubs,
369
+ writeHistory,
370
+ terminalState,
371
+ noticePending: d.noticePending,
372
+ pendingIsolateBranches,
373
+ git,
374
+ injectNotice,
375
+ });
376
+ gc.startTicker(ctx);
377
+ scoped.on('turn_start', () => gc.onTurnStart());
924
378
 
925
- const gcTickMs = Number(process.env.PI_HERDR_GC_TICK_MS ?? 30000);
926
- const gcTicker = gcTickMs > 0 ? setInterval(() => { void runGcSafely(); }, gcTickMs) : null;
927
- // GC ticker 经 effect 拆(hmr 重载本模块 = 旧 ticker 拆、新 ticker 起,D80⑤ 语义)。
928
- // 注意:pipe server 的关闭 effect **不在这里**——server 由 index common 段创建持有,
929
- // 挂进本插件会被 hmr 重载误杀(跨属资源,d87 修)。
930
- ctx.effect(() => () => {
931
- if (gcTicker) clearInterval(gcTicker);
932
- }, 'gc-ticker');
379
+ const { resolveSessionFile, collectFinalText, readAskFlag, probeAlive, subSessionState } = session;
380
+ const { spawnPaneInTaskTab, launchLine, approveFor, waitSubReady, findExistingPane } = spawn;
381
+ const { startPoller, pollers } = poller;
382
+ const runGit = (...args: Parameters<typeof git.runGit>) => git.runGit(...args);
383
+ const listWorktrees = (...args: Parameters<typeof git.listWorktrees>) => git.listWorktrees(...args);
933
384
 
934
- /**
935
- * D94:查找已存在的同 session pane(resume 复用而非重复 spawn)。
936
- * 返回 paneId + tabId,若无匹配或 agent 已死则 null。
937
- */
938
- async function findExistingPaneWithSession(sessionFile: string | null): Promise<{ paneId: string; tabId: string } | null> {
939
- if (!sessionFile) return null;
940
- try {
941
- const agents = await client.listAgents();
942
- const match = agents.find((a) => a.session === sessionFile && a.status !== 'unknown');
943
- if (!match) return null;
944
- // listAgents 不带 tabId,需要 pane.list 补
945
- const panes = await client.listPanes();
946
- const pane = panes.find((p) => p.paneId === match.paneId);
947
- return pane ? { paneId: match.paneId, tabId: pane.tabId } : null;
948
- } catch {
949
- return null;
950
- }
951
- }
952
-
953
- /** 复活已关闭任务(resume 工具与 send_message 自动复活共用)。 */
385
+ /** Revive a closed task; resume and automatic send_message revival share this path. */
954
386
  async function reviveEntry(entry: SubEntry): Promise<SubEntry> {
387
+ // D98: Do not revive a released isolate worktree because its directory is gone and cwd is invalid.
388
+ if (entry.isolate?.releasedAt != null) {
389
+ throw new Error(`isolate worktree ${entry.isolate.branch} was released (merged) — delegate a new subagent instead`);
390
+ }
955
391
  const latest = latestGeneration(readHistory(histFile(entry.cwd)), entry.taskId) ?? entry;
956
392
  const resumeFile = latest.sessionFile && /\.jsonl$/.test(latest.sessionFile) ? latest.sessionFile : null;
957
- // D86 信任旗标同 spawn:master 检出/worktree 才 -a(revive 无 toolCtx,用进程 cwd 代 master 检出)
393
+ // D86 trust matches spawn: pass -a only for the master's checkout/worktrees; revive has no tool context, so use process cwd.
958
394
  const approve = await approveFor(entry.cwd, process.cwd());
959
395
  const spawned = await spawnPaneInTaskTab(
960
396
  { desiredTab: entry.tabName || latest.tabName || null, description: entry.description },
@@ -985,41 +421,63 @@ export default function subagentPlugin(ctx: Context): void {
985
421
  description: Type.String({ description: 'Short label for this subtask (pane title)' }),
986
422
  prompt: Type.String({ description: 'The complete self-contained task for the subagent' }),
987
423
  run_in_background: Type.Optional(Type.Boolean({ description: 'Return immediately with an agentId; the subagent keeps running in its own pane (default false)' })),
988
- cwd: Type.Optional(Type.String({ description: 'Working directory for the subagent (absolute, or relative to this workspace). Use it to delegate into a git worktree: panes group by worktree — same checkout as you share your tab; a separate worktree gets its own tab named after the worktree directory. Create worktrees yourself with git worktree add' })),
989
- role: Type.Optional(Type.String({ description: 'Role for this pane. Role profiles are searched in this order: workspace .pi-herdr/roles/<name>.json → user-global ~/.pi/agent/herdr-pi/roles/<name>.json → builtin src/roles/. Builtins: worker-default [default, read+write; omit role = this]. Custom roles: add a JSON profile in .pi-herdr/roles/ (fields: role/version/manifest{tools,rules,unknownTools}); names master/worker-default are reserved. A matching profile governs the toolset (deny rules cannot be bypassed); unknown role names remain display labels only' })),
990
- allowed_tools: Type.Optional(Type.Array(Type.String(), { description: 'Extra tools the task needs beyond the role baseline (union; role deny rules always win). Only meaningful with a role profile' })),
991
- tab: Type.Optional(Type.String({ description: 'Task tab name: join the existing tab with this name, or create a new tab named this. Overrides the default worktree-based placement; omit for automatic placement (same checkout = your tab, separate worktree = its own tab)' })),
424
+ cwd: Type.Optional(Type.String({ description: 'Working directory for the subagent (absolute, or relative to this workspace). Use it to delegate into a git worktree: panes group by worktree — same checkout as you share your tab; a separate worktree gets its own tab named after the worktree directory. Create worktrees yourself with git worktree add. Use isolate:true instead when you want a FRESH worktree created for this task rather than targeting an existing one' })),
425
+ isolate: Type.Optional(Type.Boolean({ description: 'Create a fresh git worktree and run the subagent there. Use when the task writes files heavily and independently — in parallel with your own edits or other workers\' — or needs its own clean, reviewable diff. For read-mostly or sequential helper work omit it (shared checkout, writes guarded by the write-lock); use `cwd` only to target an existing directory/worktree (e.g. a retained pier worktree). Mechanics: branch pier/<slug> from your HEAD under ~/.herdr/worktrees/<repo>/; its writes cannot conflict with your checkout; panes group into a tab named after the worktree; the prompt is prefixed with commit discipline (commit to its branch, never push); on settle you get a diff summary. Review with git log/diff HEAD..<branch>, merge with git merge --no-ff <branch>; once merged and clean the worktree auto-removes (branch kept). Mutually exclusive with cwd' })),
992
426
  }),
993
427
  async execute(toolCallId, params, signal, onUpdate, toolCtx) {
994
428
  void toolCallId;
995
429
  void signal;
996
- if (!client.available) {
997
- return {
998
- content: [{
999
- type: 'text',
1000
- text: 'Error: subagent requires pi to run inside a herdr-managed pane (HERDR_ENV not set).',
1001
- }],
1002
- details: {},
1003
- };
430
+ const launch = planLaunchValidation(params, client.available);
431
+ if (launch.kind === 'error') {
432
+ return { content: [{ type: 'text', text: launch.text }], details: {} };
1004
433
  }
1005
- const spec: SubagentSpec = {
1006
- description: String(params?.description ?? 'subagent'),
1007
- prompt: String(params?.prompt ?? ''),
1008
- };
1009
- if (!spec.prompt.trim()) {
1010
- return {
1011
- content: [{ type: 'text', text: 'Error: `prompt` must be a non-empty string' }],
1012
- details: {},
1013
- };
1014
- }
1015
- const background = params?.run_in_background === true;
1016
- const kind = normalizeEntryKind(typeof params?.role === 'string' ? params.role.trim() : undefined);
434
+ const { spec, background, isolate, cwdParam, roleKind: kind, suggested, manifestRole, tab } = launch;
1017
435
  const masterCwd = (toolCtx as { cwd?: string }).cwd ?? process.cwd();
1018
- // D86 R5:cwd 参数 = 跨 worktree 委派的事实键(目录是事实,worktree 是分组推论)。
1019
- // 相对路径相对 master cwd 解析;不存在的目录拒绝(spawn 进无效目录只会白开 pane)。
1020
- const cwdParam = typeof params?.cwd === 'string' && params.cwd.trim() ? params.cwd.trim() : null;
436
+ // Move taskId generation above the original spawn section because isolate planning needs taskHex; this is behavior-neutral.
437
+ const taskId = randomUUID();
438
+ // D98 2c: Create the isolate through pier's execFile git path (decision 1), not herdr's
439
+ // socket worktree.create, which has bootstrap races, missing root-pane env, worker/master
440
+ // workspace separation, and linked_worktree_source rejection. The model makes the decision; do not infer it.
441
+ let isolateMeta: SubEntry['isolate'] | null = null;
1021
442
  let cwd = masterCwd;
1022
- if (cwdParam) {
443
+ if (isolate) {
444
+ const baseSha = await runGit(masterCwd, ['rev-parse', 'HEAD']);
445
+ const isoGuard = planIsolateRepoGuard(baseSha);
446
+ if (isoGuard.kind === 'error') {
447
+ return { content: [{ type: 'text', text: isoGuard.text }], details: {} };
448
+ }
449
+ const sha = isoGuard.sha.trim();
450
+ const pierBranches = new Set(
451
+ ((await runGit(masterCwd, ['for-each-ref', '--format=%(refname:short)', 'refs/heads/pier/'])) ?? '')
452
+ .split('\n').filter(Boolean),
453
+ );
454
+ const worktrees = await listWorktrees(masterCwd);
455
+ const repoName = basename(worktrees[0] ?? masterCwd);
456
+ const plan = planIsolateWorktree({
457
+ description: spec.description,
458
+ taskHex: taskId.slice(0, 6),
459
+ existingPierBranches: pierBranches,
460
+ });
461
+ const wtPath = join(platformPaths.worktreeBaseDir, repoName, plan.worktreeDirName);
462
+ try {
463
+ mkdirSync(dirname(wtPath), { recursive: true }); // git worktree add does not create its parent directory, so a first run would fail.
464
+ } catch { /* Existing directory or permissions failure is handled by worktree add. */ }
465
+ // Keep the branch pending between worktree add and subs.set: the 2–3s readiness wait lets the ticker
466
+ // scan, and without this guard it could reclaim a new clean =HEAD-ancestor worktree as an orphan.
467
+ pendingIsolateBranches.add(plan.branch);
468
+ const added = await runGit(masterCwd, ['worktree', 'add', '-b', plan.branch, wtPath, sha]);
469
+ if (added === null) {
470
+ pendingIsolateBranches.delete(plan.branch);
471
+ return {
472
+ content: [{ type: 'text', text: `Error: failed to create worktree ${wtPath} (branch ${plan.branch}) — run \`git worktree prune\` and retry if it reports stale entries` }],
473
+ details: {},
474
+ };
475
+ }
476
+ git.invalidateWorktreesCache(); // Invalidate the 5s cache so the zone classifier sees the new worktree immediately.
477
+ cwd = wtPath;
478
+ isolateMeta = { worktreePath: wtPath, branch: plan.branch, baseSha: sha, releasedAt: null, retainNotified: false };
479
+ spec.prompt = `${buildIsolatePreamble({ worktreePath: wtPath, branch: plan.branch, baseShort: sha.slice(0, 7) })}\n\n${spec.prompt}`;
480
+ } else if (cwdParam) {
1023
481
  cwd = pathResolve(masterCwd, cwdParam);
1024
482
  try {
1025
483
  const st = await statAsync(cwd);
@@ -1031,22 +489,14 @@ export default function subagentPlugin(ctx: Context): void {
1031
489
  };
1032
490
  }
1033
491
  }
1034
- // D86 R1:分组键 = cwd 所属 git worktree(主检出 → main tab;其他 worktree → 目录名 tab)
492
+ // D86 R1: Group by the git worktree containing cwd (main checkout → main tab; other worktree → directory-named tab).
1035
493
  const zone = classifyWorktreeZone({
1036
494
  cwd,
1037
495
  masterCwd,
1038
496
  worktrees: await listWorktrees(masterCwd),
1039
497
  });
1040
- // D86 信任旗标:master 自己的检出/worktree 才 -a;外来目录留 Trust 对话框作闸门
498
+ // D86 trust: pass -a only for the master's checkout/worktrees; external directories remain behind pi's Trust dialog.
1041
499
  const approve = isPathUnder(cwd, masterCwd) || zone.zone === 'worktree';
1042
- // 档2(C7 v2):role 命中档案 → 三态合成 manifest,env 下发 worker。
1043
- // roles-v1.0(V57/V58):省略 role = worker-default manifest 生效
1044
- // (默认 executor:能写能执行、禁嵌套 spawn)——kind 历史分类不受影响。
1045
- const suggested = Array.isArray(params?.allowed_tools)
1046
- ? params.allowed_tools.filter((t): t is string => typeof t === 'string' && t.trim() !== '')
1047
- : [];
1048
- const manifestRole =
1049
- typeof params?.role === 'string' && params.role.trim() ? params.role.trim() : 'worker-default';
1050
500
  let roleManifestEnv: Record<string, string> = {};
1051
501
  let roleModel: string | null = null;
1052
502
  try {
@@ -1061,7 +511,7 @@ export default function subagentPlugin(ctx: Context): void {
1061
511
  services: role.services ?? {},
1062
512
  }),
1063
513
  };
1064
- // WS-D10:按角色路由模型;省略 = 进程默认
514
+ // WS-D10: Route the model by role; omission intentionally uses the process default.
1065
515
  if (typeof role.model === 'string' && role.model.trim()) roleModel = role.model.trim();
1066
516
  } catch (err) {
1067
517
  const msg = err instanceof Error ? err.message : String(err);
@@ -1074,9 +524,8 @@ export default function subagentPlugin(ctx: Context): void {
1074
524
  let paneId = '';
1075
525
  try {
1076
526
  const spawnedAt = Date.now();
1077
- const taskId = randomUUID();
1078
527
  const spawned = await spawnPaneInTaskTab(
1079
- { desiredTab: typeof params?.tab === 'string' ? params.tab : null, description: spec.description, zone },
528
+ { desiredTab: tab, description: spec.description, zone },
1080
529
  cwd,
1081
530
  { PI_HERDR_SUBAGENT: '1', ...roleManifestEnv },
1082
531
  launchLine(null, roleModel, approve),
@@ -1096,6 +545,7 @@ export default function subagentPlugin(ctx: Context): void {
1096
545
  launchCommand: [launchLine(null, roleModel, approve)],
1097
546
  createdAt: Date.now(),
1098
547
  revivedFrom: null,
548
+ ...(isolateMeta ? { isolate: isolateMeta } : {}),
1099
549
  };
1100
550
  const ready = await waitSubReady(cwd, paneId);
1101
551
  if (!ready) throw new Error(`subagent pane ${paneId} pipe not ready within ${SUB_READY_TIMEOUT_MS}ms`);
@@ -1103,13 +553,13 @@ export default function subagentPlugin(ctx: Context): void {
1103
553
  subs.set(paneId, entry);
1104
554
  persistSubs();
1105
555
  onUpdate?.(makeProgressUpdate(`subagent ready in pane ${paneId}; injecting prompt via pipe…`));
1106
- // M11(D45/D46):注入走扩展管道 → 子扩展 sendUserMessage(followUp);
1107
- // PTY 键盘通道 100% 归人(无软锁窗口、无混行)。
1108
- // 回归修复(session 01a03bf0 实证):ecc0bc4 重写前台等待时误删本块——
1109
- // prompt 永不注入 + injectTs 未定义(ReferenceError → spawn-failed,
1110
- // 台账留幽灵 running 条目)。任何后续重构不得把注入与 injectTs 拆开。
556
+ // M11 (D45/D46): Inject through the extension pipe to the child extension's sendUserMessage(followUp);
557
+ // reserve the PTY keyboard channel entirely for the person, with no soft-lock window or mixed input.
558
+ // Regression evidence (session 01a03bf0): ecc0bc4's foreground-wait rewrite once removed this block,
559
+ // leaving the prompt uninjected and injectTs undefined (ReferenceError → spawn-failed, with a ghost
560
+ // running ledger entry). Keep injection and injectTs together in future refactors.
1111
561
  const injectTs = Date.now();
1112
- const injected = await pipeRequest(pipeNameFor(cwd, paneId), {
562
+ const injected = await pipeRequestTo(cwd, paneId, {
1113
563
  type: 'prompt',
1114
564
  id: `prompt-${taskId}`,
1115
565
  text: spec.prompt,
@@ -1119,18 +569,21 @@ export default function subagentPlugin(ctx: Context): void {
1119
569
  if (injected.type !== 'ok') {
1120
570
  throw new Error(`pipe prompt rejected: ${injected.type === 'error' ? injected.message : 'unknown response'}`);
1121
571
  }
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;
572
+ lastMachineInjectAt.set(paneId, injectTs); // B4: attribute working state during the observation window.
573
+ // A1+A2 (user-verified fix): foreground waiting uses a content gate plus a patience threshold before backgrounding.
574
+ // Treating idle as settled with a hard 90s window misclassified real 4–6 minute working periods as no-output;
575
+ // three healthy subagents were observed becoming consumed at 101s while producing results four minutes later.
576
+ const PATIENCE_MS = runtimePolicy.foregroundPatienceMs;
1127
577
  const patienceDeadline = Date.now() + PATIENCE_MS;
1128
578
  let text: string | null = null;
1129
579
  let settledKind: 'settled' | 'timeout' = 'timeout';
1130
580
  while (Date.now() < Math.min(patienceDeadline, spawnedAt + SUBAGENT_TIMEOUT_MS)) {
1131
581
  const state = await client.waitAgent(paneId, ['idle', 'done', 'blocked'], 30_000);
1132
- if (state === 'blocked') {
1133
- // 返回 gate 通知:不揽活、提醒用户去 pane 答题、授权例外经 send_message 转达。
582
+ const session = (state === 'idle' || state === 'done')
583
+ ? await subSessionState(paneId, cwd, injectTs)
584
+ : { text: null, pendingTool: false, activity: false };
585
+ const tick = planForegroundTick({ state, session });
586
+ if (tick.kind === 'blocked') {
1134
587
  const question = await readAskFlag(paneId);
1135
588
  entry.background = true;
1136
589
  lastRequestIdByPane.set(paneId, `prompt-${taskId}`);
@@ -1142,27 +595,25 @@ export default function subagentPlugin(ctx: Context): void {
1142
595
  details: { paneId, taskId, background: true, blocked: true, role: kind },
1143
596
  };
1144
597
  }
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;
1153
- }
1154
- // 有活动无文本(活动已停止)→ 再给 collectFinalText 的重试窗口
598
+ if (tick.kind === 'settled') {
599
+ text = tick.text;
600
+ settledKind = 'settled';
601
+ break;
602
+ }
603
+ if (tick.kind === 'wait') {
604
+ await new Promise((r) => setTimeout(r, tick.delayMs));
605
+ continue;
606
+ }
607
+ if (tick.kind === 'collect-final') {
1155
608
  text = await collectFinalText(paneId, cwd, injectTs, 6);
1156
609
  if (text) { settledKind = 'settled'; break; }
1157
- await new Promise((r) => setTimeout(r, 2000));
610
+ await new Promise((r) => setTimeout(r, FOREGROUND_POLL_MS));
1158
611
  continue;
1159
612
  }
1160
- // null = 30s 切片超时(working 中)→ 继续循环
1161
613
  }
1162
- // A2 耐心阈值到点仍在跑 → 探活;活着 → 转后台并返回统一存活通知
1163
614
  if (!text && settledKind !== 'blocked') {
1164
615
  const probe = await probeAlive(paneId, cwd);
1165
- if (isAlive(probe, Date.now())) {
616
+ if (planPatienceExpiry(isAlive(probe, Date.now())) === 'move-to-background') {
1166
617
  entry.background = true;
1167
618
  lastRequestIdByPane.set(paneId, `prompt-${taskId}`);
1168
619
  startPoller(paneId, cwd, spawnedAt, injectTs, spec.description, `prompt-${taskId}`);
@@ -1176,7 +627,6 @@ export default function subagentPlugin(ctx: Context): void {
1176
627
  details: { paneId, taskId, background: true, movedToBackground: true, role: kind },
1177
628
  };
1178
629
  }
1179
- // 探活失败(pane 消失且会话沉寂)→ 保留原 timeout 语义
1180
630
  settledKind = 'timeout';
1181
631
  }
1182
632
  const outcome =
@@ -1192,13 +642,20 @@ export default function subagentPlugin(ctx: Context): void {
1192
642
  details: { paneId, taskId, background, role: kind },
1193
643
  };
1194
644
  } catch (err) {
1195
- // 回归修复(session 01a03bf0):spawn 中途失败必须回收台账 + 关 pane——
1196
- // 否则幽灵 running 条目永不结算 → D96 提醒风暴 + send_message 打到
1197
- // 无任务上下文的空会话。pane 关闭尽力而为(board pane 例外场景由 GC 兜底)。
645
+ // Regression fix (session 01a03bf0): a mid-spawn failure must remove the ledger entry and close its pane,
646
+ // otherwise a ghost running entry never settles, causing D96 reminder storms and send_message calls
647
+ // to land in an empty session with no task context. Pane closure is best effort; GC covers board exceptions.
1198
648
  if (paneId) {
1199
649
  subs.delete(paneId);
1200
650
  persistSubs();
1201
- void client.closePane(paneId).catch(() => { /* 尽力而为 */ });
651
+ void client.closePane(paneId).catch(() => { /* Best effort only. */ });
652
+ }
653
+ // D98 2d: If isolate startup never becomes ready, best-effort remove the new worktree and branch (no work
654
+ // exists to preserve); failure stays silent because the branch is harmless and remains visible to git worktree list.
655
+ if (isolateMeta) {
656
+ void runGit(masterCwd, ['worktree', 'remove', '--force', isolateMeta.worktreePath])
657
+ .then(() => runGit(masterCwd, ['branch', '-D', isolateMeta!.branch]))
658
+ .catch(() => { /* Best effort only. */ });
1202
659
  }
1203
660
  return {
1204
661
  content: [{
@@ -1212,11 +669,12 @@ export default function subagentPlugin(ctx: Context): void {
1212
669
  };
1213
670
  } finally {
1214
671
  release();
672
+ if (isolateMeta) pendingIsolateBranches.delete(isolateMeta.branch); // D98: release the creation-window guard on every success/failure path.
1215
673
  }
1216
674
  },
1217
675
  });
1218
676
 
1219
- /* ── 工具:resume_subagent(历史复活,v1.2) ── */
677
+ /* ── Tool: resume_subagent (historical revival, v1.2) ── */
1220
678
 
1221
679
  scoped.registerTool({
1222
680
  name: 'resume_subagent',
@@ -1244,8 +702,8 @@ export default function subagentPlugin(ctx: Context): void {
1244
702
  }
1245
703
  const release = await subSemaphore.acquire();
1246
704
  try {
1247
- // D94:复用已存在的同 session pane(避免双 pi 冲突)
1248
- const existing = await findExistingPaneWithSession(latest.sessionFile);
705
+ // D94: Reuse an existing pane for the same session to avoid competing pi processes.
706
+ const existing = await findExistingPane(latest.sessionFile);
1249
707
  if (existing) {
1250
708
  const entry: SubEntry = {
1251
709
  taskId,
@@ -1274,7 +732,7 @@ export default function subagentPlugin(ctx: Context): void {
1274
732
  details: { paneId: entry.paneId, taskId },
1275
733
  };
1276
734
  }
1277
- // 无现成 pane,才创建新的
735
+ // Create a new pane only when no existing one can be reused.
1278
736
  const entry: SubEntry = {
1279
737
  taskId,
1280
738
  kind: latest.kind,
@@ -1314,7 +772,7 @@ export default function subagentPlugin(ctx: Context): void {
1314
772
  },
1315
773
  });
1316
774
 
1317
- /* ── 工具:list_agents(仅后台/常驻子代理;前台短 pane 是瞬态 UI) ── */
775
+ /* ── Tool: list_agents (background/long-lived subagents only; foreground short panes are transient UI) ── */
1318
776
 
1319
777
  scoped.registerTool({
1320
778
  name: 'list_agents',
@@ -1327,45 +785,47 @@ export default function subagentPlugin(ctx: Context): void {
1327
785
  if (listed.length === 0) {
1328
786
  return { content: [{ type: 'text', text: 'No background subagents started (from this session branch).' }], details: {} };
1329
787
  }
1330
- // 按 taskId 去重:只显示每任务的最新代(复活后旧 paneId 不重复出现)
788
+ // Deduplicate by taskId so only the newest generation appears after revival; old paneIds do not repeat.
1331
789
  const byTask = new Map<string, SubEntry>();
1332
790
  for (const sub of listed) {
1333
791
  const prev = byTask.get(sub.taskId);
1334
792
  if (!prev || sub.createdAt >= prev.createdAt) byTask.set(sub.taskId, sub);
1335
793
  }
1336
- // C1:实时探活(pane 状态 + 会话活动时间)——master 能区分"在干活"vs"真卡死"
794
+ // C1: Probe liveness in real time (pane status and session activity) so the master can distinguish work from a true hang.
1337
795
  const probes = new Map<string, AliveProbe>();
1338
796
  await Promise.all([...byTask.values()].map(async (sub) => {
1339
797
  if (sub.status === 'closed') return;
1340
- try { probes.set(sub.paneId, await probeAlive(sub.paneId, sub.cwd)); } catch { /* 显示层尽力 */ }
798
+ try { probes.set(sub.paneId, await probeAlive(sub.paneId, sub.cwd)); } catch { /* Best effort for display. */ }
1341
799
  }));
1342
800
  const lines: string[] = [];
1343
801
  for (const sub of byTask.values()) {
1344
802
  const tabTag = sub.tabName ? ` [tab: ${sub.tabName}]` : '';
803
+ // D98: Include the worktree branch on isolate entries, including closed ones; settlement stats already report merge state.
804
+ const wtTag = sub.isolate ? ` [wt: ${sub.isolate.branch}]` : '';
1345
805
  if (sub.status === 'closed') {
1346
- lines.push(`${sub.taskId.slice(0, 8)} [idle] (${sub.kind}, closed; send_message revives)${tabTag} ${sub.description}`);
806
+ lines.push(`${sub.taskId.slice(0, 8)} [idle] (${sub.kind}, closed; send_message revives)${tabTag}${wtTag} ${sub.description}`);
1347
807
  continue;
1348
808
  }
1349
- // 注册表状态即权威:settled = idle,其余 = running(含 blocked 人类闸门,对齐 DSH 映射)
809
+ // The registry is authoritative: settled is idle, everything else is running, including blocked human gates (DSH mapping).
1350
810
  const state = sub.status === 'settled' ? 'idle' : 'running';
1351
- // D94:用户接管标记
811
+ // D94: Mark when the user has taken over.
1352
812
  const takeoverMark = sub.userTakeover ? ', user-controlled' : '';
1353
813
  const probe = probes.get(sub.paneId);
1354
814
  const statusTag = probe?.agentStatus ? ` ${probe.agentStatus}` : '';
1355
815
  const activityTag = probe?.lastActivityMs != null ? `, active ${agoText(probe.lastActivityMs, Date.now())}` : '';
1356
- // E:blocked 时附上闸门问题摘要(提示 master 该叫用户去 pane,不是自己接手)
816
+ // E: For blocked agents, include the gate-question summary so the master sends the user to that pane instead of taking over.
1357
817
  let gateTag = '';
1358
818
  if (probe?.agentStatus === 'blocked') {
1359
819
  const q = await readAskFlag(sub.paneId);
1360
820
  gateTag = q ? ` — AWAITING HUMAN: "${q}"` : ' — AWAITING HUMAN decision';
1361
821
  }
1362
- lines.push(`${sub.paneId} [${state}${takeoverMark}${statusTag}${activityTag}${gateTag}] (${sub.kind})${tabTag} ${sub.description}`);
822
+ lines.push(`${sub.paneId} [${state}${takeoverMark}${statusTag}${activityTag}${gateTag}] (${sub.kind})${tabTag}${wtTag} ${sub.description}`);
1363
823
  }
1364
824
  return { content: [{ type: 'text', text: lines.join('\n') }], details: {} };
1365
825
  },
1366
826
  });
1367
827
 
1368
- /* ── 工具:send_message(steer 间隙投递) ── */
828
+ /* ── Tool: send_message (delivery during steer gaps) ── */
1369
829
 
1370
830
  scoped.registerTool({
1371
831
  name: 'send_message',
@@ -1383,19 +843,18 @@ export default function subagentPlugin(ctx: Context): void {
1383
843
  }
1384
844
  const spawnedAt = Date.now();
1385
845
  try {
1386
- // 已关闭 → 自动复活(任务级可继续:pane 是临时宿主)
846
+ // A closed task is revived automatically because the pane is only a temporary host.
1387
847
  if (entry.status === 'closed') {
1388
848
  await reviveEntry(entry);
1389
849
  subs.set(entry.paneId, entry);
1390
850
  persistSubs();
1391
851
  }
1392
- // M11(D46):follow_up 走扩展管道。B3:带 steer——worker 长跑中途的补充
1393
- // 指令在 tool-call 间隙秒级到达(旧 followUp 队列语义 = 整个 run 结束才投,
1394
- // 01a03c0d 实证 20min 延迟致 worker 按旧契约返工)。
852
+ // M11 (D46): follow_up uses the extension pipe. B3 adds steering so supplemental instructions reach a long-running worker
853
+ // within seconds during a tool-call gap; the old followUp queue waited for the entire run and caused 20-minute rework (01a03c0d).
1395
854
  const ready = await waitSubReady(entry.cwd, entry.paneId);
1396
855
  if (!ready) throw new Error(`subagent pane ${entry.paneId} pipe not ready`);
1397
856
  const fuId = `fu-${Date.now()}`;
1398
- const res = await pipeRequest(pipeNameFor(entry.cwd, entry.paneId), {
857
+ const res = await pipeRequestTo(entry.cwd, entry.paneId, {
1399
858
  type: 'follow_up',
1400
859
  id: fuId,
1401
860
  text: String(params?.message ?? ''),
@@ -1406,11 +865,20 @@ export default function subagentPlugin(ctx: Context): void {
1406
865
  if (res.type !== 'ok') {
1407
866
  throw new Error(`pipe follow_up rejected: ${res.type === 'error' ? res.message : 'unknown response'}`);
1408
867
  }
1409
- lastMachineInjectAt.set(entry.paneId, Date.now()); // B4:观察窗内 working 归因判据
868
+ lastMachineInjectAt.set(entry.paneId, Date.now()); // B4: attribute working state during the observation window.
869
+ // Regression hardening (D98 liveness evidence): an exception between status='running' and startPoller could leave a ghost
870
+ // running entry without a poller, never settling and skipped by GC; roll back to the previous status on failure.
871
+ const prevStatus = entry.status;
1410
872
  entry.status = 'running';
1411
873
  persistSubs();
1412
- lastRequestIdByPane.set(entry.paneId, fuId);
1413
- startPoller(entry.paneId, entry.cwd, spawnedAt, Date.now(), entry.description, fuId);
874
+ try {
875
+ lastRequestIdByPane.set(entry.paneId, fuId);
876
+ startPoller(entry.paneId, entry.cwd, spawnedAt, Date.now(), entry.description, fuId);
877
+ } catch (inner) {
878
+ entry.status = prevStatus;
879
+ persistSubs();
880
+ throw inner;
881
+ }
1414
882
  return { content: [{ type: 'text', text: `Message sent to subagent ${entry.paneId}.` }], details: { paneId: entry.paneId } };
1415
883
  } catch (err) {
1416
884
  return { content: [{ type: 'text', text: `Error: failed to reach subagent ${entry.paneId}: ${(err as Error).message}` }], details: {} };
@@ -1418,7 +886,7 @@ export default function subagentPlugin(ctx: Context): void {
1418
886
  },
1419
887
  });
1420
888
 
1421
- /* ── 工具:interrupt_agent(D48:管道 → 子扩展 ctx.abort() 进程内中止) ── */
889
+ /* ── Tool: interrupt_agent (D48: pipe → child extension ctx.abort(), in-process cancellation) ── */
1422
890
 
1423
891
  scoped.registerTool({
1424
892
  name: 'interrupt_agent',
@@ -1434,18 +902,18 @@ export default function subagentPlugin(ctx: Context): void {
1434
902
  return { content: [{ type: 'text', text: `Error: unknown subagent id "${params?.agentId}" (see list_agents)` }], details: {} };
1435
903
  }
1436
904
  if (entry.status === 'closed') {
1437
- // DSH 对齐:空闲/已结束目标是幂等 no-op
905
+ // DSH alignment: an idle or finished target is an idempotent no-op.
1438
906
  return { content: [{ type: 'text', text: `Interrupt accepted for subagent ${entry.paneId} (already idle/closed; no-op).` }], details: {} };
1439
907
  }
1440
908
  try {
1441
- const res = await pipeRequest(pipeNameFor(entry.cwd, entry.paneId), {
909
+ const res = await pipeRequestTo(entry.cwd, entry.paneId, {
1442
910
  type: 'interrupt',
1443
911
  id: `int-${Date.now()}`,
1444
912
  });
1445
913
  if (res.type !== 'ok') {
1446
914
  throw new Error(`pipe interrupt rejected: ${res.type === 'error' ? res.message : 'unknown response'}`);
1447
915
  }
1448
- // 被中断的轮次不结算通知(请求方已知情)
916
+ // Do not send a settlement notice for an interrupted turn because the requester already knows.
1449
917
  const lastId = lastRequestIdByPane.get(entry.paneId);
1450
918
  if (lastId) d.claimSettleNotice(`${entry.paneId}:${lastId}`);
1451
919
  return { content: [{ type: 'text', text: `Interrupt accepted for subagent ${entry.paneId} (fire-and-return).` }], details: { paneId: entry.paneId } };