pi-pier 0.2.6 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +20 -7
- package/package.json +7 -2
- package/schemas/efficiency-config.schema.json +77 -0
- package/skills/pi-herdr-workflow/SKILL.md +13 -1
- package/src/ansi-text.ts +83 -0
- package/src/ask-multi.ts +260 -0
- package/src/ask-user.ts +458 -0
- package/src/bootstrap.ts +5 -2
- package/src/compact-coordinator.ts +454 -0
- package/src/compact-economics-core.ts +396 -0
- package/src/config-catalog-core.ts +452 -0
- package/src/config-command.ts +181 -0
- package/src/config-guide.ts +361 -0
- package/src/core/grid-shape.ts +4 -2
- package/src/core/heat-plan.ts +160 -0
- package/src/core/observation.ts +555 -0
- package/src/core/subagent.ts +187 -393
- package/src/core/terminal.ts +270 -47
- package/src/core/todo.ts +49 -14
- package/src/dashboard-command.ts +89 -0
- package/src/efficiency-config-core.ts +661 -0
- package/src/efficiency-config-io.ts +53 -0
- package/src/efficiency-store.ts +312 -0
- package/src/focus-poller.ts +244 -0
- package/src/gc-core.ts +85 -0
- package/src/git-adapter.ts +12 -3
- package/src/herdr-client.ts +292 -36
- package/src/index-locks.ts +27 -9
- package/src/index-master.ts +5 -0
- package/src/index-notices.ts +13 -2
- package/src/index-pipe.ts +5 -0
- package/src/index-worker.ts +5 -0
- package/src/index.ts +360 -60
- package/src/jev-client.ts +146 -0
- package/src/jev-core.ts +381 -0
- package/src/lock-core.ts +105 -16
- package/src/observation-core.ts +248 -0
- package/src/pier-options.ts +96 -0
- package/src/pipe-channel.ts +52 -7
- package/src/reducer-core.ts +358 -0
- package/src/reducer-invoker.ts +392 -0
- package/src/renderers.ts +198 -0
- package/src/role-loader.ts +2 -1
- package/src/role-manifest.ts +0 -4
- package/src/runtime-policy.ts +5 -8
- package/src/session-tail.ts +60 -5
- package/src/settle-wake-core.ts +3 -1
- package/src/slim-frame.ts +60 -3
- package/src/storage-layout.ts +23 -0
- package/src/subagent-core.ts +3 -1
- package/src/subagent-execute.ts +328 -0
- package/src/subagent-gc.ts +33 -13
- package/src/subagent-launch.ts +4 -0
- package/src/subagent-list-action.ts +69 -0
- package/src/subagent-output-action.ts +104 -0
- package/src/subagent-output-adapter.ts +27 -0
- package/src/subagent-output-core.ts +263 -0
- package/src/subagent-poll-loop.ts +59 -39
- package/src/subagent-poller.ts +46 -0
- package/src/subagent-ready.ts +89 -0
- package/src/subagent-registry.ts +70 -0
- package/src/subagent-resolution.ts +41 -0
- package/src/subagent-scope.ts +3 -1
- package/src/subagent-session-io.ts +115 -32
- package/src/subagent-spawn.ts +153 -8
- package/src/swallow.ts +75 -0
- package/src/terminal-core.ts +161 -10
- package/src/todo-core.ts +5 -4
- package/src/todo-reminder-core.ts +8 -1
- package/src/todos-service.ts +18 -6
- package/src/tool-error.ts +26 -0
- package/src/vocab.ts +7 -4
|
@@ -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 {
|
|
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
|
-
|
|
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<
|
|
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 (
|
|
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
|
|
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
|
|
78
|
-
|
|
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
|
|
101
|
-
|
|
102
|
-
probe.paneExists =
|
|
103
|
-
probe.agentStatus =
|
|
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
|
-
/*
|
|
177
|
+
/* pane.list unavailable — agent.list is a degraded fallback, not a death signal */
|
|
106
178
|
}
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
if (
|
|
111
|
-
|
|
112
|
-
|
|
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<
|
|
202
|
+
): Promise<SubSessionState> {
|
|
123
203
|
for (const file of await resolveSessionFileCandidates(paneId, cwd)) {
|
|
124
|
-
const
|
|
125
|
-
//
|
|
126
|
-
//
|
|
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
|
|
131
|
-
|
|
132
|
-
|
|
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 {
|
package/src/subagent-spawn.ts
CHANGED
|
@@ -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 {
|
|
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<
|
|
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
|
-
|
|
82
|
-
|
|
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)
|
|
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
|
|
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
|
+
}
|