pi-pier 0.3.0 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (96) hide show
  1. package/README.md +12 -11
  2. package/package.json +2 -2
  3. package/schemas/role-manifest.schema.json +7 -1
  4. package/skills/pi-herdr-workflow/SKILL.md +4 -5
  5. package/src/ansi-text.ts +62 -11
  6. package/src/ask-dialog.ts +308 -0
  7. package/src/ask-user.ts +113 -112
  8. package/src/bootstrap.ts +8 -12
  9. package/src/compact-coordinator.ts +278 -261
  10. package/src/compact-economics-core.ts +77 -145
  11. package/src/config-catalog-core.ts +77 -197
  12. package/src/config-command.ts +271 -64
  13. package/src/dashboard-command.ts +38 -19
  14. package/src/efficiency-config-core.ts +287 -484
  15. package/src/efficiency-store.ts +16 -29
  16. package/src/focus-poller.ts +14 -35
  17. package/src/gc-core.ts +10 -36
  18. package/src/herdr-client.ts +82 -208
  19. package/src/history-store.ts +36 -23
  20. package/src/index-gates.ts +124 -0
  21. package/src/index-locks.ts +2 -7
  22. package/src/index-master.ts +24 -18
  23. package/src/index-notices.ts +28 -15
  24. package/src/index-pipe.ts +17 -12
  25. package/src/index-roles.ts +306 -0
  26. package/src/index-runtime.ts +2 -6
  27. package/src/index-worker.ts +4 -8
  28. package/src/index.ts +305 -564
  29. package/src/jev-client.ts +12 -17
  30. package/src/jev-core.ts +137 -87
  31. package/src/ledger.ts +13 -21
  32. package/src/lock-core.ts +28 -56
  33. package/src/manifest-compose.ts +35 -20
  34. package/src/observation-core.ts +52 -100
  35. package/src/pane-title.ts +19 -38
  36. package/src/pi-surface.ts +34 -22
  37. package/src/pier-options.ts +54 -50
  38. package/src/pipe-channel.ts +27 -59
  39. package/src/platform-paths.ts +19 -52
  40. package/src/{core → plugins}/grid-shape.ts +8 -10
  41. package/src/{core → plugins}/heat-plan.ts +20 -22
  42. package/src/plugins/observation.ts +475 -0
  43. package/src/plugins/subagent.ts +728 -0
  44. package/src/{core → plugins}/terminal.ts +167 -127
  45. package/src/{core → plugins}/todo.ts +61 -100
  46. package/src/progress-core.ts +16 -53
  47. package/src/reconcile-core.ts +24 -33
  48. package/src/reducer-core.ts +24 -60
  49. package/src/reducer-invoker.ts +266 -239
  50. package/src/renderers.ts +76 -87
  51. package/src/role-loader.ts +33 -28
  52. package/src/role-manifest.ts +23 -28
  53. package/src/role-state.ts +119 -0
  54. package/src/routing-telemetry.ts +136 -0
  55. package/src/runtime-policy.ts +25 -40
  56. package/src/session-tail.ts +65 -41
  57. package/src/settle-wake-core.ts +52 -21
  58. package/src/slim-frame.ts +25 -122
  59. package/src/stale-core.ts +17 -24
  60. package/src/storage-layout.ts +14 -43
  61. package/src/subagent-core.ts +253 -118
  62. package/src/subagent-gc.ts +109 -67
  63. package/src/subagent-poller.ts +367 -24
  64. package/src/subagent-session.ts +516 -0
  65. package/src/subagent-spawn.ts +530 -52
  66. package/src/swallow.ts +7 -17
  67. package/src/terminal-core.ts +125 -106
  68. package/src/todo-core.ts +57 -109
  69. package/src/todo-read-hook.ts +23 -39
  70. package/src/todo-reminder-core.ts +26 -51
  71. package/src/todo-window.ts +11 -12
  72. package/src/todos-service.ts +15 -35
  73. package/src/tool-error.ts +6 -13
  74. package/src/tool-gate.ts +38 -21
  75. package/src/vocab.ts +52 -25
  76. package/src/ask-multi.ts +0 -260
  77. package/src/config-guide.ts +0 -361
  78. package/src/core/observation.ts +0 -555
  79. package/src/core/subagent.ts +0 -633
  80. package/src/efficiency-config-io.ts +0 -53
  81. package/src/git-adapter.ts +0 -113
  82. package/src/notice-buffer.ts +0 -23
  83. package/src/subagent-execute.ts +0 -328
  84. package/src/subagent-git-io.ts +0 -80
  85. package/src/subagent-launch.ts +0 -114
  86. package/src/subagent-list-action.ts +0 -69
  87. package/src/subagent-output-action.ts +0 -104
  88. package/src/subagent-output-adapter.ts +0 -27
  89. package/src/subagent-output-core.ts +0 -263
  90. package/src/subagent-poll-loop.ts +0 -305
  91. package/src/subagent-port.ts +0 -27
  92. package/src/subagent-ready.ts +0 -89
  93. package/src/subagent-registry.ts +0 -70
  94. package/src/subagent-resolution.ts +0 -41
  95. package/src/subagent-scope.ts +0 -41
  96. package/src/subagent-session-io.ts +0 -230
package/src/index.ts CHANGED
@@ -1,18 +1,13 @@
1
1
  /**
2
- * pi-herdr extension entry point (v1.1: closed-loop todos + interactive subagents,
3
- * DESIGN.md §12 option C).
2
+ * pi-herdr extension entry point and composition root.
4
3
  *
5
- * Installation: `pi package` (package.json's pi.extensions) or ~/.pi/agent/extensions/.
4
+ * Layout: index-runtime.ts picks the process mode (worker / bare pi / herdr master);
5
+ * index-roles.ts the role runtime, index-gates.ts human-gate reporting, index-pipe.ts the
6
+ * pipe protocol handler, index-notices.ts the notice buffer, index-locks.ts write locks;
7
+ * index-master.ts / index-worker.ts are the dynamic master / todo-only mounts.
6
8
  *
7
- * Capabilities:
8
- * - `todo_write`: replace the complete todo list; the current pi session JSONL is authoritative,
9
- * and branches automatically roll back in DSH-aligned semantics.
10
- * - `subagent` / `terminal`: herdr-master only. Bare pi does not register them.
11
- * - `/todos`, TUI widget, and herdr title projection with graceful degradation without herdr.
12
- *
13
- * pi 0.84.2 contract (validated):
14
- * - onUpdate must have AgentToolResult shape; a string causes the TUI to crash and exit pi;
15
- * - tool-result details persist in session JSONL, and getBranch() replay implements branch rollback.
9
+ * pi contract: onUpdate must have AgentToolResult shape (a string crashes the TUI); tool-result
10
+ * details persist in session JSONL and getBranch() replay implements branch rollback.
16
11
  */
17
12
  import type { ExtensionAPI } from '@earendil-works/pi-coding-agent';
