pi-pier 0.2.6 → 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 +13 -1
  5. package/src/ansi-text.ts +83 -0
  6. package/src/ask-multi.ts +260 -0
  7. package/src/ask-user.ts +458 -0
  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 +270 -47
  19. package/src/core/todo.ts +49 -14
  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 -0
  30. package/src/index-notices.ts +13 -2
  31. package/src/index-pipe.ts +5 -0
  32. package/src/index-worker.ts +5 -0
  33. package/src/index.ts +360 -60
  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 +60 -3
  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 +161 -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
@@ -16,10 +16,14 @@ export interface TodoOnlyMount {
16
16
  todos: TodosService;
17
17
  todoUi: TodoUiSlot;
18
18
  mirrorTodos: () => void;
19
+ /** Human-gate depth source; collapses the todo widget to one line while a gate is open. */
20
+ getBlockedDepth?: () => number;
19
21
  /** Master-only stop reminder; omit on worker panes. */
20
22
  stopReminder?: {
21
23
  getBlockedDepth: () => number;
22
24
  getRunningSubs: () => number;
25
+ isCompactionInFlight?: () => boolean;
26
+ isIntentionalAbort?: () => boolean;
23
27
  };
24
28
  }
25
29
 
@@ -36,6 +40,7 @@ export async function mountTodoOnly(m: TodoOnlyMount): Promise<void> {
36
40
  (m.pi as { appendEntry?: (t: string, d: unknown) => void }).appendEntry?.(customType, data);
37
41
  },
38
42
  state: m.todoUi,
43
+ ...(m.getBlockedDepth ? { getBlockedDepth: m.getBlockedDepth } : {}),
39
44
  ...(m.stopReminder ? { stopReminder: m.stopReminder } : {}),
40
45
  });
41
46
  await workerRoot.plugin(todoPlugin);
package/src/index.ts CHANGED
@@ -15,16 +15,24 @@
15
15
  * - tool-result details persist in session JSONL, and getBranch() replay implements branch rollback.
16
16
  */
17
17
  import type { ExtensionAPI } from '@earendil-works/pi-coding-agent';
18
- import { Type } from 'typebox';
18
+ import {
19
+ ASK_PARAMETERS,
20
+ ASK_PROMPT_GUIDELINES,
21
+ ASK_TOOL_DESCRIPTION,
22
+ ASK_TOOL_NAME,
23
+ gateLabel,
24
+ hasAskUi,
25
+ noUiResult,
26
+ prepareAsk,
27
+ runAsk,
28
+ } from './ask-user.ts';
19
29
  import { pipeNameFor, pipeRequest, startPipeServer } from './pipe-channel.ts';
20
30
  import {
21
31
  TODO_EDIT_CUSTOM_TYPE,
22
32
  currentActivity,
23
33
  } from './todo-core.ts';
24
34
 
25
- /** Tier 2: record the synthesized manifest that governed a worker session (D38-compatible and branch-replayable). */
26
- const ROLE_MANIFEST_CUSTOM_TYPE = 'pi-herdr.role-manifest';
27
-
35
+ import { APPROVAL_NEEDED_CUSTOM_TYPE, ROLE_MANIFEST_CUSTOM_TYPE, installRenderers } from './renderers.ts';
28
36
  import { createHerdrClient } from './herdr-client.ts';
29
37
  // The subagent family moved to core/subagent.ts (loader entry, D78/D81); its history-store,
30
38
  // session-tail, and gc-core imports moved with it, leaving index.ts only the common readers.
@@ -32,6 +40,7 @@ import { lastAssistantText, readSessionFile } from './session-tail.ts';
32
40
  import { fileURLToPath } from 'node:url';
33
41
  import { TodosService } from './todos-service.ts';
34
42
  import { reconcileTodos } from './reconcile-core.ts';
43
+ import type { TodoUiSlot } from './core/todo.ts';
35
44
  import {
36
45
  estimateEta,
37
46
  formatProgressSuffix,
@@ -50,6 +59,50 @@ import { emptySubagentPortBox } from './subagent-port.ts';
50
59
  import { createNoticeBuffer } from './index-notices.ts';
51
60
  import { handlePipeRequest } from './index-pipe.ts';
52
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
+ }
53
106
 
