pi-pier 0.3.1 → 0.4.2
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 +7 -5
- package/package.json +3 -3
- package/schemas/efficiency-config.schema.json +6 -0
- package/src/ask-dialog.ts +9 -2
- package/src/ask-user.ts +142 -74
- package/src/bootstrap.ts +2 -1
- package/src/compact-coordinator.ts +207 -35
- package/src/compact-economics-core.ts +62 -29
- package/src/config-catalog-core.ts +1 -0
- package/src/cordis-services.ts +64 -0
- package/src/dashboard-command.ts +40 -15
- package/src/delegation-core.ts +115 -0
- package/src/efficiency-config-core.ts +17 -6
- package/src/efficiency-store.ts +43 -6
- package/src/guards.ts +6 -0
- package/src/herdr-client.ts +141 -36
- package/src/history-store.ts +2 -0
- package/src/index-gates.ts +6 -12
- package/src/index-master.ts +12 -16
- package/src/index-pipe.ts +6 -0
- package/src/index-runtime.ts +14 -0
- package/src/index-worker.ts +4 -10
- package/src/index.ts +310 -76
- package/src/jev-client.ts +32 -7
- package/src/jev-core.ts +71 -36
- package/src/observation-core.ts +13 -1
- package/src/panel-open.ts +47 -0
- package/src/pier-options.ts +1 -2
- package/src/pipe-channel.ts +9 -0
- package/src/plugins/heat-plan.ts +18 -3
- package/src/plugins/observation.ts +118 -46
- package/src/plugins/subagent.ts +119 -68
- package/src/plugins/terminal.ts +221 -57
- package/src/plugins/todo.ts +63 -63
- package/src/reducer-core.ts +3 -5
- package/src/reducer-invoker.ts +27 -13
- package/src/renderers.ts +25 -2
- package/src/role-manifest.ts +2 -3
- package/src/roles/worker-default.json +2 -2
- package/src/settle-wake-core.ts +17 -3
- package/src/subagent-core.ts +209 -3
- package/src/subagent-poller.ts +69 -19
- package/src/subagent-session.ts +67 -4
- package/src/subagent-spawn.ts +55 -7
- package/src/terminal-core.ts +178 -50
- package/src/todo-core.ts +47 -2
- package/src/todo-read-hook.ts +54 -17
- package/src/todo-reminder-core.ts +9 -5
- package/src/todo-window.ts +8 -4
- package/src/todos-service.ts +12 -0
- package/src/tree-view.ts +162 -0
- package/src/vocab.ts +7 -1
- package/src/worker-marker.ts +76 -0
- package/src/focus-poller.ts +0 -223
package/src/jev-client.ts
CHANGED
|
@@ -12,6 +12,10 @@ import { parseJevAnswers, type JevAnswer, type JevQuestion, type JevUsage } from
|
|
|
12
12
|
import type { JevConfig } from './efficiency-config-core.ts';
|
|
13
13
|
|
|
14
14
|
const JEV_DEFAULT_BASE_URL = 'https://api.typesafe.ai';
|
|
15
|
+
/** Consecutive transport failures (timeout / network / 5xx) that open the breaker. */
|
|
16
|
+
export const JEV_BREAKER_FAILURES = 2;
|
|
17
|
+
/** While open, asks fail open locally without a request or a telemetry row; the tripping row says so. */
|
|
18
|
+
export const JEV_BREAKER_OPEN_MS = 60_000;
|
|
15
19
|
|
|
16
20
|
export interface JevAskMeta {
|
|
17
21
|
/** Stable question-set id for telemetry rows (e.g. 'epr-diagnostic-gate'). */
|
|
@@ -23,7 +27,7 @@ export interface JevAskMeta {
|
|
|
23
27
|
extra?: Record<string, unknown>;
|
|
24
28
|
/**
|
|
25
29
|
* Post-answer decision fields for telemetry (choices/scores/final verdicts — what threshold tuning
|
|
26
|
-
* needs; still never request bodies). Called exactly once per ask.
|
|
30
|
+
* needs; still never request bodies). Called exactly once per ask, with null answers on failure.
|
|
27
31
|
*/
|
|
28
32
|
enrich?: (outcome: { ok: boolean; answers: Record<string, JevAnswer> | null }) => Record<string, unknown>;
|
|
29
33
|
}
|
|
@@ -49,11 +53,14 @@ export interface JevRuntime {
|
|
|
49
53
|
export function createJevRuntime(getConfig: () => JevConfig, deps: JevRuntimeDeps = {}): JevRuntime {
|
|
50
54
|
const doFetch = deps.fetchImpl ?? ((input, init) => globalThis.fetch(input, init));
|
|
51
55
|
const now = deps.now ?? Date.now;
|
|
52
|
-
|
|
56
|
+
// A run of 12 back-to-back network failures and 2.5s timeout stalls showed every site paying
|
|
57
|
+
// the full budget during an outage; the breaker makes an outage cost one tripping ask.
|
|
58
|
+
let transportFailures = 0;
|
|
59
|
+
let breakerOpenUntil = 0;
|
|
53
60
|
async function logAsk(
|
|
54
61
|
config: JevConfig,
|
|
55
62
|
meta: JevAskMeta,
|
|
56
|
-
outcome: { ok: true; model: string; usage: JevUsage } | { ok: false; reason: string },
|
|
63
|
+
outcome: { ok: true; model: string; usage: JevUsage } | { ok: false; reason: string; extra?: Record<string, unknown> },
|
|
57
64
|
latencyMs: number,
|
|
58
65
|
state: unknown,
|
|
59
66
|
enriched: Record<string, unknown>,
|
|
@@ -77,6 +84,7 @@ export function createJevRuntime(getConfig: () => JevConfig, deps: JevRuntimeDep
|
|
|
77
84
|
stateHash: sha256Hex(serialized),
|
|
78
85
|
...(outcome.ok ? { usage: { input_tokens: outcome.usage.inputTokens, output_tokens: outcome.usage.outputTokens } } : {}),
|
|
79
86
|
...(meta.extra ?? {}),
|
|
87
|
+
...(outcome.ok ? {} : outcome.extra ?? {}),
|
|
80
88
|
...enriched,
|
|
81
89
|
};
|
|
82
90
|
try {
|
|
@@ -95,14 +103,25 @@ export function createJevRuntime(getConfig: () => JevConfig, deps: JevRuntimeDep
|
|
|
95
103
|
async ask(request, meta): Promise<JevAskResult> {
|
|
96
104
|
const startedAt = now();
|
|
97
105
|
const config = getConfig();
|
|
98
|
-
const fail = async (reason: string): Promise<JevAskResult> => {
|
|
106
|
+
const fail = async (reason: string, extra?: Record<string, unknown>): Promise<JevAskResult> => {
|
|
99
107
|
const latencyMs = now() - startedAt;
|
|
100
108
|
const enriched = meta.enrich?.({ ok: false, answers: null }) ?? {};
|
|
101
|
-
await logAsk(config, meta, { ok: false, reason }, latencyMs, request.state, enriched);
|
|
109
|
+
await logAsk(config, meta, { ok: false, reason, extra }, latencyMs, request.state, enriched);
|
|
102
110
|
return { ok: false, reason, latencyMs };
|
|
103
111
|
};
|
|
112
|
+
const transportFail = (reason: string, extra: Record<string, unknown> = {}): Promise<JevAskResult> => {
|
|
113
|
+
transportFailures += 1;
|
|
114
|
+
if (transportFailures < JEV_BREAKER_FAILURES) return fail(reason, extra);
|
|
115
|
+
transportFailures = 0;
|
|
116
|
+
breakerOpenUntil = now() + JEV_BREAKER_OPEN_MS;
|
|
117
|
+
return fail(reason, { ...extra, breakerOpenedMs: JEV_BREAKER_OPEN_MS });
|
|
118
|
+
};
|
|
104
119
|
if (!config.enabled) return fail('disabled');
|
|
105
120
|
if (!config.apiKey) return fail('no-api-key');
|
|
121
|
+
if (now() < breakerOpenUntil) {
|
|
122
|
+
meta.enrich?.({ ok: false, answers: null });
|
|
123
|
+
return { ok: false, reason: 'breaker-open', latencyMs: 0 };
|
|
124
|
+
}
|
|
106
125
|
|
|
107
126
|
const timeoutMs = meta.timeoutMs ?? config.timeoutMs;
|
|
108
127
|
const controller = new AbortController();
|
|
@@ -121,8 +140,11 @@ export function createJevRuntime(getConfig: () => JevConfig, deps: JevRuntimeDep
|
|
|
121
140
|
if (!response.ok) {
|
|
122
141
|
// 429 (and everything else) fails open immediately: limits are
|
|
123
142
|
// dynamically adjusted upstream and every pier call is droppable.
|
|
143
|
+
if (response.status >= 500) return transportFail(`http-${response.status}`);
|
|
144
|
+
transportFailures = 0;
|
|
124
145
|
return fail(response.status === 429 ? 'rate-limited' : `http-${response.status}`);
|
|
125
146
|
}
|
|
147
|
+
transportFailures = 0;
|
|
126
148
|
const raw: unknown = await response.json();
|
|
127
149
|
const parsed = parseJevAnswers(raw, Object.keys(request.questions));
|
|
128
150
|
if (!parsed.ok) return fail(parsed.reason);
|
|
@@ -131,8 +153,11 @@ export function createJevRuntime(getConfig: () => JevConfig, deps: JevRuntimeDep
|
|
|
131
153
|
await logAsk(config, meta, { ok: true, model: parsed.model, usage: parsed.usage }, latencyMs, request.state, enriched);
|
|
132
154
|
return { ok: true, answers: parsed.answers, model: parsed.model, usage: parsed.usage, latencyMs };
|
|
133
155
|
} catch (error) {
|
|
134
|
-
|
|
135
|
-
|
|
156
|
+
if (error instanceof Error && error.name === 'AbortError') return transportFail('timeout');
|
|
157
|
+
// fetch wraps the socket error in `cause`; its code (ECONNRESET, ENOTFOUND, ...) is the diagnosis.
|
|
158
|
+
const source = error instanceof Error && error.cause instanceof Error ? error.cause : error;
|
|
159
|
+
const code = source instanceof Error && 'code' in source && typeof source.code === 'string' ? source.code : undefined;
|
|
160
|
+
return transportFail('network-error', code ? { errorCode: code } : {});
|
|
136
161
|
} finally {
|
|
137
162
|
clearTimeout(timer);
|
|
138
163
|
}
|
package/src/jev-core.ts
CHANGED
|
@@ -8,7 +8,9 @@
|
|
|
8
8
|
* question needs; instructions are English (CJK is weaker, hence the stricter P0-2 gate); and any
|
|
9
9
|
* answer below its confidence gate counts as unanswered so callers fail open.
|
|
10
10
|
*/
|
|
11
|
+
import { isPlainObject } from './guards.ts';
|
|
11
12
|
import { containsLikelySecret, FAILURE_SIGNAL } from './reducer-core.ts';
|
|
13
|
+
import { completeLineExcerpt } from './observation-core.ts';
|
|
12
14
|
|
|
13
15
|
/* ── Request/response shapes (closed set) ── */
|
|
14
16
|
|
|
@@ -36,9 +38,6 @@ export type JevParsed =
|
|
|
36
38
|
| { readonly ok: true; readonly answers: Record<string, JevAnswer>; readonly model: string; readonly usage: JevUsage }
|
|
37
39
|
| { readonly ok: false; readonly reason: string };
|
|
38
40
|
|
|
39
|
-
function isPlainObject(v: unknown): v is Record<string, unknown> {
|
|
40
|
-
return typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
41
|
-
}
|
|
42
41
|
|
|
43
42
|
function finiteNumber(v: unknown): number | undefined {
|
|
44
43
|
return typeof v === 'number' && Number.isFinite(v) ? v : undefined;
|
|
@@ -155,6 +154,8 @@ export interface DiagnosticGateVerdict {
|
|
|
155
154
|
readonly choice: string | null;
|
|
156
155
|
readonly noul: number | null;
|
|
157
156
|
}
|
|
157
|
+
/** Confidence is checked before the choice: an unconfident rejection is not authoritative, so the
|
|
158
|
+
* caller's regex list must decide (same fail-open path as a typed `low-confidence` answer). */
|
|
158
159
|
export function evaluateDiagnosticGate(
|
|
159
160
|
answers: Record<string, JevAnswer>,
|
|
160
161
|
minConfidence: number,
|
|
@@ -164,12 +165,12 @@ export function evaluateDiagnosticGate(
|
|
|
164
165
|
if (kind === undefined || output === undefined) {
|
|
165
166
|
return { hit: false, reason: 'missing-answer', confidence: null, choice: null, noul: null };
|
|
166
167
|
}
|
|
167
|
-
if (kind.choice !== 'build_test_run') {
|
|
168
|
-
return { hit: false, reason: `not-build-test:${kind.choice}`, confidence: kind.confidence, choice: kind.choice, noul: output.noul };
|
|
169
|
-
}
|
|
170
168
|
if (kind.confidence < minConfidence) {
|
|
171
169
|
return { hit: false, reason: 'low-confidence', confidence: kind.confidence, choice: kind.choice, noul: output.noul };
|
|
172
170
|
}
|
|
171
|
+
if (kind.choice !== 'build_test_run') {
|
|
172
|
+
return { hit: false, reason: `not-build-test:${kind.choice}`, confidence: kind.confidence, choice: kind.choice, noul: output.noul };
|
|
173
|
+
}
|
|
173
174
|
if (output.noul < DIAGNOSTIC_OUTPUT_MIN_NOUL) {
|
|
174
175
|
return { hit: false, reason: 'non-diagnostic-output', confidence: kind.confidence, choice: kind.choice, noul: output.noul };
|
|
175
176
|
}
|
|
@@ -264,9 +265,12 @@ export interface ExcerptWindow {
|
|
|
264
265
|
readonly id: ExcerptWindowId;
|
|
265
266
|
readonly label: string;
|
|
266
267
|
readonly text: string;
|
|
268
|
+
/** 0-based first line of the window in the packed output (telemetry joins picks to objects). */
|
|
269
|
+
readonly startLine: number;
|
|
267
270
|
}
|
|
268
271
|
|
|
269
272
|
interface LineWindow {
|
|
273
|
+
readonly start: number;
|
|
270
274
|
readonly text: string;
|
|
271
275
|
readonly bytes: number;
|
|
272
276
|
readonly signalCount: number;
|
|
@@ -288,12 +292,12 @@ function buildLineWindow(lines: readonly string[], start: number, budgetBytes: n
|
|
|
288
292
|
if (signalLines.has(i)) signalCount++;
|
|
289
293
|
}
|
|
290
294
|
if (selected.length === 0) return null;
|
|
291
|
-
return { text: selected.join('\n'), bytes, signalCount };
|
|
295
|
+
return { start, text: selected.join('\n'), bytes, signalCount };
|
|
292
296
|
}
|
|
293
297
|
|
|
294
298
|
/** Code-owned middle-window candidates for a packed observation: failure-signal windows (first hit,
|
|
295
|
-
* densest cluster) when present, plus a plain mid-output window
|
|
296
|
-
*
|
|
299
|
+
* densest cluster) when present, plus a plain mid-output window. The picker asks jev only when a
|
|
300
|
+
* signal window exists; a mid-only set kept head/tail or failed in 8 of 10 asks (D123). */
|
|
297
301
|
export function buildExcerptWindows(text: string, halfBudgetBytes: number): ExcerptWindow[] {
|
|
298
302
|
if (halfBudgetBytes <= 0) return [];
|
|
299
303
|
const lines = text.split('\n');
|
|
@@ -310,7 +314,7 @@ export function buildExcerptWindows(text: string, halfBudgetBytes: number): Exce
|
|
|
310
314
|
const firstWindow = buildLineWindow(lines, Math.max(0, first - 2), halfBudgetBytes, signalLines);
|
|
311
315
|
if (firstWindow !== null && !seen.has(firstWindow.text)) {
|
|
312
316
|
seen.add(firstWindow.text);
|
|
313
|
-
out.push({ id: 'first_signal', label: 'first failure-signal region', text: firstWindow.text });
|
|
317
|
+
out.push({ id: 'first_signal', label: 'first failure-signal region', text: firstWindow.text, startLine: firstWindow.start });
|
|
314
318
|
}
|
|
315
319
|
|
|
316
320
|
let best: LineWindow | null = null;
|
|
@@ -321,49 +325,80 @@ export function buildExcerptWindows(text: string, halfBudgetBytes: number): Exce
|
|
|
321
325
|
}
|
|
322
326
|
if (best !== null && !seen.has(best.text)) {
|
|
323
327
|
seen.add(best.text);
|
|
324
|
-
out.push({ id: 'densest', label: 'densest failure-signal region', text: best.text });
|
|
328
|
+
out.push({ id: 'densest', label: 'densest failure-signal region', text: best.text, startLine: best.start });
|
|
325
329
|
}
|
|
326
330
|
}
|
|
327
331
|
|
|
328
332
|
const midAnchor = Math.min(Math.floor(lines.length / 2), Math.max(0, lines.length - 1));
|
|
329
333
|
const midWindow = buildLineWindow(lines, midAnchor, halfBudgetBytes, signalLines);
|
|
330
334
|
if (midWindow !== null && !seen.has(midWindow.text)) {
|
|
331
|
-
out.push({ id: 'mid', label: 'middle region of the output', text: midWindow.text });
|
|
335
|
+
out.push({ id: 'mid', label: 'middle region of the output', text: midWindow.text, startLine: midWindow.start });
|
|
332
336
|
}
|
|
333
337
|
return out;
|
|
334
338
|
}
|
|
335
339
|
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
const
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
340
|
+
/**
|
|
341
|
+
* Which packed outputs are worth an excerpt ask, keeping each item's index into `items`. Skipped:
|
|
342
|
+
* outputs without a failure-signal window (only `mid` competes with head/tail; that lost in 8 of 10
|
|
343
|
+
* asks, D123) and outputs whose excerpts carry a credential shape (§8 privacy boundary).
|
|
344
|
+
*/
|
|
345
|
+
export function planExcerptAsks(
|
|
346
|
+
items: ReadonlyArray<{ obsId: string; text: string }>,
|
|
347
|
+
excerptBudgetBytes: number,
|
|
348
|
+
): Array<{ index: number; obsId: string } & ExcerptPickItem> {
|
|
349
|
+
const halfBudget = Math.floor(excerptBudgetBytes / 2);
|
|
350
|
+
const asks: Array<{ index: number; obsId: string } & ExcerptPickItem> = [];
|
|
351
|
+
for (let index = 0; index < items.length; index++) {
|
|
352
|
+
const { obsId, text } = items[index]!;
|
|
353
|
+
const windows = buildExcerptWindows(text, halfBudget);
|
|
354
|
+
if (!windows.some((w) => w.id !== 'mid')) continue;
|
|
355
|
+
const headExcerpt = completeLineExcerpt(text, halfBudget, false);
|
|
356
|
+
const tailExcerpt = completeLineExcerpt(text, excerptBudgetBytes - halfBudget, true);
|
|
357
|
+
if (!excerptAskIsSafe(windows, headExcerpt, tailExcerpt)) continue;
|
|
358
|
+
asks.push({ index, obsId, windows, headExcerpt, tailExcerpt });
|
|
349
359
|
}
|
|
350
|
-
return
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
+
return asks;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
export interface ExcerptPickItem {
|
|
364
|
+
readonly windows: readonly ExcerptWindow[];
|
|
365
|
+
readonly headExcerpt: string;
|
|
366
|
+
readonly tailExcerpt: string;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/** One request for every packed output (a compaction batch packs up to 20): one round trip instead of
|
|
370
|
+
* a 20-way burst. `outputs[i]` carries item i's excerpts; its question is `excerpt_${i}_window`. */
|
|
371
|
+
export function excerptPickRequest(items: readonly ExcerptPickItem[]): JevRequest | null {
|
|
372
|
+
if (items.length === 0) return null;
|
|
373
|
+
const questions: Record<string, JevQuestion> = {};
|
|
374
|
+
const outputs: Array<Record<string, string>> = [];
|
|
375
|
+
for (let i = 0; i < items.length; i++) {
|
|
376
|
+
const item = items[i]!;
|
|
377
|
+
const criteria: Record<string, string> = {
|
|
378
|
+
keep_head_tail: 'The existing head and tail excerpts are already the most informative; keep them',
|
|
379
|
+
};
|
|
380
|
+
const excerpts: Record<string, string> = { head_excerpt: item.headExcerpt, tail_excerpt: item.tailExcerpt };
|
|
381
|
+
for (const window of item.windows) {
|
|
382
|
+
criteria[window.id] = `${window.label}: excerpt from the middle of the output`;
|
|
383
|
+
excerpts[window.id] = window.text;
|
|
384
|
+
}
|
|
385
|
+
outputs.push(excerpts);
|
|
386
|
+
// Question keys are NOT sent to the model, so the instruction names its output index.
|
|
387
|
+
questions[`excerpt_${i}_window`] = {
|
|
388
|
+
type: 'choice',
|
|
389
|
+
instructions: `A large tool output (outputs[${i}]) was replaced by a placeholder keeping two short excerpts. Which excerpt set would most help a coding agent locate the problem without recalling the full text?`,
|
|
390
|
+
criteria,
|
|
391
|
+
};
|
|
392
|
+
}
|
|
393
|
+
return { state: { outputs }, questions };
|
|
360
394
|
}
|
|
361
395
|
|
|
362
396
|
export function evaluateExcerptPick(
|
|
363
397
|
answers: Record<string, JevAnswer>,
|
|
398
|
+
index: number,
|
|
364
399
|
minConfidence: number,
|
|
365
400
|
): ExcerptWindowId | null {
|
|
366
|
-
const picked = answerOf(answers,
|
|
401
|
+
const picked = answerOf(answers, `excerpt_${index}_window`, 'choice');
|
|
367
402
|
if (picked === undefined) return null;
|
|
368
403
|
if (picked.confidence < minConfidence) return null;
|
|
369
404
|
return picked.choice === 'first_signal' || picked.choice === 'densest' || picked.choice === 'mid'
|
package/src/observation-core.ts
CHANGED
|
@@ -138,6 +138,8 @@ export interface RecallSliceResult {
|
|
|
138
138
|
text: string;
|
|
139
139
|
bytes: number;
|
|
140
140
|
lines: number;
|
|
141
|
+
/** Byte offset the returned text actually starts at (after line-start alignment). */
|
|
142
|
+
offset: number;
|
|
141
143
|
nextOffset: number;
|
|
142
144
|
eof: boolean;
|
|
143
145
|
}
|
|
@@ -157,7 +159,7 @@ export function sliceBufferChunk(
|
|
|
157
159
|
limits: { maxBytes: number; maxLines: number },
|
|
158
160
|
): RecallSliceResult {
|
|
159
161
|
if (offset >= buf.length) {
|
|
160
|
-
return { text: '', bytes: 0, lines: 0, nextOffset: buf.length, eof: true };
|
|
162
|
+
return { text: '', bytes: 0, lines: 0, offset, nextOffset: buf.length, eof: true };
|
|
161
163
|
}
|
|
162
164
|
|
|
163
165
|
let end = Math.min(buf.length - offset, limits.maxBytes);
|
|
@@ -169,6 +171,15 @@ export function sliceBufferChunk(
|
|
|
169
171
|
}
|
|
170
172
|
}
|
|
171
173
|
|
|
174
|
+
// A byte-cap cut mid-line would make the next page (and its aligned next_offset) skip the rest of
|
|
175
|
+
// that line; end this page at the previous line boundary instead. A line longer than maxBytes has
|
|
176
|
+
// no boundary to snap to and stays byte-sliced.
|
|
177
|
+
const cappedAt = offset + end;
|
|
178
|
+
if (cappedAt < buf.length && buf[cappedAt - 1] !== 0x0a) {
|
|
179
|
+
const lastNewline = buf.lastIndexOf(0x0a, cappedAt - 1);
|
|
180
|
+
if (lastNewline >= offset) end = lastNewline + 1 - offset;
|
|
181
|
+
}
|
|
182
|
+
|
|
172
183
|
const chunkBuf = buf.subarray(offset, offset + trimUtf8End(buf.subarray(offset), end));
|
|
173
184
|
const text = chunkBuf.toString('utf8');
|
|
174
185
|
const nextOffset = offset + chunkBuf.length;
|
|
@@ -176,6 +187,7 @@ export function sliceBufferChunk(
|
|
|
176
187
|
text,
|
|
177
188
|
bytes: chunkBuf.length,
|
|
178
189
|
lines: countLines(text),
|
|
190
|
+
offset,
|
|
179
191
|
nextOffset,
|
|
180
192
|
eof: nextOffset >= buf.length,
|
|
181
193
|
};
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/** Shared Pier panel opening: `/dashboard` and `/pier-tree` both render through one popup entrypoint.
|
|
2
|
+
*
|
|
3
|
+
* Herdr popups are a singleton session resource (`popup.close` has no id) and are session-modal, so
|
|
4
|
+
* the human cannot invoke another slash command while one is open. The command therefore chooses
|
|
5
|
+
* the initial tab via env; tabs are switched inside the panel (`1`/`2`/`Tab`). A `ui_busy` result is
|
|
6
|
+
* surfaced instead of silently retrying as a tab. */
|
|
7
|
+
import type { HerdrClientLike, HerdrEnv } from './herdr-client.ts';
|
|
8
|
+
|
|
9
|
+
export type PierPanel = 'dashboard' | 'tree';
|
|
10
|
+
export type PierPanelOpenResult = 'popup' | 'busy' | 'tab' | 'unavailable';
|
|
11
|
+
|
|
12
|
+
export interface PierPanelOpenDeps {
|
|
13
|
+
client: HerdrClientLike;
|
|
14
|
+
env: HerdrEnv | null;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Open the shared popup on the requested tab. `busy` means another popup/modal owns the session;
|
|
19
|
+
* the caller should tell the human to close it.
|
|
20
|
+
*/
|
|
21
|
+
export async function openPierPanel(
|
|
22
|
+
deps: PierPanelOpenDeps,
|
|
23
|
+
opts: { panel: PierPanel; treeFile?: string | null },
|
|
24
|
+
): Promise<PierPanelOpenResult> {
|
|
25
|
+
const { client, env } = deps;
|
|
26
|
+
if (!client.available || !env) return 'unavailable';
|
|
27
|
+
|
|
28
|
+
try {
|
|
29
|
+
const res = await client.openPluginPane({
|
|
30
|
+
pluginId: 'pier.workbench',
|
|
31
|
+
entrypoint: 'panel',
|
|
32
|
+
placement: 'popup',
|
|
33
|
+
width: '90%',
|
|
34
|
+
height: '80%',
|
|
35
|
+
focus: true,
|
|
36
|
+
env: {
|
|
37
|
+
PIER_PANEL: opts.panel,
|
|
38
|
+
...(opts.treeFile ? { PIER_TREE_FILE: opts.treeFile } : {}),
|
|
39
|
+
},
|
|
40
|
+
});
|
|
41
|
+
if (res.mode === 'fallback_tab') return 'tab';
|
|
42
|
+
if (res.mode === 'busy') return 'busy';
|
|
43
|
+
return 'popup';
|
|
44
|
+
} catch {
|
|
45
|
+
return 'unavailable';
|
|
46
|
+
}
|
|
47
|
+
}
|
package/src/pier-options.ts
CHANGED
|
@@ -47,11 +47,10 @@ export const PIER_OPTIONS: readonly OptionSpec[] = [
|
|
|
47
47
|
{ name: 'PIER_SETTLEMENT_WINDOW_MS', legacy: 'PI_HERDR_SETTLEMENT_WINDOW_MS', fallback: '60000', min: 0, policy: 'settlementWindowMs', description: 'settlement notice window / machine-inject grace / takeover idle' },
|
|
48
48
|
{ name: 'PIER_FOREGROUND_PATIENCE_MS', legacy: 'PI_HERDR_FOREGROUND_PATIENCE_MS', fallback: '300000', min: 0, policy: 'foregroundPatienceMs', description: 'foreground patience before demoting a subagent to background' },
|
|
49
49
|
{ name: 'PIER_SESSION_TTL_SECONDS', legacy: 'PI_HERDR_SESSION_TTL_SECONDS', fallback: '600', min: 0, policy: 'sessionTtlSeconds', description: 'session retention after a subagent exits, before GC' },
|
|
50
|
-
{ name: 'PIER_FOCUS_POLL_MS', fallback: '1500', min: 0, description: 'pane-focus sampling cadence for the heat layout (0 disables; event-first on herdr 0.9.1+)' },
|
|
51
50
|
{ name: 'PIER_TERMINAL_PROMPT', legacy: 'PI_HERDR_TERMINAL_PROMPT', fallback: '(auto from $SHELL)', description: 'prompt strategy for terminal readiness: bash|zsh|powershell|pwsh' },
|
|
52
51
|
{ name: 'PIER_SLIM_FRAME', legacy: 'PI_HERDR_SLIM_FRAME', fallback: '1', description: 'mutation frame around todo-tool cards (0 disables)' },
|
|
53
52
|
{ name: 'PIER_TRACE', legacy: 'PI_HERDR_TRACE', fallback: '', description: 'write diagnostics (renderers, swallowed errors) to stderr (or to this file when it is a path)' },
|
|
54
|
-
{ name: 'PIER_TERM_IDLE_MS', legacy: 'PI_HERDR_TERM_IDLE_MS', fallback: '
|
|
53
|
+
{ name: 'PIER_TERM_IDLE_MS', legacy: 'PI_HERDR_TERM_IDLE_MS', fallback: '300000', min: 1, description: 'idle time before the second cleanup nudge for an open terminal (per-terminal max 2; 0/NaN falls back to the default)' },
|
|
55
54
|
{ name: 'PIER_TERM_GRACE_MS', legacy: 'PI_HERDR_TERM_GRACE_MS', fallback: '30000', min: 1, description: 'settle grace before the terminal idle nudge (0/NaN falls back to the default)' },
|
|
56
55
|
{ name: 'PIER_TERM_READ_MAX', legacy: 'PI_HERDR_TERM_READ_MAX', fallback: '8000', min: 1, description: 'characters returned per terminal read (0/NaN falls back to the default)' },
|
|
57
56
|
{ name: 'PIER_TODO_GRACE_MS', legacy: 'PI_HERDR_TODO_GRACE_MS', fallback: '30000', min: 1, description: 'settle grace before the unfinished-todo reminder (0/NaN falls back to the default)' },
|
package/src/pipe-channel.ts
CHANGED
|
@@ -58,6 +58,15 @@ export type PipeRequest =
|
|
|
58
58
|
id: string;
|
|
59
59
|
role: string;
|
|
60
60
|
}
|
|
61
|
+
| {
|
|
62
|
+
/** D113 (RFC rfc-unified-widget): worker → master full todo push on every todo_write, so the
|
|
63
|
+
* master's resident widget and /pier-tree render live worker state without polling herdr. */
|
|
64
|
+
type: 'todo_sync';
|
|
65
|
+
id: string;
|
|
66
|
+
paneId: string;
|
|
67
|
+
items: Array<{ content: string; status: string; blocker?: string }>;
|
|
68
|
+
summary: string;
|
|
69
|
+
}
|
|
61
70
|
| {
|
|
62
71
|
/** D50: fast-path settlement reply from child to requester, with summary and session path for progressive disclosure. */
|
|
63
72
|
type: 'reply';
|
package/src/plugins/heat-plan.ts
CHANGED
|
@@ -10,6 +10,7 @@ import { type ShapeNode } from './grid-shape.ts';
|
|
|
10
10
|
|
|
11
11
|
export const SPAWN_PLACEHOLDER_ID = '__pier_new__';
|
|
12
12
|
const RATIO_FLOOR = 0.10;
|
|
13
|
+
const NON_AGENT_RATIO_FLOOR = 0.25;
|
|
13
14
|
const FOCUS_SHARE = 0.72;
|
|
14
15
|
const FOCUS_SHARE_BLOCKED = 0.60;
|
|
15
16
|
const MAX_AUTO_LAYOUT_PANES = 10;
|
|
@@ -57,6 +58,19 @@ function clampRatio(r: number): number {
|
|
|
57
58
|
return Math.min(1 - RATIO_FLOOR, Math.max(RATIO_FLOOR, r));
|
|
58
59
|
}
|
|
59
60
|
|
|
61
|
+
/** Agent-less panes (plain shells report no status or `unknown`) keep NON_AGENT_RATIO_FLOOR in mixed splits. */
|
|
62
|
+
function hasAgentPanes(node: LayoutNode, statuses: AgentStatusMap): boolean {
|
|
63
|
+
return flattenPanes(node).some((id) => statuses[id] !== undefined && statuses[id] !== 'unknown');
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function splitRatio(node: SplitNode, ratio: number, statuses: AgentStatusMap): number {
|
|
67
|
+
const firstAgent = hasAgentPanes(node.first, statuses);
|
|
68
|
+
const secondAgent = hasAgentPanes(node.second, statuses);
|
|
69
|
+
if (firstAgent && !secondAgent) ratio = Math.min(ratio, 1 - NON_AGENT_RATIO_FLOOR);
|
|
70
|
+
else if (!firstAgent && secondAgent) ratio = Math.max(ratio, NON_AGENT_RATIO_FLOOR);
|
|
71
|
+
return clampRatio(ratio);
|
|
72
|
+
}
|
|
73
|
+
|
|
60
74
|
function tierWeight(status: string | undefined, isAsk = false, slim = false): number {
|
|
61
75
|
if (status === 'blocked') return isAsk ? ASK_WEIGHT : BLOCKED_WEIGHT;
|
|
62
76
|
if (status === 'working') return slim ? WORKING_WEIGHT * SLIM_FACTOR : WORKING_WEIGHT;
|
|
@@ -129,7 +143,8 @@ export function planSpawnSplitRatio(opts: {
|
|
|
129
143
|
if (paneCount <= 1 || paneCount > MAX_AUTO_LAYOUT_PANES) return null;
|
|
130
144
|
if (!containsPane(simulated, opts.focusPaneId) || !containsPane(simulated, newId)) return null;
|
|
131
145
|
|
|
132
|
-
|
|
146
|
+
// The new pane is an agent pane; `idle` weighs the same as a missing status.
|
|
147
|
+
const statuses: AgentStatusMap = { [newId]: 'idle', ...opts.statuses };
|
|
133
148
|
const askFlags = opts.askFlags ?? {};
|
|
134
149
|
const slim = paneCount - 1 >= SLIM_THRESHOLD;
|
|
135
150
|
const focusSteps = findPath(simulated, opts.focusPaneId);
|
|
@@ -148,11 +163,11 @@ export function planSpawnSplitRatio(opts: {
|
|
|
148
163
|
const r = clampRatio(target ** (1 / focusSteps.length));
|
|
149
164
|
const at = focusSteps.find((s) => s.node === newSplit);
|
|
150
165
|
if (!at) return null;
|
|
151
|
-
return at.side === 'first' ? r : 1 - r;
|
|
166
|
+
return splitRatio(newSplit, at.side === 'first' ? r : 1 - r, statuses);
|
|
152
167
|
}
|
|
153
168
|
|
|
154
169
|
const wFirst = weightOf(newSplit.first, statuses, askFlags, slim);
|
|
155
170
|
const wSecond = weightOf(newSplit.second, statuses, askFlags, slim);
|
|
156
171
|
if (wFirst + wSecond <= 0) return null;
|
|
157
|
-
return
|
|
172
|
+
return splitRatio(newSplit, wFirst / (wFirst + wSecond), statuses);
|
|
158
173
|
}
|