pi-pier 0.3.1 → 0.4.2

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 (54) hide show
  1. package/README.md +7 -5
  2. package/package.json +3 -3
  3. package/schemas/efficiency-config.schema.json +6 -0
  4. package/src/ask-dialog.ts +9 -2
  5. package/src/ask-user.ts +142 -74
  6. package/src/bootstrap.ts +2 -1
  7. package/src/compact-coordinator.ts +207 -35
  8. package/src/compact-economics-core.ts +62 -29
  9. package/src/config-catalog-core.ts +1 -0
  10. package/src/cordis-services.ts +64 -0
  11. package/src/dashboard-command.ts +40 -15
  12. package/src/delegation-core.ts +115 -0
  13. package/src/efficiency-config-core.ts +17 -6
  14. package/src/efficiency-store.ts +43 -6
  15. package/src/guards.ts +6 -0
  16. package/src/herdr-client.ts +141 -36
  17. package/src/history-store.ts +2 -0
  18. package/src/index-gates.ts +6 -12
  19. package/src/index-master.ts +12 -16
  20. package/src/index-pipe.ts +6 -0
  21. package/src/index-runtime.ts +14 -0
  22. package/src/index-worker.ts +4 -10
  23. package/src/index.ts +310 -76
  24. package/src/jev-client.ts +32 -7
  25. package/src/jev-core.ts +71 -36
  26. package/src/observation-core.ts +13 -1
  27. package/src/panel-open.ts +47 -0
  28. package/src/pier-options.ts +1 -2
  29. package/src/pipe-channel.ts +9 -0
  30. package/src/plugins/heat-plan.ts +18 -3
  31. package/src/plugins/observation.ts +118 -46
  32. package/src/plugins/subagent.ts +119 -68
  33. package/src/plugins/terminal.ts +221 -57
  34. package/src/plugins/todo.ts +63 -63
  35. package/src/reducer-core.ts +3 -5
  36. package/src/reducer-invoker.ts +27 -13
  37. package/src/renderers.ts +25 -2
  38. package/src/role-manifest.ts +2 -3
  39. package/src/roles/worker-default.json +2 -2
  40. package/src/settle-wake-core.ts +17 -3
  41. package/src/subagent-core.ts +209 -3
  42. package/src/subagent-poller.ts +69 -19
  43. package/src/subagent-session.ts +67 -4
  44. package/src/subagent-spawn.ts +55 -7
  45. package/src/terminal-core.ts +178 -50
  46. package/src/todo-core.ts +47 -2
  47. package/src/todo-read-hook.ts +54 -17
  48. package/src/todo-reminder-core.ts +9 -5
  49. package/src/todo-window.ts +8 -4
  50. package/src/todos-service.ts +12 -0
  51. package/src/tree-view.ts +162 -0
  52. package/src/vocab.ts +7 -1
  53. package/src/worker-marker.ts +76 -0
  54. package/src/focus-poller.ts +0 -223
package/src/index.ts CHANGED
@@ -19,6 +19,7 @@ import {
19
19
  hasAskUi,
20
20
  noUiResult,
21
21
  prepareAsk,
22
+ prepareAskArguments,
22
23
  runAsk,
23
24
  } from './ask-user.ts';
24
25
  import { pipeNameFor, pipeRequest, startPipeServer } from './pipe-channel.ts';
@@ -28,6 +29,8 @@ import { createHerdrClient } from './herdr-client.ts';
28
29
  import { bareSessionId, lastAssistantText, readSessionFile, resolveSessionFileValue } from './session-tail.ts';
29
30
  import { fileURLToPath } from 'node:url';
30
31
  import { join } from 'node:path';
32
+ import { mkdirSync, unlinkSync, writeFileSync } from 'node:fs';
33
+ import { tmpdir } from 'node:os';
31
34
  import { platformPaths } from './platform-paths.ts';
32
35
  import { TodosService, type TodoCompletionSource } from './todos-service.ts';
33
36
  import { reconcileTodos } from './reconcile-core.ts';
@@ -38,49 +41,48 @@ import { ABORT_STOP_REASON, planSettleWake, shouldPushSettleReply } from './sett
38
41
  import { parseRuntimeManifest } from './tool-gate.ts';
39
42
  import { formatPaneTitle } from './pane-title.ts';
40
43
  import { registerSlimFrame, updateSlimFrame } from './slim-frame.ts';
41
- import { planIndexMode } from './index-runtime.ts';
42
- import { emptySubagentPortBox } from './subagent-core.ts';
44
+ import { applyRestoredWorkerMode, planIndexMode } from './index-runtime.ts';
45
+ import { buildResumeArgv, emptySubagentPortBox, sleepWhileSubsRunningNote } 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';
43
56
  import { createNoticeBuffer } from './index-notices.ts';
44
57
  import { handlePipeRequest, type MachineRequest } from './index-pipe.ts';
45
58
  import { installWriteLocks } from './index-locks.ts';
