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
@@ -5,21 +5,19 @@
5
5
  * order (reported path/id before recent-file fallback, excluding the parent
6
6
  * session). Keeping it in one adapter prevents settlement text from crossing sessions.
7
7
  */
8
- import { stat as statCb } from 'node:fs';
9
- import { promisify } from 'node:util';
8
+ import { accessSync, constants, statSync } from 'node:fs';
10
9
  import type { HerdrClientLike } from './herdr-client.ts';
11
10
  import {
12
- hasAssistantAfter,
13
- hasPendingToolCall,
11
+ deriveSubSessionState,
14
12
  lastAssistantText,
15
13
  listSessionFiles,
16
14
  readSessionFile,
17
15
  sessionFileById,
16
+ type SessionEntryLike,
17
+ type SubSessionState,
18
18
  } from './session-tail.ts';
19
19
  import type { AliveProbe } from './subagent-core.ts';
20
20
 
21
- const statAsync = promisify(statCb);
22
-
23
21
  export interface SessionIoHost {
24
22
  client: HerdrClientLike;
25
23
  getSessionId: () => string;
@@ -32,10 +30,74 @@ export interface SessionIo {
32
30
  collectFinalText(paneId: string, cwd: string, sinceTs: number, attempts?: number): Promise<string | null>;
33
31
  readAskFlag(paneId: string): Promise<string | null>;
34
32
  probeAlive(paneId: string, cwd: string): Promise<AliveProbe>;
35
- subSessionState(paneId: string, cwd: string, sinceTs: number): Promise<{ text: string | null; pendingTool: boolean; activity: boolean }>;
33
+ subSessionState(paneId: string, cwd: string, sinceTs: number): Promise<SubSessionState>;
34
+ }
35
+
36
+ /** Cheap change fingerprint of a session file; null when it does not exist. */
37
+ interface FileStamp {
38
+ size: number;
39
+ mtimeMs: number;
40
+ }
41
+
42
+ function stampOf(file: string): FileStamp | null {
43
+ try {
44
+ const s = statSync(file);
45
+ return { size: s.size, mtimeMs: s.mtimeMs };
46
+ } catch {
47
+ return null;
48
+ }
49
+ }
50
+
51
+ /** Readable regular file check: parsing the file would read it in full (up to MBs). */
52
+ function isReadableFile(file: string): boolean {
53
+ try {
54
+ if (!statSync(file).isFile()) return false;
55
+ accessSync(file, constants.R_OK);
56
+ return true;
57
+ } catch {
58
+ return false;
59
+ }
60
+ }
61
+
62
+ /**
63
+ * Memo of values derived from a session file, keyed by path and invalidated by (size, mtime).
64
+ *
65
+ * Why: worker sessions grow to tens of MB, and the poll loop re-derives the same idle state every
66
+ * tick (collectFinalText re-derives up to `attempts` times). Measured on a 31.6MB session:
67
+ * file read + JSON.parse + derive ≈ 227ms, so an unchanged file must not be parsed twice.
68
+ * An append-only JSONL changes size on every write, so (size, mtime) is a sound fingerprint.
69
+ */
70
+ class DerivedCache<T> {
71
+ private readonly entries = new Map<string, { stamp: FileStamp; sinceTs: number; value: T }>();
72
+ private readonly limit: number;
73
+
74
+ constructor(limit = 32) {
75
+ this.limit = limit;
76
+ }
77
+
78
+ get(file: string, stamp: FileStamp, sinceTs: number): T | undefined {
79
+ const hit = this.entries.get(file);
80
+ if (!hit || hit.stamp.size !== stamp.size || hit.stamp.mtimeMs !== stamp.mtimeMs || hit.sinceTs !== sinceTs) {
81
+ return undefined;
82
+ }
83
+ return hit.value;
84
+ }
85
+
86
+ set(file: string, stamp: FileStamp, sinceTs: number, value: T): void {
87
+ this.entries.delete(file);
88
+ this.entries.set(file, { stamp, sinceTs, value });
89
+ while (this.entries.size > this.limit) {
90
+ const oldest = this.entries.keys().next().value;
91
+ if (oldest === undefined) break;
92
+ this.entries.delete(oldest);
93
+ }
94
+ }
36
95
  }
37
96
 
38
97
  export function createSessionIo(h: SessionIoHost): SessionIo {
98
+ const stateCache = new DerivedCache<SubSessionState>();
99
+ const finalTextCache = new DerivedCache<string | null>();
100
+
39
101
  async function resolveSessionFileCandidates(paneId: string, cwd: string): Promise<string[]> {
40
102
  const out: string[] = [];
41
103
  try {
@@ -59,7 +121,7 @@ export function createSessionIo(h: SessionIoHost): SessionIo {
59
121
 
60
122
  async function resolveSessionFile(paneId: string, cwd: string): Promise<string | null> {
61
123
  for (const file of await resolveSessionFileCandidates(paneId, cwd)) {
62
- if (readSessionFile(file)) return file;
124
+ if (isReadableFile(file)) return file;
63
125
  }
64
126
  return null;
65
127
  }
@@ -72,10 +134,18 @@ export function createSessionIo(h: SessionIoHost): SessionIo {
72
134
  ): Promise<string | null> {
73
135
  for (let i = 0; i < attempts; i++) {
74
136
  for (const file of await resolveSessionFileCandidates(paneId, cwd)) {
75
- const entries = readSessionFile(file);
137
+ const stamp = stampOf(file);
138
+ if (!stamp) continue;
139
+ const cached = finalTextCache.get(file, stamp, sinceTs);
140
+ if (cached !== undefined) {
141
+ if (cached) return cached;
142
+ continue;
143
+ }
144
+ const entries: SessionEntryLike[] | null = readSessionFile(file);
76
145
  if (!entries) continue;
77
- const r = lastAssistantText(entries, { sinceTs });
78
- if (r?.text) return r.text;
146
+ const text = lastAssistantText(entries, { sinceTs })?.text ?? null;
147
+ finalTextCache.set(file, stamp, sinceTs, text);
148
+ if (text) return text;
79
149
  }
80
150
  const wait = Promise.withResolvers<void>();
81
151
  setTimeout(wait.resolve, 500);
@@ -96,21 +166,31 @@ export function createSessionIo(h: SessionIoHost): SessionIo {
96
166
 
97
167
  async function probeAlive(paneId: string, cwd: string): Promise<AliveProbe> {
98
168
  const probe: AliveProbe = { paneExists: false, agentStatus: null, lastActivityMs: null };
169
+ let listed = false;
99
170
  try {
100
- const agents = await h.client.listAgents();
101
- const a = agents.find((x) => x.paneId === paneId);
102
- probe.paneExists = a != null;
103
- probe.agentStatus = a?.status ?? null;
171
+ const pane = (await h.client.listPanes()).find((p) => p.paneId === paneId);
172
+ listed = true;
173
+ probe.paneExists = pane != null;
174
+ probe.agentStatus = pane?.agentStatus ?? null;
175
+ if (pane?.foregroundCwd) probe.foregroundCwd = pane.foregroundCwd;
104
176
  } catch {
105
- /* Fall back to session activity when agent.list is unavailable. */
177
+ /* pane.list unavailable — agent.list is a degraded fallback, not a death signal */
106
178
  }
107
- for (const file of await resolveSessionFileCandidates(paneId, cwd)) {
108
- try {
109
- const mtime = (await statAsync(file)).mtimeMs;
110
- if (probe.lastActivityMs == null || mtime > probe.lastActivityMs) probe.lastActivityMs = mtime;
111
- } catch {
112
- /* Session files can disappear during candidate scanning. */
179
+ try {
180
+ const a = (await h.client.listAgents()).find((x) => x.paneId === paneId);
181
+ if (a) {
182
+ if (!listed) probe.paneExists = true;
183
+ probe.agentStatus = a.status ?? probe.agentStatus;
184
+ if (a.foregroundCwd) probe.foregroundCwd = a.foregroundCwd;
113
185
  }
186
+ } catch {
187
+ /* agent.list is status-only; absence is not death */
188
+ }
189
+ for (const file of await resolveSessionFileCandidates(paneId, cwd)) {
190
+ const stamp = stampOf(file);
191
+ // Session files can disappear during candidate scanning; a vanished file has no activity.
192
+ if (!stamp) continue;
193
+ if (probe.lastActivityMs == null || stamp.mtimeMs > probe.lastActivityMs) probe.lastActivityMs = stamp.mtimeMs;
114
194
  }
115
195
  return probe;
116
196
  }
@@ -119,20 +199,23 @@ export function createSessionIo(h: SessionIoHost): SessionIo {
119
199
  paneId: string,
120
200
  cwd: string,
121
201
  sinceTs: number,
122
- ): Promise<{ text: string | null; pendingTool: boolean; activity: boolean }> {
202
+ ): Promise<SubSessionState> {
123
203
  for (const file of await resolveSessionFileCandidates(paneId, cwd)) {
124
- const entries = readSessionFile(file);
125
- // readSessionFile returns null when the path is missing or unreadable.
126
- // herdr often reports a .jsonl path before the worker creates the file;
127
- // waitAgent(idle) then returns immediately and this path used to throw
204
+ const stamp = stampOf(file);
205
+ // A missing or vanished path is skipped: herdr often reports a .jsonl path before the worker
206
+ // creates the file, and waitAgent(idle) returns immediately, which used to throw
128
207
  // `Cannot read properties of null (reading 'length')` (session 01a055c5).
208
+ if (!stamp) continue;
209
+ const cached = stateCache.get(file, stamp, sinceTs);
210
+ if (cached) return cached;
211
+ const entries = readSessionFile(file);
212
+ // An empty/unparsable file has no state to cache; the next candidate may still have one.
129
213
  if (!entries?.length) continue;
130
- const r = lastAssistantText(entries, { sinceTs });
131
- if (r?.text) return { text: r.text, pendingTool: false, activity: true };
132
- if (hasPendingToolCall(entries, sinceTs)) return { text: null, pendingTool: true, activity: true };
133
- if (hasAssistantAfter(entries, sinceTs)) return { text: null, pendingTool: false, activity: true };
214
+ const state = deriveSubSessionState(entries, sinceTs);
215
+ stateCache.set(file, stamp, sinceTs, state);
216
+ return state;
134
217
  }
135
- return { text: null, pendingTool: false, activity: false };
218
+ return { text: null, pendingTool: false, activity: false, turnEnded: false };
136
219
  }
137
220
 
138
221
  return {
@@ -5,8 +5,15 @@
5
5
  * races (D26 mutex) and D86 worktree grouping belong in one adapter.
6
6
  */
7
7
  import type { HerdrClientLike } from './herdr-client.ts';
8
- import { pingUntilReady, pipeNameCandidates } from './pipe-channel.ts';
8
+ import { pipeNameCandidates, pipeRequest } from './pipe-channel.ts';
9
9
  import { runtimePolicy } from './runtime-policy.ts';
10
+ import {
11
+ READY_LIVENESS_SAMPLE_MS,
12
+ READY_TAIL_CHARS,
13
+ planReadyAttempt,
14
+ readyFailureText,
15
+ type ReadyFailure,
16
+ } from './subagent-ready.ts';
10
17
  import {
11
18
  Semaphore,
12
19
  buildLaunchLine,
@@ -17,7 +24,8 @@ import {
17
24
  type TabPlacementPlan,
18
25
  type WorktreeZone,
19
26
  } from './subagent-core.ts';
20
- import { parseShapeTree, pickGridSplit } from './core/grid-shape.ts';
27
+ import { parseShapeTree, pickGridSplit, type PaneCell } from './core/grid-shape.ts';
28
+ import { fromShapeTree, planSpawnSplitRatio, type LayoutNode } from './core/heat-plan.ts';
21
29
  import type { GitIo } from './subagent-git-io.ts';
22
30
 
23
31
  export interface SpawnEnv {
@@ -37,8 +45,15 @@ export interface SpawnerHost {
37
45
  env: SpawnEnv | null;
38
46
  runtime: SpawnRuntime;
39
47
  git: GitIo;
48
+ /** Test override. Production reads `runtimePolicy.readinessTimeoutMs`. */
49
+ readinessTimeoutMs?: number;
40
50
  }
41
51
 
52
+ /** A14: readiness outcome — failures explain themselves instead of collapsing into `false`. */
53
+ export type ReadyOutcome =
54
+ | { ok: true }
55
+ | { ok: false; failure: ReadyFailure; message: string };
56
+
42
57
  export interface Spawner {
43
58
  spawnPaneInTaskTab(
44
59
  placement: { desiredTab?: string | null; description: string; zone?: WorktreeZone },
@@ -48,14 +63,14 @@ export interface Spawner {
48
63
  ): Promise<{ tabId: string; paneId: string; tabName: string }>;
49
64
  launchLine(resumeFile?: string | null, roleModel?: string | null, approve?: boolean): string;
50
65
  approveFor(cwd: string, masterCwd: string): Promise<boolean>;
51
- waitSubReady(cwd: string, paneId: string): Promise<boolean>;
66
+ waitSubReady(cwd: string, paneId: string): Promise<ReadyOutcome>;
52
67
  liveTabs(): Promise<Array<{ tabName: string; tabId: string }>>;
53
68
  findExistingPane(sessionFile: string | null): Promise<{ paneId: string; tabId: string } | null>;
54
69
  }
55
70
 
56
71
  export function createSpawner(h: SpawnerHost): Spawner {
57
72
  const tabMutex = new Semaphore(1);
58
- const readyTimeoutMs = runtimePolicy.readinessTimeoutMs;
73
+ const readyTimeoutMs = h.readinessTimeoutMs ?? runtimePolicy.readinessTimeoutMs;
59
74
 
60
75
  async function liveTabs(): Promise<Array<{ tabName: string; tabId: string }>> {
61
76
  try {
@@ -78,8 +93,107 @@ export function createSpawner(h: SpawnerHost): Spawner {
78
93
  return zone.zone === 'worktree';
79
94
  }
80
95
 
81
- async function waitSubReady(cwd: string, paneId: string): Promise<boolean> {
82
- return pingUntilReady(pipeNameCandidates(cwd, paneId), readyTimeoutMs);
96
+ /**
97
+ * A14: wait for the child's pipe with backoff, and give up early when the pane
98
+ * itself is gone from pane.list. Screen tail is sampled while the pane exists
99
+ * so a crash that leaves a shell still explains itself on timeout.
100
+ */
101
+ async function waitSubReady(cwd: string, paneId: string): Promise<ReadyOutcome> {
102
+ const names = pipeNameCandidates(cwd, paneId);
103
+ const startedAt = Date.now();
104
+ let attempt = 0;
105
+ let lastProbeAt = 0;
106
+ let alive: boolean | null = null;
107
+ let lastStatus: string | null = null;
108
+ let tail: string | null = null;
109
+
110
+ while (true) {
111
+ const now = Date.now();
112
+ const elapsedMs = now - startedAt;
113
+ // Liveness/agent sampling is much more expensive than a ping, so it runs on its own cadence.
114
+ if (now - lastProbeAt >= READY_LIVENESS_SAMPLE_MS || attempt === 0) {
115
+ lastProbeAt = now;
116
+ const probe = await probeChildPane(paneId);
117
+ alive = probe.alive;
118
+ lastStatus = probe.status;
119
+ if (probe.tail) tail = probe.tail;
120
+ }
121
+ const pinged = await pingAnyName(names);
122
+ const plan = planReadyAttempt({ elapsedMs, attempt, timeoutMs: readyTimeoutMs, alive, ready: pinged });
123
+ if (plan.kind === 'ready') return { ok: true };
124
+ if (plan.kind === 'give-up') {
125
+ let explainHint: string | null = null;
126
+ if (h.client.available) {
127
+ try {
128
+ const diag = await h.client.agentExplain(paneId);
129
+ if (diag && typeof diag === 'object') {
130
+ const explainObj = ((diag.explain ?? diag) as Record<string, unknown>);
131
+ const parts: string[] = [];
132
+ if (explainObj.skip_state_reason) parts.push(`skip reason: ${String(explainObj.skip_state_reason)}`);
133
+ if (explainObj.screen_detection_skip_reason) parts.push(`screen rule bypassed: ${String(explainObj.screen_detection_skip_reason)}`);
134
+ if (explainObj.matched_rule) parts.push(`matched rule: ${String(explainObj.matched_rule)}`);
135
+ if (explainObj.idle_fallback_reason) parts.push(`idle fallback: ${String(explainObj.idle_fallback_reason)}`);
136
+ if (parts.length > 0) explainHint = `Herdr detection diagnosis: ${parts.join('; ')}`;
137
+ }
138
+ } catch {
139
+ /* Best effort diagnostics; failures never mask the root error. */
140
+ }
141
+ }
142
+ const defaultHint = plan.reason === 'timeout' && lastStatus === 'working'
143
+ ? 'Tip: pass run_in_background to avoid blocking the master turn while the worker boots.'
144
+ : null;
145
+ const hint = [explainHint, defaultHint].filter(Boolean).join('\n') || null;
146
+ const failure: ReadyFailure = {
147
+ paneId,
148
+ reason: plan.reason,
149
+ elapsedMs: Math.max(elapsedMs, 1),
150
+ timeoutMs: readyTimeoutMs,
151
+ lastStatus,
152
+ tail,
153
+ hint,
154
+ };
155
+ return { ok: false, failure, message: readyFailureText(failure) };
156
+ }
157
+ attempt += 1;
158
+ await new Promise((r) => setTimeout(r, plan.delayMs));
159
+ }
160
+ }
161
+
162
+ /** One ping round over every candidate pipe name (mixed-version peers register different names). */
163
+ async function pingAnyName(names: readonly string[]): Promise<boolean> {
164
+ for (const name of names) {
165
+ try {
166
+ const res = await pipeRequest(name, { type: 'ping', id: `ping-${Date.now()}` }, 3000);
167
+ if (res.type === 'ok') return true;
168
+ } catch {
169
+ /* not ready on this name */
170
+ }
171
+ }
172
+ return false;
173
+ }
174
+
175
+ /** Pane liveness + last visible output; socket failures degrade to "unknown" rather than "dead". */
176
+ async function probeChildPane(paneId: string): Promise<{ alive: boolean | null; status: string | null; tail: string | null }> {
177
+ let alive: boolean | null = null;
178
+ let status: string | null = null;
179
+ try {
180
+ // pane.list includes unknown shells; agent.list does not (herdr 0.9.1 live probe).
181
+ // A fresh split is absent from agent.list until pi is identified — that is boot, not death.
182
+ const panes = await h.client.listPanes();
183
+ const pane = panes.find((p) => p.paneId === paneId);
184
+ alive = pane != null;
185
+ status = pane?.agentStatus ?? null;
186
+ } catch {
187
+ /* keep null: an unreachable socket must not be read as a dead child */
188
+ }
189
+ let tail: string | null = null;
190
+ try {
191
+ const read = await h.client.readPane(paneId, { stripAnsi: true });
192
+ if (read.text) tail = read.text.slice(-READY_TAIL_CHARS);
193
+ } catch {
194
+ /* recycled pane or socket blip — caller keeps the last cached tail */
195
+ }
196
+ return { alive, status, tail };
83
197
  }
84
198
 
85
199
  async function spawnPaneInTaskTab(
@@ -105,20 +219,51 @@ export function createSpawner(h: SpawnerHost): Spawner {
105
219
  allPanes.filter((p) => p.tabId === plan.tabId && p.agentStatus === 'unknown').map((p) => p.paneId),
106
220
  );
107
221
  let pick: { targetPaneId: string; direction: 'right' | 'down' } | null = null;
222
+ let heatRoot: LayoutNode | null = null;
223
+ let focusPaneId: string | null = null;
224
+ let zoomed = false;
108
225
  try {
109
226
  const snapshot = await h.client.exportLayout({ tabId: plan.tabId });
110
227
  const tree = snapshot?.root ? parseShapeTree(snapshot.root) : null;
111
- if (tree) pick = pickGridSplit(tree, { exclude });
228
+ if (tree) {
229
+ heatRoot = fromShapeTree(tree);
230
+ focusPaneId = snapshot?.focusedPaneId ?? null;
231
+ zoomed = snapshot?.zoomed === true;
232
+ let cells: PaneCell[] | undefined;
233
+ try {
234
+ const live = await h.client.paneLayout({ paneId: allPanes.find((p) => p.tabId === plan.tabId)?.paneId });
235
+ if (live?.panes.length) {
236
+ cells = live.panes.map((p) => ({ id: p.paneId, x: p.x, y: p.y, w: p.w, h: p.h }));
237
+ }
238
+ } catch { /* pane.layout missing → 200×50 model */ }
239
+ pick = pickGridSplit(tree, { exclude, cells });
240
+ }
112
241
  } catch { /* layout export failure → legacy anchor split */ }
113
242
  const anchorPaneId = pick?.targetPaneId
114
243
  ?? allPanes.find((p) => p.tabId === plan.tabId && p.agentStatus !== 'unknown')?.paneId
115
244
  ?? allPanes.find((p) => p.tabId === plan.tabId)?.paneId;
116
245
  if (anchorPaneId) {
246
+ const direction = pick?.direction ?? 'down';
247
+ const statuses: Record<string, string> = {};
248
+ for (const p of allPanes) {
249
+ if (p.tabId === plan.tabId) statuses[p.paneId] = p.agentStatus;
250
+ }
251
+ const ratio = heatRoot && !zoomed
252
+ ? planSpawnSplitRatio({
253
+ root: heatRoot,
254
+ targetPaneId: anchorPaneId,
255
+ focusPaneId: focusPaneId ?? anchorPaneId,
256
+ direction,
257
+ statuses,
258
+ })
259
+ : null;
117
260
  const paneId = await h.client.splitPane({
118
- direction: pick?.direction ?? 'down',
261
+ direction,
119
262
  cwd,
120
263
  env: envOver,
121
264
  targetPaneId: anchorPaneId,
265
+ focus: false,
266
+ ...(ratio != null ? { ratio } : {}),
122
267
  });
123
268
  await h.client.sendPaneText(paneId, launch);
124
269
  return { tabId: plan.tabId, paneId, tabName: plan.tabName };
package/src/swallow.ts ADDED
@@ -0,0 +1,75 @@
1
+ /**
2
+ * B9: swallowed exceptions. Cleaning up after a failure is often correct (`catch {}` around a
3
+ * best-effort write), but "silently correct" and "silently broken" look identical from the outside —
4
+ * this repo has twice shipped a long-lived `ReferenceError` hidden by an empty catch (pane GC, and
5
+ * `todo` manual-edit persistence), discovered weeks later by accident.
6
+ *
7
+ * `swallow(tag, err)` keeps the cleanup behaviour, but records the event in a bounded ring buffer and
8
+ * — when `PIER_TRACE`/`PI_HERDR_TRACE` is set — writes it to stderr, so `/pier-config` can show the
9
+ * recent ones and a bug report can quote them.
10
+ */
11
+
12
+ export interface SwallowedError {
13
+ /** Short, stable site label, e.g. 'todo.persist-edit'. */
14
+ readonly tag: string;
15
+ readonly message: string;
16
+ /** Epoch ms. */
17
+ readonly at: number;
18
+ }
19
+
20
+ export const SWALLOW_BUFFER_MAX = 50;
21
+
22
+ const buffer: SwallowedError[] = [];
23
+
24
+ function describe(err: unknown): string {
25
+ if (err instanceof Error) {
26
+ const code = (err as { code?: unknown }).code;
27
+ return code !== undefined ? `${err.name}: ${err.message} (code ${String(code)})` : `${err.name}: ${err.message}`;
28
+ }
29
+ if (typeof err === 'string') return err;
30
+ try {
31
+ return JSON.stringify(err);
32
+ } catch {
33
+ return String(err);
34
+ }
35
+ }
36
+
37
+ /** Diagnostics switch shared with the rest of the extension (`PIER_TRACE`, legacy `PI_HERDR_TRACE`). */
38
+ function traceEnabled(env: NodeJS.ProcessEnv = process.env): boolean {
39
+ const raw = env.PIER_TRACE ?? env.PI_HERDR_TRACE;
40
+ return typeof raw === 'string' && raw.trim() !== '' && raw !== '0';
41
+ }
42
+
43
+ /**
44
+ * Record a caught error that is being intentionally ignored.
45
+ * Returns nothing: call sites stay `} catch (err) { swallow('tag', err); }` and keep their cleanup.
46
+ */
47
+ export function swallow(tag: string, err: unknown, env: NodeJS.ProcessEnv = process.env): void {
48
+ const entry: SwallowedError = { tag, message: describe(err), at: Date.now() };
49
+ buffer.push(entry);
50
+ if (buffer.length > SWALLOW_BUFFER_MAX) buffer.splice(0, buffer.length - SWALLOW_BUFFER_MAX);
51
+ if (traceEnabled(env)) console.error(`[pier] swallowed ${tag}: ${entry.message}`);
52
+ }
53
+
54
+ /** Recent swallowed errors, oldest first (diagnostics only; never persisted). */
55
+ export function swallowedErrors(): readonly SwallowedError[] {
56
+ return [...buffer];
57
+ }
58
+
59
+ /** Test seam: drop buffered entries. */
60
+ export function resetSwallowedErrors(): void {
61
+ buffer.length = 0;
62
+ }
63
+
64
+ /**
65
+ * Render the recent swallowed errors for `/pier-config`.
66
+ * Empty input returns an explicit "none" line so the section is never ambiguous.
67
+ */
68
+ export function formatSwallowedErrors(entries: readonly SwallowedError[] = swallowedErrors()): string {
69
+ if (entries.length === 0) return 'swallowed errors: none this session';
70
+ const lines = entries.slice(-10).map((e) => {
71
+ const hhmmss = new Date(e.at).toISOString().slice(11, 19);
72
+ return `${hhmmss} ${e.tag}: ${e.message}`;
73
+ });
74
+ return [`swallowed errors: ${entries.length} this session (last 10)`, ...lines].join('\n');
75
+ }