pi-pier 0.2.7 → 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 (85) hide show
  1. package/README.md +26 -12
  2. package/package.json +8 -3
  3. package/schemas/efficiency-config.schema.json +77 -0
  4. package/schemas/role-manifest.schema.json +7 -1
  5. package/skills/pi-herdr-workflow/SKILL.md +11 -0
  6. package/src/ansi-text.ts +134 -0
  7. package/src/ask-dialog.ts +308 -0
  8. package/src/ask-user.ts +139 -88
  9. package/src/bootstrap.ts +13 -14
  10. package/src/compact-coordinator.ts +471 -0
  11. package/src/compact-economics-core.ts +328 -0
  12. package/src/config-catalog-core.ts +332 -0
  13. package/src/config-command.ts +388 -0
  14. package/src/dashboard-command.ts +108 -0
  15. package/src/efficiency-config-core.ts +464 -0
  16. package/src/efficiency-store.ts +299 -0
  17. package/src/focus-poller.ts +223 -0
  18. package/src/gc-core.ts +77 -18
  19. package/src/herdr-client.ts +284 -154
  20. package/src/history-store.ts +36 -23
  21. package/src/index-gates.ts +124 -0
  22. package/src/index-locks.ts +29 -16
  23. package/src/index-master.ts +29 -19
  24. package/src/index-notices.ts +35 -11
  25. package/src/index-pipe.ts +17 -7
  26. package/src/index-roles.ts +306 -0
  27. package/src/index-runtime.ts +2 -6
  28. package/src/index-worker.ts +7 -9
  29. package/src/index.ts +425 -388
  30. package/src/jev-client.ts +141 -0
  31. package/src/jev-core.ts +431 -0
  32. package/src/ledger.ts +13 -21
  33. package/src/lock-core.ts +98 -37
  34. package/src/manifest-compose.ts +35 -20
  35. package/src/observation-core.ts +200 -0
  36. package/src/pane-title.ts +19 -38
  37. package/src/pi-surface.ts +34 -22
  38. package/src/pier-options.ts +100 -0
  39. package/src/pipe-channel.ts +65 -52
  40. package/src/platform-paths.ts +19 -52
  41. package/src/{core → plugins}/grid-shape.ts +12 -12
  42. package/src/plugins/heat-plan.ts +158 -0
  43. package/src/plugins/observation.ts +475 -0
  44. package/src/plugins/subagent.ts +728 -0
  45. package/src/{core → plugins}/terminal.ts +237 -123
  46. package/src/{core → plugins}/todo.ts +75 -100
  47. package/src/progress-core.ts +16 -53
  48. package/src/reconcile-core.ts +24 -33
  49. package/src/reducer-core.ts +322 -0
  50. package/src/reducer-invoker.ts +419 -0
  51. package/src/renderers.ts +187 -0
  52. package/src/role-loader.ts +35 -29
  53. package/src/role-manifest.ts +23 -32
  54. package/src/role-state.ts +119 -0
  55. package/src/routing-telemetry.ts +136 -0
  56. package/src/runtime-policy.ts +25 -43
  57. package/src/session-tail.ts +102 -23
  58. package/src/settle-wake-core.ts +55 -22
  59. package/src/slim-frame.ts +29 -124
  60. package/src/stale-core.ts +17 -24
  61. package/src/storage-layout.ts +24 -30
  62. package/src/subagent-core.ts +256 -119
  63. package/src/subagent-gc.ts +131 -69
  64. package/src/subagent-poller.ts +401 -12
  65. package/src/subagent-session.ts +516 -0
  66. package/src/subagent-spawn.ts +651 -28
  67. package/src/swallow.ts +65 -0
  68. package/src/terminal-core.ts +160 -69
  69. package/src/todo-core.ts +62 -113
  70. package/src/todo-read-hook.ts +23 -39
  71. package/src/todo-reminder-core.ts +30 -48
  72. package/src/todo-window.ts +11 -12
  73. package/src/todos-service.ts +27 -35
  74. package/src/tool-error.ts +19 -0
  75. package/src/tool-gate.ts +38 -21
  76. package/src/vocab.ts +55 -25
  77. package/src/core/subagent.ts +0 -839
  78. package/src/git-adapter.ts +0 -104
  79. package/src/notice-buffer.ts +0 -23
  80. package/src/subagent-git-io.ts +0 -80
  81. package/src/subagent-launch.ts +0 -110
  82. package/src/subagent-poll-loop.ts +0 -285
  83. package/src/subagent-port.ts +0 -27
  84. package/src/subagent-scope.ts +0 -39
  85. package/src/subagent-session-io.ts +0 -147
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,149 +22,174 @@ 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
- /** Tier 2: record the synthesized manifest that governed a worker session (D38-compatible and branch-replayable). */
36
- const ROLE_MANIFEST_CUSTOM_TYPE = 'pi-herdr.role-manifest';
37
-
25
+ import { TODO_EDIT_CUSTOM_TYPE, currentActivity } from './todo-core.ts';
26
+ import { installRenderers } from './renderers.ts';
38
27
  import { createHerdrClient } from './herdr-client.ts';
39
- // The subagent family moved to core/subagent.ts (loader entry, D78/D81); its history-store,
40
- // session-tail, and gc-core imports moved with it, leaving index.ts only the common readers.
41
- import { lastAssistantText, readSessionFile } from './session-tail.ts';
28
+ import { bareSessionId, lastAssistantText, readSessionFile, resolveSessionFileValue } from './session-tail.ts';
42
29
  import { fileURLToPath } from 'node:url';
43
- 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';
44
33
  import { reconcileTodos } from './reconcile-core.ts';
