pi-pier 0.3.0 → 0.4.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 (103) hide show
  1. package/README.md +16 -12
  2. package/package.json +3 -3
  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 +10 -13
  9. package/src/compact-coordinator.ts +279 -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/cordis-services.ts +64 -0
  14. package/src/dashboard-command.ts +77 -33
  15. package/src/delegation-core.ts +115 -0
  16. package/src/efficiency-config-core.ts +286 -486
  17. package/src/efficiency-store.ts +16 -29
  18. package/src/gc-core.ts +10 -36
  19. package/src/guards.ts +6 -0
  20. package/src/herdr-client.ts +202 -233
  21. package/src/history-store.ts +38 -23
  22. package/src/index-gates.ts +118 -0
  23. package/src/index-locks.ts +2 -7
  24. package/src/index-master.ts +31 -29
  25. package/src/index-notices.ts +28 -15
  26. package/src/index-pipe.ts +31 -12
  27. package/src/index-roles.ts +306 -0
  28. package/src/index-runtime.ts +16 -6
  29. package/src/index-worker.ts +8 -18
  30. package/src/index.ts +561 -570
  31. package/src/jev-client.ts +12 -17
  32. package/src/jev-core.ts +138 -90
  33. package/src/ledger.ts +13 -21
  34. package/src/lock-core.ts +28 -56
  35. package/src/manifest-compose.ts +35 -20
  36. package/src/observation-core.ts +52 -100
  37. package/src/pane-title.ts +19 -38
  38. package/src/panel-open.ts +47 -0
  39. package/src/pi-surface.ts +34 -22
  40. package/src/pier-options.ts +53 -50
  41. package/src/pipe-channel.ts +44 -59
  42. package/src/platform-paths.ts +19 -52
  43. package/src/{core → plugins}/grid-shape.ts +8 -10
  44. package/src/{core → plugins}/heat-plan.ts +20 -22
  45. package/src/plugins/observation.ts +475 -0
  46. package/src/plugins/subagent.ts +766 -0
  47. package/src/{core → plugins}/terminal.ts +198 -139
  48. package/src/{core → plugins}/todo.ts +73 -134
  49. package/src/progress-core.ts +16 -53
  50. package/src/reconcile-core.ts +24 -33
  51. package/src/reducer-core.ts +26 -64
  52. package/src/reducer-invoker.ts +266 -239
  53. package/src/renderers.ts +108 -84
  54. package/src/role-loader.ts +33 -28
  55. package/src/role-manifest.ts +25 -31
  56. package/src/role-state.ts +119 -0
  57. package/src/roles/worker-default.json +3 -3
  58. package/src/routing-telemetry.ts +136 -0
  59. package/src/runtime-policy.ts +25 -40
  60. package/src/session-tail.ts +65 -41
  61. package/src/settle-wake-core.ts +61 -22
  62. package/src/slim-frame.ts +25 -122
  63. package/src/stale-core.ts +17 -24
  64. package/src/storage-layout.ts +14 -43
  65. package/src/subagent-core.ts +423 -120
  66. package/src/subagent-gc.ts +109 -67
  67. package/src/subagent-poller.ts +383 -24
  68. package/src/subagent-session.ts +516 -0
  69. package/src/subagent-spawn.ts +577 -52
  70. package/src/swallow.ts +7 -17
  71. package/src/terminal-core.ts +187 -134
  72. package/src/todo-core.ts +57 -109
  73. package/src/todo-read-hook.ts +46 -39
  74. package/src/todo-reminder-core.ts +26 -51
  75. package/src/todo-window.ts +11 -12
  76. package/src/todos-service.ts +15 -35
  77. package/src/tool-error.ts +6 -13
  78. package/src/tool-gate.ts +38 -21
  79. package/src/tree-view.ts +162 -0
  80. package/src/vocab.ts +52 -25
  81. package/src/worker-marker.ts +76 -0
  82. package/src/ask-multi.ts +0 -260
  83. package/src/config-guide.ts +0 -361
  84. package/src/core/observation.ts +0 -555
  85. package/src/core/subagent.ts +0 -633
  86. package/src/efficiency-config-io.ts +0 -53
  87. package/src/focus-poller.ts +0 -244
  88. package/src/git-adapter.ts +0 -113
  89. package/src/notice-buffer.ts +0 -23
  90. package/src/subagent-execute.ts +0 -328
  91. package/src/subagent-git-io.ts +0 -80
  92. package/src/subagent-launch.ts +0 -114
  93. package/src/subagent-list-action.ts +0 -69
  94. package/src/subagent-output-action.ts +0 -104
  95. package/src/subagent-output-adapter.ts +0 -27
  96. package/src/subagent-output-core.ts +0 -263
  97. package/src/subagent-poll-loop.ts +0 -305
  98. package/src/subagent-port.ts +0 -27
  99. package/src/subagent-ready.ts +0 -89
  100. package/src/subagent-registry.ts +0 -70
  101. package/src/subagent-resolution.ts +0 -41
  102. package/src/subagent-scope.ts +0 -41
  103. package/src/subagent-session-io.ts +0 -230
