pi-pier 0.3.0 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (96) hide show
  1. package/README.md +12 -11
  2. package/package.json +2 -2
  3. package/schemas/role-manifest.schema.json +7 -1
  4. package/skills/pi-herdr-workflow/SKILL.md +4 -5
  5. package/src/ansi-text.ts +62 -11
  6. package/src/ask-dialog.ts +308 -0
  7. package/src/ask-user.ts +113 -112
  8. package/src/bootstrap.ts +8 -12
  9. package/src/compact-coordinator.ts +278 -261
  10. package/src/compact-economics-core.ts +77 -145
  11. package/src/config-catalog-core.ts +77 -197
  12. package/src/config-command.ts +271 -64
  13. package/src/dashboard-command.ts +38 -19
  14. package/src/efficiency-config-core.ts +287 -484
  15. package/src/efficiency-store.ts +16 -29
  16. package/src/focus-poller.ts +14 -35
  17. package/src/gc-core.ts +10 -36
  18. package/src/herdr-client.ts +82 -208
  19. package/src/history-store.ts +36 -23
  20. package/src/index-gates.ts +124 -0
  21. package/src/index-locks.ts +2 -7
  22. package/src/index-master.ts +24 -18
  23. package/src/index-notices.ts +28 -15
  24. package/src/index-pipe.ts +17 -12
  25. package/src/index-roles.ts +306 -0
  26. package/src/index-runtime.ts +2 -6
  27. package/src/index-worker.ts +4 -8
  28. package/src/index.ts +305 -564
  29. package/src/jev-client.ts +12 -17
  30. package/src/jev-core.ts +137 -87
  31. package/src/ledger.ts +13 -21
  32. package/src/lock-core.ts +28 -56
  33. package/src/manifest-compose.ts +35 -20
  34. package/src/observation-core.ts +52 -100
  35. package/src/pane-title.ts +19 -38
  36. package/src/pi-surface.ts +34 -22
  37. package/src/pier-options.ts +54 -50
  38. package/src/pipe-channel.ts +27 -59
  39. package/src/platform-paths.ts +19 -52
  40. package/src/{core → plugins}/grid-shape.ts +8 -10
  41. package/src/{core → plugins}/heat-plan.ts +20 -22
  42. package/src/plugins/observation.ts +475 -0
  43. package/src/plugins/subagent.ts +728 -0
  44. package/src/{core → plugins}/terminal.ts +167 -127
  45. package/src/{core → plugins}/todo.ts +61 -100
  46. package/src/progress-core.ts +16 -53
  47. package/src/reconcile-core.ts +24 -33
  48. package/src/reducer-core.ts +24 -60
  49. package/src/reducer-invoker.ts +266 -239
  50. package/src/renderers.ts +76 -87
  51. package/src/role-loader.ts +33 -28
  52. package/src/role-manifest.ts +23 -28
  53. package/src/role-state.ts +119 -0
  54. package/src/routing-telemetry.ts +136 -0
  55. package/src/runtime-policy.ts +25 -40
  56. package/src/session-tail.ts +65 -41
  57. package/src/settle-wake-core.ts +52 -21
  58. package/src/slim-frame.ts +25 -122
  59. package/src/stale-core.ts +17 -24
  60. package/src/storage-layout.ts +14 -43
  61. package/src/subagent-core.ts +253 -118
  62. package/src/subagent-gc.ts +109 -67
  63. package/src/subagent-poller.ts +367 -24
  64. package/src/subagent-session.ts +516 -0
  65. package/src/subagent-spawn.ts +530 -52
  66. package/src/swallow.ts +7 -17
  67. package/src/terminal-core.ts +125 -106
  68. package/src/todo-core.ts +57 -109
  69. package/src/todo-read-hook.ts +23 -39
  70. package/src/todo-reminder-core.ts +26 -51
  71. package/src/todo-window.ts +11 -12
  72. package/src/todos-service.ts +15 -35
  73. package/src/tool-error.ts +6 -13
  74. package/src/tool-gate.ts +38 -21
  75. package/src/vocab.ts +52 -25
  76. package/src/ask-multi.ts +0 -260
  77. package/src/config-guide.ts +0 -361
  78. package/src/core/observation.ts +0 -555
  79. package/src/core/subagent.ts +0 -633
  80. package/src/efficiency-config-io.ts +0 -53
  81. package/src/git-adapter.ts +0 -113
  82. package/src/notice-buffer.ts +0 -23
  83. package/src/subagent-execute.ts +0 -328
  84. package/src/subagent-git-io.ts +0 -80
  85. package/src/subagent-launch.ts +0 -114
  86. package/src/subagent-list-action.ts +0 -69
  87. package/src/subagent-output-action.ts +0 -104
  88. package/src/subagent-output-adapter.ts +0 -27
  89. package/src/subagent-output-core.ts +0 -263
  90. package/src/subagent-poll-loop.ts +0 -305
  91. package/src/subagent-port.ts +0 -27
  92. package/src/subagent-ready.ts +0 -89
  93. package/src/subagent-registry.ts +0 -70
  94. package/src/subagent-resolution.ts +0 -41
  95. package/src/subagent-scope.ts +0 -41
  96. package/src/subagent-session-io.ts +0 -230