45
- import type { TodoUiSlot } from './core/todo.ts';
46
- import {
47
- estimateEta,
48
- formatProgressSuffix,
49
- planToolBadge,
50
- progressOf,
51
- } from './progress-core.ts';
34
+ import type { TodoUiSlot } from './plugins/todo.ts';
35
+ import { estimateEta, formatProgressSuffix, planToolBadge, progressOf } from './progress-core.ts';
52
36
  import { WRITE_LOCK_ENV } from './lock-core.ts';
53
- import { ABORT_STOP_REASON, planSettleWake } from './settle-wake-core.ts';
54
- import { composeForRole } from './manifest-compose.ts';
55
- import { parseRuntimeManifest, planActiveTools, planToolGate, type RuntimeRoleManifest } from './tool-gate.ts';
56
-
37
+ import { ABORT_STOP_REASON, planSettleWake, shouldPushSettleReply } from './settle-wake-core.ts';
38
+ import { parseRuntimeManifest } from './tool-gate.ts';
57
39
  import { formatPaneTitle } from './pane-title.ts';
58
40
  import { registerSlimFrame, updateSlimFrame } from './slim-frame.ts';
59
41
  import { planIndexMode } from './index-runtime.ts';
60
- import { emptySubagentPortBox } from './subagent-port.ts';
42
+ import { emptySubagentPortBox } from './subagent-core.ts';
61
43
  import { createNoticeBuffer } from './index-notices.ts';
62
- import { handlePipeRequest } from './index-pipe.ts';
44
+ import { handlePipeRequest, type MachineRequest } from './index-pipe.ts';
63
45
  import { installWriteLocks } from './index-locks.ts';
46
+ import { installDashboardCommand } from './dashboard-command.ts';
47
+ import { registerObservationPack, createCompactionBatchPackHook, type PickMiddleExcerpt } from './plugins/observation.ts';
48
+ import { createJevRuntime } from './jev-client.ts';
49
+ import {
50
+ NOTICE_RANK_MIN_CONFIDENCE,
51
+ buildExcerptWindows,
52
+ composeNoticeRanking,
53
+ evaluateExcerptPick,
54
+ excerptAskIsSafe,
55
+ excerptPickRequest,
56
+ noticeRankRequest,
57
+ } from './jev-core.ts';
58
+ import { completeLineExcerpt } from './observation-core.ts';
59
+ import { installConfigCommand } from './config-command.ts';
60
+ import { CompactCoordinator } from './compact-coordinator.ts';
61
+ import { handleReducerToolResult, type ToolResultEventLike } from './reducer-invoker.ts';
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. */
70
+ function focusPollIntervalMs(env: NodeJS.ProcessEnv = process.env, herdrVersion?: string | null): number {
71
+ const defaultMs = resolveDefaultFocusPollMs(herdrVersion);
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;
76
+ }
64
77
 
65
- /**
66
- * WS-D7: apply the master-pane manifest to itself through the same mandatory chain as subagents
67
- * (gate, visible layer, badge, and session record); the baseline includes our core plus inspect/execute tools.
68
- * Match only a herdr pane (HERDR_ENV=1) that is not a subagent and has no explicitly supplied manifest.
69
- * Standalone pi sessions without herdr remain full-featured for backward compatibility.
70
- * A malformed master.json fails open to no-role state, matching malformed env semantics.
71
- */
72
- function composeMasterRuntime(): RuntimeRoleManifest | null {
73
- try {
74
- // 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.
75
- const { role, manifest } = composeForRole('master', [], { loadRoleOpts: { builtinDirect: true } });
76
- return {
77
- role: role.role,
78
- version: role.version,
79
- tools: manifest.tools,
80
- permissions: manifest.permissions,
81
- unknownTools: manifest.unknownTools,
82
- services: role.services ?? {},
83
- };
84
- } catch (err) {
85
- console.error(`[pi-herdr] master manifest invalid (fail-open): ${err instanceof Error ? err.message : String(err)}`);
86
- return null;
87
- }
78
+ function agentSessionsDir(): string {
79
+ return process.env.PI_CODING_AGENT_DIR ? join(process.env.PI_CODING_AGENT_DIR, 'sessions') : platformPaths.sessionsDir;
88
80
  }
89
- // C3: cordis belongs only in the master process; subagent-scope includes @deepseek-ai/cordis.
90
- // Dynamically import it on the master branch so worker processes never load the module.
91
- // Subagent constants/helpers moved with the family to core/subagent.ts.
92
81
 
93
82
  export default async function (pi: ExtensionAPI) {
94
83
  const mode = planIndexMode();
95
84
  const isSubagent = mode.isSubagent;
96
- const runtimeManifest =
97
- parseRuntimeManifest(process.env.PI_HERDR_ROLE_MANIFEST) ??
98
- (mode.composeMaster ? composeMasterRuntime() : null);
99
- const todos = new TodosService(TodosService.configFromRuntime(runtimeManifest, isSubagent));
100
- const { client, env } = createHerdrClient();
85
+ const roleBase = process.env.PI_HERDR_ROLE_BASE || process.cwd();
101
86
 
102
87
  let sessionId: string = process.env.PI_SESSION_FILE ?? process.env.PI_SESSION_ID ?? '';
103
- /** M16: completion timestamps used as rate-estimation input, appended with todo.completed events. */
104
- const completedStamps: number[] = [];
105
-
106
- /* ── State reconstruction (branch correctness comes from the last todo_write snapshot on the branch) ── */
107
-
108
- function rebuildFromBranch(ctx: unknown): void {
109
- try {
110
- const entries = (ctx as { sessionManager?: { getBranch?: () => readonly unknown[] } })
111
- ?.sessionManager?.getBranch?.() ?? [];
112
- todos.rebuild(entries);
113
- } catch {
114
- // A reconstruction failure must not disrupt the main flow; the next todo_write re-anchors state.
115
- }
116
- }
88
+ let sessionRoot: string | null = null;
89
+ let effConfig: EfficiencyConfig = resolveEfficiencyConfig();
90
+ const coordinator = new CompactCoordinator();
91
+ const { client, env } = createHerdrClient();
117
92
 
118
- /* ── herdr reporting (zero cost for Noop; silent failures never affect pi's main flow) ── */
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
+ };
119
105
 
120
- /** Tier 2: worker role badge parsed from the env manifest and kept visible while idle. */
121
- let roleBadge: string | null = null;
122
- /** Human-gate depth; ask_user_question and external herdr:blocked events share it. */
123
- let blockedDepth = 0;
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 });
111
+ /** P0-3: candidate windows are generated in code; jev only picks (rerank pattern). */
112
+ const pickMiddleExcerpt: PickMiddleExcerpt = async (text, excerptBudgetBytes) => {
113
+ if (!jevRuntime.available) return null;
114
+ const halfBudget = Math.floor(excerptBudgetBytes / 2);
115
+ const windows = buildExcerptWindows(text, halfBudget);
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);
122
+ if (!request) return null;
123
+ const result = await jevRuntime.ask(request, {
124
+ questionId: 'obs-excerpt-window',
125
+ extra: { site: 'obs-excerpt', candidates: windows.map((w) => w.id) },
126
+ enrich: ({ answers }) => {
127
+ if (!answers || answers.window === undefined || answers.window.type !== 'choice') return {};
128
+ return { picked: answers.window.choice, confidence: answers.window.confidence };
129
+ },
130
+ });
131
+ if (!result.ok) return null;
132
+ const picked = evaluateExcerptPick(result.answers, effConfig.jev.minConfidence);
133
+ const window = picked === null ? undefined : windows.find((w) => w.id === picked);
134
+ return window ? { text: window.text, label: window.label } : null;
135
+ };
124
136
 