package/src/index.ts CHANGED
@@ -1,20 +1,16 @@
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';
13
+ import { Type } from 'typebox';
18
14
  import {
19
15
  ASK_PARAMETERS,
20
16
  ASK_PROMPT_GUIDELINES,
@@ -27,46 +23,49 @@ import {
27
23
  runAsk,
28
24
  } from './ask-user.ts';
29
25
  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';
26
+ import { TODO_EDIT_CUSTOM_TYPE, currentActivity } from './todo-core.ts';
27
+ import { installRenderers } from './renderers.ts';
36
28
  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';
29
+ import { bareSessionId, lastAssistantText, readSessionFile, resolveSessionFileValue } from './session-tail.ts';
40
30
  import { fileURLToPath } from 'node:url';
41
- import { TodosService } from './todos-service.ts';
31
+ import { join } from 'node:path';
32
+ import { mkdirSync, unlinkSync, writeFileSync } from 'node:fs';
33
+ import { tmpdir } from 'node:os';
34
+ import { platformPaths } from './platform-paths.ts';
35
+ import { TodosService, type TodoCompletionSource } from './todos-service.ts';
42
36
  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';
37
+ import type { TodoUiSlot } from './plugins/todo.ts';
38
+ import { estimateEta, formatProgressSuffix, planToolBadge, progressOf } from './progress-core.ts';
50
39
  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
-
40
+ import { ABORT_STOP_REASON, planSettleWake, shouldPushSettleReply } from './settle-wake-core.ts';
41
+ import { parseRuntimeManifest } from './tool-gate.ts';
55
42
  import { formatPaneTitle } from './pane-title.ts';
56
43
  import { registerSlimFrame, updateSlimFrame } from './slim-frame.ts';
57
- import { planIndexMode } from './index-runtime.ts';
58
- import { emptySubagentPortBox } from './subagent-port.ts';
44
+ import { applyRestoredWorkerMode, planIndexMode } from './index-runtime.ts';
45
+ import { buildResumeArgv, emptySubagentPortBox, PROGRESS_CUSTOM_TYPE } from './subagent-core.ts';
46
+ import { hasWorkerMarker, writeWorkerMarker, readWorkerMarker } from './worker-marker.ts';
47
+ import {
48
+ DELEGATION_CUSTOM_TYPE,
49
+ applyWorkerTag,
50
+ buildDelegationSection,
51
+ foldDelegation,
52
+ parseDelegationContext,
53
+ type DelegationContext,
54
+ } from './delegation-core.ts';
55
+ import { formatDelegationTree, type WorkerTodoSnapshot, type WorkerWidgetRow } from './tree-view.ts';
59
56
  import { createNoticeBuffer } from './index-notices.ts';
60
- import { handlePipeRequest } from './index-pipe.ts';
57
+ import { handlePipeRequest, type MachineRequest } from './index-pipe.ts';
61
58
  import { installWriteLocks } from './index-locks.ts';
62
59
  import { installDashboardCommand } from './dashboard-command.ts';
63
- import { registerObservationPack, createCompactionBatchPackHook, type PickMiddleExcerpt } from './core/observation.ts';
60
+ import { openPierPanel } from './panel-open.ts';
61
+ import { registerObservationPack, createCompactionBatchPackHook, type PickMiddleExcerpt } from './plugins/observation.ts';
64
62
  import { createJevRuntime } from './jev-client.ts';
65
63
  import {
66
64
  NOTICE_RANK_MIN_CONFIDENCE,
67
65
  buildExcerptWindows,
68
66
  composeNoticeRanking,
69
67
  evaluateExcerptPick,
68
+ excerptAskIsSafe,
70
69
  excerptPickRequest,
71
70
  noticeRankRequest,
72
71
  } from './jev-core.ts';
@@ -74,93 +73,57 @@ import { completeLineExcerpt } from './observation-core.ts';
74
73
  import { installConfigCommand } from './config-command.ts';
75
74
  import { CompactCoordinator } from './compact-coordinator.ts';
76
75
  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
- */
99
- function focusPollIntervalMs(env: NodeJS.ProcessEnv = process.env, herdrVersion?: string | null): number {
100
- 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;
76
+ import { appendEfficiencyLog, efficiencyLogPath, pruneSessionObjects, resolveSessionRoot } from './efficiency-store.ts';
77
+ import type { RoutingTelemetryRecord, RoutingTelemetryRow } from './routing-telemetry.ts';
78
+ import { loadEfficiencyConfigFromDisk, resolveEfficiencyConfig, type EfficiencyConfig } from './efficiency-config-core.ts';
79
+ import { composeMasterRuntime, createRoleRuntime } from './index-roles.ts';
80
+ import { createGateRuntime } from './index-gates.ts';
81
+
82
+ function agentSessionsDir(): string {
83
+ return process.env.PI_CODING_AGENT_DIR ? join(process.env.PI_CODING_AGENT_DIR, 'sessions') : platformPaths.sessionsDir;
105
84
  }
106
85
 
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
- }
130
- }
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
-
135
86
  export default async function (pi: ExtensionAPI) {
136
- const mode = planIndexMode();
87
+ const mode = applyRestoredWorkerMode(planIndexMode(), process.env.PI_SESSION_FILE ?? null, hasWorkerMarker);
137
88
  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();
89
+ const roleBase = process.env.PI_HERDR_ROLE_BASE || process.cwd();
90
+
91
+ let sessionId: string = process.env.PI_SESSION_FILE ?? process.env.PI_SESSION_ID ?? '';
92
+ let sessionRoot: string | null = null;
143
93
  let effConfig: EfficiencyConfig = resolveEfficiencyConfig();
144
94
  const coordinator = new CompactCoordinator();
145
- let sessionRoot: string | null = null;
95
+ const { client, env } = createHerdrClient();
96
+ /** RFC rfc-delegation-identity §2.1: resolved env → marker → branch fold; worker panes only. */
97
+ let delegation: DelegationContext | null = parseDelegationContext(process.env.PI_HERDR_DELEGATION);
98
+ const initialManifest =
99
+ parseRuntimeManifest(process.env.PI_HERDR_ROLE_MANIFEST) ??
100
+ (mode.composeMaster ? composeMasterRuntime() : null);
101
+ const todos = new TodosService(TodosService.configFromRuntime(initialManifest, isSubagent));
102
+
103
+ /** Phase 0 routing telemetry (RFC docs/rfc-jev-role-routing.md §8): best-effort append; never gates anything. */
104
+ const appendRoutingLog = (row: RoutingTelemetryRecord): void => {
105
+ const root = sessionRoot;
106
+ if (!root) return;
107
+ const stamped: RoutingTelemetryRow = { ...row, sessionId: sessionId || 'unknown' };
108
+ void appendEfficiencyLog(efficiencyLogPath(root, 'routing'), stamped).catch(() => {});
109
+ };
146
110
 
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;
111
+ const roles = createRoleRuntime({ pi, client, roleBase, initialManifest, isSubagent, todos, appendRoutingLog });
112
+
113
+ /* ── Jev decision layer (RFC docs/rfc-jev-integration.md): direct HTTP, total-budget
114
+ * abort, fail-open at every site. Disabled or keyless → behavior identical to before. ── */
115
+ const jevRuntime = createJevRuntime(() => effConfig.jev, { getSessionRoot: () => sessionRoot, getSessionId: () => sessionId || null });
153
116
  /** P0-3: candidate windows are generated in code; jev only picks (rerank pattern). */
154
117
  const pickMiddleExcerpt: PickMiddleExcerpt = async (text, excerptBudgetBytes) => {
155
118
  if (!jevRuntime.available) return null;
156
119
  const halfBudget = Math.floor(excerptBudgetBytes / 2);
157
120
  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
- );
121
+ if (windows.length === 0) return null; // degenerate budget only
122
+ const headExcerpt = completeLineExcerpt(text, halfBudget, false);
123
+ const tailExcerpt = completeLineExcerpt(text, excerptBudgetBytes - halfBudget, true);
124
+ // Credential-shaped text never leaves the process (§8 privacy boundary).
125
+ if (!excerptAskIsSafe(windows, headExcerpt, tailExcerpt)) return null;
126
+ const request = excerptPickRequest(windows, headExcerpt, tailExcerpt);
164
127
  if (!request) return null;
165
128
  const result = await jevRuntime.ask(request, {
166
129
  questionId: 'obs-excerpt-window',
@@ -171,95 +134,148 @@ export default async function (pi: ExtensionAPI) {
171
134
  },
172
135
  });
173
136
  if (!result.ok) return null;
174
- const picked = evaluateExcerptPick(result.answers, jevMinConfidence());
137
+ const picked = evaluateExcerptPick(result.answers, effConfig.jev.minConfidence);
175
138
  const window = picked === null ? undefined : windows.find((w) => w.id === picked);
176
139
  return window ? { text: window.text, label: window.label } : null;
177
140
  };
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).
141
+
180
142
  const rendererTypes = installRenderers(pi);
