pi-pier 0.3.1 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/index.ts CHANGED
@@ -10,6 +10,7 @@
10
10
  * details persist in session JSONL and getBranch() replay implements branch rollback.
11
11
  */
12
12
  import type { ExtensionAPI } from '@earendil-works/pi-coding-agent';
13
+ import { Type } from 'typebox';
13
14
  import {
14
15
  ASK_PARAMETERS,
15
16
  ASK_PROMPT_GUIDELINES,
@@ -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,12 +41,23 @@ 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, PROGRESS_CUSTOM_TYPE } from './subagent-core.ts';
46
+ import { hasWorkerMarker, writeWorkerMarker, readWorkerMarker } from './worker-marker.ts';
47
+ import {
48
+ DELEGATION_CUSTOM_TYPE,
49
+ applyWorkerTag,
50
+ buildDelegationSection,
51
+ foldDelegation,
52
+ parseDelegationContext,
53
+ type DelegationContext,
54
+ } from './delegation-core.ts';
55
+ import { formatDelegationTree, type WorkerTodoSnapshot, type WorkerWidgetRow } from './tree-view.ts';
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';
60
+ import { openPierPanel } from './panel-open.ts';
47
61
  import { registerObservationPack, createCompactionBatchPackHook, type PickMiddleExcerpt } from './plugins/observation.ts';
48
62
  import { createJevRuntime } from './jev-client.ts';
