pi-pier 0.2.7 → 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 (85) hide show
  1. package/README.md +26 -12
  2. package/package.json +8 -3
  3. package/schemas/efficiency-config.schema.json +77 -0
  4. package/schemas/role-manifest.schema.json +7 -1
  5. package/skills/pi-herdr-workflow/SKILL.md +11 -0
  6. package/src/ansi-text.ts +134 -0
  7. package/src/ask-dialog.ts +308 -0
  8. package/src/ask-user.ts +139 -88
  9. package/src/bootstrap.ts +13 -14
  10. package/src/compact-coordinator.ts +471 -0
  11. package/src/compact-economics-core.ts +328 -0
  12. package/src/config-catalog-core.ts +332 -0
  13. package/src/config-command.ts +388 -0
  14. package/src/dashboard-command.ts +108 -0
  15. package/src/efficiency-config-core.ts +464 -0
  16. package/src/efficiency-store.ts +299 -0
  17. package/src/focus-poller.ts +223 -0
  18. package/src/gc-core.ts +77 -18
  19. package/src/herdr-client.ts +284 -154
  20. package/src/history-store.ts +36 -23
  21. package/src/index-gates.ts +124 -0
  22. package/src/index-locks.ts +29 -16
  23. package/src/index-master.ts +29 -19
  24. package/src/index-notices.ts +35 -11
  25. package/src/index-pipe.ts +17 -7
  26. package/src/index-roles.ts +306 -0
  27. package/src/index-runtime.ts +2 -6
  28. package/src/index-worker.ts +7 -9
  29. package/src/index.ts +425 -388
  30. package/src/jev-client.ts +141 -0
  31. package/src/jev-core.ts +431 -0
  32. package/src/ledger.ts +13 -21
  33. package/src/lock-core.ts +98 -37
  34. package/src/manifest-compose.ts +35 -20
  35. package/src/observation-core.ts +200 -0
  36. package/src/pane-title.ts +19 -38
  37. package/src/pi-surface.ts +34 -22
  38. package/src/pier-options.ts +100 -0
  39. package/src/pipe-channel.ts +65 -52
  40. package/src/platform-paths.ts +19 -52
  41. package/src/{core → plugins}/grid-shape.ts +12 -12
  42. package/src/plugins/heat-plan.ts +158 -0
  43. package/src/plugins/observation.ts +475 -0
  44. package/src/plugins/subagent.ts +728 -0
  45. package/src/{core → plugins}/terminal.ts +237 -123
  46. package/src/{core → plugins}/todo.ts +75 -100
  47. package/src/progress-core.ts +16 -53
  48. package/src/reconcile-core.ts +24 -33
  49. package/src/reducer-core.ts +322 -0
  50. package/src/reducer-invoker.ts +419 -0
  51. package/src/renderers.ts +187 -0
  52. package/src/role-loader.ts +35 -29
  53. package/src/role-manifest.ts +23 -32
  54. package/src/role-state.ts +119 -0
  55. package/src/routing-telemetry.ts +136 -0
  56. package/src/runtime-policy.ts +25 -43
  57. package/src/session-tail.ts +102 -23
  58. package/src/settle-wake-core.ts +55 -22
  59. package/src/slim-frame.ts +29 -124
  60. package/src/stale-core.ts +17 -24
  61. package/src/storage-layout.ts +24 -30
  62. package/src/subagent-core.ts +256 -119
  63. package/src/subagent-gc.ts +131 -69
  64. package/src/subagent-poller.ts +401 -12
  65. package/src/subagent-session.ts +516 -0
  66. package/src/subagent-spawn.ts +651 -28
  67. package/src/swallow.ts +65 -0
  68. package/src/terminal-core.ts +160 -69
  69. package/src/todo-core.ts +62 -113
  70. package/src/todo-read-hook.ts +23 -39
  71. package/src/todo-reminder-core.ts +30 -48
  72. package/src/todo-window.ts +11 -12
  73. package/src/todos-service.ts +27 -35
  74. package/src/tool-error.ts +19 -0
  75. package/src/tool-gate.ts +38 -21
  76. package/src/vocab.ts +55 -25
  77. package/src/core/subagent.ts +0 -839
  78. package/src/git-adapter.ts +0 -104
  79. package/src/notice-buffer.ts +0 -23
  80. package/src/subagent-git-io.ts +0 -80
  81. package/src/subagent-launch.ts +0 -110
  82. package/src/subagent-poll-loop.ts +0 -285
  83. package/src/subagent-port.ts +0 -27
  84. package/src/subagent-scope.ts +0 -39
  85. package/src/subagent-session-io.ts +0 -147
@@ -1,27 +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 type { GitIo } from './subagent-git-io.ts';
14
- import type { TerminalStateSlot } from './core/terminal.ts';
15
-
16
- function sleep(ms: number): Promise<void> {
17
- const wait = Promise.withResolvers<void>();
18
- setTimeout(wait.resolve, ms);
19
- return wait.promise;
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
+
15
+ /* ── registry projection + recovery ─────────────────────────────── */
16
+
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 };
20
78
  }
