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
package/src/lock-core.ts
CHANGED
|
@@ -65,21 +65,105 @@ export function writePathsOfTool(toolName: string, input: unknown): string[] {
|
|
|
65
65
|
return typeof p === 'string' && p.trim() ? [p] : [];
|
|
66
66
|
}
|
|
67
67
|
|
|
68
|
+
/**
|
|
69
|
+
* B7: write-locks only see the write/edit tools, so a `bash` command that redirects into a file
|
|
70
|
+
* bypasses the beacon entirely (`> file`, `>> file`, `tee file`, `sed -i`, `truncate -s 0`).
|
|
71
|
+
* We do not try to parse shell: this extracts the *obvious* redirect/tee targets so the lock layer
|
|
72
|
+
* can raise a soft warning, and the limitation stays documented rather than implied.
|
|
73
|
+
*
|
|
74
|
+
* Pure and conservative: unknown syntax yields no paths, and a failure here must never block.
|
|
75
|
+
*/
|
|
76
|
+
export function bashWriteTargets(command: unknown): string[] {
|
|
77
|
+
if (typeof command !== 'string' || command.trim() === '') return [];
|
|
78
|
+
const found: string[] = [];
|
|
79
|
+
// Quoted or bare targets; stops at shell metacharacters. Deliberately ignores process substitution.
|
|
80
|
+
const target = String.raw`(?:'([^']+)'|"([^"]+)"|([^\s;|&<>()]+))`;
|
|
81
|
+
const patterns = [
|
|
82
|
+
new RegExp(String.raw`(?:^|[^0-9>])>>?\s*` + target, 'g'), // > file, >> file
|
|
83
|
+
new RegExp(String.raw`\btee\s+(?:-a\s+)?` + target, 'g'), // tee [-a] file
|
|
84
|
+
new RegExp(String.raw`\btruncate\s+-s\s*0\s+` + target, 'g'), // truncate -s 0 file
|
|
85
|
+
];
|
|
86
|
+
for (const re of patterns) {
|
|
87
|
+
for (const m of command.matchAll(re)) {
|
|
88
|
+
const path = (m[1] ?? m[2] ?? m[3] ?? '').trim();
|
|
89
|
+
const quoted = m[1] !== undefined || m[2] !== undefined;
|
|
90
|
+
if (isObviousPath(path, quoted)) found.push(path);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
// `sed -i '' 's/a/b/' file`: the first argument is the (possibly empty) backup suffix and the second
|
|
94
|
+
// the expression, so take the LAST argument as the file rather than guessing the argument order.
|
|
95
|
+
for (const m of command.matchAll(/\bsed\s+-i[^\s]*\s+([^|;&\n]+)/g)) {
|
|
96
|
+
const args = splitShellWords(m[1] ?? '');
|
|
97
|
+
for (let i = args.length - 1; i >= 0; i -= 1) {
|
|
98
|
+
const candidate = args[i]!;
|
|
99
|
+
if (candidate.startsWith('-')) continue;
|
|
100
|
+
if (isObviousPath(candidate)) found.push(candidate);
|
|
101
|
+
break;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
return [...new Set(found)];
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Only unquoted, metacharacter-free paths count as "obvious" (anything else is left to the shell). */
|
|
108
|
+
function isObviousPath(path: string, quoted = false): boolean {
|
|
109
|
+
if (path === '' || path === '/' || path.startsWith('$')) return false;
|
|
110
|
+
if (/['"`$*?{}()[\]]/.test(path)) return false; // Never a literal path (expansion, glob, nested quotes).
|
|
111
|
+
return quoted ? true : !/\s/.test(path); // An explicitly quoted target may contain spaces.
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Whitespace split that keeps quoted segments together and strips their quotes. */
|
|
115
|
+
function splitShellWords(input: string): string[] {
|
|
116
|
+
const out: string[] = [];
|
|
117
|
+
const re = /'([^']*)'|"([^"]*)"|(\S+)/g;
|
|
118
|
+
for (const m of input.matchAll(re)) out.push(m[1] ?? m[2] ?? m[3] ?? '');
|
|
119
|
+
return out.filter((s) => s !== '');
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Soft hint shown after a bash call that wrote into a path another pane has locked. */
|
|
123
|
+
export function formatBashLockHint(normPath: string, holders: readonly string[]): string {
|
|
124
|
+
return `Note: this bash command wrote to ${normPath}, which ${holdersLabel(holders)} is editing. `
|
|
125
|
+
+ `Write-locks only cover the write/edit tools, so this write was not blocked: re-read the file before your next edit.`;
|
|
126
|
+
}
|
|
127
|
+
|
|
68
128
|
/** Why: Preserve the established compatibility and safety behavior. */
|
|
69
129
|
|
|
70
|
-
|
|
130
|
+
/**
|
|
131
|
+
* Every *other* pane currently holding a beacon for this path (deduped, in list order).
|
|
132
|
+
* A lock is a beacon rather than a mutex, so several panes can hold the same path.
|
|
133
|
+
*/
|
|
134
|
+
export function findLockHolders(
|
|
71
135
|
agents: readonly LockAgentView[],
|
|
72
136
|
ownPaneId: string,
|
|
73
137
|
normPath: string,
|
|
74
|
-
):
|
|
138
|
+
): string[] {
|
|
75
139
|
const key = lockTokenKey(normPath);
|
|
140
|
+
const holders: string[] = [];
|
|
76
141
|
for (const a of agents) {
|
|
77
142
|
const v = a.tokens[key];
|
|
78
143
|
if (typeof v !== 'string' || !v) continue;
|
|
79
144
|
const parsed = parseLockTokenValue(v);
|
|
80
|
-
if (parsed && parsed.holderPaneId !== ownPaneId
|
|
145
|
+
if (parsed && parsed.holderPaneId !== ownPaneId && !holders.includes(parsed.holderPaneId)) {
|
|
146
|
+
holders.push(parsed.holderPaneId);
|
|
147
|
+
}
|
|
81
148
|
}
|
|
82
|
-
return
|
|
149
|
+
return holders;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export function findLockConflict(
|
|
153
|
+
agents: readonly LockAgentView[],
|
|
154
|
+
ownPaneId: string,
|
|
155
|
+
normPath: string,
|
|
156
|
+
): { holderPaneId: string } | null {
|
|
157
|
+
const [first] = findLockHolders(agents, ownPaneId, normPath);
|
|
158
|
+
return first === undefined ? null : { holderPaneId: first };
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** Where to look for the full holder table: humans use the slash command, agents can read herdr's tokens. */
|
|
162
|
+
export const LOCK_HOLDERS_HINT =
|
|
163
|
+
'holders: /locks (human view in this pane) · herdr agent list → tokens (agent-readable)';
|
|
164
|
+
|
|
165
|
+
function holdersLabel(holders: readonly string[]): string {
|
|
166
|
+
return holders.length === 1 ? `pane ${holders[0]}` : `panes ${holders.join(', ')}`;
|
|
83
167
|
}
|
|
84
168
|
|
|
85
169
|
/** Why: Preserve the established compatibility and safety behavior. */
|
|
@@ -87,8 +171,8 @@ export function findLockConflict(
|
|
|
87
171
|
export type WriteGuardPlan =
|
|
88
172
|
| { kind: 'skip' }
|
|
89
173
|
| { kind: 'pass'; paths: string[] }
|
|
90
|
-
| { kind: 'warn'; paths: string[];
|
|
91
|
-
| { kind: 'block'; paths: string[];
|
|
174
|
+
| { kind: 'warn'; paths: string[]; holderPaneIds: string[]; warning: string }
|
|
175
|
+
| { kind: 'block'; paths: string[]; holderPaneIds: string[]; reason: string };
|
|
92
176
|
|
|
93
177
|
export function planWriteGuard(opts: {
|
|
94
178
|
toolName: string;
|
|
@@ -98,25 +182,30 @@ export function planWriteGuard(opts: {
|
|
|
98
182
|
cwd: string;
|
|
99
183
|
hard: boolean;
|
|
100
184
|
}): WriteGuardPlan {
|
|
101
|
-
|
|
185
|
+
// B7: bash redirects are invisible to the write tools, so warn (never block) when their obvious
|
|
186
|
+
// targets collide with a lock beacon held by another pane.
|
|
187
|
+
const isBash = opts.toolName === 'bash';
|
|
188
|
+
const raw = isBash
|
|
189
|
+
? bashWriteTargets((opts.input as { command?: unknown } | null | undefined)?.command)
|
|
190
|
+
: writePathsOfTool(opts.toolName, opts.input);
|
|
102
191
|
if (raw.length === 0) return { kind: 'skip' };
|
|
103
192
|
const paths = raw.map((p) => normalizeLockPath(p, opts.cwd));
|
|
104
193
|
for (const norm of paths) {
|
|
105
|
-
const
|
|
106
|
-
if (
|
|
107
|
-
if (opts.hard) {
|
|
194
|
+
const holders = findLockHolders(opts.agents, opts.ownPaneId, norm);
|
|
195
|
+
if (holders.length === 0) continue;
|
|
196
|
+
if (opts.hard && !isBash) {
|
|
108
197
|
return {
|
|
109
198
|
kind: 'block',
|
|
110
199
|
paths,
|
|
111
|
-
|
|
112
|
-
reason: `file locked by
|
|
200
|
+
holderPaneIds: holders,
|
|
201
|
+
reason: `file locked by ${holdersLabel(holders)}: ${norm} is being edited in another pane. Wait for it to settle, or coordinate before writing. ${LOCK_HOLDERS_HINT}`,
|
|
113
202
|
};
|
|
114
203
|
}
|
|
115
204
|
return {
|
|
116
205
|
kind: 'warn',
|
|
117
206
|
paths,
|
|
118
|
-
|
|
119
|
-
warning: formatConflictWarning(norm,
|
|
207
|
+
holderPaneIds: holders,
|
|
208
|
+
warning: isBash ? formatBashLockHint(norm, holders) : formatConflictWarning(norm, holders),
|
|
120
209
|
};
|
|
121
210
|
}
|
|
122
211
|
return { kind: 'pass', paths };
|
|
@@ -136,6 +225,6 @@ export function releaseTokensFor(normPaths: readonly string[]): Record<string, n
|
|
|
136
225
|
return out;
|
|
137
226
|
}
|
|
138
227
|
|
|
139
|
-
export function formatConflictWarning(normPath: string,
|
|
140
|
-
return `⚠️ write conflict: ${normPath} is locked by
|
|
228
|
+
export function formatConflictWarning(normPath: string, holders: readonly string[]): string {
|
|
229
|
+
return `⚠️ write conflict: ${normPath} is locked by ${holdersLabel(holders)} (edited in another pane that has not settled). This write went through (soft mode) — coordinate to avoid clobbering each other's changes. ${LOCK_HOLDERS_HINT}`;
|
|
141
230
|
}
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* D101 ObservationPack Core.
|
|
3
|
+
*
|
|
4
|
+
* Pure algorithmic functions for large observation detection, deterministic ID derivation,
|
|
5
|
+
* complete-line excerpt generation, chunk slicing with UTF-8 safety, and rolling prefix cache
|
|
6
|
+
* economics.
|
|
7
|
+
*
|
|
8
|
+
* No I/O in this core; storage I/O lives in efficiency-store.ts.
|
|
9
|
+
*/
|
|
10
|
+
import { createHash } from 'node:crypto';
|
|
11
|
+
import { TOKEN_ACCOUNT_CACHE_RATIO } from './compact-economics-core.ts';
|
|
12
|
+
import { FAILURE_SIGNAL } from './reducer-core.ts';
|
|
13
|
+
|
|
14
|
+
export const DEFAULT_THRESHOLD_BYTES = 10 * 1024; // 10KB
|
|
15
|
+
export const DEFAULT_FULL_SENDS = 2;
|
|
16
|
+
export const DEFAULT_EXCERPT_BYTES = 1024;
|
|
17
|
+
export const CHARS_PER_TOKEN = 4;
|
|
18
|
+
export const OBSERVATION_ID_RE = /^obs_[a-f0-9]{24}$/;
|
|
19
|
+
|
|
20
|
+
/** Marker from Evidence-Preserving Reducer to avoid double packing. */
|
|
21
|
+
export const REDUCER_RECEIPT_PREFIX = 'sol_pi_evidence_receipt_v1';
|
|
22
|
+
|
|
23
|
+
export function sha256Hex(val: string | Buffer): string {
|
|
24
|
+
return createHash('sha256').update(val).digest('hex');
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function estimateTokens(text: string): number {
|
|
28
|
+
return Math.ceil(text.length / CHARS_PER_TOKEN);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function countLines(text: string): number {
|
|
32
|
+
if (text.length === 0) return 0;
|
|
33
|
+
let lines = text.endsWith('\n') ? 0 : 1;
|
|
34
|
+
for (let i = 0; i < text.length; i++) {
|
|
35
|
+
if (text.charCodeAt(i) === 0x0a) lines++;
|
|
36
|
+
}
|
|
37
|
+
return lines;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function isObservationId(id: string): boolean {
|
|
41
|
+
return OBSERVATION_ID_RE.test(id);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function deriveObservationId(toolName: string, toolCallId: string, contentHash: string): string {
|
|
45
|
+
const combined = `${toolName}\0${toolCallId}\0${contentHash}`;
|
|
46
|
+
return `obs_${sha256Hex(combined).slice(0, 24)}`;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function containsReducerReceipt(text: string): boolean {
|
|
50
|
+
if (!text.includes(REDUCER_RECEIPT_PREFIX)) return false;
|
|
51
|
+
return text.split('\n').some((line) => line.trim() === REDUCER_RECEIPT_PREFIX);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function completeLineExcerpt(text: string, budgetBytes: number, fromEnd: boolean): string {
|
|
55
|
+
if (budgetBytes <= 0 || text.length === 0) return '';
|
|
56
|
+
|
|
57
|
+
if (!fromEnd) {
|
|
58
|
+
// Head excerpt: candidate never exceeds budgetBytes * 4 chars
|
|
59
|
+
const candidate = text.slice(0, Math.min(text.length, budgetBytes * 4));
|
|
60
|
+
let lastEnd = 0;
|
|
61
|
+
let totalBytes = 0;
|
|
62
|
+
let searchIndex = 0;
|
|
63
|
+
while (searchIndex < candidate.length) {
|
|
64
|
+
const nextNewline = candidate.indexOf('\n', searchIndex);
|
|
65
|
+
if (nextNewline === -1) break;
|
|
66
|
+
const lineEnd = nextNewline + 1;
|
|
67
|
+
const line = candidate.slice(lastEnd, lineEnd);
|
|
68
|
+
const lineBytes = Buffer.byteLength(line, 'utf8');
|
|
69
|
+
if (totalBytes + lineBytes > budgetBytes) break;
|
|
70
|
+
totalBytes += lineBytes;
|
|
71
|
+
lastEnd = lineEnd;
|
|
72
|
+
searchIndex = lineEnd;
|
|
73
|
+
}
|
|
74
|
+
return candidate.slice(0, lastEnd);
|
|
75
|
+
} else {
|
|
76
|
+
// Tail excerpt: candidate never exceeds budgetBytes * 4 chars from end
|
|
77
|
+
const startOffset = Math.max(0, text.length - budgetBytes * 4);
|
|
78
|
+
const candidate = text.slice(startOffset);
|
|
79
|
+
const lines = candidate.split(/(?<=\n)/);
|
|
80
|
+
const selected: string[] = [];
|
|
81
|
+
let selectedBytes = 0;
|
|
82
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
83
|
+
const line = lines[i]!;
|
|
84
|
+
// If startOffset > 0, the first line of candidate might be partial, skip unless it follows a newline
|
|
85
|
+
if (i === 0 && startOffset > 0 && text[startOffset - 1] !== '\n') {
|
|
86
|
+
break;
|
|
87
|
+
}
|
|
88
|
+
const lineBytes = Buffer.byteLength(line, 'utf8');
|
|
89
|
+
if (selectedBytes + lineBytes > budgetBytes) break;
|
|
90
|
+
selected.unshift(line);
|
|
91
|
+
selectedBytes += lineBytes;
|
|
92
|
+
}
|
|
93
|
+
return selected.join('');
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export interface ObservationPlaceholderInput {
|
|
98
|
+
id: string;
|
|
99
|
+
toolName: string;
|
|
100
|
+
bytes: number;
|
|
101
|
+
lines: number;
|
|
102
|
+
tokens: number;
|
|
103
|
+
text: string;
|
|
104
|
+
fullSends?: number;
|
|
105
|
+
excerptBudget?: number;
|
|
106
|
+
/**
|
|
107
|
+
* P0-3 (RFC docs/rfc-jev-integration.md §3): a selected middle window that
|
|
108
|
+
* replaces whichever head/tail half carries fewer failure-signal lines.
|
|
109
|
+
* Absent (jev off / low confidence / no signal lines) -> byte-identical
|
|
110
|
+
* legacy head+tail layout.
|
|
111
|
+
*/
|
|
112
|
+
middle?: { text: string; label: string };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function signalLineCount(text: string): number {
|
|
116
|
+
if (text.length === 0) return 0;
|
|
117
|
+
let count = 0;
|
|
118
|
+
for (const line of text.split('\n')) {
|
|
119
|
+
if (FAILURE_SIGNAL.test(line)) count++;
|
|
120
|
+
}
|
|
121
|
+
return count;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export function formatObservationPlaceholder(input: ObservationPlaceholderInput): string {
|
|
125
|
+
const fullSends = input.fullSends ?? DEFAULT_FULL_SENDS;
|
|
126
|
+
const excerptBudget = input.excerptBudget ?? DEFAULT_EXCERPT_BYTES;
|
|
127
|
+
const headBudget = Math.floor(excerptBudget / 2);
|
|
128
|
+
const tailBudget = excerptBudget - headBudget;
|
|
129
|
+
|
|
130
|
+
let head = completeLineExcerpt(input.text, headBudget, false);
|
|
131
|
+
let tail = completeLineExcerpt(input.text, tailBudget, true);
|
|
132
|
+
let headLabel = `[first complete lines, up to ${headBudget} bytes]`;
|
|
133
|
+
let tailLabel = `[middle omitted; last complete lines, up to ${tailBudget} bytes]`;
|
|
134
|
+
|
|
135
|
+
if (input.middle) {
|
|
136
|
+
// Fewer signal lines loses its slot; ties replace head because the tail
|
|
137
|
+
// half usually carries the log's final summary.
|
|
138
|
+
if (signalLineCount(tail) < signalLineCount(head)) {
|
|
139
|
+
tail = completeLineExcerpt(input.middle.text, tailBudget, false);
|
|
140
|
+
tailLabel = `[middle omitted; selected excerpt — ${input.middle.label}, up to ${tailBudget} bytes]`;
|
|
141
|
+
} else {
|
|
142
|
+
head = completeLineExcerpt(input.middle.text, headBudget, false);
|
|
143
|
+
headLabel = `[selected excerpt — ${input.middle.label}, up to ${headBudget} bytes]`;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
return [
|
|
148
|
+
`[large tool result replaced after its first ${fullSends} provider requests]`,
|
|
149
|
+
`id: ${input.id}`,
|
|
150
|
+
`tool: ${input.toolName}`,
|
|
151
|
+
`original_bytes: ${input.bytes}`,
|
|
152
|
+
`original_lines: ${input.lines}`,
|
|
153
|
+
`estimated_tokens: ${input.tokens}`,
|
|
154
|
+
`retrieve: call obs_recall with {"id":"${input.id}","offset":0}; continue with returned next_offset`,
|
|
155
|
+
headLabel,
|
|
156
|
+
head,
|
|
157
|
+
tailLabel,
|
|
158
|
+
tail,
|
|
159
|
+
`[${input.bytes} original bytes omitted; recall via obs_recall]`,
|
|
160
|
+
].join('\n');
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export interface RecallSliceResult {
|
|
164
|
+
text: string;
|
|
165
|
+
bytes: number;
|
|
166
|
+
lines: number;
|
|
167
|
+
nextOffset: number;
|
|
168
|
+
eof: boolean;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Trims multi-byte UTF-8 sequence from buffer boundary if cut mid-character.
|
|
173
|
+
*/
|
|
174
|
+
export function trimUtf8End(buffer: Buffer, limit: number): number {
|
|
175
|
+
let end = limit;
|
|
176
|
+
while (end > 0 && end < buffer.length && ((buffer[end] ?? 0) & 0xc0) === 0x80) {
|
|
177
|
+
end -= 1;
|
|
178
|
+
}
|
|
179
|
+
return end;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Slices a memory buffer safely at byte offsets without cutting UTF-8 characters.
|
|
184
|
+
*/
|
|
185
|
+
export function sliceBufferChunk(
|
|
186
|
+
buf: Buffer,
|
|
187
|
+
offset: number,
|
|
188
|
+
limits: { maxBytes: number; maxLines: number },
|
|
189
|
+
): RecallSliceResult {
|
|
190
|
+
if (offset >= buf.length) {
|
|
191
|
+
return { text: '', bytes: 0, lines: 0, nextOffset: buf.length, eof: true };
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const available = buf.length - offset;
|
|
195
|
+
let end = Math.min(available, limits.maxBytes);
|
|
196
|
+
let newlineCount = 0;
|
|
197
|
+
|
|
198
|
+
for (let i = 0; i < end; i++) {
|
|
199
|
+
if (buf[offset + i] === 0x0a) {
|
|
200
|
+
newlineCount++;
|
|
201
|
+
if (newlineCount === limits.maxLines) {
|
|
202
|
+
end = i + 1;
|
|
203
|
+
break;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const trimmedEnd = trimUtf8End(buf.subarray(offset), end);
|
|
209
|
+
const chunkBuf = buf.subarray(offset, offset + trimmedEnd);
|
|
210
|
+
const text = chunkBuf.toString('utf8');
|
|
211
|
+
const bytes = chunkBuf.length;
|
|
212
|
+
const lines = countLines(text);
|
|
213
|
+
const nextOffset = offset + bytes;
|
|
214
|
+
const eof = nextOffset >= buf.length;
|
|
215
|
+
|
|
216
|
+
return { text, bytes, lines, nextOffset, eof };
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Evaluates whether replacing an earlier message with a placeholder is profitable
|
|
221
|
+
* under rolling prefix cache models.
|
|
222
|
+
*
|
|
223
|
+
* `cacheWriteReadRatio` must already be resolved by the caller via
|
|
224
|
+
* `resolveCacheRatioFromCost` (same value OCC uses). It used to coerce
|
|
225
|
+
* `'auto'|null` to a hardcoded 12.5 here, which silently diverged from OCC's
|
|
226
|
+
* resolution and made OBS under-pack on implicit-cache models (2026-09-17
|
|
227
|
+
* review: 20/72 packs deferred to sendCount 3–175).
|
|
228
|
+
*/
|
|
229
|
+
export function shouldPackForCache(opts: {
|
|
230
|
+
removedTokens: number;
|
|
231
|
+
tailTokensAfter: number;
|
|
232
|
+
expectedRemainingRequests: number;
|
|
233
|
+
cacheWriteReadRatio: number | null;
|
|
234
|
+
}): boolean {
|
|
235
|
+
if (opts.removedTokens <= 0) return false;
|
|
236
|
+
const ratio = opts.cacheWriteReadRatio ?? TOKEN_ACCOUNT_CACHE_RATIO;
|
|
237
|
+
|
|
238
|
+
if (ratio <= 1.0) {
|
|
239
|
+
return true; // No incremental write cost over reads
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const incrementalRatio = ratio - 1.0;
|
|
243
|
+
const remaining = Math.max(1, opts.expectedRemainingRequests);
|
|
244
|
+
const benefitTokens = opts.removedTokens * remaining;
|
|
245
|
+
const costTokens = opts.tailTokensAfter * incrementalRatio;
|
|
246
|
+
|
|
247
|
+
return benefitTokens > costTokens;
|
|
248
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* B10: environment naming. Two prefixes grew side by side — `PI_HERDR_*` (mostly older tuning knobs)
|
|
3
|
+
* and `PIER_*` (newer ones). Telling them apart by memory is impossible, so this module fixes one
|
|
4
|
+
* rule and enforces it in code:
|
|
5
|
+
*
|
|
6
|
+
* - `PIER_*` is the canonical namespace for *pier options* (timeouts, windows, toggles). It reads
|
|
7
|
+
* the historical `PI_HERDR_*` spelling as a fallback alias, so existing shells keep working.
|
|
8
|
+
* - `PI_HERDR_SUBAGENT`, `PI_HERDR_ROLE_MANIFEST`, `PI_HERDR_TUI`, `PI_HERDR_META_KEY` stay as they
|
|
9
|
+
* are: those are *contracts* handed to the child process by pier itself (or read by pi/herdr), and
|
|
10
|
+
* renaming them would split running workers from their parent.
|
|
11
|
+
*
|
|
12
|
+
* `pierOption()` is the single reader; `pierConfigCatalog()` feeds `/pier-config` so the knobs are
|
|
13
|
+
* discoverable in one place instead of only by reading the source.
|
|
14
|
+
*/
|
|
15
|
+
export interface OptionSpec {
|
|
16
|
+
/** Canonical name. */
|
|
17
|
+
readonly name: string;
|
|
18
|
+
/** Historical `PI_HERDR_*` spelling still accepted. */
|
|
19
|
+
readonly legacy?: string;
|
|
20
|
+
/** Default when neither name is set. */
|
|
21
|
+
readonly fallback: string;
|
|
22
|
+
readonly description: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Every pier option, with its alias and default. Keep in sync with `runtimePolicy`/config readers. */
|
|
26
|
+
export const PIER_OPTIONS: readonly OptionSpec[] = [
|
|
27
|
+
// Numeric fallbacks MUST equal runtime-policy.ts (guarded by test/pier-options.test.ts): the table is
|
|
28
|
+
// what `/pier-config doctor` shows, so a hand-typed number here is a documented lie.
|
|
29
|
+
{ name: 'PIER_GIT_TIMEOUT_MS', legacy: 'PI_HERDR_GIT_TIMEOUT_MS', fallback: '10000', description: 'git command timeout (worktree/status batches)' },
|
|
30
|
+
{ name: 'PIER_SUBAGENT_TIMEOUT_MS', legacy: 'PI_HERDR_SUBAGENT_TIMEOUT_MS', fallback: '600000', description: 'subagent inactivity budget before forced termination' },
|
|
31
|
+
{ name: 'PIER_READY_TIMEOUT_MS', legacy: 'PI_HERDR_READY_TIMEOUT_MS', fallback: '90000', description: 'how long to wait for a spawned pane to open its pipe' },
|
|
32
|
+
{ name: 'PIER_OBSERVATION_WINDOW_MS', legacy: 'PI_HERDR_OBSERVATION_WINDOW_MS', fallback: '30000', description: 'observation window before a subagent is called settled' },
|
|
33
|
+
{ name: 'PIER_POLL_INTERVAL_MS', legacy: 'PI_HERDR_POLL_INTERVAL_MS', fallback: '30000', description: 'subagent poll cadence' },
|
|
34
|
+
{ name: 'PIER_GC_TICK_MS', legacy: 'PI_HERDR_GC_TICK_MS', fallback: '30000', description: 'idle GC sweep cadence (panes/worktrees)' },
|
|
35
|
+
{ name: 'PIER_FOCUS_POLL_MS', fallback: '1500', description: 'pane-focus sampling cadence for the heat layout (0 disables; 0.9.1+ defaults to 0)' },
|
|
36
|
+
{ name: 'PIER_TERMINAL_PROMPT', legacy: 'PI_HERDR_TERMINAL_PROMPT', fallback: '(auto from $SHELL)', description: 'prompt strategy for terminal readiness: bash|zsh|powershell|pwsh' },
|
|
37
|
+
{ name: 'PIER_SLIM_FRAME', legacy: 'PI_HERDR_SLIM_FRAME', fallback: '1', description: 'mutation frame around todo-tool cards (0 disables)' },
|
|
38
|
+
{ name: 'PIER_TRACE', legacy: 'PI_HERDR_TRACE', fallback: '', description: 'write diagnostics (renderers, swallowed errors) to stderr' },
|
|
39
|
+
{ name: 'PIER_TERM_IDLE_MS', legacy: 'PI_HERDR_TERM_IDLE_MS', fallback: '1800000', description: 'idle time before pier nudges about an open terminal' },
|
|
40
|
+
{ name: 'PIER_TERM_GRACE_MS', legacy: 'PI_HERDR_TERM_GRACE_MS', fallback: '30000', description: 'settle grace before the terminal idle nudge' },
|
|
41
|
+
{ name: 'PIER_TERM_READ_MAX', legacy: 'PI_HERDR_TERM_READ_MAX', fallback: '8000', description: 'characters returned per terminal read' },
|
|
42
|
+
{ name: 'PIER_TODO_GRACE_MS', legacy: 'PI_HERDR_TODO_GRACE_MS', fallback: '30000', description: 'settle grace before the unfinished-todo reminder' },
|
|
43
|
+
{ name: 'PIER_HMR', legacy: 'PI_HERDR_HMR', fallback: '', description: 'dev: enable the cordis HMR boundary (requires --expose-internals)' },
|
|
44
|
+
{ name: 'PIER_SETTLEMENT_WINDOW_MS', legacy: 'PI_HERDR_SETTLEMENT_WINDOW_MS', fallback: '60000', description: 'settlement notice window / machine-inject grace / takeover idle' },
|
|
45
|
+
{ name: 'PIER_FOREGROUND_PATIENCE_MS', legacy: 'PI_HERDR_FOREGROUND_PATIENCE_MS', fallback: '300000', description: 'foreground patience before demoting a subagent to background' },
|
|
46
|
+
{ name: 'PIER_SESSION_TTL_SECONDS', legacy: 'PI_HERDR_SESSION_TTL_SECONDS', fallback: '600', description: 'session retention after a subagent exits, before GC' },
|
|
47
|
+
{ name: 'PIER_ISOLATE_SWEEP_ORPHANS', legacy: 'PI_HERDR_ISOLATE_SWEEP_ORPHANS', fallback: '', description: 'opt-in sweeping of isolate worktrees this session never registered' },
|
|
48
|
+
];
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Read a pier option: canonical name first, then the legacy alias, then the fallback.
|
|
52
|
+
* An empty string counts as unset for both names (shells export empty vars easily).
|
|
53
|
+
*/
|
|
54
|
+
export function pierOption(
|
|
55
|
+
name: string,
|
|
56
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
57
|
+
): string | undefined {
|
|
58
|
+
const spec = PIER_OPTIONS.find((o) => o.name === name);
|
|
59
|
+
const legacy = spec?.legacy;
|
|
60
|
+
for (const key of [name, legacy]) {
|
|
61
|
+
if (!key) continue;
|
|
62
|
+
const raw = env[key];
|
|
63
|
+
if (typeof raw === 'string' && raw.trim() !== '') return raw;
|
|
64
|
+
}
|
|
65
|
+
return undefined;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Human-readable option table for `/pier-config doctor`, one line per option:
|
|
70
|
+
* `NAME = value (source) — description`. Legacy env names are called out so a stale export is visible.
|
|
71
|
+
*/
|
|
72
|
+
export function formatOptionRows(env: NodeJS.ProcessEnv = process.env): string[] {
|
|
73
|
+
return pierOptionRows(env).map((row) => {
|
|
74
|
+
const source = row.source === 'env' ? 'env' : row.source === 'legacy-env' ? 'env (legacy name)' : 'default';
|
|
75
|
+
return ` ${row.name} = ${row.value} (${source}) — ${row.description}`;
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** `/pier-config` catalog rows: name, effective value and where it came from. */
|
|
80
|
+
export function pierOptionRows(env: NodeJS.ProcessEnv = process.env): Array<{
|
|
81
|
+
name: string;
|
|
82
|
+
value: string;
|
|
83
|
+
source: 'env' | 'legacy-env' | 'default';
|
|
84
|
+
description: string;
|
|
85
|
+
}> {
|
|
86
|
+
return PIER_OPTIONS.map((spec) => {
|
|
87
|
+
const own = typeof env[spec.name] === 'string' && env[spec.name]!.trim() !== '';
|
|
88
|
+
if (own) return { name: spec.name, value: String(env[spec.name]), source: 'env' as const, description: spec.description };
|
|
89
|
+
const legacyRaw = spec.legacy ? env[spec.legacy] : undefined;
|
|
90
|
+
const viaLegacy = typeof legacyRaw === 'string' && legacyRaw.trim() !== '';
|
|
91
|
+
if (viaLegacy) {
|
|
92
|
+
return { name: spec.name, value: String(legacyRaw), source: 'legacy-env' as const, description: spec.description };
|
|
93
|
+
}
|
|
94
|
+
return { name: spec.name, value: spec.fallback, source: 'default' as const, description: spec.description };
|
|
95
|
+
});
|
|
96
|
+
}
|
package/src/pipe-channel.ts
CHANGED
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
* performs pi injection in the server callback.
|
|
13
13
|
*/
|
|
14
14
|
import * as net from 'node:net';
|
|
15
|
+
import * as fs from 'node:fs';
|
|
15
16
|
import { sessionDirCandidates, sessionDirName } from './storage-layout.ts';
|
|
16
17
|
|
|
17
18
|
function encodePaneId(paneId: string): string {
|
|
@@ -64,6 +65,14 @@ export type PipeResponse =
|
|
|
64
65
|
| { type: 'ok'; id: string; detail?: string }
|
|
65
66
|
| { type: 'error'; id: string; message: string };
|
|
66
67
|
|
|
68
|
+
/**
|
|
69
|
+
* Response side of the protocol (`ok` / `error`). Requests never use these tags, so this is a sound
|
|
70
|
+
* discriminator for narrowing a parsed line at the answering end.
|
|
71
|
+
*/
|
|
72
|
+
export function isPipeResponse(value: PipeRequest | PipeResponse): value is PipeResponse {
|
|
73
|
+
return value.type === 'ok' || value.type === 'error';
|
|
74
|
+
}
|
|
75
|
+
|
|
67
76
|
/** Parse one JSON line, returning null for malformed input. */
|
|
68
77
|
export function parsePipeLine(line: string): PipeRequest | PipeResponse | null {
|
|
69
78
|
const t = (line ?? '').trim();
|
|
@@ -110,7 +119,7 @@ export function pipeRequest(
|
|
|
110
119
|
settled = true;
|
|
111
120
|
sock.destroy();
|
|
112
121
|
const parsed = parsePipeLine(buf.slice(0, i));
|
|
113
|
-
if (!parsed) {
|
|
122
|
+
if (!parsed || !isPipeResponse(parsed)) {
|
|
114
123
|
reject(new Error(`pipe ${pipeName}: bad response frame`));
|
|
115
124
|
return;
|
|
116
125
|
}
|
|
@@ -123,6 +132,13 @@ export function pipeRequest(
|
|
|
123
132
|
reject(err);
|
|
124
133
|
}
|
|
125
134
|
});
|
|
135
|
+
sock.on('close', () => {
|
|
136
|
+
if (!settled) {
|
|
137
|
+
settled = true;
|
|
138
|
+
clearTimeout(timer);
|
|
139
|
+
reject(new Error(`pipe ${pipeName}: connection closed`));
|
|
140
|
+
}
|
|
141
|
+
});
|
|
126
142
|
});
|
|
127
143
|
}
|
|
128
144
|
|
|
@@ -178,7 +194,21 @@ export type PipeMessageHandler = (req: PipeRequest) => Promise<PipeResponse>;
|
|
|
178
194
|
export function startPipeServer(
|
|
179
195
|
pipeName: string,
|
|
180
196
|
handler: PipeMessageHandler,
|
|
197
|
+
onError?: (err: Error) => void,
|
|
181
198
|
): net.Server {
|
|
199
|
+
const socketPath = pipePathFor(pipeName);
|
|
200
|
+
// Why: On POSIX, a crash or ungraceful shutdown leaves the UNIX domain socket file behind.
|
|
201
|
+
// Subsequent listen calls fail with EADDRINUSE unless the stale socket is unlinked before bind.
|
|
202
|
+
// Windows named pipes live in kernel namespace (\\.\pipe\...) and require no unlinking.
|
|
203
|
+
// Best-effort on purpose: if the path cannot be removed (EACCES/EISDIR/ENAMETOOLONG) the
|
|
204
|
+
// subsequent listen reports the real failure through onError instead of a synchronous throw here.
|
|
205
|
+
if (process.platform !== 'win32') {
|
|
206
|
+
try {
|
|
207
|
+
fs.unlinkSync(socketPath);
|
|
208
|
+
} catch {
|
|
209
|
+
/* see above: listen surfaces the actionable error */
|
|
210
|
+
}
|
|
211
|
+
}
|
|
182
212
|
const server = net.createServer((sock) => {
|
|
183
213
|
sock.setEncoding('utf8');
|
|
184
214
|
let buf = '';
|
|
@@ -188,11 +218,12 @@ export function startPipeServer(
|
|
|
188
218
|
if (i < 0) return;
|
|
189
219
|
const line = buf.slice(0, i);
|
|
190
220
|
buf = buf.slice(i + 1);
|
|
191
|
-
const
|
|
192
|
-
if (!
|
|
221
|
+
const parsedLine = parsePipeLine(line);
|
|
222
|
+
if (!parsedLine || isPipeResponse(parsedLine)) {
|
|
193
223
|
sock.end(JSON.stringify({ type: 'error', id: '', message: 'bad frame' }) + '\n');
|
|
194
224
|
return;
|
|
195
225
|
}
|
|
226
|
+
const req: PipeRequest = parsedLine;
|
|
196
227
|
void handler(req)
|
|
197
228
|
.then((res) => {
|
|
198
229
|
sock.end(JSON.stringify(res) + '\n');
|
|
@@ -209,9 +240,23 @@ export function startPipeServer(
|
|
|
209
240
|
/* client disconnect: silent */
|
|
210
241
|
});
|
|
211
242
|
});
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
}
|
|
215
|
-
|
|
243
|
+
if (onError) {
|
|
244
|
+
server.on('error', onError);
|
|
245
|
+
} else {
|
|
246
|
+
// Always attach a listener: an EventEmitter 'error' event with no listener throws and would
|
|
247
|
+
// take the whole extension host down on a transient listen failure (EADDRINUSE/ENOTDIR).
|
|
248
|
+
// Callers that care pass onError to make the failure observable (F04).
|
|
249
|
+
server.on('error', () => { /* swallowed by design; pass onError to observe */ });
|
|
250
|
+
}
|
|
251
|
+
if (process.platform !== 'win32') {
|
|
252
|
+
server.on('close', () => {
|
|
253
|
+
try {
|
|
254
|
+
fs.unlinkSync(socketPath);
|
|
255
|
+
} catch {
|
|
256
|
+
/* best effort */
|
|
257
|
+
}
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
server.listen(socketPath);
|
|
216
261
|
return server;
|
|
217
262
|
}
|