49
63
  import {
@@ -62,25 +76,15 @@ import { handleReducerToolResult, type ToolResultEventLike } from './reducer-inv
62
76
  import { appendEfficiencyLog, efficiencyLogPath, pruneSessionObjects, resolveSessionRoot } from './efficiency-store.ts';
63
77
  import type { RoutingTelemetryRecord, RoutingTelemetryRow } from './routing-telemetry.ts';
64
78
  import { loadEfficiencyConfigFromDisk, resolveEfficiencyConfig, type EfficiencyConfig } from './efficiency-config-core.ts';
65
- import { resolveDefaultFocusPollMs, parseFocusSample, spawnReflow, startFocusPoller, type FocusPoller } from './focus-poller.ts';
66
79
  import { composeMasterRuntime, createRoleRuntime } from './index-roles.ts';
67
80
  import { createGateRuntime } from './index-gates.ts';
68
81
 
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
82
  function agentSessionsDir(): string {
79
83
  return process.env.PI_CODING_AGENT_DIR ? join(process.env.PI_CODING_AGENT_DIR, 'sessions') : platformPaths.sessionsDir;
80
84
  }
81
85
 
82
86
  export default async function (pi: ExtensionAPI) {
83
- const mode = planIndexMode();
87
+ const mode = applyRestoredWorkerMode(planIndexMode(), process.env.PI_SESSION_FILE ?? null, hasWorkerMarker);
84
88
  const isSubagent = mode.isSubagent;
85
89
  const roleBase = process.env.PI_HERDR_ROLE_BASE || process.cwd();
86
90
 
@@ -89,7 +93,8 @@ export default async function (pi: ExtensionAPI) {
89
93
  let effConfig: EfficiencyConfig = resolveEfficiencyConfig();
90
94
  const coordinator = new CompactCoordinator();
91
95
  const { client, env } = createHerdrClient();
92
-
96
+ /** RFC rfc-delegation-identity §2.1: resolved env → marker → branch fold; worker panes only. */
97
+ let delegation: DelegationContext | null = parseDelegationContext(process.env.PI_HERDR_DELEGATION);
93
98
  const initialManifest =
94
99
  parseRuntimeManifest(process.env.PI_HERDR_ROLE_MANIFEST) ??
95
100
  (mode.composeMaster ? composeMasterRuntime() : null);
@@ -189,16 +194,87 @@ export default async function (pi: ExtensionAPI) {
189
194
  const p = progressOf(todos.items);
190
195
  const eta = estimateEta({ completedAt: completedStamps, total: p.total, now: Date.now() });
191
196
  const suffix = formatProgressSuffix({ completed: p.completed, total: p.total, eta });
192
- const title = formatPaneTitle(todos.items, null, { progressSuffix: suffix, lastWriteAt: todos.lastWriteAt });
197
+ const title = applyWorkerTag(
198
+ formatPaneTitle(todos.items, null, { progressSuffix: suffix, lastWriteAt: todos.lastWriteAt }),
199
+ delegation?.description ?? null,
200
+ );
193
201
  updateSlimFrame({ title, items: todos.items, lastWriteAt: todos.lastWriteAt });
194
202
  client.reportMetadata({
195
203
  session: label,
196
204
  items: todos.items,
197
205
  progressSuffix: suffix,
198
206
  lastWriteAt: todos.lastWriteAt,
207
+ workerTag: delegation?.description ?? null,
199
208
  }).catch(() => {});
209
+ // D113: delegated workers also push the full list to the master's pipe so its resident widget
210
+ // and /pier-tree stay live without polling. Best effort — a lost push self-heals on the next write.
211
+ if (isSubagent && delegation?.parentPipe) {
212
+ void pipeRequest(delegation.parentPipe, {
213
+ type: 'todo_sync',
214
+ id: `tsync-${Date.now()}`,
215
+ paneId: env?.paneId ?? '',
216
+ items: todos.items.map((it) => ({ content: it.content, status: it.status, ...(it.blocker ? { blocker: it.blocker } : {}) })),
217
+ summary: title ?? delegation.description,
218
+ }, 3000).catch(() => {});
219
+ }
220
+ }
221
+
222
+ /** D113: per-worker todo cache (pipe pushes; summary fallback after a master resume). */
223
+ const workerTodos = new Map<string, WorkerTodoSnapshot>();
224
+ function applyWorkerTodos(workerPaneId: string, items: WorkerTodoSnapshot['items'], summary: string): void {
225
+ if (!workerPaneId) return;
226
+ workerTodos.set(workerPaneId, { items, summary, ts: Date.now() });
227
+ todoUi.rerenderWidget?.();
228
+ refreshTreeSnapshot();
229
+ }
230
+ /** Running rows joined with their cached todos — feeds the unified widget and /pier-tree. */
231
+ function widgetWorkers(): WorkerWidgetRow[] {
232
+ return (subagentPort.current?.listRunningSubs() ?? []).map((s) => ({
233
+ ...s,
234
+ todos: workerTodos.get(s.paneId) ?? null,
235
+ }));
200
236
  }
201
237
 
238
+ /* ── /pier-tree panel: the pi process owns the data, the popup/overlay owns the surface. ── */
239
+ /** Set while a panel is open; todo/worker updates republish the snapshot it polls. */
240
+ let treeSnapshot: { path: string } | null = null;
241
+
242
+ function renderTreeLines(): string[] {
243
+ const rows = subagentPort.current?.listRows() ?? [];
244
+ return formatDelegationTree({
245
+ selfItems: todos.items,
246
+ workers: widgetWorkers(),
247
+ settled: rows
248
+ .filter((r) => r.status === 'settled' || r.status === 'consumed')
249
+ .map((r) => ({ paneId: r.paneId, slug: r.slug, description: r.description, settledAt: r.settledAt })),
250
+ now: Date.now(),
251
+ });
252
+ }
253
+
254
+ function treeSnapshotPath(): string {
255
+ const id = (sessionId || (env ? `pane-${env.paneId}` : 'session')).replace(/[^A-Za-z0-9._-]/g, '_').slice(0, 80);
256
+ return join(tmpdir(), 'pier-tree', `${id}.txt`);
257
+ }
258
+
259
+ function publishTreeSnapshot(): string {
260
+ const path = treeSnapshotPath();
261
+ mkdirSync(join(tmpdir(), 'pier-tree'), { recursive: true });
262
+ writeFileSync(path, `${renderTreeLines().join('\n')}\n`);
263
+ treeSnapshot = { path };
264
+ return path;
265
+ }
266
+
267
+ function refreshTreeSnapshot(): void {
268
+ if (!treeSnapshot) return;
269
+ try {
270
+ writeFileSync(treeSnapshot.path, `${renderTreeLines().join('\n')}\n`);
271
+ } catch {
272
+ /* The panel may have been closed and its temp dir swept; the next command republishes. */
273
+ }
274
+ }
275
+
276
+ todos.on('todo.updated', () => refreshTreeSnapshot());
277
+
202
278
  /** M17 settlement reconciliation: auto-complete settled todos / unblock on failure notes (idempotent). */
203
279
  function reconcileOnSettlement(description: string, outcome: 'settled' | 'failed'): string[] {
204
280
  if (!description) return [];
@@ -246,10 +322,24 @@ export default async function (pi: ExtensionAPI) {
246
322
  reportToolBadge();
247
323
  }
248
324
  });
249
- pi.on('tool_execution_end', async (event: { toolCallId?: string }) => {
250
- if (typeof event?.toolCallId === 'string') {
251
- runningTools.delete(event.toolCallId);
252
- reportToolBadge();
325
+ pi.on('tool_execution_end', async (event: { toolCallId?: string; toolName?: string; durationMs?: number }) => {
326
+ if (typeof event?.toolCallId !== 'string') return;
327
+ const tool = typeof event.toolName === 'string' ? event.toolName : runningTools.get(event.toolCallId) ?? null;
328
+ runningTools.delete(event.toolCallId);
329
+ reportToolBadge();
330
+ // pi ≥ 1.1 records the real execution time on the event; the tools ledger replaces wall-clock
331
+ // estimates in the efficiency trial with measured numbers. Absent on older pi — nothing logged.
332
+ if (typeof event.durationMs === 'number' && sessionRoot) {
333
+ try {
334
+ await appendEfficiencyLog(efficiencyLogPath(sessionRoot, 'tools'), {
335
+ sessionId: sessionId || 'unknown',
336
+ ts: Date.now(),
337
+ tool,
338
+ durationMs: event.durationMs,
339
+ });
340
+ } catch {
341
+ /* ledger is observability only */
342
+ }
253
343
  }
254
344
  });
255
345
 
@@ -274,6 +364,18 @@ export default async function (pi: ExtensionAPI) {
274
364
  .sendUserMessage?.(content, { deliverAs: mode, triggerTurn: true });
275
365
  return Promise.resolve();
276
366
  };
367
+
368
+ /** Phase 2 (RFC rfc-delegation-identity §3.2): a worker's progress note enters master context as
369
+ * a display-less custom message — no turn, no visible chat line; renderers.ts gives it a card. */
370
+ const deliverProgress = (workerPaneId: string, text: string): Promise<void> => {
371
+ (pi as unknown as {
372
+ sendMessage?: (msg: { customType: string; content: string; display: boolean; details?: Record<string, unknown> }, opts?: { triggerTurn?: boolean }) => void;
373
+ }).sendMessage?.(
374
+ { customType: PROGRESS_CUSTOM_TYPE, content: text.slice(0, 2000), display: false, details: { paneId: workerPaneId } },
375
+ { triggerTurn: false },
376
+ );
377
+ return Promise.resolve();
378
+ };
277
379
  const notices = createNoticeBuffer({
278
380
  isBusy: () => agentActive || lastStopReason === ABORT_STOP_REASON,
279
381
  send: sendUserMessageAs,
@@ -341,13 +443,15 @@ export default async function (pi: ExtensionAPI) {
341
443
  abort: () => { latestCtx?.abort?.(); },
342
444
  setPendingMachineRequest: (next) => { pendingMachineRequest = next; },
343
445
  applyRoleSwitch: roles.applyRoleSwitch,
446
+ deliverProgress,
447
+ applyWorkerTodos,
344
448
  }));
345
449
  } catch {
346
450
  /* Pipe name collision (rare): this session has no channel; callers report after ping timeout. */
347
451
  }
348
452
  }
349
453
 
350
- async function pushSettleReply(): Promise<void> {
454
+ async function pushSettleReply(aborted = false): Promise<void> {
351
455
  const req = pendingMachineRequest;
352
456
  if (!req || !req.push || !req.from) return;
353
457
  // An aborted settle is not a settlement: OCC's abort means the continuation turn is coming, and
@@ -356,6 +460,7 @@ export default async function (pi: ExtensionAPI) {
356
460
  // request armed; the post-compaction settle pushes it (settle-wake-core.ts).
357
461
  if (!shouldPushSettleReply({
358
462
  intentionalAbort: coordinator.intentionalAbort,
463
+ aborted,
359
464
  compactionInFlight: coordinator.compactionInFlight,
360
465
  lastStopReason,
361
466
  })) {
@@ -383,8 +488,6 @@ export default async function (pi: ExtensionAPI) {
383
488
  }
384
489
  }
385
490
 
386
- const sessionFocusPoller: { current: FocusPoller | null } = { current: null };
387
-
388
491
  pi.on('session_start', async (event, ctx) => {
389
492
  sessionId = resolveSessionId(ctx);
390
493
  latestCtx = ctx as { abort?: () => void } | null;
@@ -412,31 +515,57 @@ export default async function (pi: ExtensionAPI) {
412
515
  // Self-healing: the ask marker lives in herdr with a 24h TTL; a session killed
413
516
  // while a dialog was open must not keep the pane looking blocked forever.
414
517
  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
- });
518
+ if (isSubagent) {
519
+ // herdr 0.9.2 restore identity: the marker re-derives worker mode for the resumed boot
520
+ // (resume_argv cannot carry spawn env), and the self-reported command replays this exact
521
+ // session — extension, role model, approve, fullscreen — instead of a bare `pi --resume`.
522
+ const sm = typeof ctx === 'object' && ctx !== null && 'sessionManager' in ctx ? ctx.sessionManager : undefined;
523
+ const sessionFile = (typeof sm?.getSessionFile === 'function' ? sm.getSessionFile() : undefined)
524
+ ?? process.env.PI_SESSION_FILE ?? null;
525
+ // RFC rfc-delegation-identity §2.1: env (spawn) > marker (herdr restart) > branch fold (/resume).
526
+ if (!delegation) delegation = readWorkerMarker(sessionFile)?.delegation ?? null;
527
+ const branchEntries = (ctx as { sessionManager?: { getBranch?: () => readonly unknown[] } })
528
+ ?.sessionManager?.getBranch?.() ?? [];
529
+ const folded = foldDelegation(branchEntries as Parameters<typeof foldDelegation>[0]);
530
+ if (!delegation && folded) delegation = folded;
531
+ if (delegation && !folded) {
532
+ // First-frame card: write once — the entry doubles as the /resume replay source.
533
+ try {
534
+ pi.appendEntry(DELEGATION_CUSTOM_TYPE, { version: 1, ...delegation, ts: Date.now() });
535
+ } catch {
536
+ /* Best effort; the marker already carries the identity. */
537
+ }
538
+ }
539
+ if (delegation) void client.reportDelegationTokens(delegation).catch(() => {});
540
+ if (sessionRoot) {
541
+ writeWorkerMarker(sessionFile, roles.state.manifest?.role, delegation ?? undefined);
542
+ const resumeArgv = buildResumeArgv(process.argv);
543
+ if (resumeArgv && client.available) {
544
+ void client.reportAgentSessionResume(sessionId, resumeArgv).catch(() => {});
545
+ }
546
+ }
436
547
  }
548
+
549
+ // Focus heat is event-driven on herdr 0.9.5+: the workbench receives pane.focused directly,
550
+ // so no per-pane layout sampler is started here.
437
551
  startPipeServerFor(cwd);
438
552
  });
439
553
 
554
+ /* RFC rfc-delegation-identity §2.2: worker identity + delivery contract ride the prompt
555
+ * sections each turn (same diff discipline as pier-role; removal cleans up). */
556
+ if (isSubagent) {
557
+ pi.on('before_agent_start', async (event) => {
558
+ const sections = event.systemPromptOptions?.sections;
559
+ if (!sections) return;
560
+ if (delegation) {
561
+ const text = buildDelegationSection(delegation, env?.paneId ?? '');
562
+ if (sections['pier-delegation'] !== text) sections['pier-delegation'] = text;
563
+ } else {
564
+ delete sections['pier-delegation'];
565
+ }
566
+ });
567
+ }
568
+
440
569
  pi.on('session_tree', async (_event, ctx) => {
441
570
  sessionId = resolveSessionId(ctx);
442
571
  rebuildFromBranch(ctx);
@@ -447,6 +576,12 @@ export default async function (pi: ExtensionAPI) {
447
576
  mirrorTodos();
448
577
  });
449
578
 
579
+ /* pi 1.2 counts the run as busy from `before_agent_start`, not `turn_start`: a settlement
580
+ * notice arriving in that window must enter the D92 buffer instead of being sent immediately. */
581
+ pi.on('before_agent_start', async () => {
582
+ agentActive = true;
583
+ });
584
+
450
585
  pi.on('turn_start', async () => {
451
586
  agentActive = true;
452
587
  // A11: reset so a previous turn's abort cannot taint this one.
@@ -493,12 +628,16 @@ export default async function (pi: ExtensionAPI) {
493
628
  void notices.flush('steer');
494
629
  });
495
630
 
496
- pi.on('agent_settled', async (_event: unknown, ctx) => {
631
+ pi.on('agent_settled', async (event: unknown, ctx) => {
497
632
  agentActive = false;
498
633
  gates.report('idle', null);
634
+ // pi ≥ 1.1 stamps cancelled runs on the event itself; stopReason tracking misses ESC mid-tool
635
+ // (no final assistant message), so the event field is authoritative when present.
636
+ const aborted = event !== null && typeof event === 'object' && 'aborted' in event && event.aborted === true;
499
637
  const plan = planSettleWake({
500
638
  lastStopReason,
501
639
  intentionalAbort: coordinator.intentionalAbort,
640
+ aborted,
502
641
  compactionPending: coordinator.selectedCompaction !== null || coordinator.compactionInFlight,
503
642
  running: subagentPort.current?.listRunningSubs() ?? [],
504
643
  lastNoticeKey: d96NoticeKey,
@@ -529,8 +668,8 @@ export default async function (pi: ExtensionAPI) {
529
668
  }),
530
669
  });
531
670
  }
532
- if (lastStopReason !== ABORT_STOP_REASON) void notices.flush('followUp');
533
- void pushSettleReply();
671
+ if (lastStopReason !== ABORT_STOP_REASON && !aborted) void notices.flush('followUp');
672
+ void pushSettleReply(aborted);
534
673
  });
535
674
 
536
675
  /* ── D101 ObservationPack (obs_recall tool & context projector) ── */
@@ -552,6 +691,7 @@ export default async function (pi: ExtensionAPI) {
552
691
  env,
553
692
  getTodoItems: () => todos.items,
554
693
  getHeldLocks: () => locksHandle.getHeldLocks(),
694
+ prepareTree: () => publishTreeSnapshot(),
555
695
  });
556
696
 
557
697
  /* ── ask_user_question (v1.3 M8 human gate; master and subagents) ── */
@@ -577,11 +717,52 @@ export default async function (pi: ExtensionAPI) {
577
717
  },
578
718
  });
579
719
 
720
+ /* ── report_progress (Phase 2, RFC rfc-delegation-identity §3.2): worker → master checkpoint ── */
721
+ if (isSubagent) {
722
+ pi.registerTool({
723
+ name: 'report_progress',
724
+ label: 'Report Progress',
725
+ description:
726
+ 'Send the delegating master a short progress update without finishing the task. Use at meaningful checkpoints (a module done, a test went green, a blocker found) — the master sees it in its context but is not interrupted.',
727
+ promptSnippet: 'report_progress: mid-task checkpoint to the delegating master (does not end the task).',
728
+ promptGuidelines: [
729
+ 'Call at meaningful checkpoints only — a milestone reached, a risk discovered, a decision the master should know early — not after every tool call.',
730
+ 'One or two sentences per call; the final answer still goes in your normal closing message when the task finishes.',
731
+ ],
732
+ parameters: Type.Object({
733
+ message: Type.String({ description: 'Short checkpoint update for the delegating master.' }),
734
+ }),
735
+ async execute(_tc, params) {
736
+ const parentPipe = delegation?.parentPipe;
737
+ const message = typeof (params as { message?: unknown })?.message === 'string'
738
+ ? (params as { message: string }).message.trim()
739
+ : '';
740
+ if (!message) {
741
+ return { content: [{ type: 'text', text: 'Error: `message` is required (one or two sentences).' }], details: {} };
742
+ }
743
+ if (!parentPipe) {
744
+ return { content: [{ type: 'text', text: 'Error: no delegating master pipe is known to this session; progress cannot be delivered.' }], details: {} };
745
+ }
746
+ try {
747
+ const res = await pipeRequest(parentPipe, {
748
+ type: 'progress',
749
+ id: `prog-${Date.now()}`,
750
+ paneId: env?.paneId ?? '',
751
+ text: message,
752
+ }, 5000);
753
+ return res.type === 'ok'
754
+ ? { content: [{ type: 'text', text: 'Progress delivered to the master (visible in its context, no interruption).' }], details: {} }
755
+ : { content: [{ type: 'text', text: `Error: master rejected progress report: ${res.message}` }], details: {} };
756
+ } catch (err) {
757
+ return { content: [{ type: 'text', text: `Error: could not reach the master pipe: ${(err as Error)?.message ?? err}` }], details: {} };
758
+ }
759
+ },
760
+ });
761
+ }
762
+
580
763
  pi.on('session_shutdown', async () => {
581
764
  coordinator.compactionInFlight = false;
582
765
  coordinator.intentionalAbort = false;
583
- sessionFocusPoller.current?.stop();
584
- sessionFocusPoller.current = null;
585
766
  if (pipeServerBox.current) {
586
767
  try { pipeServerBox.current.close(); } catch { /* Already closed. */ }
587
768
  pipeServerBox.current = null;
@@ -592,6 +773,14 @@ export default async function (pi: ExtensionAPI) {
592
773
  } catch {
593
774
  }
594
775
  }
776
+ if (treeSnapshot) {
777
+ try {
778
+ unlinkSync(treeSnapshot.path);
779
+ } catch {
780
+ /* Already swept. */
781
+ }
782
+ treeSnapshot = null;
783
+ }
595
784
  client.close();
596
785
  });
597
786
 
@@ -612,6 +801,7 @@ export default async function (pi: ExtensionAPI) {
612
801
  noticePending: notices.noticePending,
613
802
  getSessionId: () => sessionId,
614
803
  getBlockedDepth: () => gates.depth(),
804
+ getWorkers: widgetWorkers,
615
805
  reconcileOnSettlement,
616
806
  withReconcileNotes,
617
807
  claimSettleNotice,
@@ -620,6 +810,66 @@ export default async function (pi: ExtensionAPI) {
620
810
  jev: { ask: (request, meta) => jevRuntime.ask(request, meta), getMinConfidence: () => effConfig.jev.minConfidence },
621
811
  appendRoutingLog,
622
812
  });
813
+
814
+ /* ── /pier-tree (D113): full todo × subagent delegation tree; the resident widget is minimal ── */
815
+ pi.registerCommand('pier-tree', {
816
+ description: 'Show the full delegation tree: your todos plus every subagent\'s todos and settled nodes',
817
+ handler: async (args: unknown, ctx: unknown) => {
818
+ const ui = (ctx as { ui?: { notify?: (text: string, level?: string) => void } }).ui;
819
+ const lines = renderTreeLines();
820
+ const overlay = typeof args === 'string' && args.trim().toLowerCase() === 'overlay';
821
+
822
+ if (client.available && env) {
823
+ const treeFile = publishTreeSnapshot();
824
+ if (overlay) {
825
+ try {
826
+ const res = await client.openPluginPane({
827
+ pluginId: 'pier.workbench',
828
+ entrypoint: 'tree',
829
+ placement: 'overlay',
830
+ focus: true,
831
+ env: { PIER_PANEL: 'tree', PIER_TREE_FILE: treeFile },
832
+ });
833
+ if (res.mode === 'pane') return;
834
+ } catch {
835
+ // Plugin missing / older herdr: fall back to the in-pane notify view below.
836
+ }
837
+ } else {
838
+ const res = await openPierPanel({ client, env }, { panel: 'tree', treeFile });
839
+ if (res === 'popup') return;
840
+ if (res === 'tab') {
841
+ ui?.notify?.('Herdr < 0.9.1: Pier Tree opened in new tab', 'info');
842
+ return;
843
+ }
844
+ if (res === 'busy') {
845
+ ui?.notify?.('Another popup is already open — close it and run /pier-tree again.', 'warning');
846
+ return;
847
+ }
848
+ }
849
+ // 'unavailable' / failed overlay → the old in-pane view.
850
+ }
851
+ treeSnapshot = null;
852
+ ui?.notify?.(lines.join('\n'), 'info');
853
+ },
854
+ });
855
+
856
+ /** D113 resync: a resumed master rebuilds worker summaries from herdr pi-todo tokens until the
857
+ * next live push replaces them with full lists. */
858
+ const resyncWorkerTodos = async (): Promise<void> => {
859
+ try {
860
+ const agents = await client.listAgents();
861
+ for (const a of agents) {
862
+ const summary = a.tokens?.['pi-todo'];
863
+ if (typeof summary === 'string' && summary && !workerTodos.has(a.paneId)) {
864
+ workerTodos.set(a.paneId, { summary, ts: Date.now() });
865
+ }
866
+ }
867
+ todoUi.rerenderWidget?.();
868
+ } catch {
869
+ /* herdr absent or unreachable: live pushes remain the source */
870
+ }
871
+ };
872
+ pi.on('session_start', () => { void resyncWorkerTodos(); });
623
873
  } else {
624
874
  const { mountTodoOnly } = await import('./index-worker.ts');
625
875
  await mountTodoOnly({
package/src/jev-core.ts CHANGED
@@ -8,6 +8,7 @@
8
8
  * question needs; instructions are English (CJK is weaker, hence the stricter P0-2 gate); and any
9
9
  * answer below its confidence gate counts as unanswered so callers fail open.
10
10
  */
11
+ import { isPlainObject } from './guards.ts';
11
12
  import { containsLikelySecret, FAILURE_SIGNAL } from './reducer-core.ts';
12
13
 
13
14
  /* ── Request/response shapes (closed set) ── */
@@ -36,9 +37,6 @@ export type JevParsed =
36
37
  | { readonly ok: true; readonly answers: Record<string, JevAnswer>; readonly model: string; readonly usage: JevUsage }
37
38
  | { readonly ok: false; readonly reason: string };
38
39
 
39
- function isPlainObject(v: unknown): v is Record<string, unknown> {
40
- return typeof v === 'object' && v !== null && !Array.isArray(v);
41
- }
42
40
 
43
41
  function finiteNumber(v: unknown): number | undefined {
44
42
  return typeof v === 'number' && Number.isFinite(v) ? v : undefined;
@@ -0,0 +1,47 @@
1
+ /** Shared Pier panel opening: `/dashboard` and `/pier-tree` both render through one popup entrypoint.
2
+ *
3
+ * Herdr popups are a singleton session resource (`popup.close` has no id) and are session-modal, so
4
+ * the human cannot invoke another slash command while one is open. The command therefore chooses
5
+ * the initial tab via env; tabs are switched inside the panel (`1`/`2`/`Tab`). A `ui_busy` result is
6
+ * surfaced instead of silently retrying as a tab. */
7
+ import type { HerdrClientLike, HerdrEnv } from './herdr-client.ts';
8
+
9
+ export type PierPanel = 'dashboard' | 'tree';
10
+ export type PierPanelOpenResult = 'popup' | 'busy' | 'tab' | 'unavailable';
11
+
12
+ export interface PierPanelOpenDeps {
13
+ client: HerdrClientLike;
14
+ env: HerdrEnv | null;
15
+ }
16
+
17
+ /**
18
+ * Open the shared popup on the requested tab. `busy` means another popup/modal owns the session;
19
+ * the caller should tell the human to close it.
20
+ */
21
+ export async function openPierPanel(
22
+ deps: PierPanelOpenDeps,
23
+ opts: { panel: PierPanel; treeFile?: string | null },
24
+ ): Promise<PierPanelOpenResult> {
25
+ const { client, env } = deps;
26
+ if (!client.available || !env) return 'unavailable';
27
+
28
+ try {
29
+ const res = await client.openPluginPane({
30
+ pluginId: 'pier.workbench',
31
+ entrypoint: 'panel',
32
+ placement: 'popup',
33
+ width: '90%',
34
+ height: '80%',
35
+ focus: true,
36
+ env: {
37
+ PIER_PANEL: opts.panel,
38
+ ...(opts.treeFile ? { PIER_TREE_FILE: opts.treeFile } : {}),
39
+ },
40
+ });
41
+ if (res.mode === 'fallback_tab') return 'tab';
42
+ if (res.mode === 'busy') return 'busy';
43
+ return 'popup';
44
+ } catch {
45
+ return 'unavailable';
46
+ }
47
+ }
@@ -47,11 +47,10 @@ export const PIER_OPTIONS: readonly OptionSpec[] = [
47
47
  { name: 'PIER_SETTLEMENT_WINDOW_MS', legacy: 'PI_HERDR_SETTLEMENT_WINDOW_MS', fallback: '60000', min: 0, policy: 'settlementWindowMs', description: 'settlement notice window / machine-inject grace / takeover idle' },
48
48
  { name: 'PIER_FOREGROUND_PATIENCE_MS', legacy: 'PI_HERDR_FOREGROUND_PATIENCE_MS', fallback: '300000', min: 0, policy: 'foregroundPatienceMs', description: 'foreground patience before demoting a subagent to background' },
49
49
  { name: 'PIER_SESSION_TTL_SECONDS', legacy: 'PI_HERDR_SESSION_TTL_SECONDS', fallback: '600', min: 0, policy: 'sessionTtlSeconds', description: 'session retention after a subagent exits, before GC' },
50
- { name: 'PIER_FOCUS_POLL_MS', fallback: '1500', min: 0, description: 'pane-focus sampling cadence for the heat layout (0 disables; event-first on herdr 0.9.1+)' },
51
50
  { name: 'PIER_TERMINAL_PROMPT', legacy: 'PI_HERDR_TERMINAL_PROMPT', fallback: '(auto from $SHELL)', description: 'prompt strategy for terminal readiness: bash|zsh|powershell|pwsh' },
52
51
  { name: 'PIER_SLIM_FRAME', legacy: 'PI_HERDR_SLIM_FRAME', fallback: '1', description: 'mutation frame around todo-tool cards (0 disables)' },
53
52
  { name: 'PIER_TRACE', legacy: 'PI_HERDR_TRACE', fallback: '', description: 'write diagnostics (renderers, swallowed errors) to stderr (or to this file when it is a path)' },
54
- { name: 'PIER_TERM_IDLE_MS', legacy: 'PI_HERDR_TERM_IDLE_MS', fallback: '1800000', min: 1, description: 'idle time before pier nudges about an open terminal (0/NaN falls back to the default)' },
53
+ { name: 'PIER_TERM_IDLE_MS', legacy: 'PI_HERDR_TERM_IDLE_MS', fallback: '300000', min: 1, description: 'idle time before the second cleanup nudge for an open terminal (per-terminal max 2; 0/NaN falls back to the default)' },
55
54
  { name: 'PIER_TERM_GRACE_MS', legacy: 'PI_HERDR_TERM_GRACE_MS', fallback: '30000', min: 1, description: 'settle grace before the terminal idle nudge (0/NaN falls back to the default)' },
56
55
  { name: 'PIER_TERM_READ_MAX', legacy: 'PI_HERDR_TERM_READ_MAX', fallback: '8000', min: 1, description: 'characters returned per terminal read (0/NaN falls back to the default)' },
57
56
  { name: 'PIER_TODO_GRACE_MS', legacy: 'PI_HERDR_TODO_GRACE_MS', fallback: '30000', min: 1, description: 'settle grace before the unfinished-todo reminder (0/NaN falls back to the default)' },
@@ -58,6 +58,23 @@ export type PipeRequest =
58
58
  id: string;
59
59
  role: string;
60
60
  }
61
+ | {
62
+ /** Phase 2 (RFC rfc-delegation-identity §3.2): worker → master progress report. The master
63
+ * injects it as a display-less custom message (no turn, no visible chat noise). */
64
+ type: 'progress';
65
+ id: string;
66
+ paneId: string;
67
+ text: string;
68
+ }
69
+ | {
70
+ /** D113 (RFC rfc-unified-widget): worker → master full todo push on every todo_write, so the
71
+ * master's resident widget and /pier-tree render live worker state without polling herdr. */
72
+ type: 'todo_sync';
73
+ id: string;
74
+ paneId: string;
75
+ items: Array<{ content: string; status: string; blocker?: string }>;
76
+ summary: string;
77
+ }
61
78
  | {
62
79
  /** D50: fast-path settlement reply from child to requester, with summary and session path for progressive disclosure. */
63
80
  type: 'reply';