21
79
 
22
- export interface GcHost {
80
+ /* ── GC ─────────────────────────────────────────────────────────── */
81
+
82
+ interface GcHost {
23
83
  client: HerdrClientLike;
24
- env: { tabId: string } | null;
84
+ env: { tabId: string; paneId?: string } | null;
25
85
  subs: Map<string, SubEntry>;
26
86
  persistSubs(): void;
27
87
  writeHistory(e: SubEntry, patch?: { outcome?: string | null; status?: SubEntry['status']; closedAt?: number }, via?: string): void;
@@ -32,7 +92,7 @@ export interface GcHost {
32
92
  injectNotice(content: string): Promise<void>;
33
93
  }
34
94
 
35
- export interface GcController {
95
+ interface GcController {
36
96
  runGcSafely(): Promise<void>;
37
97
  onTurnStart(): Promise<void>;
38
98
  startTicker(ctx: Context): void;
@@ -63,66 +123,58 @@ export function createGcController(h: GcHost): GcController {
63
123
  arr.push(e);
64
124
  byTab.set(e.tabId, arr);
65
125
  }
66
- const taskTabIds = new Set(byTab.keys());
67
126
  const mainTabId = h.env?.tabId ?? '';
68
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
+
69
134
  for (const [tabId, entries] of byTab) {
70
135
  if (tabId === mainTabId) continue;
71
136
  const tabPanes = panesList.filter((p) => p.tabId === tabId);
72
137
  if (tabPanes.length === 0) {
73
- for (const e of entries) {
74
- if (e.status !== 'closed') {
75
- e.status = 'closed';
76
- h.writeHistory(e, { status: 'closed', closedAt: Date.now() }, 'gc');
77
- }
78
- }
138
+ for (const e of entries) closeRow(e);
79
139
  continue;
80
140
  }
81
- const should = shouldCloseTaskTab({
141
+ if (!autoCloseTabs) continue;
142
+ if (!shouldCloseTaskTab({
82
143
  entries,
83
144
  paneStatuses: tabPanes.map((p) => p.agentStatus),
84
145
  ttlMs,
85
146
  now: Date.now(),
86
- });
87
- if (!autoCloseTabs || !should) continue;
147
+ })) continue;
148
+ // Live terminal panes and not-yet-delivered settlement notices keep the tab alive.
88
149
  if (tabPanes.some((p) => termPaneIds.has(p.paneId) || pendingNoticeIds.has(p.paneId))) continue;
89
150
  try {
90
151
  await h.client.tabClose(tabId);
91
- } catch {
92
- /* tab may already be gone */
93
- }
94
- for (const e of entries) {
95
- if (e.status !== 'closed') {
96
- e.status = 'closed';
97
- h.writeHistory(e, { status: 'closed', closedAt: Date.now() }, 'gc');
98
- }
99
- }
152
+ } catch { /* tab may already be gone */ }
153
+ for (const e of entries) closeRow(e);
100
154
  await sleep(300);
101
155
  }
102
156
 
103
- const closableTaskTabIds = new Set([...taskTabIds].filter((t) => t !== mainTabId));
157
+ const closableTaskTabIds = new Set([...byTab.keys()].filter((t) => t !== mainTabId));
104
158
  const candidates = [...h.subs.values()].filter(
105
159
  (e) => e.status === 'consumed' && !(e.tabId && closableTaskTabIds.has(e.tabId)),
106
160
  );
107
161
  for (const e of candidates) {
108
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;
109
165
  if (!shouldClosePane({
110
166
  consumedAt: e.consumedAt ?? null,
111
167
  herdrStatus: statuses.get(e.paneId),
112
168
  prevTurnStart,
113
169
  })) continue;
114
170
  if (statuses.get(e.paneId) === undefined) {
115
- e.status = 'closed';
116
- h.writeHistory(e, { status: 'closed', closedAt: Date.now() }, 'gc');
171
+ closeRow(e);
117
172
  continue;
118
173
  }
119
174
  try {
120
175
  await h.client.closePane(e.paneId);
121
- } catch {
122
- /* pane may already be gone */
123
- }
124
- e.status = 'closed';
125
- h.writeHistory(e, { status: 'closed', closedAt: Date.now() }, 'gc');
176
+ } catch { /* pane may already be gone */ }
177
+ closeRow(e);
126
178
  await sleep(300);
127
179
  }
128
180
  h.persistSubs();
@@ -140,24 +192,35 @@ export function createGcController(h: GcHost): GcController {
140
192
  const wtPorcelain = await h.git.runGit(masterCwd, ['worktree', 'list', '--porcelain']);
141
193
  if (wtPorcelain === null) return;
142
194
  const wtByBranch = parseWorktreePorcelain(wtPorcelain);
143
- const pierBranchOut = await h.git.runGit(masterCwd, ['for-each-ref', '--format=%(refname:short)', 'refs/heads/pier/']);
144
- const pierBranches = (pierBranchOut ?? '').split('\n').map((l) => l.trim()).filter(Boolean);
145
- type Cand = { branch: string; wtPath: string; entry: SubEntry | null };
146
- const byBranch = new Map<string, Cand>();
147
- for (const branch of pierBranches) {
148
- if (registeredBranches.has(branch) || h.pendingIsolateBranches.has(branch)) continue;
149
- const wtPath = wtByBranch.get(branch);
150
- if (wtPath) byBranch.set(branch, { branch, wtPath, entry: null });
151
- }
195
+ const sessionOwned = [...h.subs.values()]
196
+ .filter((e) => e.isolate && e.isolate.releasedAt == null && e.status !== 'running')
197
+ .map((e) => ({ branch: e.isolate!.branch, worktreePath: e.isolate!.worktreePath }));
198
+ // Untracked `pier/*` branches are only swept on explicit request: they may belong to another
199
+ // session whose process is even running inside them.
200
+ const sweepOrphans = process.env.PIER_ISOLATE_SWEEP_ORPHANS === '1';
201
+ const branches = sweepOrphans
202
+ ? (await h.git.runGit(masterCwd, ['for-each-ref', '--format=%(refname:short)', 'refs/heads/pier/']))
203
+ ?.split('\n').map((l) => l.trim()).filter(Boolean) ?? []
204
+ : [];
205
+ const plan = planIsolateSweep({
206
+ branches,
207
+ worktreesByBranch: wtByBranch,
208
+ registeredBranches,
209
+ pendingBranches: h.pendingIsolateBranches,
210
+ sessionOwned,
211
+ cwd: masterCwd,
212
+ sweepOrphans,
213
+ });
214
+ const entryByBranch = new Map<string, SubEntry>();
152
215
  for (const e of h.subs.values()) {
153
- if (!e.isolate || e.isolate.releasedAt != null || e.status === 'running') continue;
154
- const prev = byBranch.get(e.isolate.branch);
155
- byBranch.set(e.isolate.branch, { branch: e.isolate.branch, wtPath: prev?.wtPath ?? e.isolate.worktreePath, entry: e });
216
+ if (e.isolate && e.isolate.releasedAt == null && e.status !== 'running') entryByBranch.set(e.isolate.branch, e);
156
217
  }
157
- trace?.(`cands=${[...byBranch.keys()].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'}`);
158
219
  let persisted = false;
159
- for (const cand of byBranch.values()) {
160
- 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.
161
224
  if (entry && !wtByBranch.has(branch)) {
162
225
  if (existsSync(wtPath)) {
163
226
  try { rmSync(wtPath, { recursive: true, force: true }); } catch { continue; }
@@ -168,9 +231,10 @@ export function createGcController(h: GcHost): GcController {
168
231
  continue;
169
232
  }
170
233
  const mergedOut = await h.git.runGit(masterCwd, ['merge-base', '--is-ancestor', branch, 'HEAD']);
171
- const merged = mergedOut !== null ? true : null;
172
- let mergedFinal = merged;
173
- 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.
174
238
  const sha = await h.git.runGit(masterCwd, ['rev-parse', branch]);
175
239
  const headSha = await h.git.runGit(masterCwd, ['rev-parse', 'HEAD']);
176
240
  if (sha != null && headSha != null) mergedFinal = false;
@@ -179,8 +243,10 @@ export function createGcController(h: GcHost): GcController {
179
243
  const dirtyCount = dirtyOut === null ? null : dirtyOut.split('\n').filter((l) => l.trim() !== '').length;
180
244
  const decision = evaluateRelease({ merged: mergedFinal, dirtyCount });
181
245
  if (decision.action === 'release') {
182
- const removed = await h.git.runGit(masterCwd, ['worktree', 'remove', wtPath]);
183
- let ok = removed !== null;
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.
248
+ if (isPathInside(masterCwd, wtPath)) continue;
249
+ let ok = (await h.git.runGit(masterCwd, ['worktree', 'remove', wtPath])) !== null;
184
250
  if (!ok) {
185
251
  await sleep(2000);
186
252
  ok = (await h.git.runGit(masterCwd, ['worktree', 'remove', wtPath])) !== null;
@@ -207,14 +273,10 @@ export function createGcController(h: GcHost): GcController {
207
273
  gcRunning = true;
208
274
  try {
209
275
  await gcPass();
210
- } catch {
211
- /* next turn/tick retries */
212
- }
276
+ } catch { /* next turn/tick retries */ }
213
277
  try {
214
278
  await isolateSweep();
215
- } catch {
216
- /* next turn/tick retries */
217
- } finally {
279
+ } catch { /* next turn/tick retries */ } finally {
218
280
  gcRunning = false;
219
281
  }
220
282
  }