125
- function reportAgent(state: 'working' | 'idle' | 'blocked', activity: string | null): void {
126
- if (!client.available) return;
127
- // While a human gate is open, never let tool-badge / turn_start / settle overwrite blocked.
128
- // Needed when herdr:pi is absent (pi-herdr is the authority); no-op under full-lifecycle herdr:pi.
129
- if (blockedDepth > 0 && state !== 'blocked') return;
130
- const message = activity ?? (state === 'idle' ? roleBadge : null);
131
- client.reportAgent(state, message).catch(() => {});
137
+ const rendererTypes = installRenderers(pi);
138
+ if (process.env.PI_HERDR_TRACE) {
139
+ console.error(`[pi-herdr] transcript renderers: ${rendererTypes.length ? rendererTypes.join(', ') : 'none'}`);
132
140
  }
133
141
 
134
- function reportSession(path: string): void {
135
- if (!client.available) return;
136
- client.reportAgentSession(path).catch(() => {});
137
- }
142
+ let agentActive = false;
143
+ let lastStopReason: string | null = null;
144
+ let latestCtx: { abort?: () => void } | null = null;
145
+ const completedStamps: number[] = [];
146
+ const subagentPort = emptySubagentPortBox();
147
+ let d96NoticeKey: string | null = null;
148
+ let d96NoticeAt = 0;
138
149
 
139
- /** Session identity: sessionManager is authoritative (env may be unset in print/RPC mode), with env as fallback. */
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
+ });
159
+
160
+ /** Session identity: sessionManager is authoritative (env may be unset in print/RPC mode). */
140
161
  function resolveSessionId(ctx: unknown): string {
141
162
  try {
142
163
  const sm = (ctx as {
143
- sessionManager?: {
144
- getSessionFile?: () => string | undefined;
145
- getSessionId?: () => string;
146
- };
164
+ sessionManager?: { getSessionFile?: () => string | undefined; getSessionId?: () => string };
147
165
  }).sessionManager;
148
- return (
149
- sm?.getSessionFile?.() ??
150
- sm?.getSessionId?.() ??
151
- process.env.PI_SESSION_FILE ??
152
- process.env.PI_SESSION_ID ??
153
- ''
166
+ return bareSessionId(
167
+ sm?.getSessionFile?.() ?? sm?.getSessionId?.() ?? process.env.PI_SESSION_FILE ?? process.env.PI_SESSION_ID ?? '',
154
168
  );
155
169
  } catch {
156
- return process.env.PI_SESSION_FILE ?? '';
170
+ return bareSessionId(process.env.PI_SESSION_FILE ?? '');
171
+ }
172
+ }
173
+
174
+ /** Branch reconstruction: todos + OCC debt state (branch correctness from the last snapshot on the branch). */
175
+ function rebuildFromBranch(ctx: unknown): void {
176
+ try {
177
+ const entries = (ctx as { sessionManager?: { getBranch?: () => readonly unknown[] } })
178
+ ?.sessionManager?.getBranch?.() ?? [];
179
+ todos.rebuild(entries);
180
+ coordinator.rebuildFromBranch(entries);
181
+ } catch {
182
+ /* A reconstruction failure must not disrupt the main flow; the next todo_write re-anchors. */
157
183
  }
158
184
  }
159
185
 
160
186
  function mirrorTodos(): void {
161
187
  if (!client.available) return;
162
188
  const label = sessionId || (env ? `pane:${env.paneId}` : '');
163
- // M16: progress badge (conservative N/M plus confidence ETA; fall back to a plain count when estimates are unreliable).
164
189
  const p = progressOf(todos.items);
165
190
  const eta = estimateEta({ completedAt: completedStamps, total: p.total, now: Date.now() });
166
191
  const suffix = formatProgressSuffix({ completed: p.completed, total: p.total, eta });
167
- // Unfreeze by carrying lastWriteAt into the title-side archived check (pane-title).
168
- const title = formatPaneTitle(todos.items, null, {
169
- progressSuffix: suffix,
170
- lastWriteAt: todos.lastWriteAt,
171
- });
172
- // D97: overlay content shares the todo snapshot; thinking tokens cannot reach it.
192
+ const title = formatPaneTitle(todos.items, null, { progressSuffix: suffix, lastWriteAt: todos.lastWriteAt });
173
193
  updateSlimFrame({ title, items: todos.items, lastWriteAt: todos.lastWriteAt });
174
194
  client.reportMetadata({
175
195
  session: label,
@@ -179,8 +199,7 @@ export default async function (pi: ExtensionAPI) {
179
199
  }).catch(() => {});
180
200
  }
181
201
 
182
- /* ── M17: automatic settlement reconciliation (pure planner + D38 authority; idempotent and safe to run through both paths) ── */
183
-
202
+ /** M17 settlement reconciliation: auto-complete settled todos / unblock on failure notes (idempotent). */
184
203
  function reconcileOnSettlement(description: string, outcome: 'settled' | 'failed'): string[] {
185
204
  if (!description) return [];
186
205
  try {
@@ -192,9 +211,8 @@ export default async function (pi: ExtensionAPI) {
192
211
  { version: 1, edits: plan.edits, ts: Date.now() },
193
212
  );
194
213
  } catch {
195
- /* Best effort persistence; in-memory state still advances. */
196
214
  }
197
- todos.applyEdits(plan.edits);
215
+ todos.applyEdits(plan.edits, { source: 'reconcile' });
198
216
  mirrorTodos();
199
217
  }
200
218
  return plan.noteLines;
@@ -203,29 +221,25 @@ export default async function (pi: ExtensionAPI) {
203
221
  }
204
222
  }
205
223
 
206
- /** M17: append reconciliation note lines to settlement notices; an empty list returns the original text. */
207
- function withReconcileNotes(base: string, notes: readonly string[]): string {
208
- return notes.length ? `${base}\n${notes.join('\n')}` : base;
209
- }
210
-
211
- /* ── 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;
212
226
 
213
- todos.on('todo.completed', (e: { count: number; at: number }) => {
227
+ todos.on('todo.completed', (e: { count: number; at: number; source?: TodoCompletionSource }) => {
214
228
  for (let i = 0; i < e.count; i++) completedStamps.push(e.at);
229
+ coordinator.recordBoundaryCompleted(e.count, e.source);
215
230
  mirrorTodos();
216
231
  });
217
232
 
218
- // 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 ── */
219
234
  const runningTools = new Map<string, string>(); // toolCallId → toolName
220
235
  let lastToolBadge: string | null = null;
221
236
  function reportToolBadge(): void {
222
237
  const badge = planToolBadge([...new Set(runningTools.values())]);
223
- if (badge === lastToolBadge) return; // Idempotent: report only changes (M13 rendering discipline).
238
+ if (badge === lastToolBadge) return; // report only changes (M13 rendering discipline)
224
239
  lastToolBadge = badge;
225
- if (badge) reportAgent('working', badge);
226
- 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);
227
242
  }
