pi-pier 0.2.6 → 0.3.0
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 +20 -7
- package/package.json +7 -2
- package/schemas/efficiency-config.schema.json +77 -0
- package/skills/pi-herdr-workflow/SKILL.md +13 -1
- package/src/ansi-text.ts +83 -0
- package/src/ask-multi.ts +260 -0
- package/src/ask-user.ts +458 -0
- package/src/bootstrap.ts +5 -2
- package/src/compact-coordinator.ts +454 -0
- package/src/compact-economics-core.ts +396 -0
- package/src/config-catalog-core.ts +452 -0
- package/src/config-command.ts +181 -0
- package/src/config-guide.ts +361 -0
- package/src/core/grid-shape.ts +4 -2
- package/src/core/heat-plan.ts +160 -0
- package/src/core/observation.ts +555 -0
- package/src/core/subagent.ts +187 -393
- package/src/core/terminal.ts +270 -47
- package/src/core/todo.ts +49 -14
- package/src/dashboard-command.ts +89 -0
- package/src/efficiency-config-core.ts +661 -0
- package/src/efficiency-config-io.ts +53 -0
- package/src/efficiency-store.ts +312 -0
- package/src/focus-poller.ts +244 -0
- package/src/gc-core.ts +85 -0
- package/src/git-adapter.ts +12 -3
- package/src/herdr-client.ts +292 -36
- package/src/index-locks.ts +27 -9
- package/src/index-master.ts +5 -0
- package/src/index-notices.ts +13 -2
- package/src/index-pipe.ts +5 -0
- package/src/index-worker.ts +5 -0
- package/src/index.ts +360 -60
- package/src/jev-client.ts +146 -0
- package/src/jev-core.ts +381 -0
- package/src/lock-core.ts +105 -16
- package/src/observation-core.ts +248 -0
- package/src/pier-options.ts +96 -0
- package/src/pipe-channel.ts +52 -7
- package/src/reducer-core.ts +358 -0
- package/src/reducer-invoker.ts +392 -0
- package/src/renderers.ts +198 -0
- package/src/role-loader.ts +2 -1
- package/src/role-manifest.ts +0 -4
- package/src/runtime-policy.ts +5 -8
- package/src/session-tail.ts +60 -5
- package/src/settle-wake-core.ts +3 -1
- package/src/slim-frame.ts +60 -3
- package/src/storage-layout.ts +23 -0
- package/src/subagent-core.ts +3 -1
- package/src/subagent-execute.ts +328 -0
- package/src/subagent-gc.ts +33 -13
- package/src/subagent-launch.ts +4 -0
- package/src/subagent-list-action.ts +69 -0
- package/src/subagent-output-action.ts +104 -0
- package/src/subagent-output-adapter.ts +27 -0
- package/src/subagent-output-core.ts +263 -0
- package/src/subagent-poll-loop.ts +59 -39
- package/src/subagent-poller.ts +46 -0
- package/src/subagent-ready.ts +89 -0
- package/src/subagent-registry.ts +70 -0
- package/src/subagent-resolution.ts +41 -0
- package/src/subagent-scope.ts +3 -1
- package/src/subagent-session-io.ts +115 -32
- package/src/subagent-spawn.ts +153 -8
- package/src/swallow.ts +75 -0
- package/src/terminal-core.ts +161 -10
- package/src/todo-core.ts +5 -4
- package/src/todo-reminder-core.ts +8 -1
- package/src/todos-service.ts +18 -6
- package/src/tool-error.ts +26 -0
- package/src/vocab.ts +7 -4
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
|
|
5
|
+
export interface PiCompactionSettings {
|
|
6
|
+
enabled?: boolean;
|
|
7
|
+
keepRecentTokens?: number;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function readCompactionSettings(filePath: string): PiCompactionSettings {
|
|
11
|
+
if (!existsSync(filePath)) return {};
|
|
12
|
+
try {
|
|
13
|
+
const parsed: unknown = JSON.parse(readFileSync(filePath, 'utf8'));
|
|
14
|
+
if (!parsed || typeof parsed !== 'object' || !('compaction' in parsed)) return {};
|
|
15
|
+
const compaction = parsed.compaction;
|
|
16
|
+
if (!compaction || typeof compaction !== 'object') return {};
|
|
17
|
+
const result: PiCompactionSettings = {};
|
|
18
|
+
if ('enabled' in compaction && typeof compaction.enabled === 'boolean') result.enabled = compaction.enabled;
|
|
19
|
+
if ('keepRecentTokens' in compaction && typeof compaction.keepRecentTokens === 'number') {
|
|
20
|
+
result.keepRecentTokens = compaction.keepRecentTokens;
|
|
21
|
+
}
|
|
22
|
+
return result;
|
|
23
|
+
} catch {
|
|
24
|
+
return {};
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function defaultAgentDir(): string {
|
|
29
|
+
return process.env.PI_CODING_AGENT_DIR ?? join(homedir(), '.pi', 'agent');
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Read pi's own compaction settings (global, plus the project file when trusted). */
|
|
33
|
+
export function loadPiNativeCompactionSettings(opts: {
|
|
34
|
+
cwd?: string;
|
|
35
|
+
isProjectTrusted?: boolean;
|
|
36
|
+
agentDir?: string;
|
|
37
|
+
} = {}): PiCompactionSettings {
|
|
38
|
+
const cwd = opts.cwd ?? process.cwd();
|
|
39
|
+
const globalSettings = readCompactionSettings(join(opts.agentDir ?? defaultAgentDir(), 'settings.json'));
|
|
40
|
+
if (!opts.isProjectTrusted) return globalSettings;
|
|
41
|
+
const projectSettings = readCompactionSettings(join(cwd, '.pi', 'settings.json'));
|
|
42
|
+
return { ...globalSettings, ...projectSettings };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function readJsonConfig(filePath: string, onError?: (error: unknown) => void): unknown | undefined {
|
|
46
|
+
if (!existsSync(filePath)) return undefined;
|
|
47
|
+
try {
|
|
48
|
+
return JSON.parse(readFileSync(filePath, 'utf8')) as unknown;
|
|
49
|
+
} catch (error) {
|
|
50
|
+
onError?.(error);
|
|
51
|
+
return undefined;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* D101-D103 Efficiency Storage & I/O Adapter.
|
|
3
|
+
*
|
|
4
|
+
* Implements safe, content-addressed storage for ObservationPack and
|
|
5
|
+
* Evidence-Preserving Reducer, along with telemetry log appending and
|
|
6
|
+
* secure bash un-truncated output reading.
|
|
7
|
+
*
|
|
8
|
+
* Security & Integrity:
|
|
9
|
+
* - Mode 0700 for directories, 0600 for object and log files.
|
|
10
|
+
* - O_NOFOLLOW to forbid symlinks.
|
|
11
|
+
* - Safe sessionId regex validation.
|
|
12
|
+
* - EEXIST integrity verification (rejects corrupted collisions).
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { constants } from 'node:fs';
|
|
16
|
+
import { lstat, mkdir, open, readdir, realpath, rename, rm, stat } from 'node:fs/promises';
|
|
17
|
+
import { tmpdir } from 'node:os';
|
|
18
|
+
import { basename, dirname, isAbsolute, join, relative, resolve } from 'node:path';
|
|
19
|
+
import {
|
|
20
|
+
countLines,
|
|
21
|
+
sha256Hex,
|
|
22
|
+
sliceBufferChunk,
|
|
23
|
+
type RecallSliceResult,
|
|
24
|
+
} from './observation-core.ts';
|
|
25
|
+
|
|
26
|
+
export const SAFE_SESSION_ID_RE = /^[a-z0-9][a-z0-9._-]*$/i;
|
|
27
|
+
export const BASH_LOG_NAME_RE = /^pi-bash-.*\.log$/;
|
|
28
|
+
|
|
29
|
+
const READ_OBJECT_FLAGS = constants.O_RDONLY | constants.O_NOFOLLOW;
|
|
30
|
+
const CREATE_OBJECT_FLAGS =
|
|
31
|
+
constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW;
|
|
32
|
+
|
|
33
|
+
const verifiedObjectCache = new Set<string>();
|
|
34
|
+
|
|
35
|
+
export function clearVerifiedObjectCacheForTest(): void {
|
|
36
|
+
verifiedObjectCache.clear();
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface StorageObjectResult {
|
|
40
|
+
path: string;
|
|
41
|
+
bytes: number;
|
|
42
|
+
lines: number;
|
|
43
|
+
hash: string;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function isValidSessionId(id: string | null | undefined): boolean {
|
|
47
|
+
return typeof id === 'string' && id.length > 0 && SAFE_SESSION_ID_RE.test(id);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function resolveSessionRoot(
|
|
51
|
+
sessionDir: string | null | undefined,
|
|
52
|
+
sessionId: string | null | undefined,
|
|
53
|
+
): string | null {
|
|
54
|
+
if (!sessionDir || typeof sessionDir !== 'string') return null;
|
|
55
|
+
if (!isValidSessionId(sessionId)) return null;
|
|
56
|
+
return join(sessionDir, 'herdr-pi', sessionId!);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function observationObjectPath(root: string, obsId: string): string {
|
|
60
|
+
return join(root, 'observation-pack', 'objects', `${obsId}.txt`);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function reducerObjectPath(root: string, sha256: string): string {
|
|
64
|
+
return join(root, 'evidence-preserving-reducer', 'objects', `${sha256}.txt`);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function efficiencyLogPath(
|
|
68
|
+
root: string,
|
|
69
|
+
mechanism: 'compact' | 'observation' | 'reducer' | 'jev',
|
|
70
|
+
customPath?: string,
|
|
71
|
+
): string {
|
|
72
|
+
if (customPath && typeof customPath === 'string') return resolve(customPath);
|
|
73
|
+
return join(root, 'efficiency-logs', `${mechanism}.jsonl`);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export const MAX_OBJECTS_PER_DIR = 300;
|
|
77
|
+
export const MAX_OBJECTS_TOTAL_BYTES = 50 * 1024 * 1024; // 50MB
|
|
78
|
+
|
|
79
|
+
export async function pruneObjectsDirectory(
|
|
80
|
+
dir: string,
|
|
81
|
+
limits: { maxFiles?: number; maxTotalBytes?: number } = {},
|
|
82
|
+
): Promise<number> {
|
|
83
|
+
const maxFiles = limits.maxFiles ?? MAX_OBJECTS_PER_DIR;
|
|
84
|
+
const maxTotalBytes = limits.maxTotalBytes ?? MAX_OBJECTS_TOTAL_BYTES;
|
|
85
|
+
|
|
86
|
+
try {
|
|
87
|
+
const entries = await readdir(dir);
|
|
88
|
+
const files: Array<{ name: string; path: string; size: number; mtimeMs: number }> = [];
|
|
89
|
+
let totalBytes = 0;
|
|
90
|
+
|
|
91
|
+
for (const name of entries) {
|
|
92
|
+
if (!name.endsWith('.txt')) continue;
|
|
93
|
+
const p = join(dir, name);
|
|
94
|
+
try {
|
|
95
|
+
const s = await stat(p);
|
|
96
|
+
if (s.isFile() && !s.isSymbolicLink()) {
|
|
97
|
+
files.push({ name, path: p, size: s.size, mtimeMs: s.mtimeMs });
|
|
98
|
+
totalBytes += s.size;
|
|
99
|
+
}
|
|
100
|
+
} catch {
|
|
101
|
+
/* ignore stat errors */
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (files.length <= maxFiles && totalBytes <= maxTotalBytes) {
|
|
106
|
+
return 0;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Sort by mtime ascending (oldest first)
|
|
110
|
+
files.sort((a, b) => a.mtimeMs - b.mtimeMs);
|
|
111
|
+
|
|
112
|
+
let removedCount = 0;
|
|
113
|
+
for (const f of files) {
|
|
114
|
+
if (files.length - removedCount <= maxFiles && totalBytes <= maxTotalBytes) {
|
|
115
|
+
break;
|
|
116
|
+
}
|
|
117
|
+
try {
|
|
118
|
+
await rm(f.path, { force: true });
|
|
119
|
+
removedCount++;
|
|
120
|
+
totalBytes -= f.size;
|
|
121
|
+
for (const k of verifiedObjectCache.keys()) {
|
|
122
|
+
if (k.startsWith(f.path)) verifiedObjectCache.delete(k);
|
|
123
|
+
}
|
|
124
|
+
} catch {
|
|
125
|
+
/* ignore unlink errors */
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
return removedCount;
|
|
129
|
+
} catch {
|
|
130
|
+
return 0;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Prune both content-addressed object dirs of one session root; returns total removed files. */
|
|
135
|
+
export async function pruneSessionObjects(
|
|
136
|
+
sessionRoot: string,
|
|
137
|
+
limits: { maxFiles?: number; maxTotalBytes?: number } = {},
|
|
138
|
+
): Promise<number> {
|
|
139
|
+
const targets = [
|
|
140
|
+
join(sessionRoot, 'observation-pack', 'objects'),
|
|
141
|
+
join(sessionRoot, 'evidence-preserving-reducer', 'objects'),
|
|
142
|
+
];
|
|
143
|
+
let removed = 0;
|
|
144
|
+
for (const dir of targets) {
|
|
145
|
+
removed += await pruneObjectsDirectory(dir, limits);
|
|
146
|
+
}
|
|
147
|
+
return removed;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
let storeWriteCounter = 0;
|
|
151
|
+
|
|
152
|
+
export async function storeContentAddressedObject(
|
|
153
|
+
filePath: string,
|
|
154
|
+
content: string,
|
|
155
|
+
precomputed?: { bytes?: number; hash?: string; lines?: number },
|
|
156
|
+
): Promise<StorageObjectResult> {
|
|
157
|
+
const contentBytes = precomputed?.bytes ?? Buffer.byteLength(content, 'utf8');
|
|
158
|
+
const hash = precomputed?.hash ?? sha256Hex(content);
|
|
159
|
+
const lines = precomputed?.lines ?? countLines(content);
|
|
160
|
+
|
|
161
|
+
const dir = dirname(filePath);
|
|
162
|
+
await mkdir(dir, { recursive: true, mode: 0o700 });
|
|
163
|
+
const dirStats = await lstat(dir);
|
|
164
|
+
if (!dirStats.isDirectory() || dirStats.isSymbolicLink()) {
|
|
165
|
+
throw new Error(`Target directory is not a safe directory: ${dir}`);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
let handle;
|
|
169
|
+
try {
|
|
170
|
+
handle = await open(filePath, CREATE_OBJECT_FLAGS, 0o600);
|
|
171
|
+
await handle.writeFile(content, { encoding: 'utf8' });
|
|
172
|
+
const writtenStat = await handle.stat();
|
|
173
|
+
verifiedObjectCache.add(`${filePath}:${contentBytes}:${writtenStat.mtimeMs}:${hash}`);
|
|
174
|
+
} catch (err) {
|
|
175
|
+
if (err instanceof Error && 'code' in err && err.code === 'EEXIST') {
|
|
176
|
+
// Verify existing file integrity
|
|
177
|
+
const existingHandle = await open(filePath, READ_OBJECT_FLAGS);
|
|
178
|
+
try {
|
|
179
|
+
const existingStat = await existingHandle.stat();
|
|
180
|
+
if (!existingStat.isFile()) {
|
|
181
|
+
throw new Error(`Existing object is not a regular file: ${filePath}`);
|
|
182
|
+
}
|
|
183
|
+
if (existingStat.size !== contentBytes) {
|
|
184
|
+
throw new Error(`Existing object size mismatch: ${existingStat.size} vs ${contentBytes}`);
|
|
185
|
+
}
|
|
186
|
+
// mtime-aware cache key: prevents reading large files repeatedly if mtime hasn't changed
|
|
187
|
+
const mtimeKey = `${filePath}:${contentBytes}:${existingStat.mtimeMs}:${hash}`;
|
|
188
|
+
if (!verifiedObjectCache.has(mtimeKey)) {
|
|
189
|
+
const existingData = await existingHandle.readFile();
|
|
190
|
+
if (sha256Hex(existingData) !== hash) {
|
|
191
|
+
throw new Error(`Existing object hash mismatch: ${filePath}`);
|
|
192
|
+
}
|
|
193
|
+
verifiedObjectCache.add(mtimeKey);
|
|
194
|
+
}
|
|
195
|
+
} finally {
|
|
196
|
+
await existingHandle.close();
|
|
197
|
+
}
|
|
198
|
+
} else {
|
|
199
|
+
throw err;
|
|
200
|
+
}
|
|
201
|
+
} finally {
|
|
202
|
+
await handle?.close();
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
storeWriteCounter++;
|
|
206
|
+
if (storeWriteCounter % 50 === 0) {
|
|
207
|
+
void pruneObjectsDirectory(dir).catch(() => {});
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
return {
|
|
211
|
+
path: filePath,
|
|
212
|
+
bytes: contentBytes,
|
|
213
|
+
lines,
|
|
214
|
+
hash,
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
export async function readStoredObjectChunk(
|
|
219
|
+
filePath: string,
|
|
220
|
+
offset: number,
|
|
221
|
+
limits: { maxBytes: number; maxLines: number },
|
|
222
|
+
): Promise<RecallSliceResult> {
|
|
223
|
+
let handle;
|
|
224
|
+
try {
|
|
225
|
+
handle = await open(filePath, READ_OBJECT_FLAGS);
|
|
226
|
+
const fileStat = await handle.stat();
|
|
227
|
+
if (!fileStat.isFile()) {
|
|
228
|
+
throw new Error(`Stored object is not a regular file: ${filePath}`);
|
|
229
|
+
}
|
|
230
|
+
if (offset > fileStat.size) {
|
|
231
|
+
throw new Error(`Offset ${offset} exceeds object size ${fileStat.size}`);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const available = Math.max(0, fileStat.size - offset);
|
|
235
|
+
const buffer = Buffer.alloc(Math.min(available, limits.maxBytes + 4));
|
|
236
|
+
const { bytesRead } = await handle.read(buffer, 0, buffer.length, offset);
|
|
237
|
+
return sliceBufferChunk(buffer.subarray(0, bytesRead), 0, limits);
|
|
238
|
+
} finally {
|
|
239
|
+
await handle?.close();
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
export const MAX_EFFICIENCY_LOG_BYTES = 5 * 1024 * 1024; // 5MB rotation ceiling
|
|
244
|
+
|
|
245
|
+
export async function appendEfficiencyLog(
|
|
246
|
+
logPath: string,
|
|
247
|
+
record: Record<string, unknown>,
|
|
248
|
+
): Promise<void> {
|
|
249
|
+
const line = `${JSON.stringify(record)}\n`;
|
|
250
|
+
const dir = dirname(logPath);
|
|
251
|
+
await mkdir(dir, { recursive: true, mode: 0o700 });
|
|
252
|
+
|
|
253
|
+
// Rotate log if exceeds maximum size to prevent unbounded disk growth
|
|
254
|
+
try {
|
|
255
|
+
const s = await stat(logPath);
|
|
256
|
+
if (s.size > MAX_EFFICIENCY_LOG_BYTES) {
|
|
257
|
+
const oldPath = `${logPath}.old`;
|
|
258
|
+
await rm(oldPath, { force: true });
|
|
259
|
+
await rename(logPath, oldPath);
|
|
260
|
+
}
|
|
261
|
+
} catch {
|
|
262
|
+
/* file may not exist yet, continue */
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
let handle;
|
|
266
|
+
try {
|
|
267
|
+
handle = await open(logPath, constants.O_WRONLY | constants.O_CREAT | constants.O_APPEND, 0o600);
|
|
268
|
+
await handle.writeFile(line, { encoding: 'utf8' });
|
|
269
|
+
} finally {
|
|
270
|
+
await handle?.close();
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* Safely reads Pi's untruncated bash output from temporary file.
|
|
276
|
+
*/
|
|
277
|
+
export async function readBashFullOutput(
|
|
278
|
+
rawPath: string | null | undefined,
|
|
279
|
+
maxChars: number,
|
|
280
|
+
): Promise<{ content: string; bytes: number; lines: number } | null> {
|
|
281
|
+
if (!rawPath || typeof rawPath !== 'string') return null;
|
|
282
|
+
|
|
283
|
+
const fileName = basename(rawPath);
|
|
284
|
+
if (!BASH_LOG_NAME_RE.test(fileName)) return null;
|
|
285
|
+
|
|
286
|
+
try {
|
|
287
|
+
const realTmp = await realpath(tmpdir());
|
|
288
|
+
const realFile = await realpath(rawPath);
|
|
289
|
+
const rel = relative(realTmp, realFile);
|
|
290
|
+
if (rel.startsWith('..') || isAbsolute(rel)) return null;
|
|
291
|
+
|
|
292
|
+
const fileStat = await stat(realFile);
|
|
293
|
+
if (!fileStat.isFile() || fileStat.isSymbolicLink()) return null;
|
|
294
|
+
|
|
295
|
+
// Check size within reasonable limit
|
|
296
|
+
if (fileStat.size > maxChars * 4) return null;
|
|
297
|
+
|
|
298
|
+
let handle = await open(realFile, READ_OBJECT_FLAGS);
|
|
299
|
+
try {
|
|
300
|
+
const content = await handle.readFile({ encoding: 'utf8' });
|
|
301
|
+
return {
|
|
302
|
+
content,
|
|
303
|
+
bytes: Buffer.byteLength(content, 'utf8'),
|
|
304
|
+
lines: countLines(content),
|
|
305
|
+
};
|
|
306
|
+
} finally {
|
|
307
|
+
await handle.close();
|
|
308
|
+
}
|
|
309
|
+
} catch {
|
|
310
|
+
return null;
|
|
311
|
+
}
|
|
312
|
+
}
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* D-4: herdr 0.9.0 resolved mouse focus client-side, so `pane.focused` never reached plugins.
|
|
3
|
+
* Each pier pane sampled `layout.export` and replayed `pane.focused` into heat-reflow.mjs.
|
|
4
|
+
*
|
|
5
|
+
* Herdr 0.9.1 delivers `pane.focused` to the workbench hook. Default polling is therefore off
|
|
6
|
+
* (0 ms) on ≥0.9.1 so a click is not followed by a second reflow ~8s later. Herdr <0.9.1 keeps
|
|
7
|
+
* the 1500ms sampler. `PIER_FOCUS_POLL_MS` overrides either default; 0 disables.
|
|
8
|
+
*/
|
|
9
|
+
import { spawn } from 'node:child_process';
|
|
10
|
+
import { dirname, join } from 'node:path';
|
|
11
|
+
import { fileURLToPath } from 'node:url';
|
|
12
|
+
|
|
13
|
+
/** Poll cadence for older Herdr (< 0.9.1). */
|
|
14
|
+
export const FOCUS_POLL_DEFAULT_MS = 1500;
|
|
15
|
+
/** Herdr 0.9.1+: native pane.focused is authoritative; default poller off to avoid a delayed second reflow. */
|
|
16
|
+
export const FOCUS_POLL_HERDR_091_MS = 0;
|
|
17
|
+
|
|
18
|
+
/** Check if Herdr server version is 0.9.1 or later. */
|
|
19
|
+
export function isHerdr091OrLater(version?: string | null): boolean {
|
|
20
|
+
if (!version) return false;
|
|
21
|
+
const m = /^(\d+)\.(\d+)\.(\d+)/.exec(version.trim());
|
|
22
|
+
if (!m) return false;
|
|
23
|
+
const major = Number(m[1]);
|
|
24
|
+
const minor = Number(m[2]);
|
|
25
|
+
const patch = Number(m[3]);
|
|
26
|
+
if (major > 0) return true;
|
|
27
|
+
if (minor > 9) return true;
|
|
28
|
+
if (minor === 9 && patch >= 1) return true;
|
|
29
|
+
return false;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Determine default poll cadence based on Herdr server version. */
|
|
33
|
+
export function resolveDefaultFocusPollMs(herdrVersion?: string | null): number {
|
|
34
|
+
return isHerdr091OrLater(herdrVersion) ? FOCUS_POLL_HERDR_091_MS : FOCUS_POLL_DEFAULT_MS;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Minimum gap between two reflow triggers. Re-clicking the same pane quickly must not spawn a
|
|
38
|
+
* process per click; the workbench debounces per tab (150 ms) on top of this.
|
|
39
|
+
*/
|
|
40
|
+
export const FOCUS_FIRE_MIN_INTERVAL_MS = 700;
|
|
41
|
+
|
|
42
|
+
export interface FocusSample {
|
|
43
|
+
/** Pane focused in the sampled tab (null when the server reported no focus). */
|
|
44
|
+
readonly focusedPaneId: string | null;
|
|
45
|
+
/** Panes currently in that tab, used to tell a click apart from a spawn auto-focus. */
|
|
46
|
+
readonly paneIds: readonly string[];
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface FocusPollerState {
|
|
50
|
+
readonly lastFocusedPaneId: string | null;
|
|
51
|
+
readonly lastPaneIds: readonly string[];
|
|
52
|
+
readonly lastFireAt: number;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Minimal flatten of a herdr layout tree (`{type:'pane',pane_id}` / `{type:'split',first,second}`). */
|
|
56
|
+
export function collectPaneIds(root: unknown): string[] {
|
|
57
|
+
const out: string[] = [];
|
|
58
|
+
const walk = (node: unknown): void => {
|
|
59
|
+
if (node === null || typeof node !== 'object') return;
|
|
60
|
+
const n = node as { pane_id?: unknown; first?: unknown; second?: unknown };
|
|
61
|
+
if (typeof n.pane_id === 'string') {
|
|
62
|
+
out.push(n.pane_id);
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
walk(n.first);
|
|
66
|
+
walk(n.second);
|
|
67
|
+
};
|
|
68
|
+
walk(root);
|
|
69
|
+
return out;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** `layout.export` payload → sample. `focusedPaneId` is optional for older clients. */
|
|
73
|
+
export function parseFocusSample(
|
|
74
|
+
layout: { root: unknown; focusedPaneId?: string | null } | null | undefined,
|
|
75
|
+
): FocusSample | null {
|
|
76
|
+
if (!layout || !('root' in layout)) return null;
|
|
77
|
+
const paneIds = collectPaneIds(layout.root);
|
|
78
|
+
const focused = typeof layout.focusedPaneId === 'string' ? layout.focusedPaneId : null;
|
|
79
|
+
return { focusedPaneId: focused, paneIds };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export interface FocusTick {
|
|
83
|
+
readonly fire: boolean;
|
|
84
|
+
readonly cause: 'user' | null;
|
|
85
|
+
readonly state: FocusPollerState;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Decide whether one sample should trigger a reflow.
|
|
90
|
+
*
|
|
91
|
+
* Fires only on a *transition* into "I am focused" (so a steady focus never re-triggers) and at most
|
|
92
|
+
* once per `minIntervalMs`. `cause` is 'user' when the pane set did not change in the same sample:
|
|
93
|
+
* a click moves focus and nothing else, whereas a spawn auto-focus always comes with a new pane.
|
|
94
|
+
* The workbench uses that distinction for its 3 s pane-age whitelist (F1: a freshly created pane
|
|
95
|
+
* must not grab the layout through an automatic focus).
|
|
96
|
+
*/
|
|
97
|
+
export function planFocusTick(opts: {
|
|
98
|
+
myPaneId: string;
|
|
99
|
+
sample: FocusSample;
|
|
100
|
+
prev: FocusPollerState | null;
|
|
101
|
+
now: number;
|
|
102
|
+
minIntervalMs?: number;
|
|
103
|
+
}): FocusTick {
|
|
104
|
+
const minInterval = opts.minIntervalMs ?? FOCUS_FIRE_MIN_INTERVAL_MS;
|
|
105
|
+
const prev = opts.prev;
|
|
106
|
+
const next: FocusPollerState = {
|
|
107
|
+
lastFocusedPaneId: opts.sample.focusedPaneId,
|
|
108
|
+
lastPaneIds: opts.sample.paneIds,
|
|
109
|
+
lastFireAt: prev?.lastFireAt ?? 0,
|
|
110
|
+
};
|
|
111
|
+
const noFire = (): FocusTick => ({ fire: false, cause: null, state: next });
|
|
112
|
+
|
|
113
|
+
if (opts.myPaneId === '') return noFire();
|
|
114
|
+
// First sample only records the baseline: a pi that starts in the focused pane must not reflow on
|
|
115
|
+
// startup (that is not a click, and it would fight whatever layout the user had).
|
|
116
|
+
if (prev === null) return noFire();
|
|
117
|
+
if (opts.sample.focusedPaneId !== opts.myPaneId) return noFire();
|
|
118
|
+
if (prev.lastFocusedPaneId === opts.myPaneId) return noFire();
|
|
119
|
+
if (opts.now - prev.lastFireAt < minInterval) return noFire();
|
|
120
|
+
|
|
121
|
+
const setUnchanged =
|
|
122
|
+
prev.lastPaneIds.length === opts.sample.paneIds.length &&
|
|
123
|
+
prev.lastPaneIds.every((id, i) => id === opts.sample.paneIds[i]);
|
|
124
|
+
return {
|
|
125
|
+
fire: true,
|
|
126
|
+
cause: setUnchanged ? 'user' : null,
|
|
127
|
+
state: { ...next, lastFireAt: opts.now },
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/* ─────────────────────────── runner ─────────────────────────── */
|
|
132
|
+
|
|
133
|
+
export interface FocusPollerDeps {
|
|
134
|
+
/** Layout of the caller's own tab (the client already exposes `layout.export`). */
|
|
135
|
+
sample(): Promise<FocusSample | null>;
|
|
136
|
+
/** Replay a pane.focused reflow for `paneId`. Must never throw. */
|
|
137
|
+
fire(paneId: string, cause: 'user' | null): void;
|
|
138
|
+
myPaneId: string;
|
|
139
|
+
intervalMs?: number;
|
|
140
|
+
minIntervalMs?: number;
|
|
141
|
+
now?: () => number;
|
|
142
|
+
setIntervalFn?: (fn: () => void, ms: number) => NodeJS.Timeout;
|
|
143
|
+
clearIntervalFn?: (handle: NodeJS.Timeout) => void;
|
|
144
|
+
/** Diagnostics only: polling failures are expected while herdr restarts. */
|
|
145
|
+
onError?: (err: unknown) => void;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export interface FocusPoller {
|
|
149
|
+
/** One sampling pass (exposed for tests; the interval calls the same function). */
|
|
150
|
+
tick(): Promise<void>;
|
|
151
|
+
stop(): void;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export function startFocusPoller(deps: FocusPollerDeps): FocusPoller {
|
|
155
|
+
const intervalMs = deps.intervalMs ?? FOCUS_POLL_DEFAULT_MS;
|
|
156
|
+
const now = deps.now ?? (() => Date.now());
|
|
157
|
+
const setIntervalFn = deps.setIntervalFn ?? ((fn, ms) => setInterval(fn, ms));
|
|
158
|
+
const clearIntervalFn =
|
|
159
|
+
deps.clearIntervalFn ?? ((handle: NodeJS.Timeout) => { clearInterval(handle); });
|
|
160
|
+
let state: FocusPollerState | null = null;
|
|
161
|
+
let inFlight = false;
|
|
162
|
+
let stopped = false;
|
|
163
|
+
let handle: NodeJS.Timeout | null = null;
|
|
164
|
+
|
|
165
|
+
async function tick(): Promise<void> {
|
|
166
|
+
if (stopped || inFlight) return;
|
|
167
|
+
inFlight = true;
|
|
168
|
+
try {
|
|
169
|
+
const sample = await deps.sample();
|
|
170
|
+
if (sample === null) return;
|
|
171
|
+
const decision = planFocusTick({
|
|
172
|
+
myPaneId: deps.myPaneId,
|
|
173
|
+
sample,
|
|
174
|
+
prev: state,
|
|
175
|
+
now: now(),
|
|
176
|
+
minIntervalMs: deps.minIntervalMs,
|
|
177
|
+
});
|
|
178
|
+
state = decision.state;
|
|
179
|
+
if (decision.fire) deps.fire(deps.myPaneId, decision.cause);
|
|
180
|
+
} catch (err) {
|
|
181
|
+
// A failed sample must not advance the baseline: the next tick re-reads the layout.
|
|
182
|
+
deps.onError?.(err);
|
|
183
|
+
} finally {
|
|
184
|
+
inFlight = false;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
if (intervalMs > 0) {
|
|
189
|
+
handle = setIntervalFn(() => { void tick(); }, intervalMs);
|
|
190
|
+
handle.unref?.(); // never keep pi's event loop alive on our account
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
return {
|
|
194
|
+
tick,
|
|
195
|
+
stop(): void {
|
|
196
|
+
stopped = true;
|
|
197
|
+
if (handle !== null) {
|
|
198
|
+
clearIntervalFn(handle);
|
|
199
|
+
handle = null;
|
|
200
|
+
}
|
|
201
|
+
},
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** The workbench's reflow entry point; overridable for relocated checkouts. */
|
|
206
|
+
export function reflowScriptPath(env: NodeJS.ProcessEnv = process.env): string {
|
|
207
|
+
const root = env.PIER_WORKBENCH_ROOT?.trim();
|
|
208
|
+
if (root) return join(root, 'scripts', 'heat-reflow.mjs');
|
|
209
|
+
// packages/pier-ext/src/focus-poller.ts -> packages/pier-workbench/scripts/heat-reflow.mjs
|
|
210
|
+
return join(dirname(fileURLToPath(import.meta.url)), '..', '..', 'pier-workbench', 'scripts', 'heat-reflow.mjs');
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
export interface SpawnReflowOpts {
|
|
214
|
+
paneId: string;
|
|
215
|
+
cause: 'user' | null;
|
|
216
|
+
env?: NodeJS.ProcessEnv;
|
|
217
|
+
/** Injection seam for tests (defaults to child_process.spawn). */
|
|
218
|
+
spawnFn?: typeof spawn;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Fire-and-forget replay of the `pane.focused` hook.
|
|
223
|
+
* Why a child process: it is exactly how herdr runs the hook, so the workbench script keeps its own
|
|
224
|
+
* event parsing, debounce and state file — pier never duplicates layout logic.
|
|
225
|
+
*/
|
|
226
|
+
export function spawnReflow(opts: SpawnReflowOpts): void {
|
|
227
|
+
const env = opts.env ?? process.env;
|
|
228
|
+
const spawnFn = opts.spawnFn ?? spawn;
|
|
229
|
+
const payload = JSON.stringify({
|
|
230
|
+
event: 'pane_focused',
|
|
231
|
+
type: 'pane_focused',
|
|
232
|
+
data: { type: 'pane_focused', pane_id: opts.paneId, cause: opts.cause },
|
|
233
|
+
});
|
|
234
|
+
try {
|
|
235
|
+
const child = spawnFn(process.execPath, [reflowScriptPath(env)], {
|
|
236
|
+
env: { ...env, HERDR_PLUGIN_EVENT: 'pane.focused', HERDR_PLUGIN_EVENT_JSON: payload },
|
|
237
|
+
stdio: 'ignore',
|
|
238
|
+
});
|
|
239
|
+
child.on?.('error', () => { /* Missing script / dead exec: polling must never break pi. */ });
|
|
240
|
+
child.unref?.();
|
|
241
|
+
} catch {
|
|
242
|
+
/* Best-effort: focus heat is a comfort feature; the click still focuses the pane. */
|
|
243
|
+
}
|
|
244
|
+
}
|
package/src/gc-core.ts
CHANGED
|
@@ -54,3 +54,88 @@ export function shouldClosePane(opts: {
|
|
|
54
54
|
if (opts.herdrStatus !== 'idle' && opts.herdrStatus !== 'done') return false;
|
|
55
55
|
return (opts.consumedAt ?? 0) > 0 && opts.consumedAt! < opts.prevTurnStart;
|
|
56
56
|
}
|
|
57
|
+
|
|
58
|
+
/* ── Isolate worktree collection (why the rules are this narrow) ──────
|
|
59
|
+
* The first version treated ANY branch under `refs/heads/pier/` as pier-owned, so
|
|
60
|
+
* every pier master session swept the whole namespace. A worker session lives in
|
|
61
|
+
* exactly such a worktree, and its own branch is trivially an ancestor of its own
|
|
62
|
+
* HEAD and clean once it commits — so the sweep deleted the worktree the process
|
|
63
|
+
* was running in (observed twice: 01a06ae3-era workers and the env-hook worker).
|
|
64
|
+
* Ownership must therefore be explicit: only branches registered in THIS session's
|
|
65
|
+
* subagent registry are candidates, the current working directory is never a
|
|
66
|
+
* candidate, and untracked `pier/*` branches require an explicit opt-in.
|
|
67
|
+
*/
|
|
68
|
+
|
|
69
|
+
/** True when `path` is `parent` or lives inside it (both resolved, no I/O). */
|
|
70
|
+
export function isPathInside(path: string, parent: string): boolean {
|
|
71
|
+
const norm = (p: string): string => p.replace(/[\\/]+/g, '/').replace(/\/+$/, '');
|
|
72
|
+
const child = norm(path);
|
|
73
|
+
const base = norm(parent);
|
|
74
|
+
if (!child || !base) return false;
|
|
75
|
+
return child === base || child.startsWith(`${base}/`);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export interface IsolateSweepCandidate {
|
|
79
|
+
branch: string;
|
|
80
|
+
worktreePath: string;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export interface IsolateSweepSkip {
|
|
84
|
+
branch: string;
|
|
85
|
+
reason: 'unregistered' | 'pending' | 'self' | 'no-worktree';
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export interface IsolateSweepPlan {
|
|
89
|
+
candidates: IsolateSweepCandidate[];
|
|
90
|
+
skipped: IsolateSweepSkip[];
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Decide which isolate worktrees this session may collect.
|
|
95
|
+
* Pure: callers pass git output, the subagent registry and the process cwd.
|
|
96
|
+
*/
|
|
97
|
+
export function planIsolateSweep(input: {
|
|
98
|
+
/** Every `refs/heads/pier/*` branch name. */
|
|
99
|
+
branches: readonly string[];
|
|
100
|
+
/** branch → worktree path, from `git worktree list --porcelain`. */
|
|
101
|
+
worktreesByBranch: ReadonlyMap<string, string>;
|
|
102
|
+
/** Branches of isolate entries in this session's registry. */
|
|
103
|
+
registeredBranches: ReadonlySet<string>;
|
|
104
|
+
/** Branches created but not yet registered (worktree add → subs.set window). */
|
|
105
|
+
pendingBranches: ReadonlySet<string>;
|
|
106
|
+
/** Session-owned isolates that may be collected, with their recorded path. */
|
|
107
|
+
sessionOwned: ReadonlyArray<{ branch: string; worktreePath: string }>;
|
|
108
|
+
/** Path to protect (normally process.cwd()). */
|
|
109
|
+
cwd: string;
|
|
110
|
+
/** Explicit opt-in for branches this session never registered. */
|
|
111
|
+
sweepOrphans: boolean;
|
|
112
|
+
}): IsolateSweepPlan {
|
|
113
|
+
const candidates = new Map<string, IsolateSweepCandidate>();
|
|
114
|
+
const skipped: IsolateSweepSkip[] = [];
|
|
115
|
+
const add = (branch: string, worktreePath: string): void => {
|
|
116
|
+
if (isPathInside(input.cwd, worktreePath)) {
|
|
117
|
+
skipped.push({ branch, reason: 'self' });
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
candidates.set(branch, { branch, worktreePath });
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
if (input.sweepOrphans) {
|
|
124
|
+
for (const branch of input.branches) {
|
|
125
|
+
if (input.registeredBranches.has(branch)) continue;
|
|
126
|
+
if (input.pendingBranches.has(branch)) {
|
|
127
|
+
skipped.push({ branch, reason: 'pending' });
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
const wtPath = input.worktreesByBranch.get(branch);
|
|
131
|
+
if (wtPath) add(branch, wtPath);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
for (const entry of input.sessionOwned) {
|
|
136
|
+
const wtPath = input.worktreesByBranch.get(entry.branch) ?? entry.worktreePath;
|
|
137
|
+
add(entry.branch, wtPath);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
return { candidates: [...candidates.values()], skipped };
|
|
141
|
+
}
|