46
59
  import { installDashboardCommand } from './dashboard-command.ts';
47
- import { registerObservationPack, createCompactionBatchPackHook, type PickMiddleExcerpt } from './plugins/observation.ts';
60
+ import { openPierPanel } from './panel-open.ts';
61
+ import { registerObservationPack, createCompactionBatchPackHook, type PickMiddleExcerpts } from './plugins/observation.ts';
48
62
  import { createJevRuntime } from './jev-client.ts';
49
63
  import {
50
64
  NOTICE_RANK_MIN_CONFIDENCE,
51
- buildExcerptWindows,
52
65
  composeNoticeRanking,
53
66
  evaluateExcerptPick,
54
- excerptAskIsSafe,
55
67
  excerptPickRequest,
56
68
  noticeRankRequest,
69
+ planExcerptAsks,
57
70
  } from './jev-core.ts';
58
- import { completeLineExcerpt } from './observation-core.ts';
59
71
  import { installConfigCommand } from './config-command.ts';
60
72
  import { CompactCoordinator } from './compact-coordinator.ts';
61
73
  import { handleReducerToolResult, type ToolResultEventLike } from './reducer-invoker.ts';
62
74
  import { appendEfficiencyLog, efficiencyLogPath, pruneSessionObjects, resolveSessionRoot } from './efficiency-store.ts';
63
75
  import type { RoutingTelemetryRecord, RoutingTelemetryRow } from './routing-telemetry.ts';
64
76
  import { loadEfficiencyConfigFromDisk, resolveEfficiencyConfig, type EfficiencyConfig } from './efficiency-config-core.ts';
65
- import { resolveDefaultFocusPollMs, parseFocusSample, spawnReflow, startFocusPoller, type FocusPoller } from './focus-poller.ts';
66
77
  import { composeMasterRuntime, createRoleRuntime } from './index-roles.ts';
67
78
  import { createGateRuntime } from './index-gates.ts';
68
79
 
69
- /** D-4: PIER_FOCUS_POLL_MS=0 disables the poller; invalid values fall back to the version default. */
70
- function focusPollIntervalMs(env: NodeJS.ProcessEnv = process.env, herdrVersion?: string | null): number {
71
- const defaultMs = resolveDefaultFocusPollMs(herdrVersion);
72
- const ms = Number(env.PIER_FOCUS_POLL_MS);
73
- return env.PIER_FOCUS_POLL_MS !== undefined && env.PIER_FOCUS_POLL_MS.trim() !== '' && Number.isFinite(ms) && ms >= 0
74
- ? ms
75
- : defaultMs;
76
- }
77
-
78
80
  function agentSessionsDir(): string {
79
81
  return process.env.PI_CODING_AGENT_DIR ? join(process.env.PI_CODING_AGENT_DIR, 'sessions') : platformPaths.sessionsDir;
80
82
  }
81
83
 
