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
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* D101-D103 efficiency storage: content-addressed objects (ObservationPack / EPR), telemetry appends
|
|
3
|
+
* and the un-truncated bash log reader. Integrity: directories 0700, files 0600, O_NOFOLLOW everywhere,
|
|
4
|
+
* session ids regex-validated, and an EEXIST collision is re-verified (size + sha256) instead of trusted.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { constants } from 'node:fs';
|
|
8
|
+
import { lstat, mkdir, open, readdir, realpath, rename, rm, stat } from 'node:fs/promises';
|
|
9
|
+
import { tmpdir } from 'node:os';
|
|
10
|
+
import { basename, dirname, isAbsolute, join, relative, resolve } from 'node:path';
|
|
11
|
+
import {
|
|
12
|
+
countLines,
|
|
13
|
+
sha256Hex,
|
|
14
|
+
sliceBufferChunk,
|
|
15
|
+
type RecallSliceResult,
|
|
16
|
+
} from './observation-core.ts';
|
|
17
|
+
|
|
18
|
+
const SAFE_SESSION_ID_RE = /^[a-z0-9][a-z0-9._-]*$/i;
|
|
19
|
+
const BASH_LOG_NAME_RE = /^pi-bash-.*\.log$/;
|
|
20
|
+
|
|
21
|
+
const READ_OBJECT_FLAGS = constants.O_RDONLY | constants.O_NOFOLLOW;
|
|
22
|
+
const CREATE_OBJECT_FLAGS =
|
|
23
|
+
constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW;
|
|
24
|
+
|
|
25
|
+
const verifiedObjectCache = new Set<string>();
|
|
26
|
+
|
|
27
|
+
export function clearVerifiedObjectCacheForTest(): void {
|
|
28
|
+
verifiedObjectCache.clear();
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface StorageObjectResult {
|
|
32
|
+
path: string;
|
|
33
|
+
bytes: number;
|
|
34
|
+
lines: number;
|
|
35
|
+
hash: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function isValidSessionId(id: string | null | undefined): boolean {
|
|
39
|
+
return typeof id === 'string' && id.length > 0 && SAFE_SESSION_ID_RE.test(id);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function resolveSessionRoot(
|
|
43
|
+
sessionDir: string | null | undefined,
|
|
44
|
+
sessionId: string | null | undefined,
|
|
45
|
+
): string | null {
|
|
46
|
+
if (!sessionDir || typeof sessionDir !== 'string') return null;
|
|
47
|
+
if (!isValidSessionId(sessionId)) return null;
|
|
48
|
+
return join(sessionDir, 'herdr-pi', sessionId!);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function observationObjectPath(root: string, obsId: string): string {
|
|
52
|
+
return join(root, 'observation-pack', 'objects', `${obsId}.txt`);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function reducerObjectPath(root: string, sha256: string): string {
|
|
56
|
+
return join(root, 'evidence-preserving-reducer', 'objects', `${sha256}.txt`);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function efficiencyLogPath(
|
|
60
|
+
root: string,
|
|
61
|
+
mechanism: 'compact' | 'observation' | 'reducer' | 'jev' | 'routing',
|
|
62
|
+
customPath?: string,
|
|
63
|
+
): string {
|
|
64
|
+
if (customPath && typeof customPath === 'string') return resolve(customPath);
|
|
65
|
+
return join(root, 'efficiency-logs', `${mechanism}.jsonl`);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const MAX_OBJECTS_PER_DIR = 300;
|
|
69
|
+
const MAX_OBJECTS_TOTAL_BYTES = 50 * 1024 * 1024;
|
|
70
|
+
|
|
71
|
+
export async function pruneObjectsDirectory(
|
|
72
|
+
dir: string,
|
|
73
|
+
limits: { maxFiles?: number; maxTotalBytes?: number } = {},
|
|
74
|
+
): Promise<number> {
|
|
75
|
+
const maxFiles = limits.maxFiles ?? MAX_OBJECTS_PER_DIR;
|
|
76
|
+
const maxTotalBytes = limits.maxTotalBytes ?? MAX_OBJECTS_TOTAL_BYTES;
|
|
77
|
+
|
|
78
|
+
try {
|
|
79
|
+
const entries = await readdir(dir);
|
|
80
|
+
const files: Array<{ name: string; path: string; size: number; mtimeMs: number }> = [];
|
|
81
|
+
let totalBytes = 0;
|
|
82
|
+
|
|
83
|
+
for (const name of entries) {
|
|
84
|
+
if (!name.endsWith('.txt')) continue;
|
|
85
|
+
const p = join(dir, name);
|
|
86
|
+
try {
|
|
87
|
+
const s = await stat(p);
|
|
88
|
+
if (s.isFile() && !s.isSymbolicLink()) {
|
|
89
|
+
files.push({ name, path: p, size: s.size, mtimeMs: s.mtimeMs });
|
|
90
|
+
totalBytes += s.size;
|
|
91
|
+
}
|
|
92
|
+
} catch {
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (files.length <= maxFiles && totalBytes <= maxTotalBytes) {
|
|
97
|
+
return 0;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
files.sort((a, b) => a.mtimeMs - b.mtimeMs);
|
|
101
|
+
|
|
102
|
+
let removedCount = 0;
|
|
103
|
+
for (const f of files) {
|
|
104
|
+
if (files.length - removedCount <= maxFiles && totalBytes <= maxTotalBytes) {
|
|
105
|
+
break;
|
|
106
|
+
}
|
|
107
|
+
try {
|
|
108
|
+
await rm(f.path, { force: true });
|
|
109
|
+
removedCount++;
|
|
110
|
+
totalBytes -= f.size;
|
|
111
|
+
for (const k of verifiedObjectCache.keys()) {
|
|
112
|
+
if (k.startsWith(f.path)) verifiedObjectCache.delete(k);
|
|
113
|
+
}
|
|
114
|
+
} catch {
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return removedCount;
|
|
118
|
+
} catch {
|
|
119
|
+
return 0;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export async function pruneSessionObjects(
|
|
124
|
+
sessionRoot: string,
|
|
125
|
+
limits: { maxFiles?: number; maxTotalBytes?: number } = {},
|
|
126
|
+
): Promise<number> {
|
|
127
|
+
const targets = [
|
|
128
|
+
join(sessionRoot, 'observation-pack', 'objects'),
|
|
129
|
+
join(sessionRoot, 'evidence-preserving-reducer', 'objects'),
|
|
130
|
+
];
|
|
131
|
+
let removed = 0;
|
|
132
|
+
for (const dir of targets) {
|
|
133
|
+
removed += await pruneObjectsDirectory(dir, limits);
|
|
134
|
+
}
|
|
135
|
+
return removed;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
let storeWriteCounter = 0;
|
|
139
|
+
|
|
140
|
+
export async function storeContentAddressedObject(
|
|
141
|
+
filePath: string,
|
|
142
|
+
content: string,
|
|
143
|
+
precomputed?: { bytes?: number; hash?: string; lines?: number },
|
|
144
|
+
): Promise<StorageObjectResult> {
|
|
145
|
+
const contentBytes = precomputed?.bytes ?? Buffer.byteLength(content, 'utf8');
|
|
146
|
+
const hash = precomputed?.hash ?? sha256Hex(content);
|
|
147
|
+
const lines = precomputed?.lines ?? countLines(content);
|
|
148
|
+
|
|
149
|
+
const dir = dirname(filePath);
|
|
150
|
+
await mkdir(dir, { recursive: true, mode: 0o700 });
|
|
151
|
+
const dirStats = await lstat(dir);
|
|
152
|
+
if (!dirStats.isDirectory() || dirStats.isSymbolicLink()) {
|
|
153
|
+
throw new Error(`Target directory is not a safe directory: ${dir}`);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
let handle;
|
|
157
|
+
try {
|
|
158
|
+
handle = await open(filePath, CREATE_OBJECT_FLAGS, 0o600);
|
|
159
|
+
await handle.writeFile(content, { encoding: 'utf8' });
|
|
160
|
+
const writtenStat = await handle.stat();
|
|
161
|
+
verifiedObjectCache.add(`${filePath}:${contentBytes}:${writtenStat.mtimeMs}:${hash}`);
|
|
162
|
+
} catch (err) {
|
|
163
|
+
if (err instanceof Error && 'code' in err && err.code === 'EEXIST') {
|
|
164
|
+
const existingHandle = await open(filePath, READ_OBJECT_FLAGS);
|
|
165
|
+
try {
|
|
166
|
+
const existingStat = await existingHandle.stat();
|
|
167
|
+
if (!existingStat.isFile()) {
|
|
168
|
+
throw new Error(`Existing object is not a regular file: ${filePath}`);
|
|
169
|
+
}
|
|
170
|
+
if (existingStat.size !== contentBytes) {
|
|
171
|
+
throw new Error(`Existing object size mismatch: ${existingStat.size} vs ${contentBytes}`);
|
|
172
|
+
}
|
|
173
|
+
// mtime-aware cache key: prevents reading large files repeatedly if mtime hasn't changed
|
|
174
|
+
const mtimeKey = `${filePath}:${contentBytes}:${existingStat.mtimeMs}:${hash}`;
|
|
175
|
+
if (!verifiedObjectCache.has(mtimeKey)) {
|
|
176
|
+
const existingData = await existingHandle.readFile();
|
|
177
|
+
if (sha256Hex(existingData) !== hash) {
|
|
178
|
+
throw new Error(`Existing object hash mismatch: ${filePath}`);
|
|
179
|
+
}
|
|
180
|
+
verifiedObjectCache.add(mtimeKey);
|
|
181
|
+
}
|
|
182
|
+
} finally {
|
|
183
|
+
await existingHandle.close();
|
|
184
|
+
}
|
|
185
|
+
} else {
|
|
186
|
+
throw err;
|
|
187
|
+
}
|
|
188
|
+
} finally {
|
|
189
|
+
await handle?.close();
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
storeWriteCounter++;
|
|
193
|
+
if (storeWriteCounter % 50 === 0) {
|
|
194
|
+
void pruneObjectsDirectory(dir).catch(() => {});
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
return {
|
|
198
|
+
path: filePath,
|
|
199
|
+
bytes: contentBytes,
|
|
200
|
+
lines,
|
|
201
|
+
hash,
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
export async function readStoredObjectChunk(
|
|
206
|
+
filePath: string,
|
|
207
|
+
offset: number,
|
|
208
|
+
limits: { maxBytes: number; maxLines: number },
|
|
209
|
+
): Promise<RecallSliceResult> {
|
|
210
|
+
let handle;
|
|
211
|
+
try {
|
|
212
|
+
handle = await open(filePath, READ_OBJECT_FLAGS);
|
|
213
|
+
const fileStat = await handle.stat();
|
|
214
|
+
if (!fileStat.isFile()) {
|
|
215
|
+
throw new Error(`Stored object is not a regular file: ${filePath}`);
|
|
216
|
+
}
|
|
217
|
+
if (offset > fileStat.size) {
|
|
218
|
+
throw new Error(`Offset ${offset} exceeds object size ${fileStat.size}`);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const available = Math.max(0, fileStat.size - offset);
|
|
222
|
+
const buffer = Buffer.alloc(Math.min(available, limits.maxBytes + 4));
|
|
223
|
+
const { bytesRead } = await handle.read(buffer, 0, buffer.length, offset);
|
|
224
|
+
const sliced = sliceBufferChunk(buffer.subarray(0, bytesRead), 0, limits);
|
|
225
|
+
// sliceBufferChunk's nextOffset is relative to the window it was given (offset 0). Callers page
|
|
226
|
+
// with the absolute file offset from the header, so add the seek back.
|
|
227
|
+
const nextOffset = offset + sliced.nextOffset;
|
|
228
|
+
return { ...sliced, nextOffset, eof: nextOffset >= fileStat.size };
|
|
229
|
+
} finally {
|
|
230
|
+
await handle?.close();
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** Rotation ceiling: rename to `<log>.old` past this size so a log can never grow unbounded. */
|
|
235
|
+
const MAX_EFFICIENCY_LOG_BYTES = 5 * 1024 * 1024;
|
|
236
|
+
|
|
237
|
+
export async function appendEfficiencyLog(
|
|
238
|
+
logPath: string,
|
|
239
|
+
record: Record<string, unknown>,
|
|
240
|
+
): Promise<void> {
|
|
241
|
+
const line = `${JSON.stringify(record)}\n`;
|
|
242
|
+
const dir = dirname(logPath);
|
|
243
|
+
await mkdir(dir, { recursive: true, mode: 0o700 });
|
|
244
|
+
|
|
245
|
+
try {
|
|
246
|
+
const s = await stat(logPath);
|
|
247
|
+
if (s.size > MAX_EFFICIENCY_LOG_BYTES) {
|
|
248
|
+
const oldPath = `${logPath}.old`;
|
|
249
|
+
await rm(oldPath, { force: true });
|
|
250
|
+
await rename(logPath, oldPath);
|
|
251
|
+
}
|
|
252
|
+
} catch {
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
let handle;
|
|
256
|
+
try {
|
|
257
|
+
handle = await open(logPath, constants.O_WRONLY | constants.O_CREAT | constants.O_APPEND, 0o600);
|
|
258
|
+
await handle.writeFile(line, { encoding: 'utf8' });
|
|
259
|
+
} finally {
|
|
260
|
+
await handle?.close();
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/** Safely reads Pi's untruncated bash output from temporary file. */
|
|
265
|
+
export async function readBashFullOutput(
|
|
266
|
+
rawPath: string | null | undefined,
|
|
267
|
+
maxChars: number,
|
|
268
|
+
): Promise<{ content: string; bytes: number; lines: number } | null> {
|
|
269
|
+
if (!rawPath || typeof rawPath !== 'string') return null;
|
|
270
|
+
|
|
271
|
+
const fileName = basename(rawPath);
|
|
272
|
+
if (!BASH_LOG_NAME_RE.test(fileName)) return null;
|
|
273
|
+
|
|
274
|
+
try {
|
|
275
|
+
const realTmp = await realpath(tmpdir());
|
|
276
|
+
const realFile = await realpath(rawPath);
|
|
277
|
+
const rel = relative(realTmp, realFile);
|
|
278
|
+
if (rel.startsWith('..') || isAbsolute(rel)) return null;
|
|
279
|
+
|
|
280
|
+
const fileStat = await stat(realFile);
|
|
281
|
+
if (!fileStat.isFile() || fileStat.isSymbolicLink()) return null;
|
|
282
|
+
|
|
283
|
+
if (fileStat.size > maxChars * 4) return null;
|
|
284
|
+
|
|
285
|
+
let handle = await open(realFile, READ_OBJECT_FLAGS);
|
|
286
|
+
try {
|
|
287
|
+
const content = await handle.readFile({ encoding: 'utf8' });
|
|
288
|
+
return {
|
|
289
|
+
content,
|
|
290
|
+
bytes: Buffer.byteLength(content, 'utf8'),
|
|
291
|
+
lines: countLines(content),
|
|
292
|
+
};
|
|
293
|
+
} finally {
|
|
294
|
+
await handle.close();
|
|
295
|
+
}
|
|
296
|
+
} catch {
|
|
297
|
+
return null;
|
|
298
|
+
}
|
|
299
|
+
}
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* D-4: herdr 0.9.1+ delivers `pane.focused`, so default polling is off (0 ms) there — a sampler
|
|
3
|
+
* would reflow a second time ~8 s after a click; older servers keep the 1500 ms sampler.
|
|
4
|
+
* `PIER_FOCUS_POLL_MS` overrides either default, 0 disables.
|
|
5
|
+
*/
|
|
6
|
+
import { spawn } from 'node:child_process';
|
|
7
|
+
import { dirname, join } from 'node:path';
|
|
8
|
+
import { fileURLToPath } from 'node:url';
|
|
9
|
+
|
|
10
|
+
/** Poll cadence for older Herdr (< 0.9.1). */
|
|
11
|
+
export const FOCUS_POLL_DEFAULT_MS = 1500;
|
|
12
|
+
/** Herdr 0.9.1+: native pane.focused is authoritative; default poller off to avoid a delayed second reflow. */
|
|
13
|
+
export const FOCUS_POLL_HERDR_091_MS = 0;
|
|
14
|
+
|
|
15
|
+
export function isHerdr091OrLater(version?: string | null): boolean {
|
|
16
|
+
if (!version) return false;
|
|
17
|
+
const m = /^(\d+)\.(\d+)\.(\d+)/.exec(version.trim());
|
|
18
|
+
if (!m) return false;
|
|
19
|
+
const major = Number(m[1]);
|
|
20
|
+
const minor = Number(m[2]);
|
|
21
|
+
const patch = Number(m[3]);
|
|
22
|
+
if (major > 0) return true;
|
|
23
|
+
if (minor > 9) return true;
|
|
24
|
+
if (minor === 9 && patch >= 1) return true;
|
|
25
|
+
return false;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function resolveDefaultFocusPollMs(herdrVersion?: string | null): number {
|
|
29
|
+
return isHerdr091OrLater(herdrVersion) ? FOCUS_POLL_HERDR_091_MS : FOCUS_POLL_DEFAULT_MS;
|
|
30
|
+
}
|
|
31
|
+
/** Min gap between reflow triggers: re-clicking the same pane must not spawn a process per click (the
|
|
32
|
+
* workbench debounces 150 ms per tab on top of this). */
|
|
33
|
+
export const FOCUS_FIRE_MIN_INTERVAL_MS = 700;
|
|
34
|
+
|
|
35
|
+
export interface FocusSample {
|
|
36
|
+
/** Pane focused in the sampled tab (null when the server reported no focus). */
|
|
37
|
+
readonly focusedPaneId: string | null;
|
|
38
|
+
/** Panes currently in that tab, used to tell a click apart from a spawn auto-focus. */
|
|
39
|
+
readonly paneIds: readonly string[];
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface FocusPollerState {
|
|
43
|
+
readonly lastFocusedPaneId: string | null;
|
|
44
|
+
readonly lastPaneIds: readonly string[];
|
|
45
|
+
readonly lastFireAt: number;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Minimal flatten of a herdr layout tree (`{type:'pane',pane_id}` / `{type:'split',first,second}`). */
|
|
49
|
+
export function collectPaneIds(root: unknown): string[] {
|
|
50
|
+
const out: string[] = [];
|
|
51
|
+
const walk = (node: unknown): void => {
|
|
52
|
+
if (node === null || typeof node !== 'object') return;
|
|
53
|
+
const n = node as { pane_id?: unknown; first?: unknown; second?: unknown };
|
|
54
|
+
if (typeof n.pane_id === 'string') {
|
|
55
|
+
out.push(n.pane_id);
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
walk(n.first);
|
|
59
|
+
walk(n.second);
|
|
60
|
+
};
|
|
61
|
+
walk(root);
|
|
62
|
+
return out;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** `layout.export` payload → sample. `focusedPaneId` is optional for older clients. */
|
|
66
|
+
export function parseFocusSample(
|
|
67
|
+
layout: { root: unknown; focusedPaneId?: string | null } | null | undefined,
|
|
68
|
+
): FocusSample | null {
|
|
69
|
+
if (!layout || !('root' in layout)) return null;
|
|
70
|
+
const paneIds = collectPaneIds(layout.root);
|
|
71
|
+
const focused = typeof layout.focusedPaneId === 'string' ? layout.focusedPaneId : null;
|
|
72
|
+
return { focusedPaneId: focused, paneIds };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export interface FocusTick {
|
|
76
|
+
readonly fire: boolean;
|
|
77
|
+
readonly cause: 'user' | null;
|
|
78
|
+
readonly state: FocusPollerState;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Decide whether one sample should trigger a reflow: only on a *transition* into "I am focused",
|
|
83
|
+
* and at most once per `minIntervalMs`. `cause` is 'user' when the pane set did not change in the
|
|
84
|
+
* same sample (a click moves focus and nothing else, a spawn auto-focus comes with a new pane), so
|
|
85
|
+
* the workbench can keep its pane-age whitelist. Baseline sample never fires.
|
|
86
|
+
*/
|
|
87
|
+
export function planFocusTick(opts: {
|
|
88
|
+
myPaneId: string;
|
|
89
|
+
sample: FocusSample;
|
|
90
|
+
prev: FocusPollerState | null;
|
|
91
|
+
now: number;
|
|
92
|
+
minIntervalMs?: number;
|
|
93
|
+
}): FocusTick {
|
|
94
|
+
const minInterval = opts.minIntervalMs ?? FOCUS_FIRE_MIN_INTERVAL_MS;
|
|
95
|
+
const prev = opts.prev;
|
|
96
|
+
const next: FocusPollerState = {
|
|
97
|
+
lastFocusedPaneId: opts.sample.focusedPaneId,
|
|
98
|
+
lastPaneIds: opts.sample.paneIds,
|
|
99
|
+
lastFireAt: prev?.lastFireAt ?? 0,
|
|
100
|
+
};
|
|
101
|
+
const noFire = (): FocusTick => ({ fire: false, cause: null, state: next });
|
|
102
|
+
|
|
103
|
+
if (opts.myPaneId === '') return noFire();
|
|
104
|
+
// First sample only records the baseline: a pi that starts in the focused pane must not reflow on
|
|
105
|
+
// startup (that is not a click, and it would fight whatever layout the user had).
|
|
106
|
+
if (prev === null) return noFire();
|
|
107
|
+
if (opts.sample.focusedPaneId !== opts.myPaneId) return noFire();
|
|
108
|
+
if (prev.lastFocusedPaneId === opts.myPaneId) return noFire();
|
|
109
|
+
if (opts.now - prev.lastFireAt < minInterval) return noFire();
|
|
110
|
+
|
|
111
|
+
const setUnchanged =
|
|
112
|
+
prev.lastPaneIds.length === opts.sample.paneIds.length &&
|
|
113
|
+
prev.lastPaneIds.every((id, i) => id === opts.sample.paneIds[i]);
|
|
114
|
+
return {
|
|
115
|
+
fire: true,
|
|
116
|
+
cause: setUnchanged ? 'user' : null,
|
|
117
|
+
state: { ...next, lastFireAt: opts.now },
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export interface FocusPollerDeps {
|
|
122
|
+
/** Layout of the caller's own tab (the client already exposes `layout.export`). */
|
|
123
|
+
sample(): Promise<FocusSample | null>;
|
|
124
|
+
/** Replay a pane.focused reflow for `paneId`. Must never throw. */
|
|
125
|
+
fire(paneId: string, cause: 'user' | null): void;
|
|
126
|
+
myPaneId: string;
|
|
127
|
+
intervalMs?: number;
|
|
128
|
+
minIntervalMs?: number;
|
|
129
|
+
now?: () => number;
|
|
130
|
+
/** Diagnostics only: polling failures are expected while herdr restarts. */
|
|
131
|
+
onError?: (err: unknown) => void;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export interface FocusPoller {
|
|
135
|
+
/** One sampling pass (exposed for tests; the interval calls the same function). */
|
|
136
|
+
tick(): Promise<void>;
|
|
137
|
+
stop(): void;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export function startFocusPoller(deps: FocusPollerDeps): FocusPoller {
|
|
141
|
+
const intervalMs = deps.intervalMs ?? FOCUS_POLL_DEFAULT_MS;
|
|
142
|
+
const now = deps.now ?? Date.now;
|
|
143
|
+
let state: FocusPollerState | null = null;
|
|
144
|
+
let inFlight = false;
|
|
145
|
+
let stopped = false;
|
|
146
|
+
let handle: NodeJS.Timeout | null = null;
|
|
147
|
+
|
|
148
|
+
async function tick(): Promise<void> {
|
|
149
|
+
if (stopped || inFlight) return;
|
|
150
|
+
inFlight = true;
|
|
151
|
+
try {
|
|
152
|
+
const sample = await deps.sample();
|
|
153
|
+
if (sample === null) return;
|
|
154
|
+
const decision = planFocusTick({
|
|
155
|
+
myPaneId: deps.myPaneId,
|
|
156
|
+
sample,
|
|
157
|
+
prev: state,
|
|
158
|
+
now: now(),
|
|
159
|
+
minIntervalMs: deps.minIntervalMs,
|
|
160
|
+
});
|
|
161
|
+
state = decision.state;
|
|
162
|
+
if (decision.fire) deps.fire(deps.myPaneId, decision.cause);
|
|
163
|
+
} catch (err) {
|
|
164
|
+
// A failed sample must not advance the baseline: the next tick re-reads the layout.
|
|
165
|
+
deps.onError?.(err);
|
|
166
|
+
} finally {
|
|
167
|
+
inFlight = false;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
if (intervalMs > 0) {
|
|
172
|
+
handle = setInterval(() => { void tick(); }, intervalMs);
|
|
173
|
+
handle.unref?.(); // never keep pi's event loop alive on our account
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
return {
|
|
177
|
+
tick,
|
|
178
|
+
stop(): void {
|
|
179
|
+
stopped = true;
|
|
180
|
+
if (handle !== null) {
|
|
181
|
+
clearInterval(handle);
|
|
182
|
+
handle = null;
|
|
183
|
+
}
|
|
184
|
+
},
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** The workbench's reflow entry point; overridable for relocated checkouts. */
|
|
189
|
+
export function reflowScriptPath(env: NodeJS.ProcessEnv = process.env): string {
|
|
190
|
+
const root = env.PIER_WORKBENCH_ROOT?.trim();
|
|
191
|
+
if (root) return join(root, 'scripts', 'heat-reflow.mjs');
|
|
192
|
+
// packages/pier-ext/src/focus-poller.ts -> packages/pier-workbench/scripts/heat-reflow.mjs
|
|
193
|
+
return join(dirname(fileURLToPath(import.meta.url)), '..', '..', 'pier-workbench', 'scripts', 'heat-reflow.mjs');
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
export interface SpawnReflowOpts {
|
|
197
|
+
paneId: string;
|
|
198
|
+
cause: 'user' | null;
|
|
199
|
+
env?: NodeJS.ProcessEnv;
|
|
200
|
+
/** Injection seam for tests (defaults to child_process.spawn). */
|
|
201
|
+
spawnFn?: typeof spawn;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** Fire-and-forget replay of the `pane.focused` hook as a child process — exactly how herdr runs it,
|
|
205
|
+
* so the workbench script keeps its own event parsing, debounce and state file. */
|
|
206
|
+
export function spawnReflow(opts: SpawnReflowOpts): void {
|
|
207
|
+
const env = opts.env ?? process.env;
|
|
208
|
+
const spawnFn = opts.spawnFn ?? spawn;
|
|
209
|
+
const payload = JSON.stringify({
|
|
210
|
+
event: 'pane_focused',
|
|
211
|
+
type: 'pane_focused',
|
|
212
|
+
data: { type: 'pane_focused', pane_id: opts.paneId, cause: opts.cause },
|
|
213
|
+
});
|
|
214
|
+
try {
|
|
215
|
+
const child = spawnFn(process.execPath, [reflowScriptPath(env)], {
|
|
216
|
+
env: { ...env, HERDR_PLUGIN_EVENT: 'pane.focused', HERDR_PLUGIN_EVENT_JSON: payload },
|
|
217
|
+
stdio: 'ignore',
|
|
218
|
+
});
|
|
219
|
+
child.on?.('error', () => { /* Missing script / dead exec: polling must never break pi. */ });
|
|
220
|
+
child.unref?.();
|
|
221
|
+
} catch {
|
|
222
|
+
}
|
|
223
|
+
}
|
package/src/gc-core.ts
CHANGED
|
@@ -1,7 +1,4 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Task-tab GC decisions (D29 rules as a pure function; M22 dropped resident exemption).
|
|
3
|
-
* No I/O, no pi/herdr — unit-testable.
|
|
4
|
-
*/
|
|
1
|
+
/** Task-tab GC decisions (D29 rules as a pure function; no I/O, no pi/herdr). */
|
|
5
2
|
export type GcEntryKind = string;
|
|
6
3
|
export type GcEntryStatus = 'running' | 'settled' | 'consumed' | 'closed';
|
|
7
4
|
|
|
@@ -17,14 +14,8 @@ function isFinished(status: GcEntryStatus): boolean {
|
|
|
17
14
|
return status === 'consumed' || status === 'closed' || status === 'settled';
|
|
18
15
|
}
|
|
19
16
|
|
|
20
|
-
/**
|
|
21
|
-
*
|
|
22
|
-
* - ≥1 work pane (the main tab with no delegated entries never closes);
|
|
23
|
-
* - every work pane is settled/consumed/closed (kind is not an exemption);
|
|
24
|
-
* - grace TTL elapsed (`ttlMs=0` means never auto-close);
|
|
25
|
-
* - no blocked pane (human-gate exemption);
|
|
26
|
-
* - remaining panes are idle/done/unknown (non-work panes may be unknown).
|
|
27
|
-
*/
|
|
17
|
+
/** Close a task tab only when every work pane finished, the grace TTL elapsed (`ttlMs=0` means never
|
|
18
|
+
* auto-close), no pane is blocked and the rest are idle/done/unknown. */
|
|
28
19
|
export function shouldCloseTaskTab(opts: {
|
|
29
20
|
entries: readonly GcEntryLike[];
|
|
30
21
|
paneStatuses: readonly string[];
|
|
@@ -39,12 +30,8 @@ export function shouldCloseTaskTab(opts: {
|
|
|
39
30
|
return true;
|
|
40
31
|
}
|
|
41
32
|
|
|
42
|
-
/**
|
|
43
|
-
*
|
|
44
|
-
* - consumed before the previous turn (grace so the settlement notice is still visible);
|
|
45
|
-
* - herdr status idle/done (unknown/working/blocked retry next turn);
|
|
46
|
-
* - missing pane (status undefined) → record closed (caller handles the write).
|
|
47
|
-
*/
|
|
33
|
+
/** Pane-level collection (orphan / compat path): consumed before the previous turn (grace so the
|
|
34
|
+
* settlement notice is still visible); missing pane → record closed (caller handles the write). */
|
|
48
35
|
export function shouldClosePane(opts: {
|
|
49
36
|
consumedAt: number | null;
|
|
50
37
|
herdrStatus: string | undefined;
|
|
@@ -54,3 +41,75 @@ export function shouldClosePane(opts: {
|
|
|
54
41
|
if (opts.herdrStatus !== 'idle' && opts.herdrStatus !== 'done') return false;
|
|
55
42
|
return (opts.consumedAt ?? 0) > 0 && opts.consumedAt! < opts.prevTurnStart;
|
|
56
43
|
}
|
|
44
|
+
|
|
45
|
+
/* Isolate worktree collection is deliberately narrow: `refs/heads/pier/*` is NOT proof of pier
|
|
46
|
+
* ownership — a worker session lives in such a worktree, so a namespace-wide sweep deletes the
|
|
47
|
+
* worktree its own process is running in. Only THIS session's registered branches are candidates,
|
|
48
|
+
* cwd never is, and untracked `pier/*` branches need an explicit opt-in. */
|
|
49
|
+
|
|
50
|
+
export function isPathInside(path: string, parent: string): boolean {
|
|
51
|
+
const norm = (p: string): string => p.replace(/[\\/]+/g, '/').replace(/\/+$/, '');
|
|
52
|
+
const child = norm(path);
|
|
53
|
+
const base = norm(parent);
|
|
54
|
+
if (!child || !base) return false;
|
|
55
|
+
return child === base || child.startsWith(`${base}/`);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface IsolateSweepCandidate {
|
|
59
|
+
branch: string;
|
|
60
|
+
worktreePath: string;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface IsolateSweepSkip {
|
|
64
|
+
branch: string;
|
|
65
|
+
reason: 'unregistered' | 'pending' | 'self' | 'no-worktree';
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export interface IsolateSweepPlan {
|
|
69
|
+
candidates: IsolateSweepCandidate[];
|
|
70
|
+
skipped: IsolateSweepSkip[];
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Pure: callers pass git output, the subagent registry and the process cwd. */
|
|
74
|
+
export function planIsolateSweep(input: {
|
|
75
|
+
branches: readonly string[];
|
|
76
|
+
/** branch → worktree path, from `git worktree list --porcelain`. */
|
|
77
|
+
worktreesByBranch: ReadonlyMap<string, string>;
|
|
78
|
+
registeredBranches: ReadonlySet<string>;
|
|
79
|
+
/** Branches created but not yet registered (worktree add → subs.set window). */
|
|
80
|
+
pendingBranches: ReadonlySet<string>;
|
|
81
|
+
sessionOwned: ReadonlyArray<{ branch: string; worktreePath: string }>;
|
|
82
|
+
/** Path to protect (normally process.cwd()). */
|
|
83
|
+
cwd: string;
|
|
84
|
+
/** Explicit opt-in for branches this session never registered. */
|
|
85
|
+
sweepOrphans: boolean;
|
|
86
|
+
}): IsolateSweepPlan {
|
|
87
|
+
const candidates = new Map<string, IsolateSweepCandidate>();
|
|
88
|
+
const skipped: IsolateSweepSkip[] = [];
|
|
89
|
+
const add = (branch: string, worktreePath: string): void => {
|
|
90
|
+
if (isPathInside(input.cwd, worktreePath)) {
|
|
91
|
+
skipped.push({ branch, reason: 'self' });
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
candidates.set(branch, { branch, worktreePath });
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
if (input.sweepOrphans) {
|
|
98
|
+
for (const branch of input.branches) {
|
|
99
|
+
if (input.registeredBranches.has(branch)) continue;
|
|
100
|
+
if (input.pendingBranches.has(branch)) {
|
|
101
|
+
skipped.push({ branch, reason: 'pending' });
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
const wtPath = input.worktreesByBranch.get(branch);
|
|
105
|
+
if (wtPath) add(branch, wtPath);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
for (const entry of input.sessionOwned) {
|
|
110
|
+
const wtPath = input.worktreesByBranch.get(entry.branch) ?? entry.worktreePath;
|
|
111
|
+
add(entry.branch, wtPath);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return { candidates: [...candidates.values()], skipped };
|
|
115
|
+
}
|