@@ -0,0 +1,124 @@
1
+ /**
2
+ * Human-gate reporting: coalesces every blocking prompt (pier's ask tool, pi's own ctx.ui dialogs,
3
+ * external herdr:blocked producers) into ONE blocked edge.
4
+ *
5
+ * Depth semantics: only the 0→1 transition reports blocked (and marks the ask flag); only the 1→0
6
+ * transition restores working/idle. While the depth is positive, working/idle reports are dropped
7
+ * so the pane stays blocked for the whole dialog. */
8
+ import type { ExtensionAPI } from '@earendil-works/pi-coding-agent';
9
+ import type { HerdrClientLike } from './herdr-client.ts';
10
+
11
+ /** pi surface pieces the gate runtime touches (ui_prompt events postdate the pinned devDependency). */
12
+ type GatePi = ExtensionAPI & {
13
+ on?: (name: 'ui_prompt_start' | 'ui_prompt_end', handler: (event: unknown) => void) => void;
14
+ };
15
+
16
+ /** Only inherently human-blocking kinds open the gate; `custom` covers resident overlays too (D97). */
17
+ const GATING_PROMPT_KINDS = new Set(['select', 'confirm', 'input', 'editor']);
18
+
19
+ export interface GateRuntimeDeps {
20
+ pi: ExtensionAPI;
21
+ client: HerdrClientLike;
22
+ /** Collapse the todo widget to one line while a gate is open; restore on release. */
23
+ rerenderWidget: () => void;
24
+ /** True while an agent turn is active (drives the state reported on release). */
25
+ isAgentActive: () => boolean;
26
+ /** Idle activity text (idle role badge / current todo) reported with the idle state. */
27
+ idleMessage: () => string | null;
28
+ /** Working activity text (current todo) reported when a gate releases mid-turn. */
29
+ workingMessage: () => string | null;
30
+ }
31
+
32
+ export interface GateRuntime {
33
+ depth(): number;
34
+ /** Gate-aware agent-state report (blocked depth swallows working/idle). */
35
+ report(state: 'working' | 'idle', activity: string | null): void;
36
+ /** Open one gate; true when this call opened the outer one. */
37
+ enter(label: string | null): boolean;
38
+ /** Close one gate; an unmatched release is ignored. True when the outer gate closed. */
39
+ exit(): boolean;
40
+ /** enter/exit + publish the herdr:blocked edge for pier's own gates. */
41
+ publish(active: boolean, label: string | null): void;
42
+ }
43
+
44
+ export function createGateRuntime(d: GateRuntimeDeps): GateRuntime {
45
+ const { client } = d;
46
+ let blockedDepth = 0;
47
+
48
+ function report(state: 'working' | 'idle', activity: string | null): void {
49
+ if (!client.available || blockedDepth > 0) return;
50
+ const message = activity ?? (state === 'idle' ? d.idleMessage() : null);
51
+ client.reportAgent(state, message).catch(() => {});
52
+ }
53
+
54
+ function enter(label: string | null): boolean {
55
+ blockedDepth += 1;
56
+ if (blockedDepth > 1) return false;
57
+ if (client.available) client.reportAgent('blocked', label).catch(() => {});
58
+ // D95: human-gate marker lets the workbench heatmap distinguish ask from block.
59
+ if (label) void client.reportAskFlag(label).catch(() => {});
60
+ d.rerenderWidget();
61
+ return true;
62
+ }
63
+
64
+ function exit(): boolean {
65
+ if (blockedDepth === 0) return false;
66
+ blockedDepth -= 1;
67
+ d.rerenderWidget();
68
+ if (blockedDepth > 0) return false;
69
+ report(d.isAgentActive() ? 'working' : 'idle', d.isAgentActive() ? d.workingMessage() : null);
70
+ void client.reportAskFlag(null).catch(() => {});
71
+ return true;
72
+ }
73
+
74
+ // emit() is synchronous; this flag stops our own listener from double-counting depth.
75
+ let publishing = false;
76
+ function emitHerdrBlocked(active: boolean, label: string | null): void {
77
+ publishing = true;
78
+ try {
79
+ d.pi.events.emit(
80
+ 'herdr:blocked',
81
+ active ? { active: true, ...(label ? { label } : {}) } : { active: false },
82
+ );
83
+ } finally {
84
+ publishing = false;
85
+ }
86
+ }
87
+
88
+ d.pi.events.on('herdr:blocked', (data) => {
89
+ if (publishing) return;
90
+ const active = typeof data === 'object' && data !== null && 'active' in data && data.active === true;
91
+ if (active) {
92
+ enter(typeof data === 'object' && 'label' in data && typeof data.label === 'string' ? data.label : null);
93
+ } else {
94
+ exit();
95
+ }
96
+ });
97
+
98
+ const gatePi = d.pi as GatePi;
99
+ gatePi.on?.('ui_prompt_start', (event) => {
100
+ const rec = (event ?? {}) as { kind?: unknown; title?: unknown };
101
+ const title = typeof rec.title === 'string' && rec.title.trim() ? rec.title.trim() : null;
102
+ const kind = typeof rec.kind === 'string' ? rec.kind : 'prompt';
103
+ if (!GATING_PROMPT_KINDS.has(kind)) return;
104
+ const label = title ?? kind;
105
+ // The ask tool opens its own gate first, so this is usually a nested no-op;
106
+ // prompts from other extensions or pi core open the gate here.
107
+ if (enter(label)) emitHerdrBlocked(true, label);
108
+ });
109
+ gatePi.on?.('ui_prompt_end', () => {
110
+ if (exit()) emitHerdrBlocked(false, null);
111
+ });
112
+
113
+ return {
114
+ depth: () => blockedDepth,
115
+ report,
116
+ enter,
117
+ exit,
118
+ publish(active: boolean, label: string | null): void {
119
+ if (active) enter(label);
120
+ else exit();
121
+ emitHerdrBlocked(active, label);
122
+ },
123
+ };
124
+ }
@@ -1,10 +1,5 @@
1
- /**
2
- * M18 file write-lock installer (soft veto / hard block).
3
- *
4
- * Why: lock acquire/release/command lived in the composition root next to
5
- * unrelated session wiring. The installer owns the three pi hooks and the
6
- * held-lock set; fail-open on agent.list remains the collaboration policy.
7
- */
1
+ /** M18 file write-lock installer (soft veto / hard block): owns the three pi hooks and the
2
+ * held-lock set; fail-open on agent.list is the collaboration policy. */
8
3
  import type { ExtensionAPI } from '@earendil-works/pi-coding-agent';
