pi-pier 0.2.7 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. package/README.md +20 -7
  2. package/package.json +7 -2
  3. package/schemas/efficiency-config.schema.json +77 -0
  4. package/skills/pi-herdr-workflow/SKILL.md +12 -0
  5. package/src/ansi-text.ts +83 -0
  6. package/src/ask-multi.ts +260 -0
  7. package/src/ask-user.ts +60 -10
  8. package/src/bootstrap.ts +5 -2
  9. package/src/compact-coordinator.ts +454 -0
  10. package/src/compact-economics-core.ts +396 -0
  11. package/src/config-catalog-core.ts +452 -0
  12. package/src/config-command.ts +181 -0
  13. package/src/config-guide.ts +361 -0
  14. package/src/core/grid-shape.ts +4 -2
  15. package/src/core/heat-plan.ts +160 -0
  16. package/src/core/observation.ts +555 -0
  17. package/src/core/subagent.ts +187 -393
  18. package/src/core/terminal.ts +121 -47
  19. package/src/core/todo.ts +25 -11
  20. package/src/dashboard-command.ts +89 -0
  21. package/src/efficiency-config-core.ts +661 -0
  22. package/src/efficiency-config-io.ts +53 -0
  23. package/src/efficiency-store.ts +312 -0
  24. package/src/focus-poller.ts +244 -0
  25. package/src/gc-core.ts +85 -0
  26. package/src/git-adapter.ts +12 -3
  27. package/src/herdr-client.ts +292 -36
  28. package/src/index-locks.ts +27 -9
  29. package/src/index-master.ts +5 -1
  30. package/src/index-notices.ts +13 -2
  31. package/src/index-pipe.ts +5 -0
  32. package/src/index-worker.ts +3 -1
  33. package/src/index.ts +330 -34
  34. package/src/jev-client.ts +146 -0
  35. package/src/jev-core.ts +381 -0
  36. package/src/lock-core.ts +105 -16
  37. package/src/observation-core.ts +248 -0
  38. package/src/pier-options.ts +96 -0
  39. package/src/pipe-channel.ts +52 -7
  40. package/src/reducer-core.ts +358 -0
  41. package/src/reducer-invoker.ts +392 -0
  42. package/src/renderers.ts +198 -0
  43. package/src/role-loader.ts +2 -1
  44. package/src/role-manifest.ts +0 -4
  45. package/src/runtime-policy.ts +5 -8
  46. package/src/session-tail.ts +60 -5
  47. package/src/settle-wake-core.ts +3 -1
  48. package/src/slim-frame.ts +4 -2
  49. package/src/storage-layout.ts +23 -0
  50. package/src/subagent-core.ts +3 -1
  51. package/src/subagent-execute.ts +328 -0
  52. package/src/subagent-gc.ts +33 -13
  53. package/src/subagent-launch.ts +4 -0
  54. package/src/subagent-list-action.ts +69 -0
  55. package/src/subagent-output-action.ts +104 -0
  56. package/src/subagent-output-adapter.ts +27 -0
  57. package/src/subagent-output-core.ts +263 -0
  58. package/src/subagent-poll-loop.ts +59 -39
  59. package/src/subagent-poller.ts +46 -0
  60. package/src/subagent-ready.ts +89 -0
  61. package/src/subagent-registry.ts +70 -0
  62. package/src/subagent-resolution.ts +41 -0
  63. package/src/subagent-scope.ts +3 -1
  64. package/src/subagent-session-io.ts +115 -32
  65. package/src/subagent-spawn.ts +153 -8
  66. package/src/swallow.ts +75 -0
  67. package/src/terminal-core.ts +82 -10
  68. package/src/todo-core.ts +5 -4
  69. package/src/todo-reminder-core.ts +8 -1
  70. package/src/todos-service.ts +18 -6
  71. package/src/tool-error.ts +26 -0
  72. package/src/vocab.ts +7 -4