18
13
  import {
@@ -27,46 +22,36 @@ import {
27
22
  runAsk,
28
23
  } from './ask-user.ts';
29
24
  import { pipeNameFor, pipeRequest, startPipeServer } from './pipe-channel.ts';
30
- import {
31
- TODO_EDIT_CUSTOM_TYPE,
32
- currentActivity,
33
- } from './todo-core.ts';
34
-
35
- import { APPROVAL_NEEDED_CUSTOM_TYPE, ROLE_MANIFEST_CUSTOM_TYPE, installRenderers } from './renderers.ts';
25
+ import { TODO_EDIT_CUSTOM_TYPE, currentActivity } from './todo-core.ts';
26
+ import { installRenderers } from './renderers.ts';
36
27
  import { createHerdrClient } from './herdr-client.ts';
37
- // The subagent family moved to core/subagent.ts (loader entry, D78/D81); its history-store,
38
- // session-tail, and gc-core imports moved with it, leaving index.ts only the common readers.
39
- import { lastAssistantText, readSessionFile } from './session-tail.ts';
28
+ import { bareSessionId, lastAssistantText, readSessionFile, resolveSessionFileValue } from './session-tail.ts';
40
29
  import { fileURLToPath } from 'node:url';
41
- import { TodosService } from './todos-service.ts';
30
+ import { join } from 'node:path';
31
+ import { platformPaths } from './platform-paths.ts';
32
+ import { TodosService, type TodoCompletionSource } from './todos-service.ts';
42
33
  import { reconcileTodos } from './reconcile-core.ts';
43
- import type { TodoUiSlot } from './core/todo.ts';
44
- import {
45
- estimateEta,
46
- formatProgressSuffix,
47
- planToolBadge,
48
- progressOf,
49
- } from './progress-core.ts';
34
+ import type { TodoUiSlot } from './plugins/todo.ts';
35
+ import { estimateEta, formatProgressSuffix, planToolBadge, progressOf } from './progress-core.ts';
50
36
  import { WRITE_LOCK_ENV } from './lock-core.ts';
51
- import { ABORT_STOP_REASON, planSettleWake } from './settle-wake-core.ts';
52
- import { composeForRole } from './manifest-compose.ts';
53
- import { parseRuntimeManifest, planActiveTools, planToolGate, type RuntimeRoleManifest } from './tool-gate.ts';
54
-
37
+ import { ABORT_STOP_REASON, planSettleWake, shouldPushSettleReply } from './settle-wake-core.ts';
38
+ import { parseRuntimeManifest } from './tool-gate.ts';
55
39
  import { formatPaneTitle } from './pane-title.ts';
56
40
  import { registerSlimFrame, updateSlimFrame } from './slim-frame.ts';
57
41
  import { planIndexMode } from './index-runtime.ts';
58
- import { emptySubagentPortBox } from './subagent-port.ts';
42
+ import { emptySubagentPortBox } from './subagent-core.ts';
59
43
  import { createNoticeBuffer } from './index-notices.ts';
60
- import { handlePipeRequest } from './index-pipe.ts';
44
+ import { handlePipeRequest, type MachineRequest } from './index-pipe.ts';
61
45
  import { installWriteLocks } from './index-locks.ts';
62
46
  import { installDashboardCommand } from './dashboard-command.ts';
63
- import { registerObservationPack, createCompactionBatchPackHook, type PickMiddleExcerpt } from './core/observation.ts';
47
+ import { registerObservationPack, createCompactionBatchPackHook, type PickMiddleExcerpt } from './plugins/observation.ts';
64
48
  import { createJevRuntime } from './jev-client.ts';
65
49
  import {
66
50
  NOTICE_RANK_MIN_CONFIDENCE,
67
51
  buildExcerptWindows,
68
52
  composeNoticeRanking,
69
53
  evaluateExcerptPick,
54
+ excerptAskIsSafe,
70
55
  excerptPickRequest,
71
56
  noticeRankRequest,
72
57
  } from './jev-core.ts';
@@ -74,93 +59,66 @@ import { completeLineExcerpt } from './observation-core.ts';
74
59
  import { installConfigCommand } from './config-command.ts';
75
60
  import { CompactCoordinator } from './compact-coordinator.ts';
76
61
  import { handleReducerToolResult, type ToolResultEventLike } from './reducer-invoker.ts';
77
- import {
78
- pruneSessionObjects,
79
- resolveSessionRoot,
80
- } from './efficiency-store.ts';
81
- import {
82
- loadEfficiencyConfigFromDisk,
83
- resolveEfficiencyConfig,
84
- type EfficiencyConfig,
85
- } from './efficiency-config-core.ts';
86
- import {
87
- resolveDefaultFocusPollMs,
88
- parseFocusSample,
89
- spawnReflow,
90
- startFocusPoller,
91
- type FocusPoller,
92
- } from './focus-poller.ts';
93
-
94
- /**
95
- * D-4: focus sampling cadence. `PIER_FOCUS_POLL_MS=0` disables the poller (heat layout then only
96
- * reacts to herdr events). Invalid values fall back to the version default: 1500ms on Herdr <0.9.1,
97
- * 0 (event-first) on 0.9.1+ where native pane.focused is authoritative.
98
- */
62
+ import { appendEfficiencyLog, efficiencyLogPath, pruneSessionObjects, resolveSessionRoot } from './efficiency-store.ts';
63
+ import type { RoutingTelemetryRecord, RoutingTelemetryRow } from './routing-telemetry.ts';
64
+ import { loadEfficiencyConfigFromDisk, resolveEfficiencyConfig, type EfficiencyConfig } from './efficiency-config-core.ts';
65
+ import { resolveDefaultFocusPollMs, parseFocusSample, spawnReflow, startFocusPoller, type FocusPoller } from './focus-poller.ts';
66
+ import { composeMasterRuntime, createRoleRuntime } from './index-roles.ts';
67
+ import { createGateRuntime } from './index-gates.ts';
68
+
69
+ /** D-4: PIER_FOCUS_POLL_MS=0 disables the poller; invalid values fall back to the version default. */
99
70
  function focusPollIntervalMs(env: NodeJS.ProcessEnv = process.env, herdrVersion?: string | null): number {
100
71
  const defaultMs = resolveDefaultFocusPollMs(herdrVersion);
101
- const raw = env.PIER_FOCUS_POLL_MS;
102
- if (raw === undefined || raw.trim() === '') return defaultMs;
103
- const ms = Number(raw);
104
- return Number.isFinite(ms) && ms >= 0 ? ms : defaultMs;
72
+ const ms = Number(env.PIER_FOCUS_POLL_MS);
73
+ return env.PIER_FOCUS_POLL_MS !== undefined && env.PIER_FOCUS_POLL_MS.trim() !== '' && Number.isFinite(ms) && ms >= 0
74
+ ? ms
75
+ : defaultMs;
105
76
  }
106
77
 
107
- /**
108
- * WS-D7: apply the master-pane manifest to itself through the same mandatory chain as subagents
109
- * (gate, visible layer, badge, and session record); the baseline includes our core plus inspect/execute tools.
110
- * Match only a herdr pane (HERDR_ENV=1) that is not a subagent and has no explicitly supplied manifest.
111
- * Standalone pi sessions without herdr remain full-featured for backward compatibility.
112
- * A malformed master.json fails open to no-role state, matching malformed env semantics.
113
- */
114
- function composeMasterRuntime(): RuntimeRoleManifest | null {
115
- try {
116
- // v1.1: read the built-in name directly so a workspace master.json decoy cannot affect self-application; the reserved-name check remains for loud spawn errors.
117
- const { role, manifest } = composeForRole('master', [], { loadRoleOpts: { builtinDirect: true } });
118
- return {
119
- role: role.role,
120
- version: role.version,
121
- tools: manifest.tools,
122
- permissions: manifest.permissions,
123
- unknownTools: manifest.unknownTools,
124
- services: role.services ?? {},
125
- };
126
- } catch (err) {
127
- console.error(`[pi-herdr] master manifest invalid (fail-open): ${err instanceof Error ? err.message : String(err)}`);
128
- return null;
129
- }
78
+ function agentSessionsDir(): string {
79
+ return process.env.PI_CODING_AGENT_DIR ? join(process.env.PI_CODING_AGENT_DIR, 'sessions') : platformPaths.sessionsDir;
130
80
  }
131
- // C3: cordis belongs only in the master process; subagent-scope includes @deepseek-ai/cordis.
132
- // Dynamically import it on the master branch so worker processes never load the module.
133
- // Subagent constants/helpers moved with the family to core/subagent.ts.
134
81
 
135
82
  export default async function (pi: ExtensionAPI) {
136
83
  const mode = planIndexMode();
137
84
  const isSubagent = mode.isSubagent;
138
- const runtimeManifest =
139
- parseRuntimeManifest(process.env.PI_HERDR_ROLE_MANIFEST) ??
140
- (mode.composeMaster ? composeMasterRuntime() : null);
141
- const todos = new TodosService(TodosService.configFromRuntime(runtimeManifest, isSubagent));
142
- const { client, env } = createHerdrClient();
85
+ const roleBase = process.env.PI_HERDR_ROLE_BASE || process.cwd();
86
+
87
+ let sessionId: string = process.env.PI_SESSION_FILE ?? process.env.PI_SESSION_ID ?? '';
88
+ let sessionRoot: string | null = null;
143
89
  let effConfig: EfficiencyConfig = resolveEfficiencyConfig();
144
90
  const coordinator = new CompactCoordinator();
145
- let sessionRoot: string | null = null;
91
+ const { client, env } = createHerdrClient();
146
92
 
147
- /* ── Jev decision layer (RFC docs/rfc-jev-integration.md): direct HTTP, total-budget abort,
148
- * fail-open at every site. Disabled or keyless => behavior is byte-identical to before. ── */
149
- const jevRuntime = createJevRuntime(() => effConfig.jev, {
150
- getSessionRoot: () => sessionRoot,
151
- });
152
- const jevMinConfidence = (): number => effConfig.jev.minConfidence;
93
+ const initialManifest =
94
+ parseRuntimeManifest(process.env.PI_HERDR_ROLE_MANIFEST) ??
95
+ (mode.composeMaster ? composeMasterRuntime() : null);
96
+ const todos = new TodosService(TodosService.configFromRuntime(initialManifest, isSubagent));
97
+
98
+ /** Phase 0 routing telemetry (RFC docs/rfc-jev-role-routing.md §8): best-effort append; never gates anything. */
99
+ const appendRoutingLog = (row: RoutingTelemetryRecord): void => {
100
+ const root = sessionRoot;
101
+ if (!root) return;
102
+ const stamped: RoutingTelemetryRow = { ...row, sessionId: sessionId || 'unknown' };
103
+ void appendEfficiencyLog(efficiencyLogPath(root, 'routing'), stamped).catch(() => {});
104
+ };
105
+
106
+ const roles = createRoleRuntime({ pi, client, roleBase, initialManifest, isSubagent, todos, appendRoutingLog });
107
+
108
+ /* ── Jev decision layer (RFC docs/rfc-jev-integration.md): direct HTTP, total-budget
109
+ * abort, fail-open at every site. Disabled or keyless → behavior identical to before. ── */
110
+ const jevRuntime = createJevRuntime(() => effConfig.jev, { getSessionRoot: () => sessionRoot, getSessionId: () => sessionId || null });
153
111
  /** P0-3: candidate windows are generated in code; jev only picks (rerank pattern). */
154
112
  const pickMiddleExcerpt: PickMiddleExcerpt = async (text, excerptBudgetBytes) => {
155
113
  if (!jevRuntime.available) return null;
156
114
  const halfBudget = Math.floor(excerptBudgetBytes / 2);
157
115
  const windows = buildExcerptWindows(text, halfBudget);
158
- if (windows.length === 0) return null; // no failure-signal lines -> keep halves, no API call
159
- const request = excerptPickRequest(
160
- windows,
161
- completeLineExcerpt(text, halfBudget, false),
162
- completeLineExcerpt(text, excerptBudgetBytes - halfBudget, true),
163
- );
116
+ if (windows.length === 0) return null; // degenerate budget only
117
+ const headExcerpt = completeLineExcerpt(text, halfBudget, false);
118
+ const tailExcerpt = completeLineExcerpt(text, excerptBudgetBytes - halfBudget, true);
119
+ // Credential-shaped text never leaves the process (§8 privacy boundary).
120
+ if (!excerptAskIsSafe(windows, headExcerpt, tailExcerpt)) return null;
121
+ const request = excerptPickRequest(windows, headExcerpt, tailExcerpt);
164
122
  if (!request) return null;
165
123
  const result = await jevRuntime.ask(request, {
166
124
  questionId: 'obs-excerpt-window',
@@ -171,84 +129,67 @@ export default async function (pi: ExtensionAPI) {
171
129
  },
172
130
  });
173
131
  if (!result.ok) return null;
174
- const picked = evaluateExcerptPick(result.answers, jevMinConfidence());
132
+ const picked = evaluateExcerptPick(result.answers, effConfig.jev.minConfidence);
175
133
  const window = picked === null ? undefined : windows.find((w) => w.id === picked);
176
134
  return window ? { text: window.text, label: window.label } : null;
177
135
  };
178
- // Transcript polish for pier's own custom entries and reminder messages; display-only and
179
- // best-effort (older pi builds without the renderer API simply keep the raw rows).
136
+
180
137
  const rendererTypes = installRenderers(pi);
181
138
  if (process.env.PI_HERDR_TRACE) {
182
139
  console.error(`[pi-herdr] transcript renderers: ${rendererTypes.length ? rendererTypes.join(', ') : 'none'}`);
183
140
  }
184
141
 
185
- let sessionId: string = process.env.PI_SESSION_FILE ?? process.env.PI_SESSION_ID ?? '';
186
- /** M16: completion timestamps used as rate-estimation input, appended with todo.completed events. */
142
+ let agentActive = false;
143
+ let lastStopReason: string | null = null;
144
+ let latestCtx: { abort?: () => void } | null = null;
187
145
  const completedStamps: number[] = [];
146
+ const subagentPort = emptySubagentPortBox();
147
+ let d96NoticeKey: string | null = null;
148
+ let d96NoticeAt = 0;
188
149
 
189
- /* ── State reconstruction (branch correctness comes from the last todo_write snapshot on the branch) ── */
150
+ const todoUi: TodoUiSlot = { renderWidget: () => { /* filled after plugin mounting */ } };
151
+ const gates = createGateRuntime({
152
+ pi,
153
+ client,
154
+ rerenderWidget: () => todoUi.rerenderWidget?.(),
155
+ isAgentActive: () => agentActive,
156
+ idleMessage: () => roles.idleBadge(),
157
+ workingMessage: () => currentActivity(todos.items),
158
+ });
190
159
 
191
- function rebuildFromBranch(ctx: unknown): void {
160
+ /** Session identity: sessionManager is authoritative (env may be unset in print/RPC mode). */
161
+ function resolveSessionId(ctx: unknown): string {
192
162
  try {
193
- const entries = (ctx as { sessionManager?: { getBranch?: () => readonly unknown[] } })
194
- ?.sessionManager?.getBranch?.() ?? [];
195
- todos.rebuild(entries);
196
- coordinator.rebuildFromBranch(entries);
163
+ const sm = (ctx as {
164
+ sessionManager?: { getSessionFile?: () => string | undefined; getSessionId?: () => string };
165
+ }).sessionManager;
166
+ return bareSessionId(
167
+ sm?.getSessionFile?.() ?? sm?.getSessionId?.() ?? process.env.PI_SESSION_FILE ?? process.env.PI_SESSION_ID ?? '',
168
+ );
197
169
  } catch {
198
- // A reconstruction failure must not disrupt the main flow; the next todo_write re-anchors state.
170
+ return bareSessionId(process.env.PI_SESSION_FILE ?? '');
199
171
  }
200
172
  }
201
173
 
202
- /* ── herdr reporting (zero cost for Noop; silent failures never affect pi's main flow) ── */
203
-
204
- /** Tier 2: worker role badge parsed from the env manifest and kept visible while idle. */
205
- let roleBadge: string | null = null;
206
- /** Human-gate depth; ask_user_question and external herdr:blocked events share it. */
207
- let blockedDepth = 0;
208
-
209
- function reportAgent(state: 'working' | 'idle' | 'blocked', activity: string | null): void {
210
- if (!client.available) return;
211
- // While a human gate is open, never let tool-badge / turn_start / settle overwrite blocked.
212
- // Needed when herdr:pi is absent (pi-herdr is the authority); no-op under full-lifecycle herdr:pi.
213
- if (blockedDepth > 0 && state !== 'blocked') return;
214
- const message = activity ?? (state === 'idle' ? roleBadge : null);
215
- client.reportAgent(state, message).catch(() => {});
216
- }
217
-
218
- /** Session identity: sessionManager is authoritative (env may be unset in print/RPC mode), with env as fallback. */
219
- function resolveSessionId(ctx: unknown): string {
174
+ /** Branch reconstruction: todos + OCC debt state (branch correctness from the last snapshot on the branch). */
175
+ function rebuildFromBranch(ctx: unknown): void {
220
176
  try {
221
- const sm = (ctx as {
222
- sessionManager?: {
223
- getSessionFile?: () => string | undefined;
224
- getSessionId?: () => string;
225
- };
226
- }).sessionManager;
227
- return (
228
- sm?.getSessionFile?.() ??
229
- sm?.getSessionId?.() ??
230
- process.env.PI_SESSION_FILE ??
231
- process.env.PI_SESSION_ID ??
232
- ''
233
- );
177
+ const entries = (ctx as { sessionManager?: { getBranch?: () => readonly unknown[] } })
178
+ ?.sessionManager?.getBranch?.() ?? [];
179
+ todos.rebuild(entries);
180
+ coordinator.rebuildFromBranch(entries);
234
181
  } catch {
235
- return process.env.PI_SESSION_FILE ?? '';
182
+ /* A reconstruction failure must not disrupt the main flow; the next todo_write re-anchors. */
236
183
  }
237
184
  }
238
185
 
239
186
  function mirrorTodos(): void {
240
187
  if (!client.available) return;
241
188
  const label = sessionId || (env ? `pane:${env.paneId}` : '');
242
- // M16: progress badge (conservative N/M plus confidence ETA; fall back to a plain count when estimates are unreliable).
243
189
  const p = progressOf(todos.items);
244
190
  const eta = estimateEta({ completedAt: completedStamps, total: p.total, now: Date.now() });
245
191
  const suffix = formatProgressSuffix({ completed: p.completed, total: p.total, eta });
246
- // Unfreeze by carrying lastWriteAt into the title-side archived check (pane-title).
247
- const title = formatPaneTitle(todos.items, null, {
248
- progressSuffix: suffix,
249
- lastWriteAt: todos.lastWriteAt,
250
- });
251
- // D97: overlay content shares the todo snapshot; thinking tokens cannot reach it.
192
+ const title = formatPaneTitle(todos.items, null, { progressSuffix: suffix, lastWriteAt: todos.lastWriteAt });
252
193
  updateSlimFrame({ title, items: todos.items, lastWriteAt: todos.lastWriteAt });
253
194
  client.reportMetadata({
254
195
  session: label,
@@ -258,8 +199,7 @@ export default async function (pi: ExtensionAPI) {
258
199
  }).catch(() => {});
259
200
  }
260
201
 
261
- /* ── M17: automatic settlement reconciliation (pure planner + D38 authority; idempotent and safe to run through both paths) ── */
262
-
202
+ /** M17 settlement reconciliation: auto-complete settled todos / unblock on failure notes (idempotent). */
263
203
  function reconcileOnSettlement(description: string, outcome: 'settled' | 'failed'): string[] {
264
204
  if (!description) return [];
265
205
  try {
@@ -271,7 +211,6 @@ export default async function (pi: ExtensionAPI) {
271
211
  { version: 1, edits: plan.edits, ts: Date.now() },
272
212
  );
273
213
  } catch {
274
- /* Best effort persistence; in-memory state still advances. */
275
214
  }
276
215
  todos.applyEdits(plan.edits, { source: 'reconcile' });
277
216
  mirrorTodos();
@@ -282,30 +221,25 @@ export default async function (pi: ExtensionAPI) {
282
221
  }
283
222
  }
284
223
 
285
- /** M17: append reconciliation note lines to settlement notices; an empty list returns the original text. */
286
- function withReconcileNotes(base: string, notes: readonly string[]): string {
287
- return notes.length ? `${base}\n${notes.join('\n')}` : base;
288
- }
289
-
290
- /* ── M16: progress and tool badges (title suffix + report_agent.message; no new protocol) ── */
224
+ const withReconcileNotes = (base: string, notes: readonly string[]): string =>
225
+ notes.length ? `${base}\n${notes.join('\n')}` : base;
291
226
 
292
- todos.on('todo.completed', (e: { count: number; at: number; source?: import('./todos-service.ts').TodoCompletionSource }) => {
227
+ todos.on('todo.completed', (e: { count: number; at: number; source?: TodoCompletionSource }) => {
293
228
  for (let i = 0; i < e.count; i++) completedStamps.push(e.at);
294
229
  coordinator.recordBoundaryCompleted(e.count, e.source);
295
230
  mirrorTodos();
296
231
  });
297
232
 
298
- // Tool badge: parallel mode starts in source order and ends in completion order (pi docs contract); track in-flight calls in a Map.
233
+ /* ── tool badges: parallel calls start in source order, end in completion order ── */
299
234
  const runningTools = new Map<string, string>(); // toolCallId → toolName
300
235
  let lastToolBadge: string | null = null;
301
236
  function reportToolBadge(): void {
302
237
  const badge = planToolBadge([...new Set(runningTools.values())]);
303
- if (badge === lastToolBadge) return; // Idempotent: report only changes (M13 rendering discipline).
238
+ if (badge === lastToolBadge) return; // report only changes (M13 rendering discipline)
304
239
  lastToolBadge = badge;
305
- if (badge) reportAgent('working', badge);
306
- else reportAgent(agentActive ? 'working' : 'idle', agentActive ? currentActivity(todos.items) : null);
240
+ if (badge) gates.report('working', badge);
241
+ else gates.report(agentActive ? 'working' : 'idle', agentActive ? currentActivity(todos.items) : null);
307
242
  }
308
-
309
243
  pi.on('tool_execution_start', async (event: { toolCallId?: string; toolName?: string }) => {
310
244
  if (typeof event?.toolCallId === 'string' && typeof event?.toolName === 'string') {
311
245
  runningTools.set(event.toolCallId, event.toolName);
@@ -319,34 +253,141 @@ export default async function (pi: ExtensionAPI) {
319
253
  }
320
254
  });
321
255
 
322
- /* ── D102: Evidence-Preserving Reducer (runs before append-handlers like write-locks) ── */
256
+ /* ── D102 evidence-preserving reducer (registered before append-handlers like write-locks) ── */
323
257
  pi.on('tool_result', async (event, ctx) => {
324
258
  if (!effConfig.evidencePreservingReducer.enabled) return;
325
259
  if (!event || typeof event !== 'object') return;
326
260
  return handleReducerToolResult(event as unknown as ToolResultEventLike, ctx, effConfig.evidencePreservingReducer, {
327
261
  epoch: coordinator.state.epoch,
328
- jev: { ask: jevRuntime.ask, getMinConfidence: jevMinConfidence },
262
+ jev: { ask: jevRuntime.ask, getMinConfidence: () => effConfig.jev.minConfidence },
329
263
  });
330
264
  });
331
265
 
332
266
  let locksHandle = { getHeldLocks: () => [] as readonly string[] };
333
267
  if (env) {
334
- locksHandle = installWriteLocks(pi, {
335
- client,
336
- env,
337
- hard: process.env[WRITE_LOCK_ENV] === '1',
338
- });
268
+ locksHandle = installWriteLocks(pi, { client, env, hard: process.env[WRITE_LOCK_ENV] === '1' });
339
269
  }
340
270
 
341
- /* ── todo family slot (core/todo.ts fills the plugin hook; widget rendering moved with the family) ── */
342
- const todoUi: TodoUiSlot = {
343
- renderWidget: () => { /* No-op before plugin mounting; filled after mounting. */ },
271
+ /** pi's sendUserMessage is fire-and-forget (void); callers of this wrapper still get a promise. */
272
+ const sendUserMessageAs = (content: string, mode: 'steer' | 'followUp'): Promise<void> => {
273
+ (pi as unknown as { sendUserMessage?: (content: string, opts?: { deliverAs?: string; triggerTurn?: boolean }) => void })
274
+ .sendUserMessage?.(content, { deliverAs: mode, triggerTurn: true });
275
+ return Promise.resolve();
344
276
  };
277
+ const notices = createNoticeBuffer({
278
+ isBusy: () => agentActive || lastStopReason === ABORT_STOP_REASON,
279
+ send: sendUserMessageAs,
280
+ // P0-2: rank collapsed batches by relevance to the master's in-progress work.
281
+ rank: async (contents) => {
282
+ if (!jevRuntime.available) return null;
283
+ const inProgressTodos = todos.items.filter((item) => item.status === 'in_progress').map((item) => item.content);
284
+ let rankedOrder: number[] | null | undefined;
285
+ const result = await jevRuntime.ask(
286
+ noticeRankRequest({ inProgressTodos, notices: [...contents] }),
287
+ {
288
+ questionId: 'notice-rank',
289
+ sessionId,
290
+ extra: { site: 'notice-rank', noticeCount: contents.length },
291
+ enrich: ({ answers }) => {
292
+ if (!answers) return {};
293
+ rankedOrder = composeNoticeRanking(
294
+ contents.length,
295
+ answers,
296
+ Math.max(effConfig.jev.minConfidence, NOTICE_RANK_MIN_CONFIDENCE),
297
+ );
298
+ const values = Array.from({ length: contents.length }, (_, i) => {
299
+ const rank = answers[`notice_${i}_rank`];
300
+ const fail = answers[`notice_${i}_fail`];
301
+ return rank !== undefined && fail !== undefined && rank.type === 'score' && fail.type === 'noul'
302
+ ? `${rank.score.toFixed(2)}:${rank.confidence.toFixed(2)}:${fail.noul.toFixed(2)}`
303
+ : '?';
304
+ });
305
+ return { order: rankedOrder === null ? 'fallback' : rankedOrder.join(','), values };
306
+ },
307
+ },
308
+ );
309
+ if (!result.ok) return null;
310
+ return rankedOrder === undefined || rankedOrder === null ? null : rankedOrder.map((index) => contents[index]!);
311
+ },
312
+ });
313
+ const deliverNotice = notices.deliverNotice;
314
+
315
+ /* ── pipe channel: this pane's NDJSON server + the settle fast-path push (D48/D49/D50) ── */
316
+ const pipeServerBox: { current: ReturnType<typeof startPipeServer> | null } = { current: null };
317
+ let pendingMachineRequest: MachineRequest | null = null;
318
+ /** Dedup latch for settle notices: one per paneId+request id per round. */
319
+ const settleNoticeLatch = new Set<string>();
320
+ function claimSettleNotice(key: string): boolean {
321
+ if (settleNoticeLatch.has(key)) return false;
322
+ settleNoticeLatch.add(key);
323
+ return true;
324
+ }
325
+
326
+ function startPipeServerFor(cwd: string): void {
327
+ const paneId = env?.paneId ?? '';
328
+ if (!paneId) return;
329
+ if (pipeServerBox.current) {
330
+ try { pipeServerBox.current.close(); } catch { /* Previous instance. */ }
331
+ pipeServerBox.current = null;
332
+ }
333
+ try {
334
+ pipeServerBox.current = startPipeServer(pipeNameFor(cwd, paneId), async (req) => handlePipeRequest(req, {
335
+ paneId,
336
+ port: subagentPort,
337
+ claimSettleNotice,
338
+ deliverNotice,
339
+ sendUserMessageIn: (content) => sendUserMessageAs(content, 'followUp'),
340
+ sendUserMessageAs,
341
+ abort: () => { latestCtx?.abort?.(); },
342
+ setPendingMachineRequest: (next) => { pendingMachineRequest = next; },
343
+ applyRoleSwitch: roles.applyRoleSwitch,
344
+ }));
345
+ } catch {
346
+ /* Pipe name collision (rare): this session has no channel; callers report after ping timeout. */
347
+ }
348
+ }
345
349
 
346
- /* ── Lifecycle ────────────────────────────────────────────────────── */
350
+ async function pushSettleReply(): Promise<void> {
351
+ const req = pendingMachineRequest;
352
+ if (!req || !req.push || !req.from) return;
353
+ // An aborted settle is not a settlement: OCC's abort means the continuation turn is coming, and
354
+ // answering now would burn the one-shot request + the parent's claim latch on a null-text reply
355
+ // ("left no closing message") — the real closing message then never gets delivered. Keep the
356
+ // request armed; the post-compaction settle pushes it (settle-wake-core.ts).
357
+ if (!shouldPushSettleReply({
358
+ intentionalAbort: coordinator.intentionalAbort,
359
+ compactionInFlight: coordinator.compactionInFlight,
360
+ lastStopReason,
361
+ })) {
362
+ return;
363
+ }
364
+ pendingMachineRequest = null;
365
+ try {
366
+ const ownFile = sessionId ? resolveSessionFileValue(process.cwd(), agentSessionsDir(), sessionId) : null;
367
+ let text: string | null = null;
368
+ if (ownFile) {
369
+ const entries = readSessionFile(ownFile);
370
+ if (entries) text = lastAssistantText(entries, { sinceTs: req.sinceTs })?.text ?? null;
371
+ }
372
+ await pipeRequest(req.from, {
373
+ type: 'reply',
374
+ id: req.id,
375
+ paneId: env?.paneId ?? '',
376
+ text,
377
+ // Push the resolved PATH when readable; fall back to the bare id for the master-side mapping.
378
+ sessionFile: ownFile ?? (sessionId || null),
379
+ }, 5000);
380
+ } catch (err) {
381
+ /* The requester's pollLoop is the fallback; surface the dead letter for triage. */
382
+ console.error(`pier: settle reply push to ${req.from} failed: ${(err as Error)?.message ?? err}`);
383
+ }
384
+ }
385
+
386
+ const sessionFocusPoller: { current: FocusPoller | null } = { current: null };
347
387
 
348
388
  pi.on('session_start', async (event, ctx) => {
349
389
  sessionId = resolveSessionId(ctx);
390
+ latestCtx = ctx as { abort?: () => void } | null;
350
391
  const cwd = (ctx as { cwd?: string }).cwd ?? process.cwd();
351
392
  const isTrusted =
352
393
  typeof (ctx as { isProjectTrusted?: () => boolean }).isProjectTrusted === 'function'
@@ -357,117 +398,63 @@ export default async function (pi: ExtensionAPI) {
357
398
  (ctx as { sessionManager?: { getSessionDir?: () => string | undefined } })?.sessionManager?.getSessionDir?.(),
358
399
  sessionId,
359
400
  );
360
- rebuildFromBranch(ctx);
361
- // v1.3 M9 fix (observed): on resume pi restores widget state from the session;
362
- // calling setWidget again breaks the TUI '/' command-panel route ('/' would be sent to the model as message text).
363
- const reason = (event as { reason?: string } | undefined)?.reason;
364
- if (reason !== 'resume') todoUi.renderWidget(ctx);
365
- // D97: narrow-frame overlay is meaningful only inside herdr; covering an interactive narrow terminal is unsafe,
366
- // and the heatmap amplification provides the exit path. Re-register on resume because session switching resets the overlay.
401
+ // On resume pi restores widget state from the session; calling setWidget again
402
+ // breaks the TUI '/' command-panel route (v1.3 M9).
403
+ if ((event as { reason?: string } | undefined)?.reason !== 'resume') todoUi.renderWidget(ctx);
404
+ // D97: narrow-frame overlay is meaningful only inside herdr; re-register (session switching resets it).
367
405
  if (env) registerSlimFrame(ctx);
406
+ // P2-5: resume folds todos/compaction state from the branch like session_tree; a fresh session folds nothing.
407
+ rebuildFromBranch(ctx);
368
408
  mirrorTodos();
369
- // D-2: the session path is NOT reported here. herdr's own pi integration publishes it
370
- // (agent.list/agent.pane show `session_source: "herdr:pi"` on every pi pane, incl. pier subagents),
371
- // so pier's pane.report_agent_session was a pure duplicate write of the same field by a second source.
372
- // D93: sidebar identity is the role name (display_agent takes precedence over detected agent, actions.rs:563 in 0.8.2).
373
- // master → 'master'; worker → manifest.role (prettify worker-default as worker).
374
- // A bare pi without a manifest does not report, so ordinary pi sessions remain undisturbed.
375
- if (runtimeManifest) {
376
- const roleDisplay = runtimeManifest.role === 'worker-default' ? 'worker' : runtimeManifest.role;
377
- void client.reportDisplayAgent(roleDisplay);
378
- }
379
- // Tier 2: worker role manifest parsed at process start supplies the badge and authoritative session record.
380
- // The custom entry mirrors D38 todo-edit and anchors execution-time replay.
381
- if (runtimeManifest) {
382
- roleBadge = `role ${runtimeManifest.role} v${runtimeManifest.version ?? '?'} (${runtimeManifest.tools.length} tools)`;
383
- try {
384
- (pi as { appendEntry?: (customType: string, data: unknown) => void }).appendEntry?.(
385
- ROLE_MANIFEST_CUSTOM_TYPE,
386
- { version: 1, role: runtimeManifest.role, manifestVersion: runtimeManifest.version, tools: runtimeManifest.tools, permissions: runtimeManifest.permissions, unknownTools: runtimeManifest.unknownTools ?? 'deny', ts: Date.now() },
387
- );
388
- } catch {
389
- /* Best effort recording. */
390
- }
391
- // D77 visible layer: remove tools outside the manifest from the model's view after all plugins load at session_start.
392
- // Intersection semantics prevent clearing everything; master without a manifest stays full. Missing APIs are skipped for old pi compatibility.
393
- const piTools = pi as { getActiveTools?: () => string[]; setActiveTools?: (names: string[]) => void };
394
- if (typeof piTools.getActiveTools === 'function' && typeof piTools.setActiveTools === 'function') {
395
- try {
396
- const active = piTools.getActiveTools();
397
- const vis = planActiveTools(runtimeManifest.tools, active, {
398
- unknownTools: runtimeManifest.unknownTools,
399
- permissions: runtimeManifest.permissions,
400
- });
401
- if (vis?.changed) {
402
- piTools.setActiveTools?.(vis.next);
403
- console.error(`[pi-herdr] D77 visible-layer: role ${runtimeManifest.role} tools ${active.length} → ${vis.next.length}`);
404
- }
405
- } catch {
406
- /* Best effort visible layer; the mandatory layer remains active. */
407
- }
408
- }
409
- }
410
- reportAgent('idle', null);
411
- // Self-healing: a gate does not survive a process restart, but the ask marker lives in herdr
412
- // with a 24h TTL, so a session killed while a dialog was open would otherwise keep the pane
413
- // looking blocked forever. Depth is 0 on session_start, so this cannot race a live gate.
409
+ roles.syncFromBranch(ctx);
410
+ if (mode.composeMaster && sessionRoot) roles.scanRoleUsage();
411
+ gates.report('idle', null);
412
+ // Self-healing: the ask marker lives in herdr with a 24h TTL; a session killed
413
+ // while a dialog was open must not keep the pane looking blocked forever.
414
414
  void client.reportAskFlag(null).catch(() => {});
415
- });
416
415
 
417
- /* ── Tier 2 Weeks 4–5: worker execution enforcement (the manifest's final gate) ──
418
- * deny / outside manifest → block (reason is given to the model); ask → v1 allows with stderr logging
419
- * (V56 acceptance anchor). Master and label-only workers without a manifest remain open.
420
- * Rate limiting was removed (WS-D6): we own the permission boundary; plugin integrators own resource quotas. */
421
- pi.on('tool_call', async (event: { toolName?: string }) => {
422
- if (!runtimeManifest) return;
423
- const tool = typeof event?.toolName === 'string' ? event.toolName : '';
424
- const gate = planToolGate(tool, runtimeManifest);
425
- if (gate.kind === 'deny') {
426
- // terminate (pi 0.84.1+) stops a batch whose results are all terminating without another
427
- // model call, so a blocked worker tool no longer costs an extra round trip.
428
- return { block: true, reason: gate.reason, terminate: true };
429
- }
430
- if (gate.kind === 'ask') {
431
- console.error(`${gate.notice} (v1 soft-approval: allowed, hard gate lands in v2)`);
432
- // Durable trace (V56 anchor): TUI redraw erases stderr, so the session custom entry is authoritative.
433
- try {
434
- (pi as { appendEntry?: (customType: string, data: unknown) => void }).appendEntry?.(
435
- APPROVAL_NEEDED_CUSTOM_TYPE,
436
- { role: runtimeManifest.role, tool, ts: Date.now() },
437
- );
438
- } catch {
439
- /* Best effort. */
440
- }
416
+ // D-4 focus sampling: herdr <0.9.1 never delivered pane.focused to plugins;
417
+ // 0.9.1+ is event-first (pollMs 0). One poller per pane, self-scoped.
418
+ if (sessionFocusPoller.current) sessionFocusPoller.current.stop();
419
+ sessionFocusPoller.current = null;
420
+ const paneId = env?.paneId ?? '';
421
+ const serverVer = await client.getServerVersion();
422
+ const pollMs = focusPollIntervalMs(process.env, serverVer);
423
+ if (paneId && client.available && pollMs > 0) {
424
+ sessionFocusPoller.current = startFocusPoller({
425
+ myPaneId: paneId,
426
+ intervalMs: pollMs,
427
+ sample: async () => {
428
+ // layout.export reports the focused pane of our own tab; without the
429
+ // field (older herdr) the tick stays silent instead of guessing.
430
+ const layout = await client.exportLayout({ paneId });
431
+ const sample = parseFocusSample(layout);
432
+ return sample && sample.focusedPaneId !== null ? sample : null;
433
+ },
434
+ fire: (focusedPaneId, cause) => spawnReflow({ paneId: focusedPaneId, cause }),
435
+ });
441
436
  }
437
+ startPipeServerFor(cwd);
442
438
  });
443
439
 
444
-
445
- // Session-tree navigation (/tree, /fork): reconstruction preserves branch correctness.
446
440
  pi.on('session_tree', async (_event, ctx) => {
447
441
  sessionId = resolveSessionId(ctx);
448
442
  rebuildFromBranch(ctx);
443
+ // pi reverts the transcript loadout at the navigated branch point; the role
444
+ // runtime follows the same point (same-origin, RFC §4.3).
445
+ roles.syncFromBranch(ctx);
449
446
  todoUi.renderWidget(ctx);
450
447
  mirrorTodos();
451
448
  });
452
449
 
453
- let agentActive = false;
454
- const subagentPort = emptySubagentPortBox();
455
- // D96 state, anchoring settle-wake-core deduplication and cooldown.
456
- let d96NoticeKey: string | null = null;
457
- let d96NoticeAt = 0;
458
- // Prevent wake-up storms by tracking the last assistant turn's stopReason;
459
- // 'aborted' means the user explicitly pressed ESC, so settlement must inject no wake-up message.
460
- let lastStopReason: string | null = null;
461
450
  pi.on('turn_start', async () => {
462
451
  agentActive = true;
463
- // Why (A11): Reset lastStopReason on turn_start so a previous turn's abort
464
- // does not taint subsequent turns or suppress settlement wake / notice flush.
452
+ // A11: reset so a previous turn's abort cannot taint this one.
465
453
  lastStopReason = null;
466
- // Safety fallback: if previous compaction finished without clean callback settlement,
467
- // ensure flags are reset. Pi core prohibits prompts while compaction is active, so this is safe.
454
+ // Safety fallback: pi core prohibits prompts while compaction is active.
468
455
  coordinator.compactionInFlight = false;
469
456
  coordinator.intentionalAbort = false;
470
- reportAgent('working', currentActivity(todos.items));
457
+ gates.report('working', currentActivity(todos.items));
471
458
  });
472
459
 
473
460
  pi.on('before_provider_request', (_event: unknown, ctx: unknown) => {
@@ -486,29 +473,33 @@ export default async function (pi: ExtensionAPI) {
486
473
  pi.on('session_before_tree', () => (coordinator.compactionInFlight ? { cancel: true } : undefined));
487
474
 
488
475
  pi.on('turn_end', async (event: unknown, ctx) => {
489
- if (event === null || typeof event !== 'object' || !('message' in event)) return;
490
- const msg = (event as { message: unknown }).message; // 'message' in has been guarded.
491
- if (msg === null || typeof msg !== 'object') return;
492
- const { role, stopReason } = msg as { role?: unknown; stopReason?: unknown };
493
- if (role === 'assistant' && typeof stopReason === 'string') {
494
- lastStopReason = stopReason;
495
- }
496
- if (ctx && typeof ctx === 'object') {
497
- coordinator.onTurnEnd({
498
- ctx,
499
- todos: todos.items,
500
- config: effConfig.onlineContextCompact,
501
- cancelReminder: todoUi.cancelReminder,
502
- });
476
+ // Master registered this twice: the lifecycle handler skipped malformed events, the notice
477
+ // flush ran regardless. The merged handler keeps both semantics.
478
+ const msg = event !== null && typeof event === 'object' && 'message' in event ? (event as { message: unknown }).message : null;
479
+ if (msg !== null && typeof msg === 'object') {
480
+ const { role, stopReason } = msg as { role?: unknown; stopReason?: unknown };
481
+ if (role === 'assistant' && typeof stopReason === 'string') {
482
+ lastStopReason = stopReason;
483
+ }
484
+ if (ctx && typeof ctx === 'object') {
485
+ coordinator.onTurnEnd({
486
+ ctx,
487
+ todos: todos.items,
488
+ config: effConfig.onlineContextCompact,
489
+ cancelReminder: todoUi.cancelReminder,
490
+ });
491
+ }
503
492
  }
493
+ void notices.flush('steer');
504
494
  });
505
495
 
506
496
  pi.on('agent_settled', async (_event: unknown, ctx) => {
507
497
  agentActive = false;
508
- reportAgent('idle', null);
498
+ gates.report('idle', null);
509
499
  const plan = planSettleWake({
510
500
  lastStopReason,
511
501
  intentionalAbort: coordinator.intentionalAbort,
502
+ compactionPending: coordinator.selectedCompaction !== null || coordinator.compactionInFlight,
512
503
  running: subagentPort.current?.listRunningSubs() ?? [],
513
504
  lastNoticeKey: d96NoticeKey,
514
505
  lastNoticeAt: d96NoticeAt,
@@ -516,19 +507,14 @@ export default async function (pi: ExtensionAPI) {
516
507
  });
517
508
  d96NoticeKey = plan.noticeKey;
518
509
  d96NoticeAt = plan.noticeAt;
519
- if (!plan.wake) {
520
- // Stay silent after a user abort while retaining the settlement buffer (do not clear pendingSettleNotices);
521
- // deliver it on the next natural run's turn_end steer or natural settlement.
522
- return;
523
- }
524
510
  // D96: master settled while background subagents still run → remind (worker port is unbound).
525
- if (plan.notice && !isSubagent) {
511
+ if (plan.wake && plan.notice && !isSubagent) {
526
512
  const running = subagentPort.current?.listRunningSubs() ?? [];
527
513
  const brief = running.map((s) => `${s.paneId} (${s.description})`).join('、');
528
514
  const notice = `注意:仍有 ${running.length} 个后台 subagent 在运行:${brief}。若你的任务依赖它们,请等待其结算(subagent list 查看状态);若不等待,请说明放弃原因。`;
529
- void sendUserMessageIn(notice);
515
+ void sendUserMessageAs(notice, 'followUp');
530
516
  }
531
- if (ctx && typeof ctx === 'object') {
517
+ if (plan.compact && ctx && typeof ctx === 'object') {
532
518
  void coordinator.onAgentSettled({
533
519
  ctx,
534
520
  todos: todos.items,
@@ -537,117 +523,21 @@ export default async function (pi: ExtensionAPI) {
537
523
  cancelReminder: todoUi.cancelReminder,
538
524
  onBeforeCompact: createCompactionBatchPackHook({
539
525
  getObsConfig: () => effConfig.observationPack,
540
- getManifest: () => runtimeManifest,
526
+ getManifest: () => roles.state.manifest,
541
527
  getSessionId: () => sessionId,
542
528
  pickMiddleExcerpt,
543
529
  }),
544
530
  });
545
531
  }
532
+ if (lastStopReason !== ABORT_STOP_REASON) void notices.flush('followUp');
533
+ void pushSettleReply();
546
534
  });
547
535
 
548
- // Official herdr:pi is full-lifecycle authority when installed: it reports blocked
549
- // only on the herdr:blocked event (pi core never emits it). Custom source=pi-herdr
550
- // pane.report_agent is ignored while that authority is live
551
- // (screen_detection_skip_reason=full_lifecycle_hook_authority). Emit the event so
552
- // herdr:pi publishes blocked; keep report_agent as fallback when it is absent.
553
- /**
554
- * Open one human gate. Nested gates (pier's ask tool plus pi's own ctx.ui prompt, or an
555
- * external herdr:blocked producer) are coalesced: only the 0→1 transition reports blocked,
556
- * marks the ask flag and folds the widget. Returns true when this call opened the outer gate.
557
- */
558
- function enterBlocked(label: string | null): boolean {
559
- blockedDepth += 1;
560
- if (blockedDepth > 1) return false;
561
- reportAgent('blocked', label);
562
- // D95: human-gate marker lets the workbench heatmap distinguish ask from block.
563
- if (label) void client.reportAskFlag(label).catch(() => {});
564
- // Gate open collapses the todo widget to one line — the question owns the fixed area.
565
- todoUi.rerenderWidget?.();
566
- return true;
567
- }
568
- /** Close one gate; only the 1→0 transition restores the pre-gate state. Returns true when closed.
569
- * An unmatched release (no gate open) is ignored rather than reported, so a stray
570
- * ui_prompt_end cannot emit a false "no longer blocked" edge. */
571
- function exitBlocked(): boolean {
572
- if (blockedDepth === 0) return false;
573
- blockedDepth -= 1;
574
- // Gate closed → restore the full widget window.
575
- todoUi.rerenderWidget?.();
576
- if (blockedDepth > 0) return false;
577
- reportAgent(agentActive ? 'working' : 'idle', agentActive ? currentActivity(todos.items) : null);
578
- // D95: gate released → clear marker.
579
- void client.reportAskFlag(null).catch(() => {});
580
- return true;
581
- }
582
-
583
- // emit() is synchronous; this flag stops our own listener from double-counting depth.
584
- let publishingHerdrBlocked = false;
585
- /** Publish the herdr:blocked edge for pi-herdr's own gates; no-op re-entry is prevented by the flag. */
586
- function emitHerdrBlocked(active: boolean, label: string | null): void {
587
- publishingHerdrBlocked = true;
588
- try {
589
- pi.events.emit(
590
- 'herdr:blocked',
591
- active ? { active: true, ...(label ? { label } : {}) } : { active: false },
592
- );
593
- } finally {
594
- publishingHerdrBlocked = false;
595
- }
596
- }
597
- function publishHerdrBlocked(active: boolean, label: string | null): void {
598
- if (active) enterBlocked(label);
599
- else exitBlocked();
600
- emitHerdrBlocked(active, label);
601
- }
602
- pi.events.on('herdr:blocked', (data) => {
603
- if (publishingHerdrBlocked) return;
604
- if (!data || typeof data !== 'object') {
605
- exitBlocked();
606
- return;
607
- }
608
- if (!('active' in data) || data.active !== true) {
609
- exitBlocked();
610
- return;
611
- }
612
- enterBlocked('label' in data && typeof data.label === 'string' ? data.label : null);
613
- });
614
-
615
- /* ── Generic human-gate reporting (pi 0.84.4+) ─────────────────────────
616
- * Why: `ui_prompt_start` / `ui_prompt_end` fire around blocking ctx.ui dialogs.
617
- * Scope: only the inherently human-blocking kinds (select / confirm / input / editor).
618
- * `custom` is deliberately excluded: pi routes both modals and *persistent overlays*
619
- * through ctx.ui.custom, and pier itself registers a resident slim-frame overlay that
620
- * never calls done() — treating it as a gate left the pane blocked (and the ask marker
621
- * set) for the entire session while the agent kept working. Note that a resident overlay
622
- * also holds pi's own prompt span open, so these events generally stop firing in herdr
623
- * panes anyway; pier's ask tool opens its gate explicitly and is the reliable path there.
624
- * Probed through a structural type because the events postdate our pinned pi devDependency. */
625
- const uiPromptEvents = pi as unknown as {
626
- on?: (name: 'ui_prompt_start' | 'ui_prompt_end', handler: (event: unknown) => void) => void;
627
- };
628
- const GATING_PROMPT_KINDS = new Set(['select', 'confirm', 'input', 'editor']);
629
- uiPromptEvents.on?.('ui_prompt_start', (event) => {
630
- const rec = (event ?? {}) as { kind?: unknown; title?: unknown };
631
- const title = typeof rec.title === 'string' && rec.title.trim() ? rec.title.trim() : null;
632
- const kind = typeof rec.kind === 'string' ? rec.kind : 'prompt';
633
- if (!GATING_PROMPT_KINDS.has(kind)) return;
634
- // Normalize once so the gate report and the published edge carry the same label.
635
- const label = title ?? kind;
636
- // The ask tool opens its own gate first, so this is usually a nested (no-op) transition;
637
- // prompts from other extensions or pi core open the gate here.
638
- if (enterBlocked(label)) emitHerdrBlocked(true, label);
639
- });
640
- uiPromptEvents.on?.('ui_prompt_end', () => {
641
- if (exitBlocked()) emitHerdrBlocked(false, null);
642
- });
643
-
644
- /* ── Tool: ask_user_question (v1.3 M8 human gate, available to master and subagents) ── */
645
-
646
- /* ── D101: ObservationPack (obs_recall tool & context projector) ── */
536
+ /* ── D101 ObservationPack (obs_recall tool & context projector) ── */
647
537
  registerObservationPack({
648
538
  pi,
649
539
  getConfig: () => effConfig,
650
- getRuntimeManifest: () => runtimeManifest,
540
+ getRuntimeManifest: () => roles.state.manifest,
651
541
  pickMiddleExcerpt,
652
542
  getRemainingHorizon: () => {
653
543
  const usage = (latestCtx as { getContextUsage?: () => { contextWindow?: number } })?.getContextUsage?.();
@@ -655,10 +545,6 @@ export default async function (pi: ExtensionAPI) {
655
545
  },
656
546
  });
657
547
 
658
- /* ── D104: /pier-config (read-only config guide + guided-change handoff) ──
659
- * Supersedes the former /efficiency command: the index line reports OCC/OBS/EPR state and
660
- * `show efficiency` lists every D100-D103 knob with its effective value and source. */
661
-
662
548
  installConfigCommand({ pi });
663
549
  installDashboardCommand({
664
550
  pi,
@@ -668,6 +554,7 @@ export default async function (pi: ExtensionAPI) {
668
554
  getHeldLocks: () => locksHandle.getHeldLocks(),
669
555
  });
670
556
 
557
+ /* ── ask_user_question (v1.3 M8 human gate; master and subagents) ── */
671
558
  pi.registerTool({
672
559
  name: ASK_TOOL_NAME,
673
560
  label: 'Ask User',
@@ -679,15 +566,13 @@ export default async function (pi: ExtensionAPI) {
679
566
  if (!prepared.ok) return prepared.result;
680
567
  const ui = ctx && typeof ctx === 'object' && 'ui' in ctx ? ctx.ui : undefined;
681
568
  if (!hasAskUi(ui)) return noUiResult();
682
- const label = gateLabel(prepared.spec);
683
- publishHerdrBlocked(true, label);
684
- // The gate stays reported for the whole wait: reportAgent() drops working/idle reports while
685
- // blockedDepth > 0, and pi's ui_prompt_start/end events keep the depth honest for prompts
686
- // that do not come from this tool. (The old 5s refresh interval is obsolete.)
569
+ gates.publish(true, gateLabel(prepared.spec));
570
+ // The gate stays reported for the whole wait: gates.report drops
571
+ // working/idle while blocked, and pi's ui_prompt events keep the depth honest.
687
572
  try {
688
573
  return await runAsk(prepared.spec, ui, signal instanceof AbortSignal ? signal : undefined);
689
574
  } finally {
690
- publishHerdrBlocked(false, null);
575
+ gates.publish(false, null);
691
576
  }
692
577
  },
693
578
  });
@@ -695,168 +580,22 @@ export default async function (pi: ExtensionAPI) {
695
580
  pi.on('session_shutdown', async () => {
696
581
  coordinator.compactionInFlight = false;
697
582
  coordinator.intentionalAbort = false;
698
- if (sessionRoot) {
699
- try {
700
- await pruneSessionObjects(sessionRoot);
701
- } catch {
702
- /* ignore shutdown pruning errors */
703
- }
704
- }
705
- client.close();
706
- });
707
-
708
- /* ── M12: bidirectional message channel (D49/D50/D48; every pane listens on its own name, with no controller/child assumption) ── */
709
-
710
- const settleNoticeLatch = new Set<string>();
711
- /** Deduplicate settlement notices: choose push fast path or pollLoop fallback; one notice per paneId+request ID per round. */
712
- function claimSettleNotice(key: string): boolean {
713
- if (settleNoticeLatch.has(key)) return false;
714
- settleNoticeLatch.add(key);
715
- return true;
716
- }
717
- const pipeServerBox: { current: ReturnType<typeof startPipeServer> | null } = { current: null };
718
- /** D-4: focus sampler for this pane (started on session_start, stopped on session_shutdown). */
719
- const sessionFocusPoller: { current: FocusPoller | null } = { current: null };
720
- let pendingMachineRequest: { id: string; from: string | null; push: boolean; sinceTs: number } | null = null;
721
- let latestCtx: { abort?: () => void } | null = null;
722
-
723
- // triggerTurn:true — idle followUp must start a new turn or settlement is lost.
724
- const sendUserMessageIn = (content: string): Promise<void> =>
725
- (pi as unknown as { sendUserMessage?: (content: string, opts?: { deliverAs?: string; triggerTurn?: boolean }) => Promise<void> })
726
- .sendUserMessage?.(content, { deliverAs: 'followUp', triggerTurn: true }) ?? Promise.resolve();
727
- const sendUserMessageAs = (content: string, mode: 'steer' | 'followUp'): Promise<void> =>
728
- (pi as unknown as { sendUserMessage?: (content: string, opts?: { deliverAs?: string; triggerTurn?: boolean }) => Promise<void> })
729
- .sendUserMessage?.(content, { deliverAs: mode, triggerTurn: true }) ?? Promise.resolve();
730
-
731
- const notices = createNoticeBuffer({
732
- isBusy: () => agentActive || lastStopReason === ABORT_STOP_REASON,
733
- send: sendUserMessageAs,
734
- // P0-2: rank collapsed batches by relevance to the master's in-progress work
735
- rank: async (contents) => {
736
- if (!jevRuntime.available) return null;
737
- const inProgressTodos = todos.items
738
- .filter((item) => item.status === 'in_progress')
739
- .map((item) => item.content);
740
- let rankedOrder: number[] | null | undefined;
741
- const result = await jevRuntime.ask(
742
- noticeRankRequest({ inProgressTodos, notices: [...contents] }),
743
- {
744
- questionId: 'notice-rank',
745
- sessionId,
746
- extra: { site: 'notice-rank', noticeCount: contents.length },
747
- enrich: ({ answers }) => {
748
- if (!answers) return {};
749
- rankedOrder = composeNoticeRanking(
750
- contents.length,
751
- answers,
752
- Math.max(effConfig.jev.minConfidence, NOTICE_RANK_MIN_CONFIDENCE),
753
- );
754
- const values = Array.from({ length: contents.length }, (_, i) => {
755
- const rank = answers[`notice_${i}_rank`];
756
- const fail = answers[`notice_${i}_fail`];
757
- return rank !== undefined && fail !== undefined && rank.type === 'score' && fail.type === 'noul'
758
- ? `${rank.score.toFixed(2)}:${rank.confidence.toFixed(2)}:${fail.noul.toFixed(2)}`
759
- : '?';
760
- });
761
- return { order: rankedOrder === null ? 'fallback' : rankedOrder.join(','), values };
762
- },
763
- },
764
- );
765
- if (!result.ok) return null;
766
- return rankedOrder === undefined || rankedOrder === null ? null : rankedOrder.map((index) => contents[index]!);
767
- },
768
- });
769
- const deliverNotice = notices.deliverNotice;
770
- pi.on('turn_end', () => {
771
- void notices.flush('steer');
772
- });
773
- pi.on('agent_settled', () => {
774
- if (lastStopReason === ABORT_STOP_REASON) return;
775
- void notices.flush('followUp');
776
- });
777
-
778
- pi.on('session_start', async (_event, ctx) => {
779
- latestCtx = ctx as { abort?: () => void } | null;
780
- const cwd = (ctx as { cwd?: string }).cwd ?? process.cwd();
781
- const paneId = env?.paneId ?? '';
782
- // D-4: herdr 0.9.0 never delivered pane.focused to plugins; 0.9.1+ does. Poller is a
783
- // fallback for old servers (see resolveDefaultFocusPollMs). One poller per pane, self-scoped.
784
- if (sessionFocusPoller.current) {
785
- sessionFocusPoller.current.stop();
786
- sessionFocusPoller.current = null;
787
- }
788
- const serverVer = await client.getServerVersion();
789
- const pollMs = focusPollIntervalMs(process.env, serverVer);
790
- if (paneId && client.available && pollMs > 0) {
791
- sessionFocusPoller.current = startFocusPoller({
792
- myPaneId: paneId,
793
- intervalMs: pollMs,
794
- sample: async () => {
795
- // layout.export reports the focused pane of our own tab. Without that field (older herdr)
796
- // the tick stays silent instead of guessing focus from the tree.
797
- const layout = await client.exportLayout({ paneId });
798
- const sample = parseFocusSample(layout);
799
- return sample && sample.focusedPaneId !== null ? sample : null;
800
- },
801
- fire: (focusedPaneId, cause) => spawnReflow({ paneId: focusedPaneId, cause }),
802
- });
803
- }
804
- if (!paneId) return;
805
- const name = pipeNameFor(cwd, paneId);
806
- if (pipeServerBox.current) {
807
- try { pipeServerBox.current.close(); } catch { /* Previous instance. */ }
808
- pipeServerBox.current = null;
809
- }
810
- try {
811
- pipeServerBox.current = startPipeServer(name, async (req) => handlePipeRequest(req, {
812
- paneId,
813
- port: subagentPort,
814
- claimSettleNotice,
815
- deliverNotice,
816
- sendUserMessageIn,
817
- sendUserMessageAs,
818
- abort: () => { latestCtx?.abort?.(); },
819
- setPendingMachineRequest: (next) => { pendingMachineRequest = next; },
820
- }));
821
- } catch {
822
- /* Pipe name collision (rare): this session has no channel; callers report an error after ping times out. */
823
- }
824
- });
825
- pi.on('session_shutdown', () => {
826
583
  sessionFocusPoller.current?.stop();
827
584
  sessionFocusPoller.current = null;
828
585
  if (pipeServerBox.current) {
829
586
  try { pipeServerBox.current.close(); } catch { /* Already closed. */ }
830
587
  pipeServerBox.current = null;
831
588
  }
832
- });
833
-
834
- // D50: when this pane settles, push a summary and session path to any machine request awaiting a reply.
835
- pi.on('agent_settled', async () => {
836
- const req = pendingMachineRequest;
837
- if (!req || !req.push || !req.from) return;
838
- pendingMachineRequest = null;
839
- try {
840
- let text: string | null = null;
841
- if (sessionId && /\.jsonl$/.test(sessionId)) {
842
- const entries = readSessionFile(sessionId);
843
- if (entries) text = lastAssistantText(entries, { sinceTs: req.sinceTs })?.text ?? null;
589
+ if (sessionRoot) {
590
+ try {
591
+ await pruneSessionObjects(sessionRoot);
592
+ } catch {
844
593
  }
845
- await pipeRequest(req.from, {
846
- type: 'reply',
847
- id: req.id,
848
- paneId: env?.paneId ?? '',
849
- text,
850
- sessionFile: sessionId || null,
851
- }, 5000);
852
- } catch {
853
- /* Push failed silently; the requester's pollLoop is the fallback. */
854
594
  }
595
+ client.close();
855
596
  });
856
597
 
857
- /* Workbench plugins (subagent/terminal + cordis) are herdr-master only.
858
- * Bare pi and worker panes mount todo only so unused tools never appear.
859
- * Dynamic import: worker/bare-pi must never load bootstrap/subagent-scope (C3). */
598
+ /* ── master vs worker mount (dynamic: workers never load the cordis tree, C3) ── */
860
599
  if (mode.composeMaster) {
861
600
  const { mountMasterPlugins } = await import('./index-master.ts');
862
601
  await mountMasterPlugins({
@@ -872,12 +611,14 @@ export default async function (pi: ExtensionAPI) {
872
611
  deliverNotice,
873
612
  noticePending: notices.noticePending,
874
613
  getSessionId: () => sessionId,
875
- getBlockedDepth: () => blockedDepth,
614
+ getBlockedDepth: () => gates.depth(),
876
615
  reconcileOnSettlement,
877
616
  withReconcileNotes,
878
617
  claimSettleNotice,
879
618
  isCompactionInFlight: () => coordinator.compactionInFlight,
880
619
  isIntentionalAbort: () => coordinator.intentionalAbort,
620
+ jev: { ask: (request, meta) => jevRuntime.ask(request, meta), getMinConfidence: () => effConfig.jev.minConfidence },
621
+ appendRoutingLog,
881
622
  });
882
623
  } else {
883
624
  const { mountTodoOnly } = await import('./index-worker.ts');
@@ -886,12 +627,12 @@ export default async function (pi: ExtensionAPI) {
886
627
  todos,
887
628
  todoUi,
888
629
  mirrorTodos,
889
- getBlockedDepth: () => blockedDepth,
630
+ getBlockedDepth: () => gates.depth(),
890
631
  ...(isSubagent
891
632
  ? {}
892
633
  : {
893
634
  stopReminder: {
894
- getBlockedDepth: () => blockedDepth,
635
+ getBlockedDepth: () => gates.depth(),
895
636
  getRunningSubs: () => subagentPort.current?.listRunningSubs().length ?? 0,
896
637
  isCompactionInFlight: () => coordinator.compactionInFlight,
897
638
  isIntentionalAbort: () => coordinator.intentionalAbort,