82
84
  export default async function (pi: ExtensionAPI) {
83
- const mode = planIndexMode();
85
+ const mode = applyRestoredWorkerMode(planIndexMode(), process.env.PI_SESSION_FILE ?? null, hasWorkerMarker);
84
86
  const isSubagent = mode.isSubagent;
85
87
  const roleBase = process.env.PI_HERDR_ROLE_BASE || process.cwd();
86
88
 
@@ -89,7 +91,8 @@ export default async function (pi: ExtensionAPI) {
89
91
  let effConfig: EfficiencyConfig = resolveEfficiencyConfig();
90
92
  const coordinator = new CompactCoordinator();
91
93
  const { client, env } = createHerdrClient();
92
-
94
+ /** RFC rfc-delegation-identity §2.1: resolved env → marker → branch fold; worker panes only. */
95
+ let delegation: DelegationContext | null = parseDelegationContext(process.env.PI_HERDR_DELEGATION);
93
96
  const initialManifest =
94
97
  parseRuntimeManifest(process.env.PI_HERDR_ROLE_MANIFEST) ??
95
98
  (mode.composeMaster ? composeMasterRuntime() : null);
@@ -108,30 +111,37 @@ export default async function (pi: ExtensionAPI) {
108
111
  /* ── Jev decision layer (RFC docs/rfc-jev-integration.md): direct HTTP, total-budget
109
112
  * abort, fail-open at every site. Disabled or keyless → behavior identical to before. ── */
110
113
  const jevRuntime = createJevRuntime(() => effConfig.jev, { getSessionRoot: () => sessionRoot, getSessionId: () => sessionId || null });
111
- /** P0-3: candidate windows are generated in code; jev only picks (rerank pattern). */
112
- const pickMiddleExcerpt: PickMiddleExcerpt = async (text, excerptBudgetBytes) => {
113
- if (!jevRuntime.available) return null;
114
- const halfBudget = Math.floor(excerptBudgetBytes / 2);
115
- const windows = buildExcerptWindows(text, halfBudget);
116
- if (windows.length === 0) return null; // degenerate budget only
117
- const headExcerpt = completeLineExcerpt(text, halfBudget, false);
118
- const tailExcerpt = completeLineExcerpt(text, excerptBudgetBytes - halfBudget, true);
119
- // Credential-shaped text never leaves the process (§8 privacy boundary).
120
- if (!excerptAskIsSafe(windows, headExcerpt, tailExcerpt)) return null;
121
- const request = excerptPickRequest(windows, headExcerpt, tailExcerpt);
122
- if (!request) return null;
114
+ /** P0-3: candidate windows are generated in code; jev only picks (rerank pattern), one request per batch. */
115
+ const pickMiddleExcerpts: PickMiddleExcerpts = async (items, excerptBudgetBytes) => {
116
+ const picks: Array<{ text: string; label: string } | null> = items.map(() => null);
117
+ if (!jevRuntime.available) return picks;
118
+ const asked = planExcerptAsks(items, excerptBudgetBytes);
119
+ const request = excerptPickRequest(asked);
120
+ if (!request) return picks;
123
121
  const result = await jevRuntime.ask(request, {
124
122
  questionId: 'obs-excerpt-window',
125
- extra: { site: 'obs-excerpt', candidates: windows.map((w) => w.id) },
123
+ extra: {
124
+ site: 'obs-excerpt',
125
+ items: asked.map((a) => ({ obsId: a.obsId, windows: a.windows.map((w) => ({ id: w.id, startLine: w.startLine })) })),
126
+ },
126
127
  enrich: ({ answers }) => {
127
- if (!answers || answers.window === undefined || answers.window.type !== 'choice') return {};
128
- return { picked: answers.window.choice, confidence: answers.window.confidence };
128
+ if (!answers) return {};
129
+ return {
130
+ picked: asked.map((_, i) => {
131
+ const answer = answers[`excerpt_${i}_window`];
132
+ return answer?.type === 'choice' ? { choice: answer.choice, confidence: answer.confidence } : null;
133
+ }),
134
+ };
129
135
  },
130
136
  });
131
- if (!result.ok) return null;
132
- const picked = evaluateExcerptPick(result.answers, effConfig.jev.minConfidence);
133
- const window = picked === null ? undefined : windows.find((w) => w.id === picked);
134
- return window ? { text: window.text, label: window.label } : null;
137
+ if (!result.ok) return picks;
138
+ for (let i = 0; i < asked.length; i++) {
139
+ const { index, windows } = asked[i]!;
140
+ const picked = evaluateExcerptPick(result.answers, i, effConfig.jev.minConfidence);
141
+ const window = picked === null ? undefined : windows.find((w) => w.id === picked);
142
+ if (window) picks[index] = { text: window.text, label: window.label };
143
+ }
144
+ return picks;
135
145
  };
136
146
 
137
147
  const rendererTypes = installRenderers(pi);
@@ -189,16 +199,87 @@ export default async function (pi: ExtensionAPI) {
189
199
  const p = progressOf(todos.items);
190
200
  const eta = estimateEta({ completedAt: completedStamps, total: p.total, now: Date.now() });
191
201
  const suffix = formatProgressSuffix({ completed: p.completed, total: p.total, eta });
192
- const title = formatPaneTitle(todos.items, null, { progressSuffix: suffix, lastWriteAt: todos.lastWriteAt });
202
+ const title = applyWorkerTag(
203
+ formatPaneTitle(todos.items, null, { progressSuffix: suffix, lastWriteAt: todos.lastWriteAt }),
204
+ delegation?.description ?? null,
205
+ );
193
206
  updateSlimFrame({ title, items: todos.items, lastWriteAt: todos.lastWriteAt });
194
207
  client.reportMetadata({
195
208
  session: label,
196
209
  items: todos.items,
197
210
  progressSuffix: suffix,
198
211
  lastWriteAt: todos.lastWriteAt,
212
+ workerTag: delegation?.description ?? null,
199
213
  }).catch(() => {});
214
+ // D113: delegated workers also push the full list to the master's pipe so its resident widget
215
+ // and /pier-tree stay live without polling. Best effort — a lost push self-heals on the next write.
216
+ if (isSubagent && delegation?.parentPipe) {
217
+ void pipeRequest(delegation.parentPipe, {
218
+ type: 'todo_sync',
219
+ id: `tsync-${Date.now()}`,
220
+ paneId: env?.paneId ?? '',
221
+ items: todos.items.map((it) => ({ content: it.content, status: it.status, ...(it.blocker ? { blocker: it.blocker } : {}) })),
222
+ summary: title ?? delegation.description,
223
+ }, 3000).catch(() => {});
224
+ }
200
225
  }
201
226
 
227
+ /** D113: per-worker todo cache (pipe pushes; summary fallback after a master resume). */
228
+ const workerTodos = new Map<string, WorkerTodoSnapshot>();
229
+ function applyWorkerTodos(workerPaneId: string, items: WorkerTodoSnapshot['items'], summary: string): void {
230
+ if (!workerPaneId) return;
231
+ workerTodos.set(workerPaneId, { items, summary, ts: Date.now() });
232
+ todoUi.rerenderWidget?.();
233
+ refreshTreeSnapshot();
234
+ }
235
+ /** Running rows joined with their cached todos — feeds the unified widget and /pier-tree. */
236
+ function widgetWorkers(): WorkerWidgetRow[] {
237
+ return (subagentPort.current?.listRunningSubs() ?? []).map((s) => ({
238
+ ...s,
239
+ todos: workerTodos.get(s.paneId) ?? null,
240
+ }));
241
+ }
242
+
243
+ /* ── /pier-tree panel: the pi process owns the data, the popup/overlay owns the surface. ── */
244
+ /** Set while a panel is open; todo/worker updates republish the snapshot it polls. */
245
+ let treeSnapshot: { path: string } | null = null;
246
+
247
+ function renderTreeLines(): string[] {
248
+ const rows = subagentPort.current?.listRows() ?? [];
249
+ return formatDelegationTree({
250
+ selfItems: todos.items,
251
+ workers: widgetWorkers(),
252
+ settled: rows
253
+ .filter((r) => r.status === 'settled' || r.status === 'consumed')
254
+ .map((r) => ({ paneId: r.paneId, slug: r.slug, description: r.description, settledAt: r.settledAt })),
255
+ now: Date.now(),
256
+ });
257
+ }
258
+
259
+ function treeSnapshotPath(): string {
260
+ const id = (sessionId || (env ? `pane-${env.paneId}` : 'session')).replace(/[^A-Za-z0-9._-]/g, '_').slice(0, 80);
261
+ return join(tmpdir(), 'pier-tree', `${id}.txt`);
262
+ }
263
+
264
+ function publishTreeSnapshot(): string {
265
+ const path = treeSnapshotPath();
266
+ mkdirSync(join(tmpdir(), 'pier-tree'), { recursive: true });
267
+ writeFileSync(path, `${renderTreeLines().join('\n')}\n`);
268
+ treeSnapshot = { path };
269
+ return path;
270
+ }
271
+
272
+ function refreshTreeSnapshot(): void {
273
+ if (!treeSnapshot) return;
274
+ try {
275
+ writeFileSync(treeSnapshot.path, `${renderTreeLines().join('\n')}\n`);
276
+ } catch {
277
+ /* The panel may have been closed and its temp dir swept; the next command republishes. */
278
+ }
279
+ }
280
+
281
+ todos.on('todo.updated', () => refreshTreeSnapshot());
282
+
202
283
  /** M17 settlement reconciliation: auto-complete settled todos / unblock on failure notes (idempotent). */
203
284
  function reconcileOnSettlement(description: string, outcome: 'settled' | 'failed'): string[] {
204
285
  if (!description) return [];
@@ -214,6 +295,8 @@ export default async function (pi: ExtensionAPI) {
214
295
  }
215
296
  todos.applyEdits(plan.edits, { source: 'reconcile' });
216
297
  mirrorTodos();
298
+ // Reconcile is not a tool call, so nothing else refreshes the resident widget after an edit.
299
+ todoUi.rerenderWidget?.();
217
300
  }
218
301
  return plan.noteLines;
219
302
  } catch {
@@ -246,10 +329,24 @@ export default async function (pi: ExtensionAPI) {
246
329
  reportToolBadge();
247
330
  }
248
331
  });
249
- pi.on('tool_execution_end', async (event: { toolCallId?: string }) => {
250
- if (typeof event?.toolCallId === 'string') {
251
- runningTools.delete(event.toolCallId);
252
- reportToolBadge();
332
+ pi.on('tool_execution_end', async (event: { toolCallId?: string; toolName?: string; durationMs?: number }) => {
333
+ if (typeof event?.toolCallId !== 'string') return;
334
+ const tool = typeof event.toolName === 'string' ? event.toolName : runningTools.get(event.toolCallId) ?? null;
335
+ runningTools.delete(event.toolCallId);
336
+ reportToolBadge();
337
+ // pi ≥ 1.1 records the real execution time on the event; the tools ledger replaces wall-clock
338
+ // estimates in the efficiency trial with measured numbers. Absent on older pi — nothing logged.
339
+ if (typeof event.durationMs === 'number' && sessionRoot) {
340
+ try {
341
+ await appendEfficiencyLog(efficiencyLogPath(sessionRoot, 'tools'), {
342
+ sessionId: sessionId || 'unknown',
343
+ ts: Date.now(),
344
+ tool,
345
+ durationMs: event.durationMs,
346
+ });
347
+ } catch {
348
+ /* ledger is observability only */
349
+ }
253
350
  }
254
351
  });
255
352
 
@@ -263,6 +360,18 @@ export default async function (pi: ExtensionAPI) {
263
360
  });
264
361
  });
265
362
 
363
+ /* ── Masters sleep-polled background workers (sleep 120…840) instead of trusting the settlement notice. ── */
364
+ if (!isSubagent) {
365
+ pi.on('tool_result', async (event: unknown) => {
366
+ const rec = (event ?? {}) as { toolName?: unknown; input?: { command?: unknown }; content?: unknown };
367
+ if (rec.toolName !== 'bash' || typeof rec.input?.command !== 'string') return;
368
+ const note = sleepWhileSubsRunningNote(rec.input.command, subagentPort.current?.listRunningSubs() ?? []);
369
+ if (!note) return;
370
+ const content = Array.isArray(rec.content) ? rec.content as Array<{ type: 'text'; text: string }> : [];
371
+ return { content: [...content, { type: 'text' as const, text: note }] };
372
+ });
373
+ }
374
+
266
375
  let locksHandle = { getHeldLocks: () => [] as readonly string[] };
267
376
  if (env) {
268
377
  locksHandle = installWriteLocks(pi, { client, env, hard: process.env[WRITE_LOCK_ENV] === '1' });
@@ -274,8 +383,12 @@ export default async function (pi: ExtensionAPI) {
274
383
  .sendUserMessage?.(content, { deliverAs: mode, triggerTurn: true });
275
384
  return Promise.resolve();
276
385
  };
386
+
277
387
  const notices = createNoticeBuffer({
278
- isBusy: () => agentActive || lastStopReason === ABORT_STOP_REASON,
388
+ // pi core rejects prompts while it is compacting (and drops fire-and-forget sends), so a
389
+ // settlement landing inside the compaction window must stay buffered until the continuation
390
+ // run settles — otherwise the result is lost with nobody left to retry it.
391
+ isBusy: () => agentActive || lastStopReason === ABORT_STOP_REASON || coordinator.compactionInFlight,
279
392
  send: sendUserMessageAs,
280
393
  // P0-2: rank collapsed batches by relevance to the master's in-progress work.
281
394
  rank: async (contents) => {
@@ -341,13 +454,14 @@ export default async function (pi: ExtensionAPI) {
341
454
  abort: () => { latestCtx?.abort?.(); },
342
455
  setPendingMachineRequest: (next) => { pendingMachineRequest = next; },
343
456
  applyRoleSwitch: roles.applyRoleSwitch,
457
+ applyWorkerTodos,
344
458
  }));
345
459
  } catch {
346
460
  /* Pipe name collision (rare): this session has no channel; callers report after ping timeout. */
347
461
  }
348
462
  }
349
463
 
350
- async function pushSettleReply(): Promise<void> {
464
+ async function pushSettleReply(aborted = false): Promise<void> {
351
465
  const req = pendingMachineRequest;
352
466
  if (!req || !req.push || !req.from) return;
353
467
  // An aborted settle is not a settlement: OCC's abort means the continuation turn is coming, and
@@ -356,6 +470,7 @@ export default async function (pi: ExtensionAPI) {
356
470
  // request armed; the post-compaction settle pushes it (settle-wake-core.ts).
357
471
  if (!shouldPushSettleReply({
358
472
  intentionalAbort: coordinator.intentionalAbort,
473
+ aborted,
359
474
  compactionInFlight: coordinator.compactionInFlight,
360
475
  lastStopReason,
361
476
  })) {
@@ -383,8 +498,6 @@ export default async function (pi: ExtensionAPI) {
383
498
  }
384
499
  }
385
500
 
386
- const sessionFocusPoller: { current: FocusPoller | null } = { current: null };
387
-
388
501
  pi.on('session_start', async (event, ctx) => {
389
502
  sessionId = resolveSessionId(ctx);
390
503
  latestCtx = ctx as { abort?: () => void } | null;
@@ -412,31 +525,57 @@ export default async function (pi: ExtensionAPI) {
412
525
  // Self-healing: the ask marker lives in herdr with a 24h TTL; a session killed
413
526
  // while a dialog was open must not keep the pane looking blocked forever.
414
527
  void client.reportAskFlag(null).catch(() => {});
415
-
416
- // D-4 focus sampling: herdr <0.9.1 never delivered pane.focused to plugins;
417
- // 0.9.1+ is event-first (pollMs 0). One poller per pane, self-scoped.
418
- if (sessionFocusPoller.current) sessionFocusPoller.current.stop();
419
- sessionFocusPoller.current = null;
420
- const paneId = env?.paneId ?? '';
421
- const serverVer = await client.getServerVersion();
422
- const pollMs = focusPollIntervalMs(process.env, serverVer);
423
- if (paneId && client.available && pollMs > 0) {
424
- sessionFocusPoller.current = startFocusPoller({
425
- myPaneId: paneId,
426
- intervalMs: pollMs,
427
- sample: async () => {
428
- // layout.export reports the focused pane of our own tab; without the
429
- // field (older herdr) the tick stays silent instead of guessing.
430
- const layout = await client.exportLayout({ paneId });
431
- const sample = parseFocusSample(layout);
432
- return sample && sample.focusedPaneId !== null ? sample : null;
433
- },
434
- fire: (focusedPaneId, cause) => spawnReflow({ paneId: focusedPaneId, cause }),
435
- });
528
+ if (isSubagent) {
529
+ // herdr 0.9.2 restore identity: the marker re-derives worker mode for the resumed boot
530
+ // (resume_argv cannot carry spawn env), and the self-reported command replays this exact
531
+ // session — extension, role model, approve, fullscreen — instead of a bare `pi --resume`.
532
+ const sm = typeof ctx === 'object' && ctx !== null && 'sessionManager' in ctx ? ctx.sessionManager : undefined;
533
+ const sessionFile = (typeof sm?.getSessionFile === 'function' ? sm.getSessionFile() : undefined)
534
+ ?? process.env.PI_SESSION_FILE ?? null;
535
+ // RFC rfc-delegation-identity §2.1: env (spawn) > marker (herdr restart) > branch fold (/resume).
536
+ if (!delegation) delegation = readWorkerMarker(sessionFile)?.delegation ?? null;
537
+ const branchEntries = (ctx as { sessionManager?: { getBranch?: () => readonly unknown[] } })
538
+ ?.sessionManager?.getBranch?.() ?? [];
539
+ const folded = foldDelegation(branchEntries as Parameters<typeof foldDelegation>[0]);
540
+ if (!delegation && folded) delegation = folded;
541
+ if (delegation && !folded) {
542
+ // First-frame card: write once — the entry doubles as the /resume replay source.
543
+ try {
544
+ pi.appendEntry(DELEGATION_CUSTOM_TYPE, { version: 1, ...delegation, ts: Date.now() });
545
+ } catch {
546
+ /* Best effort; the marker already carries the identity. */
547
+ }
548
+ }
549
+ if (delegation) void client.reportDelegationTokens(delegation).catch(() => {});
550
+ if (sessionRoot) {
551
+ writeWorkerMarker(sessionFile, roles.state.manifest?.role, delegation ?? undefined);
552
+ const resumeArgv = buildResumeArgv(process.argv);
553
+ if (resumeArgv && client.available) {
554
+ void client.reportAgentSessionResume(sessionId, resumeArgv).catch(() => {});
555
+ }
556
+ }
436
557
  }
558
+
559
+ // Focus heat is event-driven on herdr 0.9.5+: the workbench receives pane.focused directly,
560
+ // so no per-pane layout sampler is started here.
437
561
  startPipeServerFor(cwd);
438
562
  });
439
563
 
564
+ /* RFC rfc-delegation-identity §2.2: worker identity + delivery contract ride the prompt
565
+ * sections each turn (same diff discipline as pier-role; removal cleans up). */
566
+ if (isSubagent) {
567
+ pi.on('before_agent_start', async (event) => {
568
+ const sections = event.systemPromptOptions?.sections;
569
+ if (!sections) return;
570
+ if (delegation) {
571
+ const text = buildDelegationSection(delegation, env?.paneId ?? '');
572
+ if (sections['pier-delegation'] !== text) sections['pier-delegation'] = text;
573
+ } else {
574
+ delete sections['pier-delegation'];
575
+ }
576
+ });
577
+ }
578
+
440
579
  pi.on('session_tree', async (_event, ctx) => {
441
580
  sessionId = resolveSessionId(ctx);
442
581
  rebuildFromBranch(ctx);
@@ -447,6 +586,12 @@ export default async function (pi: ExtensionAPI) {
447
586
  mirrorTodos();
448
587
  });
449
588
 
589
+ /* pi 1.2 counts the run as busy from `before_agent_start`, not `turn_start`: a settlement
590
+ * notice arriving in that window must enter the D92 buffer instead of being sent immediately. */
591
+ pi.on('before_agent_start', async () => {
592
+ agentActive = true;
593
+ });
594
+
450
595
  pi.on('turn_start', async () => {
451
596
  agentActive = true;
452
597
  // A11: reset so a previous turn's abort cannot taint this one.
@@ -454,6 +599,9 @@ export default async function (pi: ExtensionAPI) {
454
599
  // Safety fallback: pi core prohibits prompts while compaction is active.
455
600
  coordinator.compactionInFlight = false;
456
601
  coordinator.intentionalAbort = false;
602
+ // A selection that never became startable must not latch every later decision (and the
603
+ // settle-wake hold) until the next human input.
604
+ coordinator.selectedCompaction = null;
457
605
  gates.report('working', currentActivity(todos.items));
458
606
  });
459
607
 
@@ -477,15 +625,20 @@ export default async function (pi: ExtensionAPI) {
477
625
  // flush ran regardless. The merged handler keeps both semantics.
478
626
  const msg = event !== null && typeof event === 'object' && 'message' in event ? (event as { message: unknown }).message : null;
479
627
  if (msg !== null && typeof msg === 'object') {
480
- const { role, stopReason } = msg as { role?: unknown; stopReason?: unknown };
628
+ const { role, stopReason, usage } = msg as { role?: unknown; stopReason?: unknown; usage?: unknown };
481
629
  if (role === 'assistant' && typeof stopReason === 'string') {
482
630
  lastStopReason = stopReason;
483
631
  }
632
+ if (role === 'assistant' && usage !== null && typeof usage === 'object') {
633
+ coordinator.onAssistantUsage(usage as { input?: number; cacheRead?: number; cacheWrite?: number });
634
+ }
484
635
  if (ctx && typeof ctx === 'object') {
485
636
  coordinator.onTurnEnd({
486
637
  ctx,
487
638
  todos: todos.items,
488
639
  config: effConfig.onlineContextCompact,
640
+ pi,
641
+ lastStopReason: typeof stopReason === 'string' ? stopReason : null,
489
642
  cancelReminder: todoUi.cancelReminder,
490
643
  });
491
644
  }
@@ -493,12 +646,16 @@ export default async function (pi: ExtensionAPI) {
493
646
  void notices.flush('steer');
494
647
  });
495
648
 
496
- pi.on('agent_settled', async (_event: unknown, ctx) => {
649
+ pi.on('agent_settled', async (event: unknown, ctx) => {
497
650
  agentActive = false;
498
651
  gates.report('idle', null);
652
+ // pi ≥ 1.1 stamps cancelled runs on the event itself; stopReason tracking misses ESC mid-tool
653
+ // (no final assistant message), so the event field is authoritative when present.
654
+ const aborted = event !== null && typeof event === 'object' && 'aborted' in event && event.aborted === true;
499
655
  const plan = planSettleWake({
500
656
  lastStopReason,
501
657
  intentionalAbort: coordinator.intentionalAbort,
658
+ aborted,
502
659
  compactionPending: coordinator.selectedCompaction !== null || coordinator.compactionInFlight,
503
660
  running: subagentPort.current?.listRunningSubs() ?? [],
504
661
  lastNoticeKey: d96NoticeKey,
@@ -515,6 +672,13 @@ export default async function (pi: ExtensionAPI) {
515
672
  void sendUserMessageAs(notice, 'followUp');
516
673
  }
517
674
  if (plan.compact && ctx && typeof ctx === 'object') {
675
+ // Settlement notices that land inside the compaction window stay buffered (isBusy includes
676
+ // compactionInFlight). The continuation run flushes them at its own agent_settled; a cancelled
677
+ // or rejected compaction sends no continuation, so release the buffer once the coordinator is
678
+ // done and no run has started.
679
+ const flushAfterCompaction = (): void => {
680
+ if (!coordinator.compactionInFlight && !agentActive) void notices.flush('followUp');
681
+ };
518
682
  void coordinator.onAgentSettled({
519
683
  ctx,
520
684
  todos: todos.items,
@@ -525,12 +689,12 @@ export default async function (pi: ExtensionAPI) {
525
689
  getObsConfig: () => effConfig.observationPack,
526
690
  getManifest: () => roles.state.manifest,
527
691
  getSessionId: () => sessionId,
528
- pickMiddleExcerpt,
692
+ pickMiddleExcerpts,
529
693
  }),
530
- });
694
+ }).then(flushAfterCompaction, flushAfterCompaction);
531
695
  }
532
- if (lastStopReason !== ABORT_STOP_REASON) void notices.flush('followUp');
533
- void pushSettleReply();
696
+ if (lastStopReason !== ABORT_STOP_REASON && !aborted) void notices.flush('followUp');
697
+ void pushSettleReply(aborted);
534
698
  });
535
699
 
536
700
  /* ── D101 ObservationPack (obs_recall tool & context projector) ── */
@@ -538,7 +702,7 @@ export default async function (pi: ExtensionAPI) {
538
702
  pi,
539
703
  getConfig: () => effConfig,
540
704
  getRuntimeManifest: () => roles.state.manifest,
541
- pickMiddleExcerpt,
705
+ pickMiddleExcerpts,
542
706
  getRemainingHorizon: () => {
543
707
  const usage = (latestCtx as { getContextUsage?: () => { contextWindow?: number } })?.getContextUsage?.();
544
708
  return coordinator.getRemainingHorizon(3, usage?.contextWindow ?? null);
@@ -552,6 +716,7 @@ export default async function (pi: ExtensionAPI) {
552
716
  env,
553
717
  getTodoItems: () => todos.items,
554
718
  getHeldLocks: () => locksHandle.getHeldLocks(),
719
+ prepareTree: () => publishTreeSnapshot(),
555
720
  });
556
721
 
557
722
  /* ── ask_user_question (v1.3 M8 human gate; master and subagents) ── */
@@ -561,6 +726,8 @@ export default async function (pi: ExtensionAPI) {
561
726
  description: ASK_TOOL_DESCRIPTION,
562
727
  promptGuidelines: ASK_PROMPT_GUIDELINES,
563
728
  parameters: ASK_PARAMETERS,
729
+ // Models sometimes send options/questions as a JSON string; repair them before pi validates.
730
+ prepareArguments: prepareAskArguments,
564
731
  async execute(_tc, params, signal, _upd, ctx) {
565
732
  const prepared = prepareAsk(params);
566
733
  if (!prepared.ok) return prepared.result;
@@ -580,8 +747,6 @@ export default async function (pi: ExtensionAPI) {
580
747
  pi.on('session_shutdown', async () => {
581
748
  coordinator.compactionInFlight = false;
582
749
  coordinator.intentionalAbort = false;
583
- sessionFocusPoller.current?.stop();
584
- sessionFocusPoller.current = null;
585
750
  if (pipeServerBox.current) {
586
751
  try { pipeServerBox.current.close(); } catch { /* Already closed. */ }
587
752
  pipeServerBox.current = null;
@@ -592,6 +757,14 @@ export default async function (pi: ExtensionAPI) {
592
757
  } catch {
593
758
  }
594
759
  }
760
+ if (treeSnapshot) {
761
+ try {
762
+ unlinkSync(treeSnapshot.path);
763
+ } catch {
764
+ /* Already swept. */
765
+ }
766
+ treeSnapshot = null;
767
+ }
595
768
  client.close();
596
769
  });
597
770
 
@@ -612,6 +785,7 @@ export default async function (pi: ExtensionAPI) {
612
785
  noticePending: notices.noticePending,
613
786
  getSessionId: () => sessionId,
614
787
  getBlockedDepth: () => gates.depth(),
788
+ getWorkers: widgetWorkers,
615
789
  reconcileOnSettlement,
616
790
  withReconcileNotes,
617
791
  claimSettleNotice,
@@ -620,6 +794,66 @@ export default async function (pi: ExtensionAPI) {
620
794
  jev: { ask: (request, meta) => jevRuntime.ask(request, meta), getMinConfidence: () => effConfig.jev.minConfidence },
621
795
  appendRoutingLog,
622
796
  });
797
+
798
+ /* ── /pier-tree (D113): full todo × subagent delegation tree; the resident widget is minimal ── */
799
+ pi.registerCommand('pier-tree', {
800
+ description: 'Show the full delegation tree: your todos plus every subagent\'s todos and settled nodes',
801
+ handler: async (args: unknown, ctx: unknown) => {
802
+ const ui = (ctx as { ui?: { notify?: (text: string, level?: string) => void } }).ui;
803
+ const lines = renderTreeLines();
804
+ const overlay = typeof args === 'string' && args.trim().toLowerCase() === 'overlay';
805
+
806
+ if (client.available && env) {
807
+ const treeFile = publishTreeSnapshot();
808
+ if (overlay) {
809
+ try {
810
+ const res = await client.openPluginPane({
811
+ pluginId: 'pier.workbench',
812
+ entrypoint: 'tree',
813
+ placement: 'overlay',
814
+ focus: true,
815
+ env: { PIER_PANEL: 'tree', PIER_TREE_FILE: treeFile },
816
+ });
817
+ if (res.mode === 'pane') return;
818
+ } catch {
819
+ // Plugin missing / older herdr: fall back to the in-pane notify view below.
820
+ }
821
+ } else {
822
+ const res = await openPierPanel({ client, env }, { panel: 'tree', treeFile });
823
+ if (res === 'popup') return;
824
+ if (res === 'tab') {
825
+ ui?.notify?.('Herdr < 0.9.1: Pier Tree opened in new tab', 'info');
826
+ return;
827
+ }
828
+ if (res === 'busy') {
829
+ ui?.notify?.('Another popup is already open — close it and run /pier-tree again.', 'warning');
830
+ return;
831
+ }
832
+ }
833
+ // 'unavailable' / failed overlay → the old in-pane view.
834
+ }
835
+ treeSnapshot = null;
836
+ ui?.notify?.(lines.join('\n'), 'info');
837
+ },
838
+ });
839
+
840
+ /** D113 resync: a resumed master rebuilds worker summaries from herdr pi-todo tokens until the
841
+ * next live push replaces them with full lists. */
842
+ const resyncWorkerTodos = async (): Promise<void> => {
843
+ try {
844
+ const agents = await client.listAgents();
845
+ for (const a of agents) {
846
+ const summary = a.tokens?.['pi-todo'];
847
+ if (typeof summary === 'string' && summary && !workerTodos.has(a.paneId)) {
848
+ workerTodos.set(a.paneId, { summary, ts: Date.now() });
849
+ }
850
+ }
851
+ todoUi.rerenderWidget?.();
852
+ } catch {
853
+ /* herdr absent or unreachable: live pushes remain the source */
854
+ }
855
+ };
856
+ pi.on('session_start', () => { void resyncWorkerTodos(); });
623
857
  } else {
624
858
  const { mountTodoOnly } = await import('./index-worker.ts');
625
859
  await mountTodoOnly({