181
143
  if (process.env.PI_HERDR_TRACE) {
182
144
  console.error(`[pi-herdr] transcript renderers: ${rendererTypes.length ? rendererTypes.join(', ') : 'none'}`);
183
145
  }
184
146
 
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. */
147
+ let agentActive = false;
148
+ let lastStopReason: string | null = null;
149
+ let latestCtx: { abort?: () => void } | null = null;
187
150
  const completedStamps: number[] = [];
151
+ const subagentPort = emptySubagentPortBox();
152
+ let d96NoticeKey: string | null = null;
153
+ let d96NoticeAt = 0;
188
154
 
189
- /* ── State reconstruction (branch correctness comes from the last todo_write snapshot on the branch) ── */
155
+ const todoUi: TodoUiSlot = { renderWidget: () => { /* filled after plugin mounting */ } };
156
+ const gates = createGateRuntime({
157
+ pi,
158
+ client,
159
+ rerenderWidget: () => todoUi.rerenderWidget?.(),
160
+ isAgentActive: () => agentActive,
161
+ idleMessage: () => roles.idleBadge(),
162
+ workingMessage: () => currentActivity(todos.items),
163
+ });
190
164
 
191
- function rebuildFromBranch(ctx: unknown): void {
165
+ /** Session identity: sessionManager is authoritative (env may be unset in print/RPC mode). */
166
+ function resolveSessionId(ctx: unknown): string {
192
167
  try {
193
- const entries = (ctx as { sessionManager?: { getBranch?: () => readonly unknown[] } })
194
- ?.sessionManager?.getBranch?.() ?? [];
195
- todos.rebuild(entries);
196
- coordinator.rebuildFromBranch(entries);
168
+ const sm = (ctx as {
169
+ sessionManager?: { getSessionFile?: () => string | undefined; getSessionId?: () => string };
170
+ }).sessionManager;
171
+ return bareSessionId(
172
+ sm?.getSessionFile?.() ?? sm?.getSessionId?.() ?? process.env.PI_SESSION_FILE ?? process.env.PI_SESSION_ID ?? '',
173
+ );
197
174
  } catch {
198
- // A reconstruction failure must not disrupt the main flow; the next todo_write re-anchors state.
175
+ return bareSessionId(process.env.PI_SESSION_FILE ?? '');
199
176
  }
200
177
  }
201
178
 
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 {
179
+ /** Branch reconstruction: todos + OCC debt state (branch correctness from the last snapshot on the branch). */
180
+ function rebuildFromBranch(ctx: unknown): void {
220
181
  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
- );
182
+ const entries = (ctx as { sessionManager?: { getBranch?: () => readonly unknown[] } })
183
+ ?.sessionManager?.getBranch?.() ?? [];
184
+ todos.rebuild(entries);
185
+ coordinator.rebuildFromBranch(entries);
234
186
  } catch {
235
- return process.env.PI_SESSION_FILE ?? '';
187
+ /* A reconstruction failure must not disrupt the main flow; the next todo_write re-anchors. */
236
188
  }
237
189
  }
238
190
 
239
191
  function mirrorTodos(): void {
240
192
  if (!client.available) return;
241
193
  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
194
  const p = progressOf(todos.items);
244
195
  const eta = estimateEta({ completedAt: completedStamps, total: p.total, now: Date.now() });
245
196
  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.
197
+ const title = applyWorkerTag(
198
+ formatPaneTitle(todos.items, null, { progressSuffix: suffix, lastWriteAt: todos.lastWriteAt }),
199
+ delegation?.description ?? null,
200
+ );
252
201
  updateSlimFrame({ title, items: todos.items, lastWriteAt: todos.lastWriteAt });
253
202
  client.reportMetadata({
254
203
  session: label,
255
204
  items: todos.items,
256
205
  progressSuffix: suffix,
257
206
  lastWriteAt: todos.lastWriteAt,
207
+ workerTag: delegation?.description ?? null,
258
208
  }).catch(() => {});
209
+ // D113: delegated workers also push the full list to the master's pipe so its resident widget
210
+ // and /pier-tree stay live without polling. Best effort — a lost push self-heals on the next write.
211
+ if (isSubagent && delegation?.parentPipe) {
212
+ void pipeRequest(delegation.parentPipe, {
213
+ type: 'todo_sync',
214
+ id: `tsync-${Date.now()}`,
215
+ paneId: env?.paneId ?? '',
216
+ items: todos.items.map((it) => ({ content: it.content, status: it.status, ...(it.blocker ? { blocker: it.blocker } : {}) })),
217
+ summary: title ?? delegation.description,
218
+ }, 3000).catch(() => {});
219
+ }
220
+ }
221
+
222
+ /** D113: per-worker todo cache (pipe pushes; summary fallback after a master resume). */
223
+ const workerTodos = new Map<string, WorkerTodoSnapshot>();
224
+ function applyWorkerTodos(workerPaneId: string, items: WorkerTodoSnapshot['items'], summary: string): void {
225
+ if (!workerPaneId) return;
226
+ workerTodos.set(workerPaneId, { items, summary, ts: Date.now() });
227
+ todoUi.rerenderWidget?.();
228
+ refreshTreeSnapshot();
229
+ }
230
+ /** Running rows joined with their cached todos — feeds the unified widget and /pier-tree. */
231
+ function widgetWorkers(): WorkerWidgetRow[] {
232
+ return (subagentPort.current?.listRunningSubs() ?? []).map((s) => ({
233
+ ...s,
234
+ todos: workerTodos.get(s.paneId) ?? null,
235
+ }));
259
236
  }
260
237
 
261
- /* ── M17: automatic settlement reconciliation (pure planner + D38 authority; idempotent and safe to run through both paths) ── */
238
+ /* ── /pier-tree panel: the pi process owns the data, the popup/overlay owns the surface. ── */
239
+ /** Set while a panel is open; todo/worker updates republish the snapshot it polls. */
240
+ let treeSnapshot: { path: string } | null = null;
241
+
242
+ function renderTreeLines(): string[] {
243
+ const rows = subagentPort.current?.listRows() ?? [];
244
+ return formatDelegationTree({
245
+ selfItems: todos.items,
246
+ workers: widgetWorkers(),
247
+ settled: rows
248
+ .filter((r) => r.status === 'settled' || r.status === 'consumed')
249
+ .map((r) => ({ paneId: r.paneId, slug: r.slug, description: r.description, settledAt: r.settledAt })),
250
+ now: Date.now(),
251
+ });
252
+ }
262
253
 
