pi-pier 0.3.0 → 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.
Files changed (103) hide show
  1. package/README.md +16 -12
  2. package/package.json +3 -3
  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 +10 -13
  9. package/src/compact-coordinator.ts +279 -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/cordis-services.ts +64 -0
  14. package/src/dashboard-command.ts +77 -33
  15. package/src/delegation-core.ts +115 -0
  16. package/src/efficiency-config-core.ts +286 -486
  17. package/src/efficiency-store.ts +16 -29
  18. package/src/gc-core.ts +10 -36
  19. package/src/guards.ts +6 -0
  20. package/src/herdr-client.ts +202 -233
  21. package/src/history-store.ts +38 -23
  22. package/src/index-gates.ts +118 -0
  23. package/src/index-locks.ts +2 -7
  24. package/src/index-master.ts +31 -29
  25. package/src/index-notices.ts +28 -15
  26. package/src/index-pipe.ts +31 -12
  27. package/src/index-roles.ts +306 -0
  28. package/src/index-runtime.ts +16 -6
  29. package/src/index-worker.ts +8 -18
  30. package/src/index.ts +561 -570
  31. package/src/jev-client.ts +12 -17
  32. package/src/jev-core.ts +138 -90
  33. package/src/ledger.ts +13 -21
  34. package/src/lock-core.ts +28 -56
  35. package/src/manifest-compose.ts +35 -20
  36. package/src/observation-core.ts +52 -100
  37. package/src/pane-title.ts +19 -38
  38. package/src/panel-open.ts +47 -0
  39. package/src/pi-surface.ts +34 -22
  40. package/src/pier-options.ts +53 -50
  41. package/src/pipe-channel.ts +44 -59
  42. package/src/platform-paths.ts +19 -52
  43. package/src/{core → plugins}/grid-shape.ts +8 -10
  44. package/src/{core → plugins}/heat-plan.ts +20 -22
  45. package/src/plugins/observation.ts +475 -0
  46. package/src/plugins/subagent.ts +766 -0
  47. package/src/{core → plugins}/terminal.ts +198 -139
  48. package/src/{core → plugins}/todo.ts +73 -134
  49. package/src/progress-core.ts +16 -53
  50. package/src/reconcile-core.ts +24 -33
  51. package/src/reducer-core.ts +26 -64
  52. package/src/reducer-invoker.ts +266 -239
  53. package/src/renderers.ts +108 -84
  54. package/src/role-loader.ts +33 -28
  55. package/src/role-manifest.ts +25 -31
  56. package/src/role-state.ts +119 -0
  57. package/src/roles/worker-default.json +3 -3
  58. package/src/routing-telemetry.ts +136 -0
  59. package/src/runtime-policy.ts +25 -40
  60. package/src/session-tail.ts +65 -41
  61. package/src/settle-wake-core.ts +61 -22
  62. package/src/slim-frame.ts +25 -122
  63. package/src/stale-core.ts +17 -24
  64. package/src/storage-layout.ts +14 -43
  65. package/src/subagent-core.ts +423 -120
  66. package/src/subagent-gc.ts +109 -67
  67. package/src/subagent-poller.ts +383 -24
  68. package/src/subagent-session.ts +516 -0
  69. package/src/subagent-spawn.ts +577 -52
  70. package/src/swallow.ts +7 -17
  71. package/src/terminal-core.ts +187 -134
  72. package/src/todo-core.ts +57 -109
  73. package/src/todo-read-hook.ts +46 -39
  74. package/src/todo-reminder-core.ts +26 -51
  75. package/src/todo-window.ts +11 -12
  76. package/src/todos-service.ts +15 -35
  77. package/src/tool-error.ts +6 -13
  78. package/src/tool-gate.ts +38 -21
  79. package/src/tree-view.ts +162 -0
  80. package/src/vocab.ts +52 -25
  81. package/src/worker-marker.ts +76 -0
  82. package/src/ask-multi.ts +0 -260
  83. package/src/config-guide.ts +0 -361
  84. package/src/core/observation.ts +0 -555
  85. package/src/core/subagent.ts +0 -633
  86. package/src/efficiency-config-io.ts +0 -53
  87. package/src/focus-poller.ts +0 -244
  88. package/src/git-adapter.ts +0 -113
  89. package/src/notice-buffer.ts +0 -23
  90. package/src/subagent-execute.ts +0 -328
  91. package/src/subagent-git-io.ts +0 -80
  92. package/src/subagent-launch.ts +0 -114
  93. package/src/subagent-list-action.ts +0 -69
  94. package/src/subagent-output-action.ts +0 -104
  95. package/src/subagent-output-adapter.ts +0 -27
  96. package/src/subagent-output-core.ts +0 -263
  97. package/src/subagent-poll-loop.ts +0 -305
  98. package/src/subagent-port.ts +0 -27
  99. package/src/subagent-ready.ts +0 -89
  100. package/src/subagent-registry.ts +0 -70
  101. package/src/subagent-resolution.ts +0 -41
  102. package/src/subagent-scope.ts +0 -41
  103. package/src/subagent-session-io.ts +0 -230