54
107
  /**
55
108
  * WS-D7: apply the master-pane manifest to itself through the same mandatory chain as subagents
@@ -87,6 +140,47 @@ export default async function (pi: ExtensionAPI) {
87
140
  (mode.composeMaster ? composeMasterRuntime() : null);
88
141
  const todos = new TodosService(TodosService.configFromRuntime(runtimeManifest, isSubagent));
89
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
+ }
90
184
 
91
185
  let sessionId: string = process.env.PI_SESSION_FILE ?? process.env.PI_SESSION_ID ?? '';
92
186
  /** M16: completion timestamps used as rate-estimation input, appended with todo.completed events. */
@@ -99,6 +193,7 @@ export default async function (pi: ExtensionAPI) {
99
193
  const entries = (ctx as { sessionManager?: { getBranch?: () => readonly unknown[] } })
100
194
  ?.sessionManager?.getBranch?.() ?? [];
101
195
  todos.rebuild(entries);
196
+ coordinator.rebuildFromBranch(entries);
102
197
  } catch {
103
198
  // A reconstruction failure must not disrupt the main flow; the next todo_write re-anchors state.
104
199
  }
@@ -120,11 +215,6 @@ export default async function (pi: ExtensionAPI) {
120
215
  client.reportAgent(state, message).catch(() => {});
121
216
  }
122
217
 
123
- function reportSession(path: string): void {
124
- if (!client.available) return;
125
- client.reportAgentSession(path).catch(() => {});
126
- }
127
-
128
218
  /** Session identity: sessionManager is authoritative (env may be unset in print/RPC mode), with env as fallback. */
129
219
  function resolveSessionId(ctx: unknown): string {
130
220
  try {
@@ -183,7 +273,7 @@ export default async function (pi: ExtensionAPI) {
183
273
  } catch {
184
274
  /* Best effort persistence; in-memory state still advances. */
185
275
  }
186
- todos.applyEdits(plan.edits);
276
+ todos.applyEdits(plan.edits, { source: 'reconcile' });
187
277
  mirrorTodos();
188
278
  }
189
279
  return plan.noteLines;
@@ -199,8 +289,9 @@ export default async function (pi: ExtensionAPI) {
199
289
 
200
290
  /* ── M16: progress and tool badges (title suffix + report_agent.message; no new protocol) ── */
201
291
 
202
- 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 }) => {
203
293
  for (let i = 0; i < e.count; i++) completedStamps.push(e.at);
294
+ coordinator.recordBoundaryCompleted(e.count, e.source);
204
295
  mirrorTodos();
205
296
  });
206
297
 
@@ -228,8 +319,19 @@ export default async function (pi: ExtensionAPI) {
228
319
  }
229
320
  });
