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.
Files changed (72) hide show
  1. package/README.md +20 -7
  2. package/package.json +7 -2
  3. package/schemas/efficiency-config.schema.json +77 -0
  4. package/skills/pi-herdr-workflow/SKILL.md +13 -1
  5. package/src/ansi-text.ts +83 -0
  6. package/src/ask-multi.ts +260 -0
  7. package/src/ask-user.ts +458 -0
  8. package/src/bootstrap.ts +5 -2
  9. package/src/compact-coordinator.ts +454 -0
  10. package/src/compact-economics-core.ts +396 -0
  11. package/src/config-catalog-core.ts +452 -0
  12. package/src/config-command.ts +181 -0
  13. package/src/config-guide.ts +361 -0
  14. package/src/core/grid-shape.ts +4 -2
  15. package/src/core/heat-plan.ts +160 -0
  16. package/src/core/observation.ts +555 -0
  17. package/src/core/subagent.ts +187 -393
  18. package/src/core/terminal.ts +270 -47
  19. package/src/core/todo.ts +49 -14
  20. package/src/dashboard-command.ts +89 -0
  21. package/src/efficiency-config-core.ts +661 -0
  22. package/src/efficiency-config-io.ts +53 -0
  23. package/src/efficiency-store.ts +312 -0
  24. package/src/focus-poller.ts +244 -0
  25. package/src/gc-core.ts +85 -0
  26. package/src/git-adapter.ts +12 -3
  27. package/src/herdr-client.ts +292 -36
  28. package/src/index-locks.ts +27 -9
  29. package/src/index-master.ts +5 -0
  30. package/src/index-notices.ts +13 -2
  31. package/src/index-pipe.ts +5 -0
  32. package/src/index-worker.ts +5 -0
  33. package/src/index.ts +360 -60
  34. package/src/jev-client.ts +146 -0
  35. package/src/jev-core.ts +381 -0
  36. package/src/lock-core.ts +105 -16
  37. package/src/observation-core.ts +248 -0
  38. package/src/pier-options.ts +96 -0
  39. package/src/pipe-channel.ts +52 -7
  40. package/src/reducer-core.ts +358 -0
  41. package/src/reducer-invoker.ts +392 -0
  42. package/src/renderers.ts +198 -0
  43. package/src/role-loader.ts +2 -1
  44. package/src/role-manifest.ts +0 -4
  45. package/src/runtime-policy.ts +5 -8
  46. package/src/session-tail.ts +60 -5
  47. package/src/settle-wake-core.ts +3 -1
  48. package/src/slim-frame.ts +60 -3
  49. package/src/storage-layout.ts +23 -0
  50. package/src/subagent-core.ts +3 -1
  51. package/src/subagent-execute.ts +328 -0
  52. package/src/subagent-gc.ts +33 -13
  53. package/src/subagent-launch.ts +4 -0
  54. package/src/subagent-list-action.ts +69 -0
  55. package/src/subagent-output-action.ts +104 -0
  56. package/src/subagent-output-adapter.ts +27 -0
  57. package/src/subagent-output-core.ts +263 -0
  58. package/src/subagent-poll-loop.ts +59 -39
  59. package/src/subagent-poller.ts +46 -0
  60. package/src/subagent-ready.ts +89 -0
  61. package/src/subagent-registry.ts +70 -0
  62. package/src/subagent-resolution.ts +41 -0
  63. package/src/subagent-scope.ts +3 -1
  64. package/src/subagent-session-io.ts +115 -32
  65. package/src/subagent-spawn.ts +153 -8
  66. package/src/swallow.ts +75 -0
  67. package/src/terminal-core.ts +161 -10
  68. package/src/todo-core.ts +5 -4
  69. package/src/todo-reminder-core.ts +8 -1
  70. package/src/todos-service.ts +18 -6
  71. package/src/tool-error.ts +26 -0
  72. 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
- export function findLockConflict(
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
- ): { holderPaneId: string } | null {
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) return { holderPaneId: parsed.holderPaneId };
145
+ if (parsed && parsed.holderPaneId !== ownPaneId && !holders.includes(parsed.holderPaneId)) {
146
+ holders.push(parsed.holderPaneId);
147
+ }
81
148
  }
82
- return null;
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[]; holderPaneId: string; warning: string }
91
- | { kind: 'block'; paths: string[]; holderPaneId: string; reason: 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
- const raw = writePathsOfTool(opts.toolName, opts.input);
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 conflict = findLockConflict(opts.agents, opts.ownPaneId, norm);
106
- if (!conflict) continue;
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
- holderPaneId: conflict.holderPaneId,
112
- reason: `file locked by pane ${conflict.holderPaneId}: ${norm} is being edited in another pane. Wait for it to settle, or coordinate before writing.`,
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
- holderPaneId: conflict.holderPaneId,
119
- warning: formatConflictWarning(norm, conflict.holderPaneId),
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, holderPaneId: string): string {
140
- return `⚠️ write conflict: ${normPath} is locked by pane ${holderPaneId} (it edited this file and has not settled). This write went through (soft mode) — coordinate to avoid clobbering each other's changes.`;
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
+ }
@@ -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 req = parsePipeLine(line);
192
- if (!req) {
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
- server.on('error', () => {
213
- /* address in use: caller observes listen errors */
214
- });
215
- server.listen(pipePathFor(pipeName));
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
  }