@@ -1,30 +1,87 @@
1
1
  /**
2
- * Tab/pane collection and isolate-worktree sweep.
3
- *
4
- * Why: GC mixed herdr close I/O with the plugin body. Predicates stay in
5
- * gc-core.ts; this adapter owns list/close, isolate git, and the ticker.
2
+ * Subagent board hygiene: registry projection + recovery, tab/pane collection, isolate-worktree sweep.
3
+ * Predicates live in gc-core.ts; this adapter owns herdr close I/O, isolate git commands, and the ticker.
6
4
  */
7
5
  import { appendFileSync, existsSync, rmSync } from 'node:fs';
8
6
  import type { Context } from '@deepseek-ai/cordis';
9
7
  import type { HerdrClientLike } from './herdr-client.ts';
10
- import { shouldClosePane, shouldCloseTaskTab } from './gc-core.ts';
8
+ import { isPathInside, planIsolateSweep, shouldClosePane, shouldCloseTaskTab } from './gc-core.ts';
11
9
  import { runtimePolicy } from './runtime-policy.ts';
12
- import { evaluateRelease, parseWorktreePorcelain, type SubEntry } from './subagent-core.ts';
13
- import { planIsolateSweep, isPathInside } from './gc-core.ts';
10
+ import { swallow } from './swallow.ts';
11
+ import { evaluateRelease, foldSubsRegistry, makeRegistry, parseWorktreePorcelain, sleep, SUBS_CUSTOM_TYPE, type SubEntry } from './subagent-core.ts';
12
+ import type { GitIo } from './subagent-spawn.ts';
13
+ import type { TerminalStateSlot } from './plugins/terminal.ts';
14
14
 
15
- interface Cand { branch: string; wtPath: string; entry: SubEntry | null }
16
- import type { GitIo } from './subagent-git-io.ts';
17
- import type { TerminalStateSlot } from './core/terminal.ts';
15
+ /* ── registry projection + recovery ─────────────────────────────── */
18
16
 