254
+ function treeSnapshotPath(): string {
255
+ const id = (sessionId || (env ? `pane-${env.paneId}` : 'session')).replace(/[^A-Za-z0-9._-]/g, '_').slice(0, 80);
256
+ return join(tmpdir(), 'pier-tree', `${id}.txt`);
257
+ }
258
+
259
+ function publishTreeSnapshot(): string {
260
+ const path = treeSnapshotPath();
261
+ mkdirSync(join(tmpdir(), 'pier-tree'), { recursive: true });
262
+ writeFileSync(path, `${renderTreeLines().join('\n')}\n`);
263
+ treeSnapshot = { path };
264
+ return path;
265
+ }
266
+
267
+ function refreshTreeSnapshot(): void {
268
+ if (!treeSnapshot) return;
269
+ try {
270
+ writeFileSync(treeSnapshot.path, `${renderTreeLines().join('\n')}\n`);
271
+ } catch {
272
+ /* The panel may have been closed and its temp dir swept; the next command republishes. */
273
+ }
274
+ }
275
+
276
+ todos.on('todo.updated', () => refreshTreeSnapshot());
277
+
278
+ /** M17 settlement reconciliation: auto-complete settled todos / unblock on failure notes (idempotent). */
263
279
  function reconcileOnSettlement(description: string, outcome: 'settled' | 'failed'): string[] {
264
280
  if (!description) return [];
265
281
  try {
@@ -271,7 +287,6 @@ export default async function (pi: ExtensionAPI) {
271
287
  { version: 1, edits: plan.edits, ts: Date.now() },
272
288
  );
273
289
  } catch {
274
- /* Best effort persistence; in-memory state still advances. */
275
290
  }
276
291
  todos.applyEdits(plan.edits, { source: 'reconcile' });
277
292
  mirrorTodos();
@@ -282,71 +297,200 @@ export default async function (pi: ExtensionAPI) {
282
297
  }
283
298
  }
284
299
 
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) ── */
300
+ const withReconcileNotes = (base: string, notes: readonly string[]): string =>
301
+ notes.length ? `${base}\n${notes.join('\n')}` : base;
291
302
 