230
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[] };
231
333
  if (env) {
232
- installWriteLocks(pi, {
334
+ locksHandle = installWriteLocks(pi, {
233
335
  client,
234
336
  env,
235
337
  hard: process.env[WRITE_LOCK_ENV] === '1',
@@ -237,7 +339,7 @@ export default async function (pi: ExtensionAPI) {
237
339
  }
238
340
 
239
341
  /* ── todo family slot (core/todo.ts fills the plugin hook; widget rendering moved with the family) ── */
240
- const todoUi: { renderWidget: (ctx: unknown) => void } = {
342
+ const todoUi: TodoUiSlot = {
241
343
  renderWidget: () => { /* No-op before plugin mounting; filled after mounting. */ },
242
344
  };
243
345
 
@@ -245,6 +347,16 @@ export default async function (pi: ExtensionAPI) {
245
347
 
246
348
  pi.on('session_start', async (event, ctx) => {
247
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
+ );
248
360
  rebuildFromBranch(ctx);
249
361
  // v1.3 M9 fix (observed): on resume pi restores widget state from the session;
250
362
  // calling setWidget again breaks the TUI '/' command-panel route ('/' would be sent to the model as message text).
@@ -254,7 +366,9 @@ export default async function (pi: ExtensionAPI) {
254
366
  // and the heatmap amplification provides the exit path. Re-register on resume because session switching resets the overlay.
255
367
  if (env) registerSlimFrame(ctx);
256
368
  mirrorTodos();
257
- 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.
258
372
  // D93: sidebar identity is the role name (display_agent takes precedence over detected agent, actions.rs:563 in 0.8.2).
259
373
  // master → 'master'; worker → manifest.role (prettify worker-default as worker).
260
374
  // A bare pi without a manifest does not report, so ordinary pi sessions remain undisturbed.
@@ -294,6 +408,10 @@ export default async function (pi: ExtensionAPI) {
294
408
  }
295
409
  }
296
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(() => {});
297
415
  });
298
416
 
299
417
  /* ── Tier 2 Weeks 4–5: worker execution enforcement (the manifest's final gate) ──
@@ -305,14 +423,16 @@ export default async function (pi: ExtensionAPI) {
305
423
  const tool = typeof event?.toolName === 'string' ? event.toolName : '';
306
424
  const gate = planToolGate(tool, runtimeManifest);
307
425
  if (gate.kind === 'deny') {
308
- 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 };
309
429
  }
310
430
  if (gate.kind === 'ask') {
311
431
  console.error(`${gate.notice} (v1 soft-approval: allowed, hard gate lands in v2)`);
312
432
  // Durable trace (V56 anchor): TUI redraw erases stderr, so the session custom entry is authoritative.
313
433
  try {
314
434
  (pi as { appendEntry?: (customType: string, data: unknown) => void }).appendEntry?.(
315
- 'pi-herdr.approval-needed',
435
+ APPROVAL_NEEDED_CUSTOM_TYPE,
316
436
  { role: runtimeManifest.role, tool, ts: Date.now() },
317
437
  );
318
438
  } catch {
@@ -340,9 +460,32 @@ export default async function (pi: ExtensionAPI) {
340
460
  let lastStopReason: string | null = null;
341
461
  pi.on('turn_start', async () => {
342
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;
343
470
  reportAgent('working', currentActivity(todos.items));
344
471
  });
345
- 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) => {
346
489
  if (event === null || typeof event !== 'object' || !('message' in event)) return;
347
490
  const msg = (event as { message: unknown }).message; // 'message' in has been guarded.
348
491
  if (msg === null || typeof msg !== 'object') return;
@@ -350,13 +493,22 @@ export default async function (pi: ExtensionAPI) {
350
493
  if (role === 'assistant' && typeof stopReason === 'string') {
351
494
  lastStopReason = stopReason;
352
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
+ }
353
504
  });
354
505
 
355
- pi.on('agent_settled', async () => {
506
+ pi.on('agent_settled', async (_event: unknown, ctx) => {
356
507
  agentActive = false;
357
508
  reportAgent('idle', null);
358
509
  const plan = planSettleWake({
359
510
  lastStopReason,
511
+ intentionalAbort: coordinator.intentionalAbort,
360
512
  running: subagentPort.current?.listRunningSubs() ?? [],
361
513
  lastNoticeKey: d96NoticeKey,
362
514
  lastNoticeAt: d96NoticeAt,
@@ -376,6 +528,21 @@ export default async function (pi: ExtensionAPI) {
376
528
  const notice = `注意:仍有 ${running.length} 个后台 subagent 在运行:${brief}。若你的任务依赖它们,请等待其结算(subagent list 查看状态);若不等待,请说明放弃原因。`;
377
529
  void sendUserMessageIn(notice);
378
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
+ }
379
546
  });
380
547
 
381
548
  // Official herdr:pi is full-lifecycle authority when installed: it reports blocked
@@ -383,28 +550,42 @@ export default async function (pi: ExtensionAPI) {
383
550
  // pane.report_agent is ignored while that authority is live
384
551
  // (screen_detection_skip_reason=full_lifecycle_hook_authority). Emit the event so
385
552
  // herdr:pi publishes blocked; keep report_agent as fallback when it is absent.
386
- 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 {
387
559
  blockedDepth += 1;
560
+ if (blockedDepth > 1) return false;
388
561
  reportAgent('blocked', label);
389
562
  // D95: human-gate marker lets the workbench heatmap distinguish ask from block.
390
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;
391
567
  }
392
- function exitBlocked(): void {
393
- blockedDepth = Math.max(0, blockedDepth - 1);
394
- if (blockedDepth === 0) {
395
- reportAgent(agentActive ? 'working' : 'idle', agentActive ? currentActivity(todos.items) : null);
396
- // D95: gate released → clear marker.
397
- void client.reportAskFlag(null).catch(() => {});
398
- }
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;
399
581
  }
400
582
 
401
583
  // emit() is synchronous; this flag stops our own listener from double-counting depth.
402
584
  let publishingHerdrBlocked = false;
403
- 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 {
404
587
  publishingHerdrBlocked = true;
405
588
  try {
406
- if (active) enterBlocked(label);
407
- else exitBlocked();
408
589
  pi.events.emit(
409
590
  'herdr:blocked',
410
591
  active ? { active: true, ...(label ? { label } : {}) } : { active: false },
@@ -413,6 +594,11 @@ export default async function (pi: ExtensionAPI) {
413
594
  publishingHerdrBlocked = false;
414
595
  }
415
596
  }
597
+ function publishHerdrBlocked(active: boolean, label: string | null): void {
598
+ if (active) enterBlocked(label);
599
+ else exitBlocked();
600
+ emitHerdrBlocked(active, label);
601
+ }
416
602
  pi.events.on('herdr:blocked', (data) => {
417
603
  if (publishingHerdrBlocked) return;
418
604
  if (!data || typeof data !== 'object') {
@@ -426,47 +612,96 @@ export default async function (pi: ExtensionAPI) {
426
612
  enterBlocked('label' in data && typeof data.label === 'string' ? data.label : null);
427
613
  });
428
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
+
429
644
  /* ── Tool: ask_user_question (v1.3 M8 human gate, available to master and subagents) ── */
430
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
+
431
671
  pi.registerTool({
432
- name: 'ask_user_question',
672
+ name: ASK_TOOL_NAME,
433
673
  label: 'Ask User',
434
- description: [
435
- 'Ask the human a question and wait for their typed answer.',
436
- 'Use this when you genuinely need a human decision (approval, direction, trade-off choice) — not for information you can find yourself.',
437
- 'While waiting, the pane shows as blocked in herdr (the human sees it and can step in).',
438
- 'The answer comes back as the tool result; then continue your work.',
439
- ].join(' '),
440
- parameters: Type.Object({
441
- question: Type.String({ description: 'The question to ask the human' }),
442
- }),
443
- async execute(_tc, params, _sig, _upd, ctx) {
444
- const question = String(params?.question ?? '').trim();
445
- if (!question) {
446
- return { content: [{ type: 'text', text: 'Error: `question` must be a non-empty string' }], details: {} };
447
- }
448
- const ui = (ctx as {
449
- ui?: { input?: (title: string, placeholder?: string) => Promise<string | undefined> };
450
- }).ui;
451
- publishHerdrBlocked(true, question);
452
- // Fallback when herdr:pi is absent: a one-shot pi-herdr blocked report can lose
453
- // to later working reports; refresh while the gate is open. Do not re-emit
454
- // herdr:blocked here — official blockedCount is edge-triggered.
455
- const hb = setInterval(() => { reportAgent('blocked', question); }, 5000);
674
+ description: ASK_TOOL_DESCRIPTION,
675
+ promptGuidelines: ASK_PROMPT_GUIDELINES,
676
+ parameters: ASK_PARAMETERS,
677
+ async execute(_tc, params, signal, _upd, ctx) {
678
+ const prepared = prepareAsk(params);
679
+ if (!prepared.ok) return prepared.result;
680
+ const ui = ctx && typeof ctx === 'object' && 'ui' in ctx ? ctx.ui : undefined;
681
+ 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.)
456
687
  try {
457
- const answer = await ui?.input?.(question, 'your answer');
458
- return {
459
- content: [{ type: 'text', text: `The human answered: ${answer ?? '(no answer given)'}` }],
460
- details: { question, answer: answer ?? null },
461
- };
688
+ return await runAsk(prepared.spec, ui, signal instanceof AbortSignal ? signal : undefined);
462
689
  } finally {
463
- clearInterval(hb);
464
690
  publishHerdrBlocked(false, null);
465
691
  }
466
692
  },
467
693
  });
468
694
 
469
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
+ }
470
705
  client.close();
471
706
  });
472
707
 
@@ -480,20 +715,56 @@ export default async function (pi: ExtensionAPI) {
480
715
  return true;
481
716
  }
482
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 };
483
720
  let pendingMachineRequest: { id: string; from: string | null; push: boolean; sinceTs: number } | null = null;
484
721
  let latestCtx: { abort?: () => void } | null = null;
485
722
 
486
723
  // triggerTurn:true — idle followUp must start a new turn or settlement is lost.
487
724
  const sendUserMessageIn = (content: string): Promise<void> =>
488
- (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> })
489
726
  .sendUserMessage?.(content, { deliverAs: 'followUp', triggerTurn: true }) ?? Promise.resolve();
490
727
  const sendUserMessageAs = (content: string, mode: 'steer' | 'followUp'): Promise<void> =>
491
- (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> })
492
729
  .sendUserMessage?.(content, { deliverAs: mode, triggerTurn: true }) ?? Promise.resolve();
