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.
- package/README.md +26 -12
- package/package.json +8 -3
- package/schemas/efficiency-config.schema.json +77 -0
- package/schemas/role-manifest.schema.json +7 -1
- package/skills/pi-herdr-workflow/SKILL.md +11 -0
- package/src/ansi-text.ts +134 -0
- package/src/ask-dialog.ts +308 -0
- package/src/ask-user.ts +139 -88
- package/src/bootstrap.ts +13 -14
- package/src/compact-coordinator.ts +471 -0
- package/src/compact-economics-core.ts +328 -0
- package/src/config-catalog-core.ts +332 -0
- package/src/config-command.ts +388 -0
- package/src/dashboard-command.ts +108 -0
- package/src/efficiency-config-core.ts +464 -0
- package/src/efficiency-store.ts +299 -0
- package/src/focus-poller.ts +223 -0
- package/src/gc-core.ts +77 -18
- package/src/herdr-client.ts +284 -154
- package/src/history-store.ts +36 -23
- package/src/index-gates.ts +124 -0
- package/src/index-locks.ts +29 -16
- package/src/index-master.ts +29 -19
- package/src/index-notices.ts +35 -11
- package/src/index-pipe.ts +17 -7
- package/src/index-roles.ts +306 -0
- package/src/index-runtime.ts +2 -6
- package/src/index-worker.ts +7 -9
- package/src/index.ts +425 -388
- package/src/jev-client.ts +141 -0
- package/src/jev-core.ts +431 -0
- package/src/ledger.ts +13 -21
- package/src/lock-core.ts +98 -37
- package/src/manifest-compose.ts +35 -20
- package/src/observation-core.ts +200 -0
- package/src/pane-title.ts +19 -38
- package/src/pi-surface.ts +34 -22
- package/src/pier-options.ts +100 -0
- package/src/pipe-channel.ts +65 -52
- package/src/platform-paths.ts +19 -52
- package/src/{core → plugins}/grid-shape.ts +12 -12
- package/src/plugins/heat-plan.ts +158 -0
- package/src/plugins/observation.ts +475 -0
- package/src/plugins/subagent.ts +728 -0
- package/src/{core → plugins}/terminal.ts +237 -123
- package/src/{core → plugins}/todo.ts +75 -100
- package/src/progress-core.ts +16 -53
- package/src/reconcile-core.ts +24 -33
- package/src/reducer-core.ts +322 -0
- package/src/reducer-invoker.ts +419 -0
- package/src/renderers.ts +187 -0
- package/src/role-loader.ts +35 -29
- package/src/role-manifest.ts +23 -32
- package/src/role-state.ts +119 -0
- package/src/routing-telemetry.ts +136 -0
- package/src/runtime-policy.ts +25 -43
- package/src/session-tail.ts +102 -23
- package/src/settle-wake-core.ts +55 -22
- package/src/slim-frame.ts +29 -124
- package/src/stale-core.ts +17 -24
- package/src/storage-layout.ts +24 -30
- package/src/subagent-core.ts +256 -119
- package/src/subagent-gc.ts +131 -69
- package/src/subagent-poller.ts +401 -12
- package/src/subagent-session.ts +516 -0
- package/src/subagent-spawn.ts +651 -28
- package/src/swallow.ts +65 -0
- package/src/terminal-core.ts +160 -69
- package/src/todo-core.ts +62 -113
- package/src/todo-read-hook.ts +23 -39
- package/src/todo-reminder-core.ts +30 -48
- package/src/todo-window.ts +11 -12
- package/src/todos-service.ts +27 -35
- package/src/tool-error.ts +19 -0
- package/src/tool-gate.ts +38 -21
- package/src/vocab.ts +55 -25
- package/src/core/subagent.ts +0 -839
- package/src/git-adapter.ts +0 -104
- package/src/notice-buffer.ts +0 -23
- package/src/subagent-git-io.ts +0 -80
- package/src/subagent-launch.ts +0 -110
- package/src/subagent-poll-loop.ts +0 -285
- package/src/subagent-port.ts +0 -27
- package/src/subagent-scope.ts +0 -39
- package/src/subagent-session-io.ts +0 -147
package/src/subagent-gc.ts
CHANGED
|
@@ -1,27 +1,87 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
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 {
|
|
13
|
-
import type
|
|
14
|
-
import type {
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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([...
|
|
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
|
|
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
|
-
|
|
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
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
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 (
|
|
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=${
|
|
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
|
|
160
|
-
const { branch, wtPath
|
|
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
|
-
|
|
172
|
-
|
|
173
|
-
|
|
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
|
-
|
|
183
|
-
|
|
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
|
}
|