292
- todos.on('todo.completed', (e: { count: number; at: number; source?: import('./todos-service.ts').TodoCompletionSource }) => {
303
+ todos.on('todo.completed', (e: { count: number; at: number; source?: TodoCompletionSource }) => {
293
304
  for (let i = 0; i < e.count; i++) completedStamps.push(e.at);
294
305
  coordinator.recordBoundaryCompleted(e.count, e.source);
295
306
  mirrorTodos();
296
307
  });
297
308
 
298
- // Tool badge: parallel mode starts in source order and ends in completion order (pi docs contract); track in-flight calls in a Map.
309
+ /* ── tool badges: parallel calls start in source order, end in completion order ── */
299
310
  const runningTools = new Map<string, string>(); // toolCallId → toolName
300
311
  let lastToolBadge: string | null = null;
301
312
  function reportToolBadge(): void {
302
313
  const badge = planToolBadge([...new Set(runningTools.values())]);
303
- if (badge === lastToolBadge) return; // Idempotent: report only changes (M13 rendering discipline).
314
+ if (badge === lastToolBadge) return; // report only changes (M13 rendering discipline)
304
315
  lastToolBadge = badge;
305
- if (badge) reportAgent('working', badge);
306
- else reportAgent(agentActive ? 'working' : 'idle', agentActive ? currentActivity(todos.items) : null);
316
+ if (badge) gates.report('working', badge);
317
+ else gates.report(agentActive ? 'working' : 'idle', agentActive ? currentActivity(todos.items) : null);
307
318
  }
308
-
309
319
  pi.on('tool_execution_start', async (event: { toolCallId?: string; toolName?: string }) => {
310
320
  if (typeof event?.toolCallId === 'string' && typeof event?.toolName === 'string') {
311
321
  runningTools.set(event.toolCallId, event.toolName);
312
322
  reportToolBadge();
313
323
  }
314
324
  });
315
- pi.on('tool_execution_end', async (event: { toolCallId?: string }) => {
316
- if (typeof event?.toolCallId === 'string') {
317
- runningTools.delete(event.toolCallId);
318
- reportToolBadge();
325
+ pi.on('tool_execution_end', async (event: { toolCallId?: string; toolName?: string; durationMs?: number }) => {
326
+ if (typeof event?.toolCallId !== 'string') return;
327
+ const tool = typeof event.toolName === 'string' ? event.toolName : runningTools.get(event.toolCallId) ?? null;
328
+ runningTools.delete(event.toolCallId);
329
+ reportToolBadge();
330
+ // pi ≥ 1.1 records the real execution time on the event; the tools ledger replaces wall-clock
331
+ // estimates in the efficiency trial with measured numbers. Absent on older pi — nothing logged.
332
+ if (typeof event.durationMs === 'number' && sessionRoot) {
333
+ try {
334
+ await appendEfficiencyLog(efficiencyLogPath(sessionRoot, 'tools'), {
335
+ sessionId: sessionId || 'unknown',
336
+ ts: Date.now(),
337
+ tool,
338
+ durationMs: event.durationMs,
339
+ });
340
+ } catch {
341
+ /* ledger is observability only */
342
+ }
319
343
  }
320
344
  });
321
345
 
322
- /* ── D102: Evidence-Preserving Reducer (runs before append-handlers like write-locks) ── */
346
+ /* ── D102 evidence-preserving reducer (registered before append-handlers like write-locks) ── */
323
347
  pi.on('tool_result', async (event, ctx) => {
324
348
  if (!effConfig.evidencePreservingReducer.enabled) return;
325
349
  if (!event || typeof event !== 'object') return;
326
350
  return handleReducerToolResult(event as unknown as ToolResultEventLike, ctx, effConfig.evidencePreservingReducer, {
327
351
  epoch: coordinator.state.epoch,
328
- jev: { ask: jevRuntime.ask, getMinConfidence: jevMinConfidence },
352
+ jev: { ask: jevRuntime.ask, getMinConfidence: () => effConfig.jev.minConfidence },
329
353
  });
330
354
  });
331
355
 
332
356
  let locksHandle = { getHeldLocks: () => [] as readonly string[] };
333
357
  if (env) {
334
- locksHandle = installWriteLocks(pi, {
335
- client,
336
- env,
337
- hard: process.env[WRITE_LOCK_ENV] === '1',
338
- });
358
+ locksHandle = installWriteLocks(pi, { client, env, hard: process.env[WRITE_LOCK_ENV] === '1' });
339
359
  }
340
360
 
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. */ },
361
+ /** pi's sendUserMessage is fire-and-forget (void); callers of this wrapper still get a promise. */
362
+ const sendUserMessageAs = (content: string, mode: 'steer' | 'followUp'): Promise<void> => {
363
+ (pi as unknown as { sendUserMessage?: (content: string, opts?: { deliverAs?: string; triggerTurn?: boolean }) => void })
364
+ .sendUserMessage?.(content, { deliverAs: mode, triggerTurn: true });
365
+ return Promise.resolve();
344
366
  };
345
367
 
346
- /* ── Lifecycle ────────────────────────────────────────────────────── */
368
+ /** Phase 2 (RFC rfc-delegation-identity §3.2): a worker's progress note enters master context as
369
+ * a display-less custom message — no turn, no visible chat line; renderers.ts gives it a card. */
370
+ const deliverProgress = (workerPaneId: string, text: string): Promise<void> => {
371
+ (pi as unknown as {
372
+ sendMessage?: (msg: { customType: string; content: string; display: boolean; details?: Record<string, unknown> }, opts?: { triggerTurn?: boolean }) => void;
373
+ }).sendMessage?.(
374
+ { customType: PROGRESS_CUSTOM_TYPE, content: text.slice(0, 2000), display: false, details: { paneId: workerPaneId } },
375
+ { triggerTurn: false },
376
+ );
377
+ return Promise.resolve();
378
+ };
379
+ const notices = createNoticeBuffer({
380
+ isBusy: () => agentActive || lastStopReason === ABORT_STOP_REASON,
381
+ send: sendUserMessageAs,
382
+ // P0-2: rank collapsed batches by relevance to the master's in-progress work.
383
+ rank: async (contents) => {
384
+ if (!jevRuntime.available) return null;
385
+ const inProgressTodos = todos.items.filter((item) => item.status === 'in_progress').map((item) => item.content);
386
+ let rankedOrder: number[] | null | undefined;
387
+ const result = await jevRuntime.ask(
388
+ noticeRankRequest({ inProgressTodos, notices: [...contents] }),
389
+ {
390
+ questionId: 'notice-rank',
391
+ sessionId,
392
+ extra: { site: 'notice-rank', noticeCount: contents.length },
393
+ enrich: ({ answers }) => {
394
+ if (!answers) return {};
395
+ rankedOrder = composeNoticeRanking(
396
+ contents.length,
397
+ answers,
398
+ Math.max(effConfig.jev.minConfidence, NOTICE_RANK_MIN_CONFIDENCE),
399
+ );
400
+ const values = Array.from({ length: contents.length }, (_, i) => {
401
+ const rank = answers[`notice_${i}_rank`];
402
+ const fail = answers[`notice_${i}_fail`];
403
+ return rank !== undefined && fail !== undefined && rank.type === 'score' && fail.type === 'noul'
404
+ ? `${rank.score.toFixed(2)}:${rank.confidence.toFixed(2)}:${fail.noul.toFixed(2)}`
405
+ : '?';
406
+ });
407
+ return { order: rankedOrder === null ? 'fallback' : rankedOrder.join(','), values };
408
+ },
409
+ },
410
+ );
411
+ if (!result.ok) return null;
412
+ return rankedOrder === undefined || rankedOrder === null ? null : rankedOrder.map((index) => contents[index]!);
413
+ },
414
+ });
415
+ const deliverNotice = notices.deliverNotice;
416
+
417
+ /* ── pipe channel: this pane's NDJSON server + the settle fast-path push (D48/D49/D50) ── */
418
+ const pipeServerBox: { current: ReturnType<typeof startPipeServer> | null } = { current: null };
419
+ let pendingMachineRequest: MachineRequest | null = null;
420
+ /** Dedup latch for settle notices: one per paneId+request id per round. */
421
+ const settleNoticeLatch = new Set<string>();
422
+ function claimSettleNotice(key: string): boolean {
423
+ if (settleNoticeLatch.has(key)) return false;
424
+ settleNoticeLatch.add(key);
425
+ return true;
426
+ }
427
+
428
+ function startPipeServerFor(cwd: string): void {
429
+ const paneId = env?.paneId ?? '';
430
+ if (!paneId) return;
431
+ if (pipeServerBox.current) {
432
+ try { pipeServerBox.current.close(); } catch { /* Previous instance. */ }
433
+ pipeServerBox.current = null;
434
+ }
435
+ try {
436
+ pipeServerBox.current = startPipeServer(pipeNameFor(cwd, paneId), async (req) => handlePipeRequest(req, {
437
+ paneId,
438
+ port: subagentPort,
439
+ claimSettleNotice,
440
+ deliverNotice,
441
+ sendUserMessageIn: (content) => sendUserMessageAs(content, 'followUp'),
442
+ sendUserMessageAs,
443
+ abort: () => { latestCtx?.abort?.(); },
444
+ setPendingMachineRequest: (next) => { pendingMachineRequest = next; },
445
+ applyRoleSwitch: roles.applyRoleSwitch,
446
+ deliverProgress,
447
+ applyWorkerTodos,
448
+ }));
449
+ } catch {
450
+ /* Pipe name collision (rare): this session has no channel; callers report after ping timeout. */
451
+ }
452
+ }
453
+
454
+ async function pushSettleReply(aborted = false): Promise<void> {
455
+ const req = pendingMachineRequest;
456
+ if (!req || !req.push || !req.from) return;
457
+ // An aborted settle is not a settlement: OCC's abort means the continuation turn is coming, and
458
+ // answering now would burn the one-shot request + the parent's claim latch on a null-text reply
459
+ // ("left no closing message") — the real closing message then never gets delivered. Keep the
460
+ // request armed; the post-compaction settle pushes it (settle-wake-core.ts).
461
+ if (!shouldPushSettleReply({
462
+ intentionalAbort: coordinator.intentionalAbort,
463
+ aborted,
464
+ compactionInFlight: coordinator.compactionInFlight,
465
+ lastStopReason,
466
+ })) {
467
+ return;
468
+ }
469
+ pendingMachineRequest = null;
470
+ try {
471
+ const ownFile = sessionId ? resolveSessionFileValue(process.cwd(), agentSessionsDir(), sessionId) : null;
472
+ let text: string | null = null;
473
+ if (ownFile) {
474
+ const entries = readSessionFile(ownFile);
475
+ if (entries) text = lastAssistantText(entries, { sinceTs: req.sinceTs })?.text ?? null;
476
+ }
477
+ await pipeRequest(req.from, {
478
+ type: 'reply',
479
+ id: req.id,
480
+ paneId: env?.paneId ?? '',
481
+ text,
482
+ // Push the resolved PATH when readable; fall back to the bare id for the master-side mapping.
483
+ sessionFile: ownFile ?? (sessionId || null),
484
+ }, 5000);
485
+ } catch (err) {
486
+ /* The requester's pollLoop is the fallback; surface the dead letter for triage. */
487
+ console.error(`pier: settle reply push to ${req.from} failed: ${(err as Error)?.message ?? err}`);
488
+ }
489
+ }
347
490
 
348
491
  pi.on('session_start', async (event, ctx) => {
349
492
  sessionId = resolveSessionId(ctx);
493
+ latestCtx = ctx as { abort?: () => void } | null;
350
494
  const cwd = (ctx as { cwd?: string }).cwd ?? process.cwd();
351
495
  const isTrusted =
352
496
  typeof (ctx as { isProjectTrusted?: () => boolean }).isProjectTrusted === 'function'
@@ -357,117 +501,95 @@ export default async function (pi: ExtensionAPI) {
357
501
  (ctx as { sessionManager?: { getSessionDir?: () => string | undefined } })?.sessionManager?.getSessionDir?.(),
358
502
  sessionId,
359
503
  );
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.
504
+ // On resume pi restores widget state from the session; calling setWidget again
505
+ // breaks the TUI '/' command-panel route (v1.3 M9).
506
+ if ((event as { reason?: string } | undefined)?.reason !== 'resume') todoUi.renderWidget(ctx);
507
+ // D97: narrow-frame overlay is meaningful only inside herdr; re-register (session switching resets it).
367
508
  if (env) registerSlimFrame(ctx);
509
+ // P2-5: resume folds todos/compaction state from the branch like session_tree; a fresh session folds nothing.
510
+ rebuildFromBranch(ctx);
368
511
  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') {
512
+ roles.syncFromBranch(ctx);
513
+ if (mode.composeMaster && sessionRoot) roles.scanRoleUsage();
514
+ gates.report('idle', null);
515
+ // Self-healing: the ask marker lives in herdr with a 24h TTL; a session killed
516
+ // while a dialog was open must not keep the pane looking blocked forever.
517
+ void client.reportAskFlag(null).catch(() => {});
518
+ if (isSubagent) {
519
+ // herdr 0.9.2 restore identity: the marker re-derives worker mode for the resumed boot
520
+ // (resume_argv cannot carry spawn env), and the self-reported command replays this exact
521
+ // session — extension, role model, approve, fullscreen — instead of a bare `pi --resume`.
522
+ const sm = typeof ctx === 'object' && ctx !== null && 'sessionManager' in ctx ? ctx.sessionManager : undefined;
523
+ const sessionFile = (typeof sm?.getSessionFile === 'function' ? sm.getSessionFile() : undefined)
524
+ ?? process.env.PI_SESSION_FILE ?? null;
525
+ // RFC rfc-delegation-identity §2.1: env (spawn) > marker (herdr restart) > branch fold (/resume).
526
+ if (!delegation) delegation = readWorkerMarker(sessionFile)?.delegation ?? null;
527
+ const branchEntries = (ctx as { sessionManager?: { getBranch?: () => readonly unknown[] } })
528
+ ?.sessionManager?.getBranch?.() ?? [];
529
+ const folded = foldDelegation(branchEntries as Parameters<typeof foldDelegation>[0]);
530
+ if (!delegation && folded) delegation = folded;
531
+ if (delegation && !folded) {
532
+ // First-frame card: write once — the entry doubles as the /resume replay source.
395
533
  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
- }
534
+ pi.appendEntry(DELEGATION_CUSTOM_TYPE, { version: 1, ...delegation, ts: Date.now() });
405
535
  } catch {
406
- /* Best effort visible layer; the mandatory layer remains active. */
536
+ /* Best effort; the marker already carries the identity. */
407
537
  }
408
538
  }
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.
414
- void client.reportAskFlag(null).catch(() => {});
415
- });
416
-
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. */
539
+ if (delegation) void client.reportDelegationTokens(delegation).catch(() => {});
540
+ if (sessionRoot) {
541
+ writeWorkerMarker(sessionFile, roles.state.manifest?.role, delegation ?? undefined);
542
+ const resumeArgv = buildResumeArgv(process.argv);
543
+ if (resumeArgv && client.available) {
544
+ void client.reportAgentSessionResume(sessionId, resumeArgv).catch(() => {});
545
+ }
440
546
  }
441
547
  }
548
+
549
+ // Focus heat is event-driven on herdr 0.9.5+: the workbench receives pane.focused directly,
550
+ // so no per-pane layout sampler is started here.
551
+ startPipeServerFor(cwd);
442
552
  });