493
730
 
494
731
  const notices = createNoticeBuffer({
495
732
  isBusy: () => agentActive || lastStopReason === ABORT_STOP_REASON,
496
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
+ },
497
768
  });
498
769
  const deliverNotice = notices.deliverNotice;
499
770
  pi.on('turn_end', () => {
@@ -508,6 +779,28 @@ export default async function (pi: ExtensionAPI) {
508
779
  latestCtx = ctx as { abort?: () => void } | null;
509
780
  const cwd = (ctx as { cwd?: string }).cwd ?? process.cwd();
510
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
+ }
511
804
  if (!paneId) return;
512
805
  const name = pipeNameFor(cwd, paneId);
513
806
  if (pipeServerBox.current) {
@@ -530,6 +823,8 @@ export default async function (pi: ExtensionAPI) {
530
823
  }
531
824
  });
532
825
  pi.on('session_shutdown', () => {
826
+ sessionFocusPoller.current?.stop();
827
+ sessionFocusPoller.current = null;
533
828
  if (pipeServerBox.current) {
534
829
  try { pipeServerBox.current.close(); } catch { /* Already closed. */ }
535
830
  pipeServerBox.current = null;
@@ -581,6 +876,8 @@ export default async function (pi: ExtensionAPI) {
581
876
  reconcileOnSettlement,
582
877
  withReconcileNotes,
583
878
  claimSettleNotice,
879
+ isCompactionInFlight: () => coordinator.compactionInFlight,
880
+ isIntentionalAbort: () => coordinator.intentionalAbort,
584
881
  });
585
882
  } else {
586
883
  const { mountTodoOnly } = await import('./index-worker.ts');
@@ -589,12 +886,15 @@ export default async function (pi: ExtensionAPI) {
589
886
  todos,
590
887
  todoUi,
591
888
  mirrorTodos,
889
+ getBlockedDepth: () => blockedDepth,
592
890
  ...(isSubagent
593
891
  ? {}
594
892
  : {
595
893
  stopReminder: {
596
894
  getBlockedDepth: () => blockedDepth,
597
895
  getRunningSubs: () => subagentPort.current?.listRunningSubs().length ?? 0,
896
+ isCompactionInFlight: () => coordinator.compactionInFlight,
897
+ isIntentionalAbort: () => coordinator.intentionalAbort,
598
898
  },
599
899
  }),
600
900
  });