9
4
  import {
10
5
  acquireTokensFor,
@@ -1,22 +1,21 @@
1
- /**
2
- * Master-process plugin mount (cordis loader + terminal/todo/subagent).
3
- *
4
- * Why: worker must never load bootstrap/subagent-scope (C3). This module is
5
- * dynamically imported only from the master branch of index.ts.
6
- */
1
+ /** Master-process plugin mount (cordis loader + terminal/todo/subagent), dynamically imported from
2
+ * the master branch of index.ts only: a worker must never load bootstrap/subagent-poller (C3). */
7
3
  import type { ExtensionAPI } from '@earendil-works/pi-coding-agent';
8
4
  import type { Context } from '@deepseek-ai/cordis';
9
5
  import type { Server } from 'node:net';
10
6
  import { PiSurface } from './pi-surface.ts';
11
7
  import { createCordisApp } from './bootstrap.ts';
12
- import { disposeSessionRoot } from './subagent-scope.ts';
13
- import terminalPlugin from './core/terminal.ts';
14
- import todoPlugin from './core/todo.ts';
15
- import subagentPlugin from './core/subagent.ts';
8
+ import { disposeSessionRoot } from './subagent-poller.ts';
9
+ import terminalPlugin from './plugins/terminal.ts';
10
+ import todoPlugin from './plugins/todo.ts';
11
+ import subagentPlugin from './plugins/subagent.ts';
16
12
  import type { HerdrClientLike, HerdrEnv } from './herdr-client.ts';
13
+ import type { JevRuntime } from './jev-client.ts';
14
+ import type { RoutingTelemetryRecord } from './routing-telemetry.ts';
17
15
  import type { TodosService } from './todos-service.ts';
18
- import type { TodoUiSlot } from './core/todo.ts';
19
- import type { SubagentPortBox } from './subagent-port.ts';
16
+ import type { TodoUiSlot } from './plugins/todo.ts';
17
+ import type { SubagentPortBox } from './subagent-core.ts';
18
+ import type { SubagentDeps } from './plugins/subagent.ts';
20
19
 
21
20
  export interface MasterPluginMount {
22
21
  pi: ExtensionAPI;
@@ -37,6 +36,10 @@ export interface MasterPluginMount {
37
36
  claimSettleNotice: (key: string) => boolean;
38
37
  isCompactionInFlight?: () => boolean;
39
38
  isIntentionalAbort?: () => boolean;
39
+ /** Optional jev seam for subagent settle attribution (fail-open when absent). */
40
+ jev?: { ask: JevRuntime['ask']; getMinConfidence: () => number };
41
+ /** Phase 0 routing telemetry append (RFC rfc-jev-role-routing §8); absent in tests. */
42
+ appendRoutingLog?: (row: RoutingTelemetryRecord) => void;
40
43
  }
41
44
 
42
45
  async function loadEntry(
@@ -62,8 +65,7 @@ export async function mountMasterPlugins(m: MasterPluginMount): Promise<void> {
62
65
  // strip-types) and misses pi's typebox alias. HMR-only; production uses plugin().
63
66
  const useLoader = cordisApp.loaderReady && cordisApp.hmrActive;
64
67
 
65
- // Pipe server is created in the common segment; closing it from a core
66
- // plugin would kill it on hmr reload (d87).
68
+ // Pipe server is created in the common segment; closing it from a core plugin would kill it on hmr reload (d87).
67
69
  sessionRoot.effect(() => () => {
68
70
  if (m.pipeServerBox.current) {
69
71
  try { m.pipeServerBox.current.close(); } catch { /* already closed */ }
@@ -80,7 +82,7 @@ export async function mountMasterPlugins(m: MasterPluginMount): Promise<void> {
80
82
  };
81
83
  sessionRoot.provide('pi-herdr.terminal-deps', terminalDeps);
82
84
 
83
- await loadEntry(sessionRoot, useLoader, './core/terminal.ts', terminalPlugin);
85
+ await loadEntry(sessionRoot, useLoader, './plugins/terminal.ts', terminalPlugin);
84
86
 
85
87
  sessionRoot.provide('pi-herdr.todo-deps', {
86
88
  todos: m.todos,
@@ -99,8 +101,10 @@ export async function mountMasterPlugins(m: MasterPluginMount): Promise<void> {
99
101
  isIntentionalAbort: m.isIntentionalAbort,
100
102
  },
101
103
  });
102
- await loadEntry(sessionRoot, useLoader, './core/todo.ts', todoPlugin);
104
+ await loadEntry(sessionRoot, useLoader, './plugins/todo.ts', todoPlugin);
103
105
 
106
+ // D98 regression guard: `satisfies` makes tsc reject a missing/mistyped key here — the bag is
107
+ // consumed untyped by plugins/subagent.ts, and the fe99252 crash was exactly a dropped key.
104
108
  sessionRoot.provide('pi-herdr.subagent-deps', {
105
109
  client: m.client,
106
110
  env: m.env,
@@ -114,8 +118,10 @@ export async function mountMasterPlugins(m: MasterPluginMount): Promise<void> {
114
118
  withReconcileNotes: m.withReconcileNotes,
115
119
  claimSettleNotice: m.claimSettleNotice,
116
120
  terminalState: terminalDeps.state,
117
- });
118
- await loadEntry(sessionRoot, useLoader, './core/subagent.ts', subagentPlugin);
121
+ ...(m.jev ? { jev: m.jev } : {}),
122
+ ...(m.appendRoutingLog ? { logRouting: m.appendRoutingLog } : {}),
123
+ } satisfies SubagentDeps);
124
+ await loadEntry(sessionRoot, useLoader, './plugins/subagent.ts', subagentPlugin);
119
125
 
120
126
  m.pi.on('session_shutdown', () => {
121
127
  void disposeSessionRoot(sessionRoot);
@@ -1,14 +1,23 @@
1
1
  /**
2
- * D92 settlement-notice buffer (busy-run collapse).
3
- *
4
- * Why: `sendUserMessage(followUp)` only delivers when the agent has no more
5
- * tool calls — a long master run queues every settlement and floods at the
6
- * end. Buffer while busy; flush at turn_end (steer) / agent_settled (followUp).
7
- */
8
- import { NOTICE_MAX_SHOWN, collapseNotices } from './notice-buffer.ts';
2
+ * D92 settlement-notice buffer: `sendUserMessage(followUp)` only delivers when the agent has no
3
+ * more tool calls, so a long master run would queue every settlement and flood at the end. Buffer
4
+ * while busy; flush at turn_end (steer) / agent_settled (followUp), collapsing large batches. */
9
5
 
10
6
  export type NoticeSendMode = 'steer' | 'followUp';
11
7
 
8
+ export const NOTICE_MAX_SHOWN = 3;
9
+
10
+ /** Collapse a batch into one message body: empty → null; above the cap, the first max originals plus
11
+ * a tail pointing at history / subagent list. A single-item batch stays byte-identical to the
12
+ * direct-send format. */
13
+ export function collapseNotices(contents: readonly string[], max = NOTICE_MAX_SHOWN): string | null {
14
+ if (contents.length === 0) return null;
15
+ if (contents.length <= max) return contents.join('\n\n');
16
+ const hidden = contents.length - max;
17
+ const tail = `…另有 ${hidden} 条结算未逐条展示。全量结果看 history 台账(路径公式见 subagent resume 工具描述);在跑代理用 subagent list 查看。`;
18
+ return [...contents.slice(0, max), tail].join('\n\n');
19
+ }
20
+
12
21
  export interface NoticeBuffer {
13
22
  deliverNotice(content: string, paneId?: string): Promise<void>;
14
23
  noticePending(): ReadonlySet<string>;
@@ -20,19 +29,18 @@ export function createNoticeBuffer(opts: {
20
29
  isBusy: () => boolean;
21
30
  send: (content: string, mode: NoticeSendMode) => Promise<void>;
22
31
  /**
23
- * P0-2 (RFC docs/rfc-jev-integration.md §3): relevance reorder of a collapsed
24
- * batch before truncation. Only consulted above the show cap; returning null
25
- * (or omitting the hook) keeps arrival order. Implementations must not throw.
26
- */
32
+ * P0-2 (RFC docs/rfc-jev-integration.md §3): relevance reorder of a collapsed batch before truncation.
33
+ * Only consulted above the show cap; returning null (or omitting the hook) keeps arrival order.
34
+ * Implementations must not throw. */
27
35
  rank?: (contents: readonly string[]) => Promise<readonly string[] | null>;
28
36
  }): NoticeBuffer {
29
- const pending: string[] = [];
37
+ const pending: Array<{ content: string; paneId?: string }> = [];
30
38
  const pendingPaneIds = new Set<string>();
31
39
  return {
32
40
  async deliverNotice(content, paneId) {
33
41
  if (content !== '' && paneId !== undefined) pendingPaneIds.add(paneId);
34
42
  if (opts.isBusy()) {
35
- pending.push(content);
43
+ pending.push({ content, paneId });
36
44
  return;
37
45
  }
38
46
  await opts.send(content, 'followUp');
@@ -41,12 +49,17 @@ export function createNoticeBuffer(opts: {
41
49
  noticePending: () => pendingPaneIds,
42
50
  async flush(mode) {
43
51
  if (pending.length === 0) return;
44
- const batch = pending.splice(0);
52
+ const flushed = pending.splice(0);
53
+ const batch = flushed.map((n) => n.content);
45
54
  let ordered: readonly string[] = batch;
46
55
  if (opts.rank && batch.length > NOTICE_MAX_SHOWN) {
47
56
  ordered = (await opts.rank(batch)) ?? batch;
48
57
  }
49
- pendingPaneIds.clear();
58
+ // Release only this batch's panes: a notice buffered during the rank await is still undelivered,
59
+ // and GC must keep its pane until it is.
60
+ for (const { paneId } of flushed) {
61
+ if (paneId !== undefined && !pending.some((n) => n.paneId === paneId)) pendingPaneIds.delete(paneId);
62
+ }
50
63
  const collapsed = collapseNotices(ordered);
51
64
  if (collapsed) await opts.send(collapsed, mode);
52
65
  },
package/src/index-pipe.ts CHANGED
@@ -1,12 +1,7 @@
1
- /**
2
- * Common-segment pipe request handler (prompt / interrupt / reply).
3
- *
4
- * Why: index.ts owned the NDJSON switch next to session lifecycle. The
5
- * dispatch is a pure-ish adapter over the subagent port and notice buffer.
6
- */
1
+ /** Common-segment pipe request handler (prompt / interrupt / reply) over the subagent port and notice buffer. */
7
2
  import { formatSettlementNotice } from './vocab.ts';
8
3
  import type { PipeRequest, PipeResponse } from './pipe-channel.ts';
9
- import type { SubagentPortBox } from './subagent-port.ts';
4
+ import type { SubagentPortBox } from './subagent-core.ts';
10
5
 
11
6
  export interface MachineRequest {
12
7
  id: string;
@@ -24,6 +19,8 @@ export interface PipeHandlerSession {
24
19
  sendUserMessageAs: (content: string, mode: 'steer' | 'followUp') => Promise<void>;
25
20
  abort: () => void;
26
21
  setPendingMachineRequest: (req: MachineRequest | null) => void;
22
+ /** P0 (RFC §4.4): apply a role switch requested over the pipe (master → worker). */
23
+ applyRoleSwitch: (role: string, switchedBy: string) => Promise<{ ok: boolean; message: string }>;
27
24
  }
28
25
 
29
26
  export async function handlePipeRequest(
@@ -51,13 +48,21 @@ export async function handlePipeRequest(
51
48
  s.abort();
52
49
  s.setPendingMachineRequest(null);
53
50
  return { type: 'ok', id: req.id };
51
+ case 'role': {
52
+ const res = await s.applyRoleSwitch(req.role, s.paneId || 'pipe');
53
+ return res.ok
54
+ ? { type: 'ok', id: req.id, detail: res.message }
55
+ : { type: 'error', id: req.id, message: res.message };
56
+ }
54
57
  case 'reply': {
55
58
  s.port.current?.applyReplySession(req.paneId, req.sessionFile);
56
- // B8: the claim key is `${paneId}:${requestId}` and the poll loop claims exactly the same shape
57
- // (subagent-poll-loop.ts settle path). The two agree because a child echoes the id of the pipe
58
- // request it answered (`id: req.id` in index.ts's settle push), and the parent hands that same
59
- // id to startPoller (`prompt-<taskId>` / `fu-<ts>` in core/subagent.ts). Keep both sides in step:
60
- // a mismatch here surfaces as two "finished" notices for one settlement.
59
+ // Consume before the finish notice: otherwise listRunningSubs still reports the pane and the
60
+ // next master settle injects "still running" for an agent just announced finished.
61
+ s.port.current?.consumeReply(req.paneId, req.text);
62
+ // B8: the claim key `${paneId}:${requestId}` must match the poll loop's (subagent-poller.ts):
63
+ // a child echoes the id of the pipe request it answered (`id: req.id` in index.ts's settle
64
+ // push) and the parent hands that same id to startPoller (`prompt-<taskId>` / `fu-<ts>`).
65
+ // A mismatch surfaces as two "finished" notices for one settlement.
61
66
  if (s.claimSettleNotice(`${req.paneId}:${req.id}`)) {
62
67
  const notes = s.port.current?.reconcileOnReply(req.paneId) ?? [];
63
68
  const statLine = await s.port.current?.settleStatLine(req.paneId) ?? null;
@@ -0,0 +1,306 @@
1
+ /**
2
+ * Role runtime for the composition root: manifest state, mid-session switching,
3
+ * the mandatory tool gate, `/pier-role`, and branch replay.
4
+ */
5
+ import type { ExtensionAPI } from '@earendil-works/pi-coding-agent';
6
+ import { readdirSync, readFileSync } from 'node:fs';
7
+ import { join } from 'node:path';
8
+ import { composeForRole, toRuntimeManifest } from './manifest-compose.ts';
9
+ import { planActiveTools, planToolGate, type RuntimeRoleManifest } from './tool-gate.ts';
10
+ import {
11
+ initialRoleState,
12
+ latestRoleManifestRecord,
13
+ manifestFromRecord,
14
+ planRoleSwitch,
15
+ planSwitchActiveTools,
16
+ roleRecordDiffers,
17
+ type RoleState,
18
+ } from './role-state.ts';
19
+ import { RESERVED_ROLE_NAMES, listRoleNames, roleLayers } from './role-loader.ts';
20
+ import { TodosService } from './todos-service.ts';
21
+ import { APPROVAL_NEEDED_CUSTOM_TYPE, ROLE_MANIFEST_CUSTOM_TYPE } from './renderers.ts';
22
+ import { planDenyHitRow, scanRoleAxisUsage, type RoutingTelemetryRecord } from './routing-telemetry.ts';
23
+ import type { HerdrClientLike } from './herdr-client.ts';
24
+
25
+ /**
26
+ * WS-D7: the master pane applies its own manifest through the same mandatory chain as
27
+ * subagents. The builtin `master` role is read directly so a workspace master.json decoy
28
+ * cannot affect self-application; a malformed manifest fails open to no-role state.
29
+ */
30
+ export function composeMasterRuntime(): RuntimeRoleManifest | null {
31
+ try {
32
+ return toRuntimeManifest(composeForRole('master', [], { loadRoleOpts: { builtinDirect: true } }));
33
+ } catch (err) {
34
+ console.error(`[pi-herdr] master manifest invalid (fail-open): ${err instanceof Error ? err.message : String(err)}`);
35
+ return null;
36
+ }
37
+ }
38
+
39
+ export interface RoleRuntimeDeps {
40
+ pi: ExtensionAPI;
41
+ /** D93 sidebar identity (`display_agent`) is a herdr RPC — pi's ExtensionAPI has no such method. */
42
+ client: HerdrClientLike;
43
+ /** Role-resolution base dir carried by the master at spawn (an isolate worker's own cwd resolves a different checkout). */
44
+ roleBase: string;
45
+ initialManifest: RuntimeRoleManifest | null;
46
+ isSubagent: boolean;
47
+ todos: TodosService;
48
+ /** Best-effort routing telemetry (RFC docs/rfc-jev-role-routing.md §8); observation only, never gates. */
49
+ appendRoutingLog: (row: RoutingTelemetryRecord) => void;
50
+ }
51
+
52
+ export interface RoleRuntime {
53
+ readonly state: RoleState;
54
+ idleBadge(): string | null;
55
+ /** Replay the branch-point role record and re-anchor side effects (D77 visible layer, sidebar identity, badge). */
56
+ syncFromBranch(ctx: unknown): void;
57
+ /** Phase 0 axis scan of the workspace+user role layers (master session_start only). */
58
+ scanRoleUsage(): void;
59
+ /** Mid-session switch: /pier-role (human) and the pipe `role` request (master). */
60
+ applyRoleSwitch(roleName: string, switchedBy: string): Promise<{ ok: boolean; message: string }>;
61
+ }
62
+
63
+ export function createRoleRuntime(d: RoleRuntimeDeps): RoleRuntime {
64
+ const { pi, client, todos, roleBase, appendRoutingLog } = d;
65
+ const roleState = initialRoleState(d.initialManifest);
66
+ let roleBadge: string | null = null;
67
+
68
+ /** D93: sidebar identity is the role name; null clears it. Best effort — herdr may be absent. */
69
+ const reportSidebarIdentity = (role: string): void => {
70
+ void client.reportDisplayAgent(role === 'worker-default' ? 'worker' : role).catch(() => {});
71
+ };
72
+
73
+ const setBadge = (m: RuntimeRoleManifest): string =>
74
+ (roleBadge = `role ${m.role} v${m.version ?? '?'} (${m.tools.length} tools)`);
75
+
76
+ /**
77
+ * Same-origin sync (RFC docs/rfc-pi-0.86-dynamic-tools.md §4.3): reconcile roleState against the CURRENT
78
+ * branch point on session_start (resume) and session_tree — pi's tool deltas revert the loadout at the
79
+ * branch point, so the gate manifest must follow or it would keep the post-switch tools. Replay only
80
+ * when the last record names a DIFFERENT role (name equality tolerates on-disk drift and keeps the env
81
+ * value authoritative on resume); the anchor entry is written only on change so resume replay still works. */
82
+ function syncFromBranch(ctx: unknown): void {
83
+ if (!roleState.manifest) return;
84
+ try {
85
+ const branchEntries =
86
+ (ctx as { sessionManager?: { getBranch?: () => readonly unknown[] } }).sessionManager?.getBranch?.() ?? [];
87
+ const record = latestRoleManifestRecord(branchEntries);
88
+ if (record && record.role !== roleState.manifest.role) {
89
+ roleState.manifest = manifestFromRecord(record);
90
+ roleState.origin = record.origin === 'switch' ? 'switch' : 'env';
91
+ roleState.switchedBy = record.switchedBy ?? null;
92
+ roleState.switchedAt = record.ts ?? null;
93
+ console.error(`[pi-herdr] role replay: ${record.role} (origin ${record.origin ?? 'env'}, switchedBy ${record.switchedBy ?? '?'})`);
94
+ }
95
+ if (roleRecordDiffers(record, roleState)) {
96
+ const m = roleState.manifest;
97
+ pi.appendEntry(ROLE_MANIFEST_CUSTOM_TYPE, {
98
+ version: 1,
99
+ role: m.role,
100
+ manifestVersion: m.version,
101
+ tools: m.tools,
102
+ permissions: m.permissions,
103
+ unknownTools: m.unknownTools ?? 'deny',
104
+ ...(m.guidelines?.length ? { guidelines: m.guidelines } : {}),
105
+ ...(roleState.origin === 'switch'
106
+ ? { origin: 'switch' as const, switchedBy: roleState.switchedBy ?? undefined }
107
+ : {}),
108
+ ts: Date.now(),
109
+ });
110
+ }
111
+ } catch {
112
+ /* Best effort recording; the mandatory layer does not depend on the session log. */
113
+ }
114
+ reportSidebarIdentity(roleState.manifest.role);
115
+ setBadge(roleState.manifest);
116
+ // D77 visible layer: hide tools outside the manifest AFTER all plugins load.
117
+ // Intersection semantics prevent clearing everything; master without a
118
+ // manifest stays full. setActiveTools persists as a tool delta (pi 0.86).
119
+ try {
120
+ const active = pi.getActiveTools();
121
+ const vis = planActiveTools(roleState.manifest.tools, active, {
122
+ unknownTools: roleState.manifest.unknownTools,
123
+ permissions: roleState.manifest.permissions,
124
+ });
125
+ if (vis && vis.changed) {
126
+ pi.setActiveTools(vis.next);
127
+ console.error(`[pi-herdr] D77 visible-layer: role ${roleState.manifest.role} tools ${active.length} → ${vis.next.length}`);
128
+ }
129
+ } catch {
130
+ /* Best effort visible layer; the mandatory layer remains active. */
131
+ }
132
+ }
133
+
134
+ /** Axis scan reads file TEXT of the two top layers; per-layer readdir is guarded (workspace layer is commonly absent). */
135
+ function scanRoleUsage(): void {
136
+ try {
137
+ const files: Array<{ name: string; text: string }> = [];
138
+ for (const layer of roleLayers({ baseDir: roleBase }).slice(0, 2)) {
139
+ let names: string[];
140
+ try {
141
+ names = readdirSync(layer.dir);
142
+ } catch {
143
+ continue;
144
+ }
145
+ for (const f of names) {
146
+ if (!f.endsWith('.json')) continue;
147
+ try {
148
+ files.push({ name: f, text: readFileSync(join(layer.dir, f), 'utf8') });
149
+ } catch {
150
+ }
151
+ }
152
+ }
153
+ appendRoutingLog(scanRoleAxisUsage({ now: Date.now(), files }));
154
+ } catch {
155
+ }
156
+ }
157
+
158
+ async function applyRoleSwitch(
159
+ roleName: string,
160
+ switchedBy: string,
161
+ ): Promise<{ ok: boolean; message: string }> {
162
+ const current = roleState.manifest;
163
+ if (!current) {
164
+ return { ok: false, message: 'Error: role system not armed (no manifest; bare pi sessions cannot switch roles)' };
165
+ }
166
+ const name = roleName.trim();
167
+ if (name === '') return { ok: false, message: 'Error: role name is required (see /pier-role with no argument)' };
168
+ if (name === current.role) return { ok: true, message: `role is already ${name}` };
169
+ let next: RuntimeRoleManifest;
170
+ try {
171
+ // Resolve against the spawn-inherited base, matching the initial manifest composition.
172
+ next = toRuntimeManifest(composeForRole(name, [], { loadRoleOpts: { baseDir: roleBase } }));
173
+ } catch (err) {
174
+ return { ok: false, message: `Error: role "${name}" unavailable: ${(err as Error).message}` };
175
+ }
176
+ const plan = planRoleSwitch(current.tools, next.tools);
177
+ // Universe = ALL registered tools: a switch may re-admit tools an earlier
178
+ // prune removed, which intersection semantics would lose.
179
+ const registered = pi.getAllTools().map((t) => t.name);
180
+ const nextActive = planSwitchActiveTools(next.tools, registered, {
181
+ unknownTools: next.unknownTools,
182
+ permissions: next.permissions,
183
+ });
184
+ pi.setActiveTools(nextActive);
185
+ // TodosService captured services.todos.mode at startup; a mode switch must reach it.
186
+ todos.config.allowParallelInProgress = TodosService.configFromRuntime(next, d.isSubagent).allowParallelInProgress;
187
+ roleState.manifest = next;
188
+ roleState.origin = 'switch';
189
+ roleState.switchedBy = switchedBy;
190
+ roleState.switchedAt = Date.now();
191
+ setBadge(next);
192
+ try {
193
+ pi.appendEntry(ROLE_MANIFEST_CUSTOM_TYPE, {
194
+ version: 1,
195
+ role: next.role,
196
+ manifestVersion: next.version,
197
+ tools: next.tools,
198
+ permissions: next.permissions,
199
+ unknownTools: next.unknownTools ?? 'deny',
200
+ ...(next.guidelines?.length ? { guidelines: next.guidelines } : {}),
201
+ origin: 'switch' as const,
202
+ switchedBy,
203
+ ts: Date.now(),
204
+ });
205
+ } catch {
206
+ }
207
+ reportSidebarIdentity(next.role);
208
+ const diff = [
209
+ plan.added.length ? `+${plan.added.join(', ')}` : null,
210
+ plan.removed.length ? `-${plan.removed.join(', ')}` : null,
211
+ ]
212
+ .filter(Boolean)
213
+ .join(' ');
214
+ return {
215
+ ok: true,
216
+ message: `role ${current.role} → ${next.role}${diff ? ` (${diff})` : ''}; active tools: ${nextActive.length}`,
217
+ };
218
+ }
219
+
220
+ /* Mandatory gate: outside-manifest tool → block + terminate the batch;
221
+ * ask → v1 soft-allow with a durable APPROVAL_NEEDED trace (V56). */
222
+ pi.on('tool_call', async (event: { toolName?: string }) => {
223
+ const manifest = roleState.manifest;
224
+ if (!manifest) return;
225
+ const tool = typeof event?.toolName === 'string' ? event.toolName : '';
226
+ const gate = planToolGate(tool, manifest);
227
+ if (gate.kind === 'deny') {
228
+ appendRoutingLog(planDenyHitRow({ now: Date.now(), role: manifest.role, tool }));
229
+ return { block: true, reason: gate.reason, terminate: true };
230
+ }
231
+ if (gate.kind === 'ask') {
232
+ console.error(`${gate.notice} (v1 soft-approval: allowed, hard gate lands in v2)`);
233
+ try {
234
+ pi.appendEntry(APPROVAL_NEEDED_CUSTOM_TYPE, { role: manifest.role, tool, ts: Date.now() });
235
+ } catch {
236
+ }
237
+ }
238
+ return undefined;
239
+ });
240
+
241
+ pi.registerCommand('pier-role', {
242
+ description:
243
+ 'Show the current role + available role names, or switch: /pier-role <name> (widening beyond the current toolset asks for confirmation)',
244
+ handler: async (args: unknown, ctx: unknown) => {
245
+ const ui = (ctx as { ui?: { notify?: (t: string, l?: string) => void; confirm?: (t: string, m: string) => Promise<boolean> } }).ui;
246
+ const notify = (text: string, level: 'info' | 'error') => ui?.notify?.(text, level) ?? console.error(text);
247
+ const current = roleState.manifest;
248
+ if (!current) {
249
+ notify('Role system not armed (no manifest).', 'error');
250
+ return;
251
+ }
252
+ const name = typeof args === 'string' ? args.trim() : '';
253
+ if (name === '') {
254
+ const names = new Set<string>(RESERVED_ROLE_NAMES);
255
+ for (const n of listRoleNames(roleBase)) names.add(n);
256
+ notify(
257
+ `Current role: ${current.role} (origin: ${roleState.origin}). Available: ${[...names].sort().join(', ')}`,
258
+ 'info',
259
+ );
260
+ return;
261
+ }
262
+ try {
263
+ const preview = toRuntimeManifest(composeForRole(name, [], { loadRoleOpts: { baseDir: roleBase } }));
264
+ const plan = planRoleSwitch(current.tools, preview.tools);
265
+ // Q2: humans may widen after confirm — the human is the final authority in the pane.
266
+ if (plan.widening && ui?.confirm) {
267
+ const okToWiden = await ui.confirm(
268
+ 'Widen role toolset',
269
+ `Role "${name}" adds tools beyond the current set (${plan.added.join(', ')}). Switch anyway?`,
270
+ );
271
+ if (!okToWiden) {
272
+ notify('Role switch cancelled.', 'info');
273
+ return;
274
+ }
275
+ }
276
+ } catch (err) {
277
+ notify(`Error: role "${name}" unavailable: ${(err as Error).message}`, 'error');
278
+ return;
279
+ }
280
+ const res = await applyRoleSwitch(name, 'human');
281
+ notify(res.message, res.ok ? 'info' : 'error');
282
+ },
283
+ });
284
+
285
+ /* Per-role guidelines ride the before_agent_start prompt-section diff; writing
286
+ * the same string each turn is a no-op (pi diffs sections), removal cleans up. */
287
+ pi.on('before_agent_start', async (event) => {
288
+ const sections = event.systemPromptOptions?.sections;
289
+ if (!sections) return;
290
+ const m = roleState.manifest;
291
+ if (m?.guidelines?.length) {
292
+ const text = `Role "${m.role}" operating constraints:\n${m.guidelines.map((g) => `- ${g}`).join('\n')}`;
293
+ if (sections['pier-role'] !== text) sections['pier-role'] = text;
294
+ } else {
295
+ delete sections['pier-role'];
296
+ }
297
+ });
298
+
299
+ return {
300
+ state: roleState,
301
+ idleBadge: () => roleBadge,
302
+ syncFromBranch,
303
+ scanRoleUsage,
304
+ applyRoleSwitch,
305
+ };
306
+ }