19
- function sleep(ms: number): Promise<void> {
20
- const wait = Promise.withResolvers<void>();
21
- setTimeout(wait.resolve, ms);
22
- return wait.promise;
17
+ interface SubagentRegistryHost {
18
+ pi: { appendEntry?: (customType: string, data: unknown) => void };
19
+ client: HerdrClientLike;
20
+ subs: Map<string, SubEntry>;
21
+ writeHistory: (entry: SubEntry, patch?: { status?: SubEntry['status']; closedAt?: number }, via?: string) => void;
22
+ }
23
+
24
+ interface SubagentRegistry {
25
+ /** Append the registry snapshot to the session branch; duplicate snapshots are skipped. */
26
+ persist(): void;
27
+ rebuild(eventCtx: unknown): void;
28
+ sweepZombieRunning(): Promise<void>;
29
+ }
30
+
31
+ export function createSubagentRegistry(host: SubagentRegistryHost): SubagentRegistry {
32
+ let lastSnapshot = '';
33
+
34
+ function persist(): void {
35
+ try {
36
+ const registry = makeRegistry([...host.subs.values()]);
37
+ const snapshot = JSON.stringify(registry);
38
+ if (snapshot === lastSnapshot) return;
39
+ lastSnapshot = snapshot;
40
+ host.pi.appendEntry?.(SUBS_CUSTOM_TYPE, registry);
41
+ } catch (err) {
42
+ // Session logging must not break delegation; a silent failure here shows up as a ghost running row.
43
+ swallow('subagent.persist-subs', err);
44
+ }
45
+ }
46
+
47
+ function rebuild(eventCtx: unknown): void {
48
+ try {
49
+ const entries = (eventCtx as { sessionManager?: { getBranch?: () => readonly unknown[] } })
50
+ ?.sessionManager?.getBranch?.() ?? [];
51
+ const registry = foldSubsRegistry(entries as Parameters<typeof foldSubsRegistry>[0]);
52
+ for (const entry of registry.subs) host.subs.set(entry.paneId, entry);
53
+ lastSnapshot = ''; // branch replay replaced live state; force the next persist
54
+ } catch {
55
+ /* registry recovery failure must not block the live session */
56
+ }
57
+ }
58
+
59
+ async function sweepZombieRunning(): Promise<void> {
60
+ if (!host.client.available || host.subs.size === 0) return;
61
+ let livePaneIds: ReadonlySet<string>;
62
+ try {
63
+ livePaneIds = new Set((await host.client.listPanes()).map((pane) => pane.paneId));
64
+ } catch {
65
+ return; // do not close agents when the liveness lookup itself failed
66
+ }
67
+ let changed = false;
68
+ for (const [paneId, entry] of host.subs) {
69
+ if (entry.status !== 'running' || livePaneIds.has(paneId)) continue;
70
+ entry.status = 'closed';
71
+ host.writeHistory(entry, { status: 'closed', closedAt: Date.now() }, 'zombie-sweep');
72
+ changed = true;
73
+ }
74
+ if (changed) persist();
75
+ }
76
+
77
+ return { persist, rebuild, sweepZombieRunning };
23
78
  }
24
79
 
25
- export interface GcHost {
80
+ /* ── GC ─────────────────────────────────────────────────────────── */
81
+
82
+ interface GcHost {
26
83
  client: HerdrClientLike;
27
- env: { tabId: string } | null;
84
+ env: { tabId: string; paneId?: string } | null;
28
85
  subs: Map<string, SubEntry>;
29
86
  persistSubs(): void;
30
87
  writeHistory(e: SubEntry, patch?: { outcome?: string | null; status?: SubEntry['status']; closedAt?: number }, via?: string): void;
@@ -35,7 +92,7 @@ export interface GcHost {
35
92
  injectNotice(content: string): Promise<void>;
36
93
  }
37
94
 
38
- export interface GcController {
95
+ interface GcController {
39
96
  runGcSafely(): Promise<void>;
40
97
  onTurnStart(): Promise<void>;
41
98
  startTicker(ctx: Context): void;
@@ -66,66 +123,58 @@ export function createGcController(h: GcHost): GcController {
66
123
  arr.push(e);
67
124
  byTab.set(e.tabId, arr);
68
125
  }
69
- const taskTabIds = new Set(byTab.keys());
70
126
  const mainTabId = h.env?.tabId ?? '';
71
127
 
128
+ const closeRow = (e: SubEntry): void => {
129
+ if (e.status === 'closed') return;
130
+ e.status = 'closed';
131
+ h.writeHistory(e, { status: 'closed', closedAt: Date.now() }, 'gc');
132
+ };
133
+
72
134
  for (const [tabId, entries] of byTab) {
73
135
  if (tabId === mainTabId) continue;
74
136
  const tabPanes = panesList.filter((p) => p.tabId === tabId);
75
137
  if (tabPanes.length === 0) {
76
- for (const e of entries) {
77
- if (e.status !== 'closed') {
78
- e.status = 'closed';
79
- h.writeHistory(e, { status: 'closed', closedAt: Date.now() }, 'gc');
80
- }
81
- }
138
+ for (const e of entries) closeRow(e);
82
139
  continue;
83
140
  }
84
- const should = shouldCloseTaskTab({
141
+ if (!autoCloseTabs) continue;
142
+ if (!shouldCloseTaskTab({
85
143
  entries,
86
144
  paneStatuses: tabPanes.map((p) => p.agentStatus),
87
145
  ttlMs,
88
146
  now: Date.now(),
89
- });
90
- if (!autoCloseTabs || !should) continue;
147
+ })) continue;
148
+ // Live terminal panes and not-yet-delivered settlement notices keep the tab alive.
91
149
  if (tabPanes.some((p) => termPaneIds.has(p.paneId) || pendingNoticeIds.has(p.paneId))) continue;
92
150
  try {
93
151
  await h.client.tabClose(tabId);
94
- } catch {
95
- /* tab may already be gone */
96
- }
97
- for (const e of entries) {
98
- if (e.status !== 'closed') {
99
- e.status = 'closed';
100
- h.writeHistory(e, { status: 'closed', closedAt: Date.now() }, 'gc');
101
- }
102
- }
152
+ } catch { /* tab may already be gone */ }
153
+ for (const e of entries) closeRow(e);
103
154
  await sleep(300);
104
155
  }
105
156
 
106
- const closableTaskTabIds = new Set([...taskTabIds].filter((t) => t !== mainTabId));
157
+ const closableTaskTabIds = new Set([...byTab.keys()].filter((t) => t !== mainTabId));
107
158
  const candidates = [...h.subs.values()].filter(
108
159
  (e) => e.status === 'consumed' && !(e.tabId && closableTaskTabIds.has(e.tabId)),
109
160
  );
110
161
  for (const e of candidates) {
111
162
  if (termPaneIds.has(e.paneId) || pendingNoticeIds.has(e.paneId)) continue;
163
+ // Never collect self, whatever the registry says: a poisoned entry once closed the master's pane.
164
+ if (h.env?.paneId && e.paneId === h.env.paneId) continue;
112
165
  if (!shouldClosePane({
113
166
  consumedAt: e.consumedAt ?? null,
114
167
  herdrStatus: statuses.get(e.paneId),
115
168
  prevTurnStart,
116
169
  })) continue;
117
170
  if (statuses.get(e.paneId) === undefined) {
118
- e.status = 'closed';
119
- h.writeHistory(e, { status: 'closed', closedAt: Date.now() }, 'gc');
171
+ closeRow(e);
120
172
  continue;
121
173
  }
122
174
  try {
123
175
  await h.client.closePane(e.paneId);
124
- } catch {
125
- /* pane may already be gone */
126
- }
127
- e.status = 'closed';
128
- h.writeHistory(e, { status: 'closed', closedAt: Date.now() }, 'gc');
176
+ } catch { /* pane may already be gone */ }
177
+ closeRow(e);
129
178
  await sleep(300);
130
179
  }
131
180
  h.persistSubs();
@@ -143,12 +192,11 @@ export function createGcController(h: GcHost): GcController {
143
192
  const wtPorcelain = await h.git.runGit(masterCwd, ['worktree', 'list', '--porcelain']);
144
193
  if (wtPorcelain === null) return;
145
194
  const wtByBranch = parseWorktreePorcelain(wtPorcelain);
146
- // Session-owned isolates: settled or consumed entries whose worktree was not released yet.
147
195
  const sessionOwned = [...h.subs.values()]
148
196
  .filter((e) => e.isolate && e.isolate.releasedAt == null && e.status !== 'running')
149
197
  .map((e) => ({ branch: e.isolate!.branch, worktreePath: e.isolate!.worktreePath }));
150
- // Untracked `pier/*` branches are only swept on explicit request: they may belong to
151
- // another session (whose process may even be running inside them).
198
+ // Untracked `pier/*` branches are only swept on explicit request: they may belong to another
199
+ // session whose process is even running inside them.
152
200
  const sweepOrphans = process.env.PIER_ISOLATE_SWEEP_ORPHANS === '1';
153
201
  const branches = sweepOrphans
154
202
  ? (await h.git.runGit(masterCwd, ['for-each-ref', '--format=%(refname:short)', 'refs/heads/pier/']))
@@ -167,14 +215,12 @@ export function createGcController(h: GcHost): GcController {
167
215
  for (const e of h.subs.values()) {
168
216
  if (e.isolate && e.isolate.releasedAt == null && e.status !== 'running') entryByBranch.set(e.isolate.branch, e);
169
217
  }
170
- const byBranch = new Map<string, Cand>();
171
- for (const cand of plan.candidates) {
172
- byBranch.set(cand.branch, { branch: cand.branch, wtPath: cand.worktreePath, entry: entryByBranch.get(cand.branch) ?? null });
173
- }
174
- trace?.(`cands=${[...byBranch.keys()].join(',') || 'none'} skipped=${plan.skipped.map((s) => `${s.branch}:${s.reason}`).join(',') || 'none'} subs=${[...h.subs.values()].map((s) => `${s.status}${s.isolate ? '/iso' : ''}`).join(',') || 'none'}`);
218
+ trace?.(`cands=${plan.candidates.map((c) => c.branch).join(',') || 'none'} skipped=${plan.skipped.map((s) => `${s.branch}:${s.reason}`).join(',') || 'none'} subs=${[...h.subs.values()].map((s) => `${s.status}${s.isolate ? '/iso' : ''}`).join(',') || 'none'}`);
175
219
  let persisted = false;
176
- for (const cand of byBranch.values()) {
177
- const { branch, wtPath, entry } = cand;
220
+ for (const cand of plan.candidates) {
221
+ const { branch, worktreePath: wtPath } = cand;
222
+ const entry = entryByBranch.get(branch) ?? null;
223
+ // The worktree is already gone (removed by hand or by a previous pass) → only release the row.
178
224
  if (entry && !wtByBranch.has(branch)) {
179
225
  if (existsSync(wtPath)) {
180
226
  try { rmSync(wtPath, { recursive: true, force: true }); } catch { continue; }
@@ -185,9 +231,10 @@ export function createGcController(h: GcHost): GcController {
185
231
  continue;
186
232
  }
187
233
  const mergedOut = await h.git.runGit(masterCwd, ['merge-base', '--is-ancestor', branch, 'HEAD']);
188
- const merged = mergedOut !== null ? true : null;
189
- let mergedFinal = merged;
190
- if (merged === null) {
234
+ let mergedFinal: boolean | null = mergedOut !== null ? true : null;
235
+ if (mergedFinal === null) {
236
+ // A non-zero exit means "not an ancestor" OR "unknown branch": only a resolving ref
237
+ // makes it unmerged; otherwise the decision stays 'unknown' and the worktree is retained.
191
238
  const sha = await h.git.runGit(masterCwd, ['rev-parse', branch]);
192
239
  const headSha = await h.git.runGit(masterCwd, ['rev-parse', 'HEAD']);
193
240
  if (sha != null && headSha != null) mergedFinal = false;
@@ -196,11 +243,10 @@ export function createGcController(h: GcHost): GcController {
196
243
  const dirtyCount = dirtyOut === null ? null : dirtyOut.split('\n').filter((l) => l.trim() !== '').length;
197
244
  const decision = evaluateRelease({ merged: mergedFinal, dirtyCount });
198
245
  if (decision.action === 'release') {
199
- // Defense in depth: planner already refuses the current worktree, but a removal here
200
- // must never be able to delete the directory this process is running in.
246
+ // Defense in depth: the planner already refuses the current worktree, but a removal here
247
+ // must never delete the directory this process is running in.
201
248
  if (isPathInside(masterCwd, wtPath)) continue;
202
- const removed = await h.git.runGit(masterCwd, ['worktree', 'remove', wtPath]);
203
- let ok = removed !== null;
249
+ let ok = (await h.git.runGit(masterCwd, ['worktree', 'remove', wtPath])) !== null;
204
250
  if (!ok) {
205
251
  await sleep(2000);
206
252
  ok = (await h.git.runGit(masterCwd, ['worktree', 'remove', wtPath])) !== null;
@@ -227,14 +273,10 @@ export function createGcController(h: GcHost): GcController {
227
273
  gcRunning = true;
228
274
  try {
229
275
  await gcPass();
230
- } catch {
231
- /* next turn/tick retries */
232
- }
276
+ } catch { /* next turn/tick retries */ }
233
277
  try {
234
278
  await isolateSweep();
235
- } catch {
236
- /* next turn/tick retries */
237
- } finally {
279
+ } catch { /* next turn/tick retries */ } finally {
238
280
  gcRunning = false;
239
281
  }
240
282
  }