228
-
229
243
  pi.on('tool_execution_start', async (event: { toolCallId?: string; toolName?: string }) => {
230
244
  if (typeof event?.toolCallId === 'string' && typeof event?.toolName === 'string') {
231
245
  runningTools.set(event.toolCallId, event.toolName);
@@ -239,135 +253,253 @@ export default async function (pi: ExtensionAPI) {
239
253
  }
240
254
  });
241
255
 
242
- if (env) {
243
- installWriteLocks(pi, {
244
- client,
245
- env,
246
- hard: process.env[WRITE_LOCK_ENV] === '1',
256
+ /* ── D102 evidence-preserving reducer (registered before append-handlers like write-locks) ── */
257
+ pi.on('tool_result', async (event, ctx) => {
258
+ if (!effConfig.evidencePreservingReducer.enabled) return;
259
+ if (!event || typeof event !== 'object') return;
260
+ return handleReducerToolResult(event as unknown as ToolResultEventLike, ctx, effConfig.evidencePreservingReducer, {
261
+ epoch: coordinator.state.epoch,
262
+ jev: { ask: jevRuntime.ask, getMinConfidence: () => effConfig.jev.minConfidence },
247
263
  });
264
+ });
265
+
266
+ let locksHandle = { getHeldLocks: () => [] as readonly string[] };
267
+ if (env) {
268
+ locksHandle = installWriteLocks(pi, { client, env, hard: process.env[WRITE_LOCK_ENV] === '1' });
248
269
  }
249
270
 
250
- /* ── todo family slot (core/todo.ts fills the plugin hook; widget rendering moved with the family) ── */
251
- const todoUi: TodoUiSlot = {
252
- 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();
253
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;
254
314
 
255
- /* ── Lifecycle ────────────────────────────────────────────────────── */
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
+ }
256
325
 
257
- pi.on('session_start', async (event, ctx) => {
258
- sessionId = resolveSessionId(ctx);
259
- rebuildFromBranch(ctx);
260
- // v1.3 M9 fix (observed): on resume pi restores widget state from the session;
261
- // calling setWidget again breaks the TUI '/' command-panel route ('/' would be sent to the model as message text).
262
- const reason = (event as { reason?: string } | undefined)?.reason;
263
- if (reason !== 'resume') todoUi.renderWidget(ctx);
264
- // D97: narrow-frame overlay is meaningful only inside herdr; covering an interactive narrow terminal is unsafe,
265
- // and the heatmap amplification provides the exit path. Re-register on resume because session switching resets the overlay.
266
- if (env) registerSlimFrame(ctx);
267
- mirrorTodos();
268
- reportSession(sessionId);
269
- // D93: sidebar identity is the role name (display_agent takes precedence over detected agent, actions.rs:563 in 0.8.2).
270
- // master → 'master'; worker → manifest.role (prettify worker-default as worker).
271
- // A bare pi without a manifest does not report, so ordinary pi sessions remain undisturbed.
272
- if (runtimeManifest) {
273
- const roleDisplay = runtimeManifest.role === 'worker-default' ? 'worker' : runtimeManifest.role;
274
- void client.reportDisplayAgent(roleDisplay);
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;
275
332
  }
276
- // Tier 2: worker role manifest parsed at process start supplies the badge and authoritative session record.
277
- // The custom entry mirrors D38 todo-edit and anchors execution-time replay.
278
- if (runtimeManifest) {
279
- roleBadge = `role ${runtimeManifest.role} v${runtimeManifest.version ?? '?'} (${runtimeManifest.tools.length} tools)`;
280
- try {
281
- (pi as { appendEntry?: (customType: string, data: unknown) => void }).appendEntry?.(
282
- ROLE_MANIFEST_CUSTOM_TYPE,
283
- { version: 1, role: runtimeManifest.role, manifestVersion: runtimeManifest.version, tools: runtimeManifest.tools, permissions: runtimeManifest.permissions, unknownTools: runtimeManifest.unknownTools ?? 'deny', ts: Date.now() },
284
- );
285
- } catch {
286
- /* Best effort recording. */
287
- }
288
- // D77 visible layer: remove tools outside the manifest from the model's view after all plugins load at session_start.
289
- // Intersection semantics prevent clearing everything; master without a manifest stays full. Missing APIs are skipped for old pi compatibility.
290
- const piTools = pi as { getActiveTools?: () => string[]; setActiveTools?: (names: string[]) => void };
291
- if (typeof piTools.getActiveTools === 'function' && typeof piTools.setActiveTools === 'function') {
292
- try {
293
- const active = piTools.getActiveTools();
294
- const vis = planActiveTools(runtimeManifest.tools, active, {
295
- unknownTools: runtimeManifest.unknownTools,
296
- permissions: runtimeManifest.permissions,
297
- });
298
- if (vis?.changed) {
299
- piTools.setActiveTools?.(vis.next);
300
- console.error(`[pi-herdr] D77 visible-layer: role ${runtimeManifest.role} tools ${active.length} → ${vis.next.length}`);
301
- }
302
- } catch {
303
- /* Best effort visible layer; the mandatory layer remains active. */
304
- }
305
- }
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. */
306
347
  }
307
- reportAgent('idle', null);
308
- });
348
+ }
309
349
 
310
- /* ── Tier 2 Weeks 4–5: worker execution enforcement (the manifest's final gate) ──
311
- * deny / outside manifest → block (reason is given to the model); ask → v1 allows with stderr logging
312
- * (V56 acceptance anchor). Master and label-only workers without a manifest remain open.
313
- * Rate limiting was removed (WS-D6): we own the permission boundary; plugin integrators own resource quotas. */
314
- pi.on('tool_call', async (event: { toolName?: string }) => {
315
- if (!runtimeManifest) return;
316
- const tool = typeof event?.toolName === 'string' ? event.toolName : '';
317
- const gate = planToolGate(tool, runtimeManifest);
318
- if (gate.kind === 'deny') {
319
- return { block: true, reason: gate.reason };
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;
320
363
  }
321
- if (gate.kind === 'ask') {
322
- console.error(`${gate.notice} (v1 soft-approval: allowed, hard gate lands in v2)`);
323
- // Durable trace (V56 anchor): TUI redraw erases stderr, so the session custom entry is authoritative.
324
- try {
325
- (pi as { appendEntry?: (customType: string, data: unknown) => void }).appendEntry?.(
326
- 'pi-herdr.approval-needed',
327
- { role: runtimeManifest.role, tool, ts: Date.now() },
328
- );
329
- } catch {
330
- /* Best effort. */
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;
331
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}`);
332
383
  }
333
- });
384
+ }
334
385
 
386
+ const sessionFocusPoller: { current: FocusPoller | null } = { current: null };
387
+
388
+ pi.on('session_start', async (event, ctx) => {
389
+ sessionId = resolveSessionId(ctx);
390
+ latestCtx = ctx as { abort?: () => void } | null;
391
+ const cwd = (ctx as { cwd?: string }).cwd ?? process.cwd();
392
+ const isTrusted =
393
+ typeof (ctx as { isProjectTrusted?: () => boolean }).isProjectTrusted === 'function'
394
+ ? (ctx as { isProjectTrusted: () => boolean }).isProjectTrusted()
395
+ : false;
396
+ effConfig = loadEfficiencyConfigFromDisk({ cwd, isProjectTrusted: isTrusted });
397
+ sessionRoot = resolveSessionRoot(
398
+ (ctx as { sessionManager?: { getSessionDir?: () => string | undefined } })?.sessionManager?.getSessionDir?.(),
399
+ sessionId,
400
+ );
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).
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);
408
+ mirrorTodos();
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
+ void client.reportAskFlag(null).catch(() => {});
415
+
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
+ });
436
+ }
437
+ startPipeServerFor(cwd);
438
+ });
335
439
 
336
- // Session-tree navigation (/tree, /fork): reconstruction preserves branch correctness.
337
440
  pi.on('session_tree', async (_event, ctx) => {
338
441
  sessionId = resolveSessionId(ctx);
339
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);
340
446
  todoUi.renderWidget(ctx);
341
447
  mirrorTodos();
342
448
  });
343
449
 
344
- let agentActive = false;
345
- const subagentPort = emptySubagentPortBox();
346
- // D96 state, anchoring settle-wake-core deduplication and cooldown.
347
- let d96NoticeKey: string | null = null;
348
- let d96NoticeAt = 0;
349
- // Prevent wake-up storms by tracking the last assistant turn's stopReason;
350
- // 'aborted' means the user explicitly pressed ESC, so settlement must inject no wake-up message.
351
- let lastStopReason: string | null = null;
352
450
  pi.on('turn_start', async () => {
353
451
  agentActive = true;
354
- reportAgent('working', currentActivity(todos.items));
452
+ // A11: reset so a previous turn's abort cannot taint this one.
453
+ lastStopReason = null;
454
+ // Safety fallback: pi core prohibits prompts while compaction is active.
455
+ coordinator.compactionInFlight = false;
456
+ coordinator.intentionalAbort = false;
457
+ gates.report('working', currentActivity(todos.items));
458
+ });
459
+
460
+ pi.on('before_provider_request', (_event: unknown, ctx: unknown) => {
461
+ const usage = (ctx as { getContextUsage?: () => { tokens?: number | null } })?.getContextUsage?.();
462
+ if (typeof usage?.tokens === 'number') {
463
+ coordinator.onBeforeProviderRequest(usage.tokens);
464
+ }
355
465
  });
356
- pi.on('turn_end', async (event: unknown) => {
357
- if (event === null || typeof event !== 'object' || !('message' in event)) return;
358
- const msg = (event as { message: unknown }).message; // 'message' in has been guarded.
359
- if (msg === null || typeof msg !== 'object') return;
360
- const { role, stopReason } = msg as { role?: unknown; stopReason?: unknown };
361
- if (role === 'assistant' && typeof stopReason === 'string') {
362
- lastStopReason = stopReason;
466
+
467
+ pi.on('input', (event: unknown) => {
468
+ if (event && typeof event === 'object') {
469
+ coordinator.onInput(event as { text?: string; source?: string; streamingBehavior?: string });
470
+ }
471
+ });
472
+
473
+ pi.on('session_before_tree', () => (coordinator.compactionInFlight ? { cancel: true } : undefined));
474
+
475
+ pi.on('turn_end', async (event: unknown, ctx) => {
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
+ }
363
492
  }
493
+ void notices.flush('steer');
364
494
  });
365
495
 
366
- pi.on('agent_settled', async () => {
496
+ pi.on('agent_settled', async (_event: unknown, ctx) => {
367
497
  agentActive = false;
368
- reportAgent('idle', null);
498
+ gates.report('idle', null);
369
499
  const plan = planSettleWake({
370
500
  lastStopReason,
501
+ intentionalAbort: coordinator.intentionalAbort,
502
+ compactionPending: coordinator.selectedCompaction !== null || coordinator.compactionInFlight,
371
503
  running: subagentPort.current?.listRunningSubs() ?? [],
372
504
  lastNoticeKey: d96NoticeKey,
373
505
  lastNoticeAt: d96NoticeAt,
@@ -375,74 +507,54 @@ export default async function (pi: ExtensionAPI) {
375
507
  });
376
508
  d96NoticeKey = plan.noticeKey;
377
509
  d96NoticeAt = plan.noticeAt;
378
- if (!plan.wake) {
379
- // Stay silent after a user abort while retaining the settlement buffer (do not clear pendingSettleNotices);
380
- // deliver it on the next natural run's turn_end steer or natural settlement.
381
- return;
382
- }
383
510
  // D96: master settled while background subagents still run → remind (worker port is unbound).
384
- if (plan.notice && !isSubagent) {
511
+ if (plan.wake && plan.notice && !isSubagent) {
385
512
  const running = subagentPort.current?.listRunningSubs() ?? [];
386
513
  const brief = running.map((s) => `${s.paneId} (${s.description})`).join('、');
387
514
  const notice = `注意:仍有 ${running.length} 个后台 subagent 在运行:${brief}。若你的任务依赖它们,请等待其结算(subagent list 查看状态);若不等待,请说明放弃原因。`;
388
- void sendUserMessageIn(notice);
515
+ void sendUserMessageAs(notice, 'followUp');
389
516
  }
390
- });
391
-
392
- // Official herdr:pi is full-lifecycle authority when installed: it reports blocked
393
- // only on the herdr:blocked event (pi core never emits it). Custom source=pi-herdr
394
- // pane.report_agent is ignored while that authority is live
395
- // (screen_detection_skip_reason=full_lifecycle_hook_authority). Emit the event so
396
- // herdr:pi publishes blocked; keep report_agent as fallback when it is absent.
397
- function enterBlocked(label: string | null): void {
398
- blockedDepth += 1;
399
- reportAgent('blocked', label);
400
- // D95: human-gate marker lets the workbench heatmap distinguish ask from block.
401
- if (label) void client.reportAskFlag(label).catch(() => {});
402
- // Gate open collapses the todo widget to one line — the question owns the fixed area.
403
- todoUi.rerenderWidget?.();
404
- }
405
- function exitBlocked(): void {
406
- blockedDepth = Math.max(0, blockedDepth - 1);
407
- if (blockedDepth === 0) {
408
- reportAgent(agentActive ? 'working' : 'idle', agentActive ? currentActivity(todos.items) : null);
409
- // D95: gate released → clear marker.
410
- void client.reportAskFlag(null).catch(() => {});
517
+ if (plan.compact && ctx && typeof ctx === 'object') {
518
+ void coordinator.onAgentSettled({
519
+ ctx,
520
+ todos: todos.items,
521
+ pi,
522
+ config: effConfig.onlineContextCompact,
523
+ cancelReminder: todoUi.cancelReminder,
524
+ onBeforeCompact: createCompactionBatchPackHook({
525
+ getObsConfig: () => effConfig.observationPack,
526
+ getManifest: () => roles.state.manifest,
527
+ getSessionId: () => sessionId,
528
+ pickMiddleExcerpt,
529
+ }),
530
+ });
411
531
  }
412
- // Gate closed → restore the full widget window.
413
- todoUi.rerenderWidget?.();
414
- }
532
+ if (lastStopReason !== ABORT_STOP_REASON) void notices.flush('followUp');
533
+ void pushSettleReply();
534
+ });
415
535
 
416
- // emit() is synchronous; this flag stops our own listener from double-counting depth.
417
- let publishingHerdrBlocked = false;
418
- function publishHerdrBlocked(active: boolean, label: string | null): void {
419
- publishingHerdrBlocked = true;
420
- try {
421
- if (active) enterBlocked(label);
422
- else exitBlocked();
423
- pi.events.emit(
424
- 'herdr:blocked',
425
- active ? { active: true, ...(label ? { label } : {}) } : { active: false },
426
- );
427
- } finally {
428
- publishingHerdrBlocked = false;
429
- }
430
- }
431
- pi.events.on('herdr:blocked', (data) => {
432
- if (publishingHerdrBlocked) return;
433
- if (!data || typeof data !== 'object') {
434
- exitBlocked();
435
- return;
436
- }
437
- if (!('active' in data) || data.active !== true) {
438
- exitBlocked();
439
- return;
440
- }
441
- enterBlocked('label' in data && typeof data.label === 'string' ? data.label : null);
536
+ /* ── D101 ObservationPack (obs_recall tool & context projector) ── */
537
+ registerObservationPack({
538
+ pi,
539
+ getConfig: () => effConfig,
540
+ getRuntimeManifest: () => roles.state.manifest,
541
+ pickMiddleExcerpt,
542
+ getRemainingHorizon: () => {
543
+ const usage = (latestCtx as { getContextUsage?: () => { contextWindow?: number } })?.getContextUsage?.();
544
+ return coordinator.getRemainingHorizon(3, usage?.contextWindow ?? null);
545
+ },
442
546
  });
443
547
 
444
- /* ── Tool: ask_user_question (v1.3 M8 human gate, available to master and subagents) ── */
548
+ installConfigCommand({ pi });
549
+ installDashboardCommand({
550
+ pi,
551
+ client,
552
+ env,
553
+ getTodoItems: () => todos.items,
554
+ getHeldLocks: () => locksHandle.getHeldLocks(),
555
+ });
445
556
 
557
+ /* ── ask_user_question (v1.3 M8 human gate; master and subagents) ── */
446
558
  pi.registerTool({
447
559
  name: ASK_TOOL_NAME,
448
560
  label: 'Ask User',
@@ -454,117 +566,36 @@ export default async function (pi: ExtensionAPI) {
454
566
  if (!prepared.ok) return prepared.result;
455
567
  const ui = ctx && typeof ctx === 'object' && 'ui' in ctx ? ctx.ui : undefined;
456
568
  if (!hasAskUi(ui)) return noUiResult();
457
- const label = gateLabel(prepared.spec);
458
- publishHerdrBlocked(true, label);
459
- // Fallback when herdr:pi is absent: a one-shot pi-herdr blocked report can lose
460
- // to later working reports; refresh while the gate is open. Do not re-emit
461
- // herdr:blocked here — official blockedCount is edge-triggered.
462
- const hb = setInterval(() => { reportAgent('blocked', label); }, 5000);
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.
463
572
  try {
464
573
  return await runAsk(prepared.spec, ui, signal instanceof AbortSignal ? signal : undefined);
465
574
  } finally {
466
- clearInterval(hb);
467
- publishHerdrBlocked(false, null);
575
+ gates.publish(false, null);
468
576
  }
469
577
  },
470
578
  });
471
579
 
472
580
  pi.on('session_shutdown', async () => {
473
- client.close();
474
- });
475
-
476
- /* ── M12: bidirectional message channel (D49/D50/D48; every pane listens on its own name, with no controller/child assumption) ── */
477
-
478
- const settleNoticeLatch = new Set<string>();
479
- /** Deduplicate settlement notices: choose push fast path or pollLoop fallback; one notice per paneId+request ID per round. */
480
- function claimSettleNotice(key: string): boolean {
481
- if (settleNoticeLatch.has(key)) return false;
482
- settleNoticeLatch.add(key);
483
- return true;
484
- }
485
- const pipeServerBox: { current: ReturnType<typeof startPipeServer> | null } = { current: null };
486
- let pendingMachineRequest: { id: string; from: string | null; push: boolean; sinceTs: number } | null = null;
487
- let latestCtx: { abort?: () => void } | null = null;
488
-
489
- // triggerTurn:true — idle followUp must start a new turn or settlement is lost.
490
- const sendUserMessageIn = (content: string): Promise<void> =>
491
- (pi as { sendUserMessage?: (content: string, opts?: { deliverAs?: string; triggerTurn?: boolean }) => Promise<void> })
492
- .sendUserMessage?.(content, { deliverAs: 'followUp', triggerTurn: true }) ?? Promise.resolve();
493
- const sendUserMessageAs = (content: string, mode: 'steer' | 'followUp'): Promise<void> =>
494
- (pi as { sendUserMessage?: (content: string, opts?: { deliverAs?: string; triggerTurn?: boolean }) => Promise<void> })
495
- .sendUserMessage?.(content, { deliverAs: mode, triggerTurn: true }) ?? Promise.resolve();
496
-
497
- const notices = createNoticeBuffer({
498
- isBusy: () => agentActive || lastStopReason === ABORT_STOP_REASON,
499
- send: sendUserMessageAs,
500
- });
501
- const deliverNotice = notices.deliverNotice;
502
- pi.on('turn_end', () => {
503
- void notices.flush('steer');
504
- });
505
- pi.on('agent_settled', () => {
506
- if (lastStopReason === ABORT_STOP_REASON) return;
507
- void notices.flush('followUp');
508
- });
509
-
510
- pi.on('session_start', async (_event, ctx) => {
511
- latestCtx = ctx as { abort?: () => void } | null;
512
- const cwd = (ctx as { cwd?: string }).cwd ?? process.cwd();
513
- const paneId = env?.paneId ?? '';
514
- if (!paneId) return;
515
- const name = pipeNameFor(cwd, paneId);
516
- if (pipeServerBox.current) {
517
- try { pipeServerBox.current.close(); } catch { /* Previous instance. */ }
518
- pipeServerBox.current = null;
519
- }
520
- try {
521
- pipeServerBox.current = startPipeServer(name, async (req) => handlePipeRequest(req, {
522
- paneId,
523
- port: subagentPort,
524
- claimSettleNotice,
525
- deliverNotice,
526
- sendUserMessageIn,
527
- sendUserMessageAs,
528
- abort: () => { latestCtx?.abort?.(); },
529
- setPendingMachineRequest: (next) => { pendingMachineRequest = next; },
530
- }));
531
- } catch {
532
- /* Pipe name collision (rare): this session has no channel; callers report an error after ping times out. */
533
- }
534
- });
535
- pi.on('session_shutdown', () => {
581
+ coordinator.compactionInFlight = false;
582
+ coordinator.intentionalAbort = false;
583
+ sessionFocusPoller.current?.stop();
584
+ sessionFocusPoller.current = null;
536
585
  if (pipeServerBox.current) {
537
586
  try { pipeServerBox.current.close(); } catch { /* Already closed. */ }
538
587
  pipeServerBox.current = null;
539
588
  }
540
- });
541
-
542
- // D50: when this pane settles, push a summary and session path to any machine request awaiting a reply.
543
- pi.on('agent_settled', async () => {
544
- const req = pendingMachineRequest;
545
- if (!req || !req.push || !req.from) return;
546
- pendingMachineRequest = null;
547
- try {
548
- let text: string | null = null;
549
- if (sessionId && /\.jsonl$/.test(sessionId)) {
550
- const entries = readSessionFile(sessionId);
551
- if (entries) text = lastAssistantText(entries, { sinceTs: req.sinceTs })?.text ?? null;
589
+ if (sessionRoot) {
590
+ try {
591
+ await pruneSessionObjects(sessionRoot);
592
+ } catch {
552
593
  }
553
- await pipeRequest(req.from, {
554
- type: 'reply',
555
- id: req.id,
556
- paneId: env?.paneId ?? '',
557
- text,
558
- sessionFile: sessionId || null,
559
- }, 5000);
560
- } catch {
561
- /* Push failed silently; the requester's pollLoop is the fallback. */
562
594
  }
595
+ client.close();
563
596
  });
564
597
 
565
- /* Workbench plugins (subagent/terminal + cordis) are herdr-master only.
566
- * Bare pi and worker panes mount todo only so unused tools never appear.
567
- * 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) ── */
568
599
  if (mode.composeMaster) {
569
600
  const { mountMasterPlugins } = await import('./index-master.ts');
570
601
  await mountMasterPlugins({
@@ -580,10 +611,14 @@ export default async function (pi: ExtensionAPI) {
580
611
  deliverNotice,
581
612
  noticePending: notices.noticePending,
582
613
  getSessionId: () => sessionId,
583
- getBlockedDepth: () => blockedDepth,
614
+ getBlockedDepth: () => gates.depth(),
584
615
  reconcileOnSettlement,
585
616
  withReconcileNotes,
586
617
  claimSettleNotice,
618
+ isCompactionInFlight: () => coordinator.compactionInFlight,
619
+ isIntentionalAbort: () => coordinator.intentionalAbort,
620
+ jev: { ask: (request, meta) => jevRuntime.ask(request, meta), getMinConfidence: () => effConfig.jev.minConfidence },
621
+ appendRoutingLog,
587
622
  });
588
623
  } else {
589
624
  const { mountTodoOnly } = await import('./index-worker.ts');
@@ -592,13 +627,15 @@ export default async function (pi: ExtensionAPI) {
592
627
  todos,
593
628
  todoUi,
594
629
  mirrorTodos,
595
- getBlockedDepth: () => blockedDepth,
630
+ getBlockedDepth: () => gates.depth(),
596
631
  ...(isSubagent
597
632
  ? {}
598
633
  : {
599
634
  stopReminder: {
600
- getBlockedDepth: () => blockedDepth,
635
+ getBlockedDepth: () => gates.depth(),
601
636
  getRunningSubs: () => subagentPort.current?.listRunningSubs().length ?? 0,
637
+ isCompactionInFlight: () => coordinator.compactionInFlight,
638
+ isIntentionalAbort: () => coordinator.intentionalAbort,
602
639
  },
603
640
  }),
604
641
  });