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.
- 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 +12 -0
- package/src/ansi-text.ts +83 -0
- package/src/ask-multi.ts +260 -0
- package/src/ask-user.ts +60 -10
- 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 +121 -47
- package/src/core/todo.ts +25 -11
- 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 -1
- package/src/index-notices.ts +13 -2
- package/src/index-pipe.ts +5 -0
- package/src/index-worker.ts +3 -1
- package/src/index.ts +330 -34
- 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 +4 -2
- 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 +82 -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
|
+
}
|
package/src/terminal-core.ts
CHANGED
|
@@ -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.
|
|
14
|
-
|
|
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: '[
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
324
|
-
|
|
325
|
-
|
|
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
|
-
|
|
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(
|
|
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). */
|