443
553
 
554
+ /* RFC rfc-delegation-identity §2.2: worker identity + delivery contract ride the prompt
555
+ * sections each turn (same diff discipline as pier-role; removal cleans up). */
556
+ if (isSubagent) {
557
+ pi.on('before_agent_start', async (event) => {
558
+ const sections = event.systemPromptOptions?.sections;
559
+ if (!sections) return;
560
+ if (delegation) {
561
+ const text = buildDelegationSection(delegation, env?.paneId ?? '');
562
+ if (sections['pier-delegation'] !== text) sections['pier-delegation'] = text;
563
+ } else {
564
+ delete sections['pier-delegation'];
565
+ }
566
+ });
567
+ }
444
568
 
445
- // Session-tree navigation (/tree, /fork): reconstruction preserves branch correctness.
446
569
  pi.on('session_tree', async (_event, ctx) => {
447
570
  sessionId = resolveSessionId(ctx);
448
571
  rebuildFromBranch(ctx);
572
+ // pi reverts the transcript loadout at the navigated branch point; the role
573
+ // runtime follows the same point (same-origin, RFC §4.3).
574
+ roles.syncFromBranch(ctx);
449
575
  todoUi.renderWidget(ctx);
450
576
  mirrorTodos();
451
577
  });
452
578
 
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;
579
+ /* pi 1.2 counts the run as busy from `before_agent_start`, not `turn_start`: a settlement
580
+ * notice arriving in that window must enter the D92 buffer instead of being sent immediately. */
581
+ pi.on('before_agent_start', async () => {
582
+ agentActive = true;
583
+ });
584
+
461
585
  pi.on('turn_start', async () => {
462
586
  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.
587
+ // A11: reset so a previous turn's abort cannot taint this one.
465
588
  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.
589
+ // Safety fallback: pi core prohibits prompts while compaction is active.
468
590
  coordinator.compactionInFlight = false;
469
591
  coordinator.intentionalAbort = false;
470
- reportAgent('working', currentActivity(todos.items));
592
+ gates.report('working', currentActivity(todos.items));
471
593
  });
472
594
 
473
595
  pi.on('before_provider_request', (_event: unknown, ctx: unknown) => {
@@ -486,29 +608,37 @@ export default async function (pi: ExtensionAPI) {
486
608
  pi.on('session_before_tree', () => (coordinator.compactionInFlight ? { cancel: true } : undefined));
487
609
 
488
610
  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
- });
611
+ // Master registered this twice: the lifecycle handler skipped malformed events, the notice
612
+ // flush ran regardless. The merged handler keeps both semantics.
613
+ const msg = event !== null && typeof event === 'object' && 'message' in event ? (event as { message: unknown }).message : null;
614
+ if (msg !== null && typeof msg === 'object') {
615
+ const { role, stopReason } = msg as { role?: unknown; stopReason?: unknown };
616
+ if (role === 'assistant' && typeof stopReason === 'string') {
617
+ lastStopReason = stopReason;
618
+ }
619
+ if (ctx && typeof ctx === 'object') {
620
+ coordinator.onTurnEnd({
621
+ ctx,
622
+ todos: todos.items,
623
+ config: effConfig.onlineContextCompact,
624
+ cancelReminder: todoUi.cancelReminder,
625
+ });
626
+ }
503
627
  }
628
+ void notices.flush('steer');
504
629
  });
505
630
 
506
- pi.on('agent_settled', async (_event: unknown, ctx) => {
631
+ pi.on('agent_settled', async (event: unknown, ctx) => {
507
632
  agentActive = false;
508
- reportAgent('idle', null);
633
+ gates.report('idle', null);
634
+ // pi ≥ 1.1 stamps cancelled runs on the event itself; stopReason tracking misses ESC mid-tool
635
+ // (no final assistant message), so the event field is authoritative when present.
636
+ const aborted = event !== null && typeof event === 'object' && 'aborted' in event && event.aborted === true;
509
637
  const plan = planSettleWake({
510
638
  lastStopReason,
511
639
  intentionalAbort: coordinator.intentionalAbort,
640
+ aborted,
641
+ compactionPending: coordinator.selectedCompaction !== null || coordinator.compactionInFlight,
512
642
  running: subagentPort.current?.listRunningSubs() ?? [],
513
643
  lastNoticeKey: d96NoticeKey,
514
644
  lastNoticeAt: d96NoticeAt,
@@ -516,19 +646,14 @@ export default async function (pi: ExtensionAPI) {
516
646
  });
517
647
  d96NoticeKey = plan.noticeKey;
518
648
  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
649
  // D96: master settled while background subagents still run → remind (worker port is unbound).
525
- if (plan.notice && !isSubagent) {
650
+ if (plan.wake && plan.notice && !isSubagent) {
526
651
  const running = subagentPort.current?.listRunningSubs() ?? [];
527
652
  const brief = running.map((s) => `${s.paneId} (${s.description})`).join('、');
528
653
  const notice = `注意:仍有 ${running.length} 个后台 subagent 在运行:${brief}。若你的任务依赖它们,请等待其结算(subagent list 查看状态);若不等待,请说明放弃原因。`;
529
- void sendUserMessageIn(notice);
654
+ void sendUserMessageAs(notice, 'followUp');
530
655
  }
531
- if (ctx && typeof ctx === 'object') {
656
+ if (plan.compact && ctx && typeof ctx === 'object') {
532
657
  void coordinator.onAgentSettled({
533
658
  ctx,
534
659
  todos: todos.items,
@@ -537,117 +662,21 @@ export default async function (pi: ExtensionAPI) {
537
662
  cancelReminder: todoUi.cancelReminder,
538
663
  onBeforeCompact: createCompactionBatchPackHook({
539
664
  getObsConfig: () => effConfig.observationPack,
540
- getManifest: () => runtimeManifest,
665
+ getManifest: () => roles.state.manifest,
541
666
  getSessionId: () => sessionId,
542
667
  pickMiddleExcerpt,
543
668
  }),
544
669
  });
545
670
  }
671
+ if (lastStopReason !== ABORT_STOP_REASON && !aborted) void notices.flush('followUp');
672
+ void pushSettleReply(aborted);
546
673
  });
547
674
 
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) ── */
675
+ /* ── D101 ObservationPack (obs_recall tool & context projector) ── */
647
676
  registerObservationPack({
648
677
  pi,
649
678
  getConfig: () => effConfig,
650
- getRuntimeManifest: () => runtimeManifest,
679
+ getRuntimeManifest: () => roles.state.manifest,
651
680
  pickMiddleExcerpt,
652
681
  getRemainingHorizon: () => {
653
682
  const usage = (latestCtx as { getContextUsage?: () => { contextWindow?: number } })?.getContextUsage?.();
@@ -655,10 +684,6 @@ export default async function (pi: ExtensionAPI) {
655
684
  },
656
685
  });
657
686
 
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
687
  installConfigCommand({ pi });
663
688
  installDashboardCommand({
664
689
  pi,
@@ -666,8 +691,10 @@ export default async function (pi: ExtensionAPI) {
666
691
  env,
667
692
  getTodoItems: () => todos.items,
668
693
  getHeldLocks: () => locksHandle.getHeldLocks(),
694
+ prepareTree: () => publishTreeSnapshot(),
669
695
  });
670
696
 
697
+ /* ── ask_user_question (v1.3 M8 human gate; master and subagents) ── */
671
698
  pi.registerTool({
672
699
  name: ASK_TOOL_NAME,
673
700
  label: 'Ask User',
@@ -679,184 +706,85 @@ export default async function (pi: ExtensionAPI) {
679
706
  if (!prepared.ok) return prepared.result;
680
707
  const ui = ctx && typeof ctx === 'object' && 'ui' in ctx ? ctx.ui : undefined;
681
708
  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.)
709
+ gates.publish(true, gateLabel(prepared.spec));
710
+ // The gate stays reported for the whole wait: gates.report drops
711
+ // working/idle while blocked, and pi's ui_prompt events keep the depth honest.
687
712
  try {
688
713
  return await runAsk(prepared.spec, ui, signal instanceof AbortSignal ? signal : undefined);
689
714
  } finally {
690
- publishHerdrBlocked(false, null);
715
+ gates.publish(false, null);
691
716
  }
692
717
  },
693
718
  });
694
719
 
720
+ /* ── report_progress (Phase 2, RFC rfc-delegation-identity §3.2): worker → master checkpoint ── */
721
+ if (isSubagent) {
722
+ pi.registerTool({
723
+ name: 'report_progress',
724
+ label: 'Report Progress',
725
+ description:
726
+ 'Send the delegating master a short progress update without finishing the task. Use at meaningful checkpoints (a module done, a test went green, a blocker found) — the master sees it in its context but is not interrupted.',
727
+ promptSnippet: 'report_progress: mid-task checkpoint to the delegating master (does not end the task).',
728
+ promptGuidelines: [
729
+ 'Call at meaningful checkpoints only — a milestone reached, a risk discovered, a decision the master should know early — not after every tool call.',
730
+ 'One or two sentences per call; the final answer still goes in your normal closing message when the task finishes.',
731
+ ],
732
+ parameters: Type.Object({
733
+ message: Type.String({ description: 'Short checkpoint update for the delegating master.' }),
734
+ }),
735
+ async execute(_tc, params) {
736
+ const parentPipe = delegation?.parentPipe;
737
+ const message = typeof (params as { message?: unknown })?.message === 'string'
738
+ ? (params as { message: string }).message.trim()
739
+ : '';
740
+ if (!message) {
741
+ return { content: [{ type: 'text', text: 'Error: `message` is required (one or two sentences).' }], details: {} };
742
+ }
743
+ if (!parentPipe) {
744
+ return { content: [{ type: 'text', text: 'Error: no delegating master pipe is known to this session; progress cannot be delivered.' }], details: {} };
745
+ }
746
+ try {
747
+ const res = await pipeRequest(parentPipe, {
748
+ type: 'progress',
749
+ id: `prog-${Date.now()}`,
750
+ paneId: env?.paneId ?? '',
751
+ text: message,
752
+ }, 5000);
753
+ return res.type === 'ok'
754
+ ? { content: [{ type: 'text', text: 'Progress delivered to the master (visible in its context, no interruption).' }], details: {} }
755
+ : { content: [{ type: 'text', text: `Error: master rejected progress report: ${res.message}` }], details: {} };
756
+ } catch (err) {
757
+ return { content: [{ type: 'text', text: `Error: could not reach the master pipe: ${(err as Error)?.message ?? err}` }], details: {} };
758
+ }
759
+ },
760
+ });
761
+ }
762
+
695
763
  pi.on('session_shutdown', async () => {
696
764
  coordinator.compactionInFlight = false;
697
765
  coordinator.intentionalAbort = false;
766
+ if (pipeServerBox.current) {
767
+ try { pipeServerBox.current.close(); } catch { /* Already closed. */ }
768
+ pipeServerBox.current = null;
769
+ }
698
770
  if (sessionRoot) {
699
771
  try {
700
772
  await pruneSessionObjects(sessionRoot);
701
773
  } catch {
702
- /* ignore shutdown pruning errors */
703
774
  }
704
775
  }
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
- sessionFocusPoller.current?.stop();
827
- sessionFocusPoller.current = null;
828
- if (pipeServerBox.current) {
829
- try { pipeServerBox.current.close(); } catch { /* Already closed. */ }
830
- pipeServerBox.current = null;
831
- }
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;
776
+ if (treeSnapshot) {
777
+ try {
778
+ unlinkSync(treeSnapshot.path);
779
+ } catch {
780
+ /* Already swept. */
844
781
  }
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. */
782
+ treeSnapshot = null;
854
783
  }
784
+ client.close();
855
785
  });
856
786
 
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). */
787
+ /* ── master vs worker mount (dynamic: workers never load the cordis tree, C3) ── */
860
788
  if (mode.composeMaster) {
861
789
  const { mountMasterPlugins } = await import('./index-master.ts');
862
790
  await mountMasterPlugins({
@@ -872,13 +800,76 @@ export default async function (pi: ExtensionAPI) {
872
800
  deliverNotice,
873
801
  noticePending: notices.noticePending,
874
802
  getSessionId: () => sessionId,
875
- getBlockedDepth: () => blockedDepth,
803
+ getBlockedDepth: () => gates.depth(),
804
+ getWorkers: widgetWorkers,
876
805
  reconcileOnSettlement,
877
806
  withReconcileNotes,
878
807
  claimSettleNotice,
879
808
  isCompactionInFlight: () => coordinator.compactionInFlight,
880
809
  isIntentionalAbort: () => coordinator.intentionalAbort,
810
+ jev: { ask: (request, meta) => jevRuntime.ask(request, meta), getMinConfidence: () => effConfig.jev.minConfidence },
811
+ appendRoutingLog,
881
812
  });
813
+
814
+ /* ── /pier-tree (D113): full todo × subagent delegation tree; the resident widget is minimal ── */
815
+ pi.registerCommand('pier-tree', {
816
+ description: 'Show the full delegation tree: your todos plus every subagent\'s todos and settled nodes',
817
+ handler: async (args: unknown, ctx: unknown) => {
818
+ const ui = (ctx as { ui?: { notify?: (text: string, level?: string) => void } }).ui;
819
+ const lines = renderTreeLines();
820
+ const overlay = typeof args === 'string' && args.trim().toLowerCase() === 'overlay';
821
+
822
+ if (client.available && env) {
823
+ const treeFile = publishTreeSnapshot();
824
+ if (overlay) {
825
+ try {
826
+ const res = await client.openPluginPane({
827
+ pluginId: 'pier.workbench',
828
+ entrypoint: 'tree',
829
+ placement: 'overlay',
830
+ focus: true,
831
+ env: { PIER_PANEL: 'tree', PIER_TREE_FILE: treeFile },
832
+ });
833
+ if (res.mode === 'pane') return;
834
+ } catch {
835
+ // Plugin missing / older herdr: fall back to the in-pane notify view below.
836
+ }
837
+ } else {
838
+ const res = await openPierPanel({ client, env }, { panel: 'tree', treeFile });
839
+ if (res === 'popup') return;
840
+ if (res === 'tab') {
841
+ ui?.notify?.('Herdr < 0.9.1: Pier Tree opened in new tab', 'info');
842
+ return;
843
+ }
844
+ if (res === 'busy') {
845
+ ui?.notify?.('Another popup is already open — close it and run /pier-tree again.', 'warning');
846
+ return;
847
+ }
848
+ }
849
+ // 'unavailable' / failed overlay → the old in-pane view.
850
+ }
851
+ treeSnapshot = null;
852
+ ui?.notify?.(lines.join('\n'), 'info');
853
+ },
854
+ });
855
+
856
+ /** D113 resync: a resumed master rebuilds worker summaries from herdr pi-todo tokens until the
857
+ * next live push replaces them with full lists. */
858
+ const resyncWorkerTodos = async (): Promise<void> => {
859
+ try {
860
+ const agents = await client.listAgents();
861
+ for (const a of agents) {
862
+ const summary = a.tokens?.['pi-todo'];
863
+ if (typeof summary === 'string' && summary && !workerTodos.has(a.paneId)) {
864
+ workerTodos.set(a.paneId, { summary, ts: Date.now() });
865
+ }
866
+ }
867
+ todoUi.rerenderWidget?.();
868
+ } catch {
869
+ /* herdr absent or unreachable: live pushes remain the source */
870
+ }
871
+ };
872
+ pi.on('session_start', () => { void resyncWorkerTodos(); });
882
873
  } else {
883
874
  const { mountTodoOnly } = await import('./index-worker.ts');
884
875
  await mountTodoOnly({
@@ -886,12 +877,12 @@ export default async function (pi: ExtensionAPI) {
886
877
  todos,
887
878
  todoUi,
888
879
  mirrorTodos,
889
- getBlockedDepth: () => blockedDepth,
880
+ getBlockedDepth: () => gates.depth(),
890
881
  ...(isSubagent
891
882
  ? {}
892
883
  : {
893
884
  stopReminder: {
894
- getBlockedDepth: () => blockedDepth,
885
+ getBlockedDepth: () => gates.depth(),
895
886
  getRunningSubs: () => subagentPort.current?.listRunningSubs().length ?? 0,
896
887
  isCompactionInFlight: () => coordinator.compactionInFlight,
897
888
  isIntentionalAbort: () => coordinator.intentionalAbort,