pi-pier 0.3.0 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (96) hide show
  1. package/README.md +12 -11
  2. package/package.json +2 -2
  3. package/schemas/role-manifest.schema.json +7 -1
  4. package/skills/pi-herdr-workflow/SKILL.md +4 -5
  5. package/src/ansi-text.ts +62 -11
  6. package/src/ask-dialog.ts +308 -0
  7. package/src/ask-user.ts +113 -112
  8. package/src/bootstrap.ts +8 -12
  9. package/src/compact-coordinator.ts +278 -261
  10. package/src/compact-economics-core.ts +77 -145
  11. package/src/config-catalog-core.ts +77 -197
  12. package/src/config-command.ts +271 -64
  13. package/src/dashboard-command.ts +38 -19
  14. package/src/efficiency-config-core.ts +287 -484
  15. package/src/efficiency-store.ts +16 -29
  16. package/src/focus-poller.ts +14 -35
  17. package/src/gc-core.ts +10 -36
  18. package/src/herdr-client.ts +82 -208
  19. package/src/history-store.ts +36 -23
  20. package/src/index-gates.ts +124 -0
  21. package/src/index-locks.ts +2 -7
  22. package/src/index-master.ts +24 -18
  23. package/src/index-notices.ts +28 -15
  24. package/src/index-pipe.ts +17 -12
  25. package/src/index-roles.ts +306 -0
  26. package/src/index-runtime.ts +2 -6
  27. package/src/index-worker.ts +4 -8
  28. package/src/index.ts +305 -564
  29. package/src/jev-client.ts +12 -17
  30. package/src/jev-core.ts +137 -87
  31. package/src/ledger.ts +13 -21
  32. package/src/lock-core.ts +28 -56
  33. package/src/manifest-compose.ts +35 -20
  34. package/src/observation-core.ts +52 -100
  35. package/src/pane-title.ts +19 -38
  36. package/src/pi-surface.ts +34 -22
  37. package/src/pier-options.ts +54 -50
  38. package/src/pipe-channel.ts +27 -59
  39. package/src/platform-paths.ts +19 -52
  40. package/src/{core → plugins}/grid-shape.ts +8 -10
  41. package/src/{core → plugins}/heat-plan.ts +20 -22
  42. package/src/plugins/observation.ts +475 -0
  43. package/src/plugins/subagent.ts +728 -0
  44. package/src/{core → plugins}/terminal.ts +167 -127
  45. package/src/{core → plugins}/todo.ts +61 -100
  46. package/src/progress-core.ts +16 -53
  47. package/src/reconcile-core.ts +24 -33
  48. package/src/reducer-core.ts +24 -60
  49. package/src/reducer-invoker.ts +266 -239
  50. package/src/renderers.ts +76 -87
  51. package/src/role-loader.ts +33 -28
  52. package/src/role-manifest.ts +23 -28
  53. package/src/role-state.ts +119 -0
  54. package/src/routing-telemetry.ts +136 -0
  55. package/src/runtime-policy.ts +25 -40
  56. package/src/session-tail.ts +65 -41
  57. package/src/settle-wake-core.ts +52 -21
  58. package/src/slim-frame.ts +25 -122
  59. package/src/stale-core.ts +17 -24
  60. package/src/storage-layout.ts +14 -43
  61. package/src/subagent-core.ts +253 -118
  62. package/src/subagent-gc.ts +109 -67
  63. package/src/subagent-poller.ts +367 -24
  64. package/src/subagent-session.ts +516 -0
  65. package/src/subagent-spawn.ts +530 -52
  66. package/src/swallow.ts +7 -17
  67. package/src/terminal-core.ts +125 -106
  68. package/src/todo-core.ts +57 -109
  69. package/src/todo-read-hook.ts +23 -39
  70. package/src/todo-reminder-core.ts +26 -51
  71. package/src/todo-window.ts +11 -12
  72. package/src/todos-service.ts +15 -35
  73. package/src/tool-error.ts +6 -13
  74. package/src/tool-gate.ts +38 -21
  75. package/src/vocab.ts +52 -25
  76. package/src/ask-multi.ts +0 -260
  77. package/src/config-guide.ts +0 -361
  78. package/src/core/observation.ts +0 -555
  79. package/src/core/subagent.ts +0 -633
  80. package/src/efficiency-config-io.ts +0 -53
  81. package/src/git-adapter.ts +0 -113
  82. package/src/notice-buffer.ts +0 -23
  83. package/src/subagent-execute.ts +0 -328
  84. package/src/subagent-git-io.ts +0 -80
  85. package/src/subagent-launch.ts +0 -114
  86. package/src/subagent-list-action.ts +0 -69
  87. package/src/subagent-output-action.ts +0 -104
  88. package/src/subagent-output-adapter.ts +0 -27
  89. package/src/subagent-output-core.ts +0 -263
  90. package/src/subagent-poll-loop.ts +0 -305
  91. package/src/subagent-port.ts +0 -27
  92. package/src/subagent-ready.ts +0 -89
  93. package/src/subagent-registry.ts +0 -70
  94. package/src/subagent-resolution.ts +0 -41
  95. package/src/subagent-scope.ts +0 -41
  96. package/src/subagent-session-io.ts +0 -230
