pi-pier 0.2.7 → 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 +12 -0
  5. package/src/ansi-text.ts +83 -0
  6. package/src/ask-multi.ts +260 -0
  7. package/src/ask-user.ts +60 -10
  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 +121 -47
  19. package/src/core/todo.ts +25 -11
  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 -1
  30. package/src/index-notices.ts +13 -2
  31. package/src/index-pipe.ts +5 -0
  32. package/src/index-worker.ts +3 -1
  33. package/src/index.ts +330 -34
  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 +4 -2
  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 +82 -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
+ }
@@ -1,4 +1,5 @@
1
1
  /** Why: Preserve the established compatibility and safety behavior (D71, M14, T1–T6, T3, T6). */
2
+ import { pierOption } from './pier-options.ts';
2
3
  import { basename } from 'node:path';
3
4
 
4
5
  /** Why: Preserve the established compatibility and safety behavior. */
@@ -10,8 +11,13 @@ export const READ_MAX_CHARS = 8000;
10
11
  export const READ_HARD_CAP_CHARS = 200;
11
12
  export const READINESS_TIMEOUT_MS = 30_000;
12
13
  export const SILENCE_IDLE_MS = 2000;
13
- /** POSIX / PowerShell / Nushell prompt tail. Why: herdr waitForOutput and local classify must share one pattern. */
14
- export const PROMPT_TAIL_RE = /[$>#❯]\s*$/;
14
+ /** POSIX / PowerShell / Nushell / Zsh prompt tail.
15
+ * Why: macOS default zsh prompt ends with `%` (e.g. `user@host ~ % `), while sh/bash use `$`
16
+ * (or `#` for root) and nushell/starship use `❯`. Anchoring to line tail (\s*$) prevents false
17
+ * matches on percentage values in ordinary command output (e.g. `downloaded 50%`), as interactive
18
+ * prompts terminate at the prompt symbol without trailing text.
19
+ */
20
+ export const PROMPT_TAIL_RE = /[$>#❯%]\s*$/;
15
21
 
16
22
  export interface PromptStrategy {
17
23
  /** herdr waitForOutput regex source. */
@@ -20,19 +26,41 @@ export interface PromptStrategy {
20
26
  }
21
27
 
22
28
  export const POSIX_PROMPT: PromptStrategy = {
23
- waitPattern: '[$>#❯]\\s*$',
29
+ waitPattern: '[$>#❯%]\\s*$',
24
30
  tailRe: PROMPT_TAIL_RE,
25
31
  };
26
32
 
33
+ export const BASH_PROMPT: PromptStrategy = POSIX_PROMPT;
34
+ export const ZSH_PROMPT: PromptStrategy = POSIX_PROMPT;
35
+
27
36
  /** cmd.exe / PowerShell: `>` still matches POSIX; this also accepts `PS C:\\>` without a trailing `$`. */
28
37
  export const POWERSHELL_PROMPT: PromptStrategy = {
29
38
  waitPattern: '(PS [^\\n>]+|>)\\s*$',
30
39
  tailRe: /(PS [^\n>]+|>)\s*$/,
31
40
  };
32
41
 
33
- /** `PIER_TERMINAL_PROMPT=powershell` selects the Windows strategy; default stays POSIX so existing panes do not flip. */
42
+ export const PWSH_PROMPT: PromptStrategy = POWERSHELL_PROMPT; // same object: identity checks stay valid
43
+
44
+ /**
45
+ * Strategy selection covering bash, zsh, powershell/pwsh.
46
+ * Explicit `PIER_TERMINAL_PROMPT` env var takes precedence; otherwise `SHELL` is inspected.
47
+ * Default remains POSIX so existing Windows/Linux panes without explicit configuration do not flip.
48
+ */
34
49
  export function promptStrategyFor(env: NodeJS.ProcessEnv = process.env): PromptStrategy {
35
- return env.PIER_TERMINAL_PROMPT === 'powershell' ? POWERSHELL_PROMPT : POSIX_PROMPT;
50
+ // B10: canonical PIER_TERMINAL_PROMPT, legacy PI_HERDR_TERMINAL_PROMPT alias.
51
+ const prompt = pierOption('PIER_TERMINAL_PROMPT', env)?.trim().toLowerCase();
52
+ if (prompt === 'powershell' || prompt === 'pwsh') return POWERSHELL_PROMPT;
53
+ if (prompt === 'bash') return BASH_PROMPT;
54
+ if (prompt === 'zsh') return ZSH_PROMPT;
55
+ if (!prompt && env.SHELL) {
56
+ const sh = basename(env.SHELL.replace(/\\/g, '/')).toLowerCase();
57
+ if (sh === 'pwsh' || sh === 'powershell' || sh === 'powershell.exe' || sh === 'pwsh.exe') {
58
+ return POWERSHELL_PROMPT;
59
+ }
60
+ if (sh === 'bash') return BASH_PROMPT;
61
+ if (sh === 'zsh') return ZSH_PROMPT;
62
+ }
63
+ return POSIX_PROMPT;
36
64
  }
37
65
  /** Why: Preserve the established compatibility and safety behavior (T6). */
38
66
  export const SIGNAL_KEYS = ['ctrl+c', 'ctrl+d', 'ctrl+z', 'esc', 'enter'] as const;
@@ -61,6 +89,11 @@ export interface TerminalEntry {
61
89
  nudgedAt: number | null;
62
90
  status: 'open' | 'closed';
63
91
  closedAt: number | null;
92
+ readRevision?: number | null;
93
+ readLen?: number;
94
+ readTail?: string;
95
+ readEoTail?: string;
96
+ initialized?: boolean;
64
97
  }
65
98
 
66
99
  export interface TerminalsRegistry {
@@ -300,6 +333,43 @@ export function makeTerminalsRegistry(terminals: TerminalEntry[] = []): Terminal
300
333
  return { version: 1, terminals };
301
334
  }
302
335
 
336
+ /**
337
+ * Why: In interactive bash and zsh, history expansion (`!`) is active by default. Commands
338
+ * containing `!` (such as `!|` or `if [ ! -f ... ]` or `!$`) trigger history expansion
339
+ * (e.g. `zsh: event not found: \|`), wedging the managed shell and leaving the controller
340
+ * waiting indefinitely (observed in session 01a08f37).
341
+ * We disable history expansion for managed shells right after creation via `set +H` (supported
342
+ * by both bash and zsh; equivalent to `unsetopt BANG_HIST` in zsh).
343
+ * On Windows / PowerShell, `set +H` is unrecognized syntax and PowerShell does not use `!`
344
+ * for history expansion, so init is skipped.
345
+ *
346
+ * Limits: `set +H` only affects the top-level persistent shell process. If a command spawns
347
+ * a nested interactive subshell (e.g. `bash -i`), that subshell would re-enable its default options.
348
+ */
349
+ export function shellInitCommandFor(strategy: PromptStrategy = POSIX_PROMPT): string | null {
350
+ return strategy === POWERSHELL_PROMPT ? null : 'set +H';
351
+ }
352
+
353
+ export interface ShellInitPlan {
354
+ readonly shouldInit: boolean;
355
+ readonly command: string | null;
356
+ }
357
+
358
+ export function planShellInit(opts: {
359
+ strategy?: PromptStrategy;
360
+ readiness?: ReadinessTier;
361
+ initialized?: boolean;
362
+ }): ShellInitPlan {
363
+ if (opts.initialized) return { shouldInit: false, command: null };
364
+ const strategy = opts.strategy ?? POSIX_PROMPT;
365
+ const cmd = shellInitCommandFor(strategy);
366
+ if (!cmd) return { shouldInit: false, command: null };
367
+ if (opts.readiness !== undefined && opts.readiness !== 'prompt') {
368
+ return { shouldInit: false, command: null };
369
+ }
370
+ return { shouldInit: true, command: cmd };
371
+ }
372
+
303
373
  export function foldTerminalsRegistry(entries: readonly BranchEntryLike3[]): TerminalEntry[] {
304
374
  let found: TerminalEntry[] = [];
305
375
  for (const entry of entries) {
@@ -316,13 +386,14 @@ export function foldTerminalsRegistry(entries: readonly BranchEntryLike3[]): Ter
316
386
  label: typeof t.label === 'string' ? t.label : null,
317
387
  createdAt: typeof t.createdAt === 'number' ? t.createdAt : 0,
318
388
  lastActivityAt: typeof t.lastActivityAt === 'number' ? t.lastActivityAt : 0,
389
+ nudgedAt: typeof t.nudgedAt === 'number' ? t.nudgedAt : null,
319
390
  status: t.status === 'closed' ? ('closed' as const) : ('open' as const),
320
391
  closedAt: typeof t.closedAt === 'number' ? t.closedAt : null,
321
392
  readRevision: typeof t.readRevision === 'number' ? t.readRevision : null,
322
393
  readLen: typeof t.readLen === 'number' ? t.readLen : 0,
323
- lastActivityAt: typeof t.lastActivityAt === 'number' ? t.lastActivityAt : 0,
324
- nudgedAt: typeof t.nudgedAt === 'number' ? t.nudgedAt : null,
325
- status: t.status === 'closed' ? ('closed' as const) : ('open' as const),
394
+ readTail: typeof t.readTail === 'string' ? t.readTail : '',
395
+ readEoTail: typeof t.readEoTail === 'string' ? t.readEoTail : '',
396
+ initialized: t.initialized === true,
326
397
  }));
327
398
  }
328
399
  return found;
@@ -339,12 +410,13 @@ export const TERM_REMINDERS_MAX = 2;
339
410
 
340
411
  /** Idle threshold before a nudge is due; PI_HERDR_TERM_IDLE_MS is read per call so tests can use small values. */
341
412
  export function terminalIdleMs(): number {
342
- return Number(process.env.PI_HERDR_TERM_IDLE_MS ?? 30 * 60_000) || 30 * 60_000;
413
+ // B10: canonical PIER_TERM_IDLE_MS, legacy PI_HERDR_TERM_IDLE_MS.
414
+ return Number(pierOption('PIER_TERM_IDLE_MS') ?? 30 * 60_000) || 30 * 60_000;
343
415
  }
344
416
 
345
417
  /** Grace from agent_settled to injection, letting the user read the answer and intervene; cancel on agent_start. */
346
418
  export function terminalReminderGraceMs(): number {
347
- return Number(process.env.PI_HERDR_TERM_GRACE_MS ?? 30_000) || 30_000;
419
+ return Number(pierOption('PIER_TERM_GRACE_MS') ?? 30_000) || 30_000;
348
420
  }
349
421
 
350
422
  /** Custom message type for the idle-terminal nudge (registerMessageRenderer may restyle it). */