package/src/index-pipe.ts CHANGED
@@ -53,6 +53,11 @@ export async function handlePipeRequest(
53
53
  return { type: 'ok', id: req.id };
54
54
  case 'reply': {
55
55
  s.port.current?.applyReplySession(req.paneId, req.sessionFile);
56
+ // B8: the claim key is `${paneId}:${requestId}` and the poll loop claims exactly the same shape
57
+ // (subagent-poll-loop.ts settle path). The two agree because a child echoes the id of the pipe
58
+ // request it answered (`id: req.id` in index.ts's settle push), and the parent hands that same
59
+ // id to startPoller (`prompt-<taskId>` / `fu-<ts>` in core/subagent.ts). Keep both sides in step:
60
+ // a mismatch here surfaces as two "finished" notices for one settlement.
56
61
  if (s.claimSettleNotice(`${req.paneId}:${req.id}`)) {
57
62
  const notes = s.port.current?.reconcileOnReply(req.paneId) ?? [];
58
63
  const statLine = await s.port.current?.settleStatLine(req.paneId) ?? null;
@@ -22,6 +22,8 @@ export interface TodoOnlyMount {
22
22
  stopReminder?: {
23
23
  getBlockedDepth: () => number;
24
24
  getRunningSubs: () => number;
25
+ isCompactionInFlight?: () => boolean;
26
+ isIntentionalAbort?: () => boolean;
25
27
  };
26
28
  }
27
29
 
@@ -35,7 +37,7 @@ export async function mountTodoOnly(m: TodoOnlyMount): Promise<void> {
35
37
  maxItems: 15,
36
38
  mirrorTodos: m.mirrorTodos,
37
39
  appendEntry: (customType: string, data: unknown) => {
38
- (m.pi as { appendEntry?: (t: string, d: unknown) => void }).appendEntry?.(customType, d);
40
+ (m.pi as { appendEntry?: (t: string, d: unknown) => void }).appendEntry?.(customType, data);
39
41
  },
40
42
  state: m.todoUi,
41
43
  ...(m.getBlockedDepth ? { getBlockedDepth: m.getBlockedDepth } : {}),
package/src/index.ts CHANGED
@@ -32,9 +32,7 @@ import {
32
32
  currentActivity,
33
33
  } from './todo-core.ts';
34
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
-
35
+ import { APPROVAL_NEEDED_CUSTOM_TYPE, ROLE_MANIFEST_CUSTOM_TYPE, installRenderers } from './renderers.ts';
38
36
  import { createHerdrClient } from './herdr-client.ts';
39
37
  // The subagent family moved to core/subagent.ts (loader entry, D78/D81); its history-store,
40
38
  // session-tail, and gc-core imports moved with it, leaving index.ts only the common readers.
@@ -61,6 +59,50 @@ import { emptySubagentPortBox } from './subagent-port.ts';
61
59
  import { createNoticeBuffer } from './index-notices.ts';
62
60
  import { handlePipeRequest } from './index-pipe.ts';
63
61
  import { installWriteLocks } from './index-locks.ts';
62
+ import { installDashboardCommand } from './dashboard-command.ts';
63
+ import { registerObservationPack, createCompactionBatchPackHook, type PickMiddleExcerpt } from './core/observation.ts';
64
+ import { createJevRuntime } from './jev-client.ts';
65
+ import {
66
+ NOTICE_RANK_MIN_CONFIDENCE,
67
+ buildExcerptWindows,
68
+ composeNoticeRanking,
69
+ evaluateExcerptPick,
70
+ excerptPickRequest,
71
+ noticeRankRequest,
72
+ } from './jev-core.ts';
73
+ import { completeLineExcerpt } from './observation-core.ts';
74
+ import { installConfigCommand } from './config-command.ts';
75
+ import { CompactCoordinator } from './compact-coordinator.ts';
76
+ 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;
105
+ }
64
106
 
65
107
  /**
66
108
  * WS-D7: apply the master-pane manifest to itself through the same mandatory chain as subagents
@@ -98,6 +140,47 @@ export default async function (pi: ExtensionAPI) {
98
140
  (mode.composeMaster ? composeMasterRuntime() : null);
99
141
  const todos = new TodosService(TodosService.configFromRuntime(runtimeManifest, isSubagent));
100
142
  const { client, env } = createHerdrClient();
143
+ let effConfig: EfficiencyConfig = resolveEfficiencyConfig();
144
+ const coordinator = new CompactCoordinator();
145
+ let sessionRoot: string | null = null;
146
+
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;
153
+ /** P0-3: candidate windows are generated in code; jev only picks (rerank pattern). */
154
+ const pickMiddleExcerpt: PickMiddleExcerpt = async (text, excerptBudgetBytes) => {
155
+ if (!jevRuntime.available) return null;
156
+ const halfBudget = Math.floor(excerptBudgetBytes / 2);
157
+ 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
+ );
164
+ if (!request) return null;
165
+ const result = await jevRuntime.ask(request, {
166
+ questionId: 'obs-excerpt-window',
167
+ extra: { site: 'obs-excerpt', candidates: windows.map((w) => w.id) },
168
+ enrich: ({ answers }) => {
169
+ if (!answers || answers.window === undefined || answers.window.type !== 'choice') return {};
170
+ return { picked: answers.window.choice, confidence: answers.window.confidence };
171
+ },
172
+ });
173
+ if (!result.ok) return null;
174
+ const picked = evaluateExcerptPick(result.answers, jevMinConfidence());
175
+ const window = picked === null ? undefined : windows.find((w) => w.id === picked);
176
+ return window ? { text: window.text, label: window.label } : null;
177
+ };
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).
180
+ const rendererTypes = installRenderers(pi);
181
+ if (process.env.PI_HERDR_TRACE) {
182
+ console.error(`[pi-herdr] transcript renderers: ${rendererTypes.length ? rendererTypes.join(', ') : 'none'}`);
183
+ }
101
184
 
102
185
  let sessionId: string = process.env.PI_SESSION_FILE ?? process.env.PI_SESSION_ID ?? '';
103
186
  /** M16: completion timestamps used as rate-estimation input, appended with todo.completed events. */
@@ -110,6 +193,7 @@ export default async function (pi: ExtensionAPI) {
110
193
  const entries = (ctx as { sessionManager?: { getBranch?: () => readonly unknown[] } })
111
194
  ?.sessionManager?.getBranch?.() ?? [];
112
195
  todos.rebuild(entries);
196
+ coordinator.rebuildFromBranch(entries);
113
197
  } catch {
114
198
  // A reconstruction failure must not disrupt the main flow; the next todo_write re-anchors state.
115
199
  }
@@ -131,11 +215,6 @@ export default async function (pi: ExtensionAPI) {
131
215
  client.reportAgent(state, message).catch(() => {});
132
216
  }
133
217
 
134
- function reportSession(path: string): void {
135
- if (!client.available) return;
136
- client.reportAgentSession(path).catch(() => {});
137
- }
138
-
139
218
  /** Session identity: sessionManager is authoritative (env may be unset in print/RPC mode), with env as fallback. */
140
219
  function resolveSessionId(ctx: unknown): string {
141
220
  try {
@@ -194,7 +273,7 @@ export default async function (pi: ExtensionAPI) {
194
273
  } catch {
195
274
  /* Best effort persistence; in-memory state still advances. */
196
275
  }
197
- todos.applyEdits(plan.edits);
276
+ todos.applyEdits(plan.edits, { source: 'reconcile' });
198
277
  mirrorTodos();
199
278
  }
200
279
  return plan.noteLines;
@@ -210,8 +289,9 @@ export default async function (pi: ExtensionAPI) {
210
289
 
211
290
  /* ── M16: progress and tool badges (title suffix + report_agent.message; no new protocol) ── */
212
291
 
213
- todos.on('todo.completed', (e: { count: number; at: number }) => {
292
+ todos.on('todo.completed', (e: { count: number; at: number; source?: import('./todos-service.ts').TodoCompletionSource }) => {
214
293
  for (let i = 0; i < e.count; i++) completedStamps.push(e.at);
294
+ coordinator.recordBoundaryCompleted(e.count, e.source);
215
295
  mirrorTodos();
216
296
  });
217
297
 
@@ -239,8 +319,19 @@ export default async function (pi: ExtensionAPI) {
239
319
  }
240
320
  });
241
321
 
322
+ /* ── D102: Evidence-Preserving Reducer (runs before append-handlers like write-locks) ── */
323
+ pi.on('tool_result', async (event, ctx) => {
324
+ if (!effConfig.evidencePreservingReducer.enabled) return;
325
+ if (!event || typeof event !== 'object') return;
326
+ return handleReducerToolResult(event as unknown as ToolResultEventLike, ctx, effConfig.evidencePreservingReducer, {
327
+ epoch: coordinator.state.epoch,
328
+ jev: { ask: jevRuntime.ask, getMinConfidence: jevMinConfidence },
329
+ });
330
+ });
331
+
332
+ let locksHandle = { getHeldLocks: () => [] as readonly string[] };
242
333
  if (env) {
243
- installWriteLocks(pi, {
334
+ locksHandle = installWriteLocks(pi, {
244
335
  client,
245
336
  env,
246
337
  hard: process.env[WRITE_LOCK_ENV] === '1',
@@ -256,6 +347,16 @@ export default async function (pi: ExtensionAPI) {
256
347
 
257
348
  pi.on('session_start', async (event, ctx) => {
258
349
  sessionId = resolveSessionId(ctx);
350
+ const cwd = (ctx as { cwd?: string }).cwd ?? process.cwd();
351
+ const isTrusted =
352
+ typeof (ctx as { isProjectTrusted?: () => boolean }).isProjectTrusted === 'function'
353
+ ? (ctx as { isProjectTrusted: () => boolean }).isProjectTrusted()
354
+ : false;
355
+ effConfig = loadEfficiencyConfigFromDisk({ cwd, isProjectTrusted: isTrusted });
356
+ sessionRoot = resolveSessionRoot(
357
+ (ctx as { sessionManager?: { getSessionDir?: () => string | undefined } })?.sessionManager?.getSessionDir?.(),
358
+ sessionId,
359
+ );
259
360
  rebuildFromBranch(ctx);
260
361
  // v1.3 M9 fix (observed): on resume pi restores widget state from the session;
261
362
  // calling setWidget again breaks the TUI '/' command-panel route ('/' would be sent to the model as message text).
@@ -265,7 +366,9 @@ export default async function (pi: ExtensionAPI) {
265
366
  // and the heatmap amplification provides the exit path. Re-register on resume because session switching resets the overlay.
266
367
  if (env) registerSlimFrame(ctx);
267
368
  mirrorTodos();
268
- reportSession(sessionId);
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.
269
372
  // D93: sidebar identity is the role name (display_agent takes precedence over detected agent, actions.rs:563 in 0.8.2).
270
373
  // master → 'master'; worker → manifest.role (prettify worker-default as worker).
271
374
  // A bare pi without a manifest does not report, so ordinary pi sessions remain undisturbed.
@@ -305,6 +408,10 @@ export default async function (pi: ExtensionAPI) {
305
408
  }
306
409
  }
307
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(() => {});
308
415
  });
309
416
 
310
417
  /* ── Tier 2 Weeks 4–5: worker execution enforcement (the manifest's final gate) ──
@@ -316,14 +423,16 @@ export default async function (pi: ExtensionAPI) {
316
423
  const tool = typeof event?.toolName === 'string' ? event.toolName : '';
317
424
  const gate = planToolGate(tool, runtimeManifest);
318
425
  if (gate.kind === 'deny') {
319
- return { block: true, reason: gate.reason };
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 };
320
429
  }
321
430
  if (gate.kind === 'ask') {
322
431
  console.error(`${gate.notice} (v1 soft-approval: allowed, hard gate lands in v2)`);
323
432
  // Durable trace (V56 anchor): TUI redraw erases stderr, so the session custom entry is authoritative.
324
433
  try {
325
434
  (pi as { appendEntry?: (customType: string, data: unknown) => void }).appendEntry?.(
326
- 'pi-herdr.approval-needed',
435
+ APPROVAL_NEEDED_CUSTOM_TYPE,
327
436
  { role: runtimeManifest.role, tool, ts: Date.now() },
328
437
  );
329
438
  } catch {
@@ -351,9 +460,32 @@ export default async function (pi: ExtensionAPI) {
351
460
  let lastStopReason: string | null = null;
352
461
  pi.on('turn_start', async () => {
353
462
  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.
465
+ 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.
468
+ coordinator.compactionInFlight = false;
469
+ coordinator.intentionalAbort = false;
354
470
  reportAgent('working', currentActivity(todos.items));
355
471
  });
356
- pi.on('turn_end', async (event: unknown) => {
472
+
473
+ pi.on('before_provider_request', (_event: unknown, ctx: unknown) => {
474
+ const usage = (ctx as { getContextUsage?: () => { tokens?: number | null } })?.getContextUsage?.();
475
+ if (typeof usage?.tokens === 'number') {
476
+ coordinator.onBeforeProviderRequest(usage.tokens);
477
+ }
478
+ });
479
+
480
+ pi.on('input', (event: unknown) => {
481
+ if (event && typeof event === 'object') {
482
+ coordinator.onInput(event as { text?: string; source?: string; streamingBehavior?: string });
483
+ }
484
+ });
485
+
486
+ pi.on('session_before_tree', () => (coordinator.compactionInFlight ? { cancel: true } : undefined));
487
+
488
+ pi.on('turn_end', async (event: unknown, ctx) => {
357
489
  if (event === null || typeof event !== 'object' || !('message' in event)) return;
358
490
  const msg = (event as { message: unknown }).message; // 'message' in has been guarded.
359
491
  if (msg === null || typeof msg !== 'object') return;
@@ -361,13 +493,22 @@ export default async function (pi: ExtensionAPI) {
361
493
  if (role === 'assistant' && typeof stopReason === 'string') {
362
494
  lastStopReason = stopReason;
363
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
+ });
503
+ }
364
504
  });
365
505
 
366
- pi.on('agent_settled', async () => {
506
+ pi.on('agent_settled', async (_event: unknown, ctx) => {
367
507
  agentActive = false;
368
508
  reportAgent('idle', null);
369
509
  const plan = planSettleWake({
370
510
  lastStopReason,
511
+ intentionalAbort: coordinator.intentionalAbort,
371
512
  running: subagentPort.current?.listRunningSubs() ?? [],
372
513
  lastNoticeKey: d96NoticeKey,
373
514
  lastNoticeAt: d96NoticeAt,
@@ -387,6 +528,21 @@ export default async function (pi: ExtensionAPI) {
387
528
  const notice = `注意:仍有 ${running.length} 个后台 subagent 在运行:${brief}。若你的任务依赖它们,请等待其结算(subagent list 查看状态);若不等待,请说明放弃原因。`;
388
529
  void sendUserMessageIn(notice);
389
530
  }
531
+ if (ctx && typeof ctx === 'object') {
532
+ void coordinator.onAgentSettled({
533
+ ctx,
534
+ todos: todos.items,
535
+ pi,
536
+ config: effConfig.onlineContextCompact,
537
+ cancelReminder: todoUi.cancelReminder,
538
+ onBeforeCompact: createCompactionBatchPackHook({
539
+ getObsConfig: () => effConfig.observationPack,
540
+ getManifest: () => runtimeManifest,
541
+ getSessionId: () => sessionId,
542
+ pickMiddleExcerpt,
543
+ }),
544
+ });
545
+ }
390
546
  });
391
547
 
392
548
  // Official herdr:pi is full-lifecycle authority when installed: it reports blocked
@@ -394,32 +550,42 @@ export default async function (pi: ExtensionAPI) {
394
550
  // pane.report_agent is ignored while that authority is live
395
551
  // (screen_detection_skip_reason=full_lifecycle_hook_authority). Emit the event so
396
552
  // herdr:pi publishes blocked; keep report_agent as fallback when it is absent.
397
- function enterBlocked(label: string | null): void {
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 {
398
559
  blockedDepth += 1;
560
+ if (blockedDepth > 1) return false;
399
561
  reportAgent('blocked', label);
400
562
  // D95: human-gate marker lets the workbench heatmap distinguish ask from block.
401
563
  if (label) void client.reportAskFlag(label).catch(() => {});
402
564
  // Gate open collapses the todo widget to one line — the question owns the fixed area.
403
565
  todoUi.rerenderWidget?.();
566
+ return true;
404
567
  }
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(() => {});
411
- }
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;
412
574
  // Gate closed → restore the full widget window.
413
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;
414
581
  }
415
582
 
416
583
  // emit() is synchronous; this flag stops our own listener from double-counting depth.
417
584
  let publishingHerdrBlocked = false;
418
- function publishHerdrBlocked(active: boolean, label: string | null): void {
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 {
419
587
  publishingHerdrBlocked = true;
420
588
  try {
421
- if (active) enterBlocked(label);
422
- else exitBlocked();
423
589
  pi.events.emit(
424
590
  'herdr:blocked',
425
591
  active ? { active: true, ...(label ? { label } : {}) } : { active: false },
@@ -428,6 +594,11 @@ export default async function (pi: ExtensionAPI) {
428
594
  publishingHerdrBlocked = false;
429
595
  }
430
596
  }
597
+ function publishHerdrBlocked(active: boolean, label: string | null): void {
598
+ if (active) enterBlocked(label);
599
+ else exitBlocked();
600
+ emitHerdrBlocked(active, label);
601
+ }
431
602
  pi.events.on('herdr:blocked', (data) => {
432
603
  if (publishingHerdrBlocked) return;
433
604
  if (!data || typeof data !== 'object') {
@@ -441,8 +612,62 @@ export default async function (pi: ExtensionAPI) {
441
612
  enterBlocked('label' in data && typeof data.label === 'string' ? data.label : null);
442
613
  });
443
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
+
444
644
  /* ── Tool: ask_user_question (v1.3 M8 human gate, available to master and subagents) ── */
445
645
 
646
+ /* ── D101: ObservationPack (obs_recall tool & context projector) ── */
647
+ registerObservationPack({
648
+ pi,
649
+ getConfig: () => effConfig,
650
+ getRuntimeManifest: () => runtimeManifest,
651
+ pickMiddleExcerpt,
652
+ getRemainingHorizon: () => {
653
+ const usage = (latestCtx as { getContextUsage?: () => { contextWindow?: number } })?.getContextUsage?.();
654
+ return coordinator.getRemainingHorizon(3, usage?.contextWindow ?? null);
655
+ },
656
+ });
657
+
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
+ installConfigCommand({ pi });
663
+ installDashboardCommand({
664
+ pi,
665
+ client,
666
+ env,
667
+ getTodoItems: () => todos.items,
668
+ getHeldLocks: () => locksHandle.getHeldLocks(),
669
+ });
670
+
446
671
  pi.registerTool({
447
672
  name: ASK_TOOL_NAME,
448
673
  label: 'Ask User',
@@ -456,20 +681,27 @@ export default async function (pi: ExtensionAPI) {
456
681
  if (!hasAskUi(ui)) return noUiResult();
457
682
  const label = gateLabel(prepared.spec);
458
683
  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);
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.)
463
687
  try {
464
688
  return await runAsk(prepared.spec, ui, signal instanceof AbortSignal ? signal : undefined);
465
689
  } finally {
466
- clearInterval(hb);
467
690
  publishHerdrBlocked(false, null);
468
691
  }
469
692
  },
470
693
  });
471
694
 
472
695
  pi.on('session_shutdown', async () => {
696
+ coordinator.compactionInFlight = false;
697
+ coordinator.intentionalAbort = false;
698
+ if (sessionRoot) {
699
+ try {
700
+ await pruneSessionObjects(sessionRoot);
701
+ } catch {
702
+ /* ignore shutdown pruning errors */
703
+ }
704
+ }
473
705
  client.close();
474
706
  });
475
707
 
@@ -483,20 +715,56 @@ export default async function (pi: ExtensionAPI) {
483
715
  return true;
484
716
  }
485
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 };
486
720
  let pendingMachineRequest: { id: string; from: string | null; push: boolean; sinceTs: number } | null = null;
487
721
  let latestCtx: { abort?: () => void } | null = null;
488
722
 
489
723
  // triggerTurn:true — idle followUp must start a new turn or settlement is lost.
490
724
  const sendUserMessageIn = (content: string): Promise<void> =>
491
- (pi as { sendUserMessage?: (content: string, opts?: { deliverAs?: string; triggerTurn?: boolean }) => Promise<void> })
725
+ (pi as unknown as { sendUserMessage?: (content: string, opts?: { deliverAs?: string; triggerTurn?: boolean }) => Promise<void> })
492
726
  .sendUserMessage?.(content, { deliverAs: 'followUp', triggerTurn: true }) ?? Promise.resolve();
493
727
  const sendUserMessageAs = (content: string, mode: 'steer' | 'followUp'): Promise<void> =>
494
- (pi as { sendUserMessage?: (content: string, opts?: { deliverAs?: string; triggerTurn?: boolean }) => Promise<void> })
728
+ (pi as unknown as { sendUserMessage?: (content: string, opts?: { deliverAs?: string; triggerTurn?: boolean }) => Promise<void> })
495
729
  .sendUserMessage?.(content, { deliverAs: mode, triggerTurn: true }) ?? Promise.resolve();
496
730
 
497
731
  const notices = createNoticeBuffer({
498
732
  isBusy: () => agentActive || lastStopReason === ABORT_STOP_REASON,
499
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
+ },
500
768
  });
501
769
  const deliverNotice = notices.deliverNotice;
502
770
  pi.on('turn_end', () => {
@@ -511,6 +779,28 @@ export default async function (pi: ExtensionAPI) {
511
779
  latestCtx = ctx as { abort?: () => void } | null;
512
780
  const cwd = (ctx as { cwd?: string }).cwd ?? process.cwd();
513
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
+ }
514
804
  if (!paneId) return;
515
805
  const name = pipeNameFor(cwd, paneId);
516
806
  if (pipeServerBox.current) {
@@ -533,6 +823,8 @@ export default async function (pi: ExtensionAPI) {
533
823
  }
534
824
  });
535
825
  pi.on('session_shutdown', () => {
826
+ sessionFocusPoller.current?.stop();
827
+ sessionFocusPoller.current = null;
536
828
  if (pipeServerBox.current) {
537
829
  try { pipeServerBox.current.close(); } catch { /* Already closed. */ }
538
830
  pipeServerBox.current = null;
@@ -584,6 +876,8 @@ export default async function (pi: ExtensionAPI) {
584
876
  reconcileOnSettlement,
585
877
  withReconcileNotes,
586
878
  claimSettleNotice,
879
+ isCompactionInFlight: () => coordinator.compactionInFlight,
880
+ isIntentionalAbort: () => coordinator.intentionalAbort,
587
881
  });
588
882
  } else {
589
883
  const { mountTodoOnly } = await import('./index-worker.ts');
@@ -599,6 +893,8 @@ export default async function (pi: ExtensionAPI) {
599
893
  stopReminder: {
600
894
  getBlockedDepth: () => blockedDepth,
601
895
  getRunningSubs: () => subagentPort.current?.listRunningSubs().length ?? 0,
896
+ isCompactionInFlight: () => coordinator.compactionInFlight,
897
+ isIntentionalAbort: () => coordinator.intentionalAbort,
602
898
  },
603
899
  }),
604
900
  });