@@ -1,15 +1,7 @@
1
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).
2
+ * D101-D103 efficiency storage: content-addressed objects (ObservationPack / EPR), telemetry appends
3
+ * and the un-truncated bash log reader. Integrity: directories 0700, files 0600, O_NOFOLLOW everywhere,
4
+ * session ids regex-validated, and an EEXIST collision is re-verified (size + sha256) instead of trusted.
13
5
  */
14
6
 
15
7
  import { constants } from 'node:fs';
@@ -23,8 +15,8 @@ import {
23
15
  type RecallSliceResult,
24
16
  } from './observation-core.ts';
25
17
 
26
- export const SAFE_SESSION_ID_RE = /^[a-z0-9][a-z0-9._-]*$/i;
27
- export const BASH_LOG_NAME_RE = /^pi-bash-.*\.log$/;
18
+ const SAFE_SESSION_ID_RE = /^[a-z0-9][a-z0-9._-]*$/i;
19
+ const BASH_LOG_NAME_RE = /^pi-bash-.*\.log$/;
28
20
 
29
21
  const READ_OBJECT_FLAGS = constants.O_RDONLY | constants.O_NOFOLLOW;
30
22
  const CREATE_OBJECT_FLAGS =
@@ -66,15 +58,15 @@ export function reducerObjectPath(root: string, sha256: string): string {
66
58
 
67
59
  export function efficiencyLogPath(
68
60
  root: string,
69
- mechanism: 'compact' | 'observation' | 'reducer' | 'jev',
61
+ mechanism: 'compact' | 'observation' | 'reducer' | 'jev' | 'routing',
70
62
  customPath?: string,
71
63
  ): string {
72
64
  if (customPath && typeof customPath === 'string') return resolve(customPath);
73
65
  return join(root, 'efficiency-logs', `${mechanism}.jsonl`);
74
66
  }
75
67
 
76
- export const MAX_OBJECTS_PER_DIR = 300;
77
- export const MAX_OBJECTS_TOTAL_BYTES = 50 * 1024 * 1024; // 50MB
68
+ const MAX_OBJECTS_PER_DIR = 300;
69
+ const MAX_OBJECTS_TOTAL_BYTES = 50 * 1024 * 1024;
78
70
 
79
71
  export async function pruneObjectsDirectory(
80
72
  dir: string,
@@ -98,7 +90,6 @@ export async function pruneObjectsDirectory(
98
90
  totalBytes += s.size;
99
91
  }
100
92
  } catch {
101
- /* ignore stat errors */
102
93
  }
103
94
  }
104
95
 
@@ -106,7 +97,6 @@ export async function pruneObjectsDirectory(
106
97
  return 0;
107
98
  }
108
99
 
109
- // Sort by mtime ascending (oldest first)
110
100
  files.sort((a, b) => a.mtimeMs - b.mtimeMs);
111
101
 
112
102
  let removedCount = 0;
@@ -122,7 +112,6 @@ export async function pruneObjectsDirectory(
122
112
  if (k.startsWith(f.path)) verifiedObjectCache.delete(k);
123
113
  }
124
114
  } catch {
125
- /* ignore unlink errors */
126
115
  }
127
116
  }
128
117
  return removedCount;
@@ -131,7 +120,6 @@ export async function pruneObjectsDirectory(
131
120
  }
132
121
  }
133
122
 
134
- /** Prune both content-addressed object dirs of one session root; returns total removed files. */
135
123
  export async function pruneSessionObjects(
136
124
  sessionRoot: string,
137
125
  limits: { maxFiles?: number; maxTotalBytes?: number } = {},
@@ -173,7 +161,6 @@ export async function storeContentAddressedObject(
173
161
  verifiedObjectCache.add(`${filePath}:${contentBytes}:${writtenStat.mtimeMs}:${hash}`);
174
162
  } catch (err) {
175
163
  if (err instanceof Error && 'code' in err && err.code === 'EEXIST') {
176
- // Verify existing file integrity
177
164
  const existingHandle = await open(filePath, READ_OBJECT_FLAGS);
178
165
  try {
179
166
  const existingStat = await existingHandle.stat();
@@ -234,13 +221,18 @@ export async function readStoredObjectChunk(
234
221
  const available = Math.max(0, fileStat.size - offset);
235
222
  const buffer = Buffer.alloc(Math.min(available, limits.maxBytes + 4));
236
223
  const { bytesRead } = await handle.read(buffer, 0, buffer.length, offset);
237
- return sliceBufferChunk(buffer.subarray(0, bytesRead), 0, limits);
224
+ const sliced = sliceBufferChunk(buffer.subarray(0, bytesRead), 0, limits);
225
+ // sliceBufferChunk's nextOffset is relative to the window it was given (offset 0). Callers page
226
+ // with the absolute file offset from the header, so add the seek back.
227
+ const nextOffset = offset + sliced.nextOffset;
228
+ return { ...sliced, nextOffset, eof: nextOffset >= fileStat.size };
238
229
  } finally {
239
230
  await handle?.close();
240
231
  }
241
232
  }
242
233
 
243
- export const MAX_EFFICIENCY_LOG_BYTES = 5 * 1024 * 1024; // 5MB rotation ceiling
234
+ /** Rotation ceiling: rename to `<log>.old` past this size so a log can never grow unbounded. */
235
+ const MAX_EFFICIENCY_LOG_BYTES = 5 * 1024 * 1024;
244
236
 
245
237
  export async function appendEfficiencyLog(
246
238
  logPath: string,
@@ -250,7 +242,6 @@ export async function appendEfficiencyLog(
250
242
  const dir = dirname(logPath);
251
243
  await mkdir(dir, { recursive: true, mode: 0o700 });
252
244
 
253
- // Rotate log if exceeds maximum size to prevent unbounded disk growth
254
245
  try {
255
246
  const s = await stat(logPath);
256
247
  if (s.size > MAX_EFFICIENCY_LOG_BYTES) {
@@ -259,7 +250,6 @@ export async function appendEfficiencyLog(
259
250
  await rename(logPath, oldPath);
260
251
  }
261
252
  } catch {
262
- /* file may not exist yet, continue */
263
253
  }
264
254
 
265
255
  let handle;
@@ -271,9 +261,7 @@ export async function appendEfficiencyLog(
271
261
  }
272
262
  }
273
263
 
274
- /**
275
- * Safely reads Pi's untruncated bash output from temporary file.
276
- */
264
+ /** Safely reads Pi's untruncated bash output from temporary file. */
277
265
  export async function readBashFullOutput(
278
266
  rawPath: string | null | undefined,
279
267
  maxChars: number,
@@ -292,7 +280,6 @@ export async function readBashFullOutput(
292
280
  const fileStat = await stat(realFile);
293
281
  if (!fileStat.isFile() || fileStat.isSymbolicLink()) return null;
294
282
 
295
- // Check size within reasonable limit
296
283
  if (fileStat.size > maxChars * 4) return null;
297
284
 
298
285
  let handle = await open(realFile, READ_OBJECT_FLAGS);
@@ -1,10 +1,7 @@
1
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.
2
+ * D-4: herdr 0.9.1+ delivers `pane.focused`, so default polling is off (0 ms) there — a sampler
3
+ * would reflow a second time ~8 s after a click; older servers keep the 1500 ms sampler.
4
+ * `PIER_FOCUS_POLL_MS` overrides either default, 0 disables.
8
5
  */
9
6
  import { spawn } from 'node:child_process';
10
7
  import { dirname, join } from 'node:path';
@@ -15,7 +12,6 @@ export const FOCUS_POLL_DEFAULT_MS = 1500;
15
12
  /** Herdr 0.9.1+: native pane.focused is authoritative; default poller off to avoid a delayed second reflow. */
16
13
  export const FOCUS_POLL_HERDR_091_MS = 0;
17
14
 
18
- /** Check if Herdr server version is 0.9.1 or later. */
19
15
  export function isHerdr091OrLater(version?: string | null): boolean {
20
16
  if (!version) return false;
21
17
  const m = /^(\d+)\.(\d+)\.(\d+)/.exec(version.trim());
@@ -29,14 +25,11 @@ export function isHerdr091OrLater(version?: string | null): boolean {
29
25
  return false;
30
26
  }
31
27
 
32
- /** Determine default poll cadence based on Herdr server version. */
33
28
  export function resolveDefaultFocusPollMs(herdrVersion?: string | null): number {
34
29
  return isHerdr091OrLater(herdrVersion) ? FOCUS_POLL_HERDR_091_MS : FOCUS_POLL_DEFAULT_MS;
35
30
  }
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
- */
31
+ /** Min gap between reflow triggers: re-clicking the same pane must not spawn a process per click (the
32
+ * workbench debounces 150 ms per tab on top of this). */
40
33
  export const FOCUS_FIRE_MIN_INTERVAL_MS = 700;
41
34
 
42
35
  export interface FocusSample {
@@ -86,13 +79,10 @@ export interface FocusTick {
86
79
  }
87
80
 
88
81
  /**
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).
82
+ * Decide whether one sample should trigger a reflow: only on a *transition* into "I am focused",
83
+ * and at most once per `minIntervalMs`. `cause` is 'user' when the pane set did not change in the
84
+ * same sample (a click moves focus and nothing else, a spawn auto-focus comes with a new pane), so
85
+ * the workbench can keep its pane-age whitelist. Baseline sample never fires.
96
86
  */
97
87
  export function planFocusTick(opts: {
98
88
  myPaneId: string;
@@ -128,8 +118,6 @@ export function planFocusTick(opts: {
128
118
  };
129
119
  }
130
120
 
131
- /* ─────────────────────────── runner ─────────────────────────── */
132
-
133
121
  export interface FocusPollerDeps {
134
122
  /** Layout of the caller's own tab (the client already exposes `layout.export`). */
135
123
  sample(): Promise<FocusSample | null>;
@@ -139,8 +127,6 @@ export interface FocusPollerDeps {
139
127
  intervalMs?: number;
140
128
  minIntervalMs?: number;
141
129
  now?: () => number;
142
- setIntervalFn?: (fn: () => void, ms: number) => NodeJS.Timeout;
143
- clearIntervalFn?: (handle: NodeJS.Timeout) => void;
144
130
  /** Diagnostics only: polling failures are expected while herdr restarts. */
145
131
  onError?: (err: unknown) => void;
146
132
  }
@@ -153,10 +139,7 @@ export interface FocusPoller {
153
139
 
154
140
  export function startFocusPoller(deps: FocusPollerDeps): FocusPoller {
155
141
  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); });
142
+ const now = deps.now ?? Date.now;
160
143
  let state: FocusPollerState | null = null;
161
144
  let inFlight = false;
162
145
  let stopped = false;
@@ -186,7 +169,7 @@ export function startFocusPoller(deps: FocusPollerDeps): FocusPoller {
186
169
  }
187
170
 
188
171
  if (intervalMs > 0) {
189
- handle = setIntervalFn(() => { void tick(); }, intervalMs);
172
+ handle = setInterval(() => { void tick(); }, intervalMs);
190
173
  handle.unref?.(); // never keep pi's event loop alive on our account
191
174
  }
192
175
 
@@ -195,7 +178,7 @@ export function startFocusPoller(deps: FocusPollerDeps): FocusPoller {
195
178
  stop(): void {
196
179
  stopped = true;
197
180
  if (handle !== null) {
198
- clearIntervalFn(handle);
181
+ clearInterval(handle);
199
182
  handle = null;
200
183
  }
201
184
  },
@@ -218,11 +201,8 @@ export interface SpawnReflowOpts {
218
201
  spawnFn?: typeof spawn;
219
202
  }
220
203
 
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
- */
204
+ /** Fire-and-forget replay of the `pane.focused` hook as a child process — exactly how herdr runs it,
205
+ * so the workbench script keeps its own event parsing, debounce and state file. */
226
206
  export function spawnReflow(opts: SpawnReflowOpts): void {
227
207
  const env = opts.env ?? process.env;
228
208
  const spawnFn = opts.spawnFn ?? spawn;
@@ -239,6 +219,5 @@ export function spawnReflow(opts: SpawnReflowOpts): void {
239
219
  child.on?.('error', () => { /* Missing script / dead exec: polling must never break pi. */ });
240
220
  child.unref?.();
241
221
  } catch {
242
- /* Best-effort: focus heat is a comfort feature; the click still focuses the pane. */
243
222
  }
244
223
  }
package/src/gc-core.ts CHANGED
@@ -1,7 +1,4 @@
1
- /**
2
- * Task-tab GC decisions (D29 rules as a pure function; M22 dropped resident exemption).
3
- * No I/O, no pi/herdr — unit-testable.
4
- */
1
+ /** Task-tab GC decisions (D29 rules as a pure function; no I/O, no pi/herdr). */
5
2
  export type GcEntryKind = string;
6
3
  export type GcEntryStatus = 'running' | 'settled' | 'consumed' | 'closed';
7
4
 
@@ -17,14 +14,8 @@ function isFinished(status: GcEntryStatus): boolean {
17
14
  return status === 'consumed' || status === 'closed' || status === 'settled';
18
15
  }
19
16
 
20
- /**
21
- * Close a task tab only when every condition holds:
22
- * - ≥1 work pane (the main tab with no delegated entries never closes);
23
- * - every work pane is settled/consumed/closed (kind is not an exemption);
24
- * - grace TTL elapsed (`ttlMs=0` means never auto-close);
25
- * - no blocked pane (human-gate exemption);
26
- * - remaining panes are idle/done/unknown (non-work panes may be unknown).
27
- */
17
+ /** Close a task tab only when every work pane finished, the grace TTL elapsed (`ttlMs=0` means never
18
+ * auto-close), no pane is blocked and the rest are idle/done/unknown. */
28
19
  export function shouldCloseTaskTab(opts: {
29
20
  entries: readonly GcEntryLike[];
30
21
  paneStatuses: readonly string[];
@@ -39,12 +30,8 @@ export function shouldCloseTaskTab(opts: {
39
30
  return true;
40
31
  }
41
32
 
42
- /**
43
- * Pane-level collection (orphan / compat path):
44
- * - consumed before the previous turn (grace so the settlement notice is still visible);
45
- * - herdr status idle/done (unknown/working/blocked retry next turn);
46
- * - missing pane (status undefined) → record closed (caller handles the write).
47
- */
33
+ /** Pane-level collection (orphan / compat path): consumed before the previous turn (grace so the
34
+ * settlement notice is still visible); missing pane → record closed (caller handles the write). */
48
35
  export function shouldClosePane(opts: {
49
36
  consumedAt: number | null;
50
37
  herdrStatus: string | undefined;
@@ -55,18 +42,11 @@ export function shouldClosePane(opts: {
55
42
  return (opts.consumedAt ?? 0) > 0 && opts.consumedAt! < opts.prevTurnStart;
56
43
  }
57
44
 
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
- */
45
+ /* Isolate worktree collection is deliberately narrow: `refs/heads/pier/*` is NOT proof of pier
46
+ * ownership — a worker session lives in such a worktree, so a namespace-wide sweep deletes the
47
+ * worktree its own process is running in. Only THIS session's registered branches are candidates,
48
+ * cwd never is, and untracked `pier/*` branches need an explicit opt-in. */
68
49
 
69
- /** True when `path` is `parent` or lives inside it (both resolved, no I/O). */
70
50
  export function isPathInside(path: string, parent: string): boolean {
71
51
  const norm = (p: string): string => p.replace(/[\\/]+/g, '/').replace(/\/+$/, '');
72
52
  const child = norm(path);
@@ -90,20 +70,14 @@ export interface IsolateSweepPlan {
90
70
  skipped: IsolateSweepSkip[];
91
71
  }
92
72
 
93
- /**
94
- * Decide which isolate worktrees this session may collect.
95
- * Pure: callers pass git output, the subagent registry and the process cwd.
96
- */
73
+ /** Pure: callers pass git output, the subagent registry and the process cwd. */
97
74
  export function planIsolateSweep(input: {
98
- /** Every `refs/heads/pier/*` branch name. */
99
75
  branches: readonly string[];
100
76
  /** branch → worktree path, from `git worktree list --porcelain`. */
101
77
  worktreesByBranch: ReadonlyMap<string, string>;
102
- /** Branches of isolate entries in this session's registry. */
103
78
  registeredBranches: ReadonlySet<string>;
104
79
  /** Branches created but not yet registered (worktree add → subs.set window). */
105
80
  pendingBranches: ReadonlySet<string>;
106
- /** Session-owned isolates that may be collected, with their recorded path. */
107
81
  sessionOwned: ReadonlyArray<{ branch: string; worktreePath: string }>;
108
82
  /** Path to protect (normally process.cwd()). */
109
83
  cwd: string;