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
|
@@ -0,0 +1,392 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* D102 Evidence-Preserving Reducer Invoker & Tool Result Handler.
|
|
3
|
+
*
|
|
4
|
+
* Implements:
|
|
5
|
+
* - Interception of diagnostic command tool results from `bash`.
|
|
6
|
+
* - Full untruncated output retrieval from Pi temporary files.
|
|
7
|
+
* - P0 content-addressed archival of original source log before replacement.
|
|
8
|
+
* - Secret pattern detection and project trust verification (security boundaries).
|
|
9
|
+
* - Fast in-process model invocation with timeout protection (default 5s).
|
|
10
|
+
* - Zero-tolerance byte-for-byte quotation validation against original source.
|
|
11
|
+
* - Block-level content replacement (preserves write-lock warnings).
|
|
12
|
+
* - Fail-open resilience: errors or validation failures transparently fallback to full text.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import type { ExtensionContext } from '@earendil-works/pi-coding-agent';
|
|
16
|
+
import { countLines } from './observation-core.ts';
|
|
17
|
+
import {
|
|
18
|
+
containsLikelySecret,
|
|
19
|
+
DEFAULT_MAX_CHARS,
|
|
20
|
+
DEFAULT_MAX_OUTPUT_TOKENS,
|
|
21
|
+
DEFAULT_MIN_BYTES,
|
|
22
|
+
DEFAULT_TIMEOUT_MS,
|
|
23
|
+
extractLikelySecretMatch,
|
|
24
|
+
formatReceiptText,
|
|
25
|
+
fullOutputPathFromNotice,
|
|
26
|
+
isDiagnosticCommand,
|
|
27
|
+
mergeUsage,
|
|
28
|
+
reducerInputPrompt,
|
|
29
|
+
reducerInstructions,
|
|
30
|
+
sha256Hex,
|
|
31
|
+
validateReceipt,
|
|
32
|
+
type UsageLike,
|
|
33
|
+
type UsageTotals,
|
|
34
|
+
} from './reducer-core.ts';
|
|
35
|
+
import {
|
|
36
|
+
appendEfficiencyLog,
|
|
37
|
+
efficiencyLogPath,
|
|
38
|
+
readBashFullOutput,
|
|
39
|
+
reducerObjectPath,
|
|
40
|
+
resolveSessionRoot,
|
|
41
|
+
storeContentAddressedObject,
|
|
42
|
+
} from './efficiency-store.ts';
|
|
43
|
+
import type { EvidencePreservingReducerConfig } from './efficiency-config-core.ts';
|
|
44
|
+
import { diagnosticGateRequest, evaluateDiagnosticGate } from './jev-core.ts';
|
|
45
|
+
import type { JevRuntime } from './jev-client.ts';
|
|
46
|
+
|
|
47
|
+
/** P0-1 seam: the runtime plus a live confidence gate (config can reload mid-session). */
|
|
48
|
+
export interface JevEprGateDependency {
|
|
49
|
+
ask: JevRuntime['ask'];
|
|
50
|
+
getMinConfidence: () => number;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface ToolResultEventLike {
|
|
54
|
+
toolName: string;
|
|
55
|
+
toolCallId: string;
|
|
56
|
+
input?: Record<string, unknown>;
|
|
57
|
+
content: Array<{ type: string; text?: string; [k: string]: unknown }>;
|
|
58
|
+
details?: Record<string, unknown>;
|
|
59
|
+
isError: boolean;
|
|
60
|
+
usage?: UsageLike;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface ReducerInvocationResult {
|
|
64
|
+
/** pi's ToolResultEventResult.content uses TextContent, so the literal type must stay narrow. */
|
|
65
|
+
content?: Array<{ type: 'text'; text: string; [k: string]: unknown }>;
|
|
66
|
+
/** Complete pi `Usage` (see mergeUsage): a partial object crashes pi's footer renderer. */
|
|
67
|
+
usage?: UsageTotals;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
let untrustedWarningEmitted = false;
|
|
71
|
+
|
|
72
|
+
export async function handleReducerToolResult(
|
|
73
|
+
event: ToolResultEventLike,
|
|
74
|
+
ctx: ExtensionContext,
|
|
75
|
+
config: EvidencePreservingReducerConfig,
|
|
76
|
+
opts?: { epoch?: number; jev?: JevEprGateDependency },
|
|
77
|
+
): Promise<ReducerInvocationResult | undefined> {
|
|
78
|
+
if (!config.enabled) return undefined;
|
|
79
|
+
const epoch = opts?.epoch ?? 0;
|
|
80
|
+
|
|
81
|
+
// 1. Candidate command & tool gate: strictly bash only
|
|
82
|
+
if (event.toolName !== 'bash') return undefined;
|
|
83
|
+
const command = typeof event.input?.command === 'string' ? event.input.command.trim() : '';
|
|
84
|
+
if (!command) return undefined;
|
|
85
|
+
if (!isDiagnosticCommand(command)) {
|
|
86
|
+
// P0-1 (RFC docs/rfc-jev-integration.md §3): the regex missed — widen the
|
|
87
|
+
// funnel through jev before giving up. The regex fast path stays
|
|
88
|
+
// authoritative; any jev failure keeps the "not diagnostic" verdict.
|
|
89
|
+
const gate = opts?.jev;
|
|
90
|
+
if (!gate) return undefined;
|
|
91
|
+
const result = await gate.ask(diagnosticGateRequest(command), {
|
|
92
|
+
questionId: 'epr-diagnostic-gate',
|
|
93
|
+
timeoutMs: 1000, // tool-result path; only regex-missed commands reach here
|
|
94
|
+
sessionId: ctx.sessionManager?.getSessionId?.(),
|
|
95
|
+
extra: { site: 'epr-gate', commandSha256: sha256Hex(command) },
|
|
96
|
+
enrich: ({ answers }) => {
|
|
97
|
+
if (!answers) return {};
|
|
98
|
+
const verdict = evaluateDiagnosticGate(answers, gate.getMinConfidence());
|
|
99
|
+
return { verdict: verdict.hit ? 'hit' : verdict.reason, choice: verdict.choice, noul: verdict.noul, confidence: verdict.confidence };
|
|
100
|
+
},
|
|
101
|
+
});
|
|
102
|
+
if (!result.ok) return undefined;
|
|
103
|
+
if (!evaluateDiagnosticGate(result.answers, gate.getMinConfidence()).hit) return undefined;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// 2. Project trust boundary check
|
|
107
|
+
const isTrusted =
|
|
108
|
+
typeof (ctx as { isProjectTrusted?: () => boolean }).isProjectTrusted === 'function'
|
|
109
|
+
? (ctx as { isProjectTrusted: () => boolean }).isProjectTrusted()
|
|
110
|
+
: false;
|
|
111
|
+
if (!isTrusted) {
|
|
112
|
+
if (!untrustedWarningEmitted) {
|
|
113
|
+
untrustedWarningEmitted = true;
|
|
114
|
+
console.warn('[pi-herdr] EPR 提炼已在未受信任项目中被安全禁用');
|
|
115
|
+
}
|
|
116
|
+
return undefined;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// 3. Find primary log text block
|
|
120
|
+
const logBlock = event.content.find((b) => b && b.type === 'text' && typeof b.text === 'string');
|
|
121
|
+
if (!logBlock || typeof logBlock.text !== 'string') return undefined;
|
|
122
|
+
|
|
123
|
+
// 4. Retrieve complete untruncated source if available
|
|
124
|
+
const maxChars = config.maxChars ?? DEFAULT_MAX_CHARS;
|
|
125
|
+
const detailPath = typeof event.details?.fullOutputPath === 'string' ? event.details.fullOutputPath : undefined;
|
|
126
|
+
// Pi repeats the path inside the truncation notice that ships with the result text; a replayed
|
|
127
|
+
// or re-shaped event can keep only that text, and reducing the preview would lose the evidence.
|
|
128
|
+
const noticePath = fullOutputPathFromNotice(logBlock.text);
|
|
129
|
+
const fullOutputPath = detailPath ?? noticePath;
|
|
130
|
+
const fullOutputSource = detailPath ? 'details' : noticePath ? 'notice' : 'none';
|
|
131
|
+
const isTruncated =
|
|
132
|
+
(event.details?.truncation as { truncated?: boolean } | undefined)?.truncated === true;
|
|
133
|
+
|
|
134
|
+
const sessionDir = ctx.sessionManager?.getSessionDir?.();
|
|
135
|
+
const sessionId = ctx.sessionManager?.getSessionId?.();
|
|
136
|
+
const sessionRoot = resolveSessionRoot(sessionDir, sessionId);
|
|
137
|
+
|
|
138
|
+
// If truncated and no fullOutputPath or fullOutputPath cannot be read safely, fail-open!
|
|
139
|
+
if (isTruncated && !fullOutputPath) {
|
|
140
|
+
if (config.logEnabled && sessionRoot) {
|
|
141
|
+
await logReducerAttempt(sessionRoot, sessionId ?? 'unknown', epoch, {
|
|
142
|
+
commandSha256: sha256Hex(command),
|
|
143
|
+
model: config.model ?? 'default',
|
|
144
|
+
verificationOk: false,
|
|
145
|
+
reason: 'truncated-source',
|
|
146
|
+
action: 'fallback_full_text',
|
|
147
|
+
fullOutputSource,
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
return undefined;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
let body = logBlock.text;
|
|
154
|
+
if (fullOutputPath) {
|
|
155
|
+
const full = await readBashFullOutput(fullOutputPath, maxChars);
|
|
156
|
+
if (!full) {
|
|
157
|
+
if (config.logEnabled && sessionRoot) {
|
|
158
|
+
await logReducerAttempt(sessionRoot, sessionId ?? 'unknown', epoch, {
|
|
159
|
+
commandSha256: sha256Hex(command),
|
|
160
|
+
model: config.model ?? 'default',
|
|
161
|
+
verificationOk: false,
|
|
162
|
+
reason: 'truncated-source',
|
|
163
|
+
action: 'fallback_full_text',
|
|
164
|
+
fullOutputSource,
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
// Log was truncated but full file could not be read safely -> fallback to avoid hallucinated receipts
|
|
168
|
+
return undefined;
|
|
169
|
+
}
|
|
170
|
+
body = full.content;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const sourceBytes = Buffer.byteLength(body, 'utf8');
|
|
174
|
+
const minBytes = config.minBytes ?? DEFAULT_MIN_BYTES;
|
|
175
|
+
if (sourceBytes < minBytes || body.length > maxChars) return undefined;
|
|
176
|
+
|
|
177
|
+
// 5. Secret detection check
|
|
178
|
+
if (containsLikelySecret(body)) {
|
|
179
|
+
if (config.logEnabled && sessionRoot) {
|
|
180
|
+
await logReducerAttempt(sessionRoot, sessionId ?? 'unknown', epoch, {
|
|
181
|
+
commandSha256: sha256Hex(command),
|
|
182
|
+
sourceBytes,
|
|
183
|
+
model: config.model ?? 'default',
|
|
184
|
+
verificationOk: false,
|
|
185
|
+
reason: 'likely-secret',
|
|
186
|
+
action: 'fallback_full_text',
|
|
187
|
+
// Which shape tripped the gate — the 2026-09-17 trial had 10 fallbacks
|
|
188
|
+
// with zero evidence of what matched (test names vs real credentials).
|
|
189
|
+
secretSnippet: extractLikelySecretMatch(body),
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
return undefined; // Fail-open on sensitive data
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
if (!sessionRoot) return undefined;
|
|
196
|
+
|
|
197
|
+
const sourceHash = sha256Hex(body);
|
|
198
|
+
const sourceLines = countLines(body);
|
|
199
|
+
const archivePath = reducerObjectPath(sessionRoot, sourceHash);
|
|
200
|
+
|
|
201
|
+
// 6. P0: Mandatory source archive before any receipt modification
|
|
202
|
+
try {
|
|
203
|
+
await storeContentAddressedObject(archivePath, body);
|
|
204
|
+
} catch {
|
|
205
|
+
return undefined; // Fail-open if storage fails
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// If localOnly mode, archival is complete; leave content unmodified
|
|
209
|
+
if (config.localOnly) return undefined;
|
|
210
|
+
// 7. Resolve model: configured cheap reducer first, session model as fallback
|
|
211
|
+
// (2026-09-17: user directive — gemini-3.8-flash-high stays the reducer;
|
|
212
|
+
// unavailability must degrade to the session model, not skip reduction).
|
|
213
|
+
let targetModel: any = undefined;
|
|
214
|
+
let reducerModelSource: 'configured' | 'session-fallback' = 'configured';
|
|
215
|
+
if (config.model && typeof config.model === 'string' && config.model.includes('/')) {
|
|
216
|
+
const [provider, ...rest] = config.model.split('/');
|
|
217
|
+
const modelId = rest.join('/');
|
|
218
|
+
try {
|
|
219
|
+
targetModel = ctx.modelRegistry?.find?.(provider, modelId);
|
|
220
|
+
} catch {
|
|
221
|
+
targetModel = undefined;
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
if (!targetModel) {
|
|
225
|
+
targetModel = ctx.model;
|
|
226
|
+
reducerModelSource = 'session-fallback';
|
|
227
|
+
}
|
|
228
|
+
if (!targetModel || typeof ctx.modelRegistry?.complete !== 'function') {
|
|
229
|
+
return undefined;
|
|
230
|
+
}
|
|
231
|
+
// Unified provider/id label: the 2026-09-17 logs split one session into
|
|
232
|
+
// "cliproxy/gemini-3.8-flash-high" (fallback rows) and "gemini-3.8-flash-high"
|
|
233
|
+
// (applied rows), fragmenting per-model grouping.
|
|
234
|
+
const reducerModelLabel =
|
|
235
|
+
typeof targetModel.provider === 'string' && typeof targetModel.id === 'string'
|
|
236
|
+
? `${targetModel.provider}/${targetModel.id}`
|
|
237
|
+
: (config.model ?? 'unknown');
|
|
238
|
+
|
|
239
|
+
// 8. Invoke model in-process with timeout
|
|
240
|
+
const timeoutMs = config.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
241
|
+
const timeoutSignal = AbortSignal.timeout(timeoutMs);
|
|
242
|
+
const signal =
|
|
243
|
+
ctx.signal && typeof (AbortSignal as any).any === 'function'
|
|
244
|
+
? (AbortSignal as any).any([ctx.signal, timeoutSignal])
|
|
245
|
+
: timeoutSignal;
|
|
246
|
+
|
|
247
|
+
const promptInput = reducerInputPrompt({
|
|
248
|
+
command,
|
|
249
|
+
isError: event.isError,
|
|
250
|
+
sourceHash,
|
|
251
|
+
sourceBytes,
|
|
252
|
+
sourceLines,
|
|
253
|
+
body,
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
const startMs = Date.now();
|
|
257
|
+
let modelResult: any;
|
|
258
|
+
try {
|
|
259
|
+
modelResult = await ctx.modelRegistry.complete(
|
|
260
|
+
targetModel,
|
|
261
|
+
{
|
|
262
|
+
systemPrompt: reducerInstructions(),
|
|
263
|
+
messages: [{ role: 'user', content: [{ type: 'text', text: promptInput }], timestamp: Date.now() }],
|
|
264
|
+
},
|
|
265
|
+
{
|
|
266
|
+
maxTokens: config.maxOutputTokens ?? DEFAULT_MAX_OUTPUT_TOKENS,
|
|
267
|
+
cacheRetention: 'none',
|
|
268
|
+
signal,
|
|
269
|
+
},
|
|
270
|
+
);
|
|
271
|
+
} catch (err) {
|
|
272
|
+
// Fail-open, but no longer silent: the 2026-09-17 trial lost 3+ attempts
|
|
273
|
+
// (timeout / provider error) with no jsonl trace at all.
|
|
274
|
+
if (config.logEnabled) {
|
|
275
|
+
await logReducerAttempt(sessionRoot, sessionId ?? 'unknown', epoch, {
|
|
276
|
+
commandSha256: sha256Hex(command),
|
|
277
|
+
sourceBytes,
|
|
278
|
+
model: reducerModelLabel,
|
|
279
|
+
reducerModelSource,
|
|
280
|
+
verificationOk: false,
|
|
281
|
+
reason: 'invoke-failed',
|
|
282
|
+
action: 'fallback_full_text',
|
|
283
|
+
error: err instanceof Error ? err.message : String(err),
|
|
284
|
+
durationMs: Date.now() - startMs,
|
|
285
|
+
});
|
|
286
|
+
}
|
|
287
|
+
return undefined; // Fail-open on timeout or provider error
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
const modelOutput = Array.isArray(modelResult?.content)
|
|
291
|
+
? modelResult.content.map((b: any) => (typeof b.text === 'string' ? b.text : '')).join('')
|
|
292
|
+
: typeof modelResult?.text === 'string'
|
|
293
|
+
? modelResult.text
|
|
294
|
+
: '';
|
|
295
|
+
|
|
296
|
+
if (!modelOutput.trim()) return undefined;
|
|
297
|
+
|
|
298
|
+
// 9. Strict byte-for-byte quotation validation
|
|
299
|
+
const validated = validateReceipt(modelOutput, sourceHash, body, event.isError);
|
|
300
|
+
if (!validated.ok) {
|
|
301
|
+
if (config.logEnabled) {
|
|
302
|
+
const failReason = validated.reason;
|
|
303
|
+
await logReducerAttempt(sessionRoot, sessionId ?? 'unknown', epoch, {
|
|
304
|
+
commandSha256: sha256Hex(command),
|
|
305
|
+
sourceBytes,
|
|
306
|
+
model: reducerModelLabel,
|
|
307
|
+
reducerModelSource,
|
|
308
|
+
verificationOk: false,
|
|
309
|
+
reason: failReason,
|
|
310
|
+
action: 'fallback_full_text',
|
|
311
|
+
// invalid-json rows carried no evidence of what the model actually
|
|
312
|
+
// returned (fences? truncation? prose?) — keep a sanitized head.
|
|
313
|
+
...(failReason === 'invalid-json' ? { rawOutputHead: JSON.stringify(modelOutput.slice(0, 200)) } : {}),
|
|
314
|
+
durationMs: Date.now() - startMs,
|
|
315
|
+
});
|
|
316
|
+
}
|
|
317
|
+
return undefined;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
const modelName = targetModel.id ?? config.model ?? 'default';
|
|
321
|
+
const providerName = typeof targetModel.provider === 'string' ? targetModel.provider : undefined;
|
|
322
|
+
const receipt = formatReceiptText({
|
|
323
|
+
command,
|
|
324
|
+
sourceHash,
|
|
325
|
+
sourceBytes,
|
|
326
|
+
sourceLines,
|
|
327
|
+
sourceArtifactPath: archivePath,
|
|
328
|
+
validated: validated.value,
|
|
329
|
+
model: modelName,
|
|
330
|
+
provider: providerName,
|
|
331
|
+
totalTokens: modelResult?.usage?.totalTokens,
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
const receiptBytes = Buffer.byteLength(receipt, 'utf8');
|
|
335
|
+
if (receiptBytes >= sourceBytes) {
|
|
336
|
+
return undefined; // Must be smaller
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// 10. Block-level replacement: only replace the log text block, preserving warnings
|
|
340
|
+
const nextContent = event.content.map((b): { type: 'text'; text: string } =>
|
|
341
|
+
(b === logBlock ? { ...b, text: receipt } : b) as { type: 'text'; text: string });
|
|
342
|
+
|
|
343
|
+
// Backfill usage tokens: the nested reducer model call plus whatever the tool result already carried.
|
|
344
|
+
// MUST stay a COMPLETE pi `Usage`: pi persists this onto the tool-result message and its footer
|
|
345
|
+
// renders it through `addUsageToTotals`, which reads `usage.cost.total` unguarded. Emitting only
|
|
346
|
+
// { input, output, totalTokens } killed the whole pi process with
|
|
347
|
+
// "TypeError: Cannot read properties of undefined (reading 'total')" (observed 2026-09-13 in a
|
|
348
|
+
// subagent pane, stack: FooterComponent.render -> addUsageToTotals). See docs/session-format.md.
|
|
349
|
+
const nextUsage = mergeUsage(event.usage, modelResult?.usage);
|
|
350
|
+
|
|
351
|
+
if (config.logEnabled) {
|
|
352
|
+
await logReducerAttempt(sessionRoot, sessionId ?? 'unknown', epoch, {
|
|
353
|
+
commandSha256: sha256Hex(command),
|
|
354
|
+
sourceBytes,
|
|
355
|
+
receiptBytes,
|
|
356
|
+
grossSavedBytes: sourceBytes - receiptBytes,
|
|
357
|
+
compressionRatio: Number((receiptBytes / sourceBytes).toFixed(3)),
|
|
358
|
+
model: reducerModelLabel,
|
|
359
|
+
reducerModelSource,
|
|
360
|
+
verificationOk: true,
|
|
361
|
+
action: 'applied',
|
|
362
|
+
fullOutputSource,
|
|
363
|
+
durationMs: Date.now() - startMs,
|
|
364
|
+
});
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
return {
|
|
368
|
+
content: nextContent,
|
|
369
|
+
usage: nextUsage,
|
|
370
|
+
};
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
async function logReducerAttempt(
|
|
374
|
+
sessionRoot: string,
|
|
375
|
+
sessionId: string,
|
|
376
|
+
epoch: number,
|
|
377
|
+
record: Record<string, unknown>,
|
|
378
|
+
): Promise<void> {
|
|
379
|
+
const logPath = efficiencyLogPath(sessionRoot, 'reducer');
|
|
380
|
+
try {
|
|
381
|
+
await appendEfficiencyLog(logPath, {
|
|
382
|
+
schema: 'pier-efficiency/1',
|
|
383
|
+
mechanism: 'evidencePreservingReducer',
|
|
384
|
+
ts: new Date().toISOString(),
|
|
385
|
+
sessionId,
|
|
386
|
+
epoch,
|
|
387
|
+
...record,
|
|
388
|
+
});
|
|
389
|
+
} catch {
|
|
390
|
+
/* ignore logging error */
|
|
391
|
+
}
|
|
392
|
+
}
|
package/src/renderers.ts
ADDED
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TUI renderers for pier's own session custom entries and reminder messages.
|
|
3
|
+
*
|
|
4
|
+
* Why: pier persists authority in the session JSONL (todo edits, subagent registry,
|
|
5
|
+
* terminal registry, role manifests, soft-approvals) and injects reminder messages.
|
|
6
|
+
* Without a renderer those transcript rows are raw JSON, which buries the one line
|
|
7
|
+
* that matters. The line builders here are pure (unit-tested); the component is a
|
|
8
|
+
* thin string array plus an ANSI-aware truncator, so this module needs no pi-tui
|
|
9
|
+
* import and degrades to "no renderer" on older pi builds.
|
|
10
|
+
*/
|
|
11
|
+
import { TODO_EDIT_CUSTOM_TYPE, type TodoEditPayload } from './todo-core.ts';
|
|
12
|
+
import { SUBS_CUSTOM_TYPE, type SubsRegistry } from './subagent-core.ts';
|
|
13
|
+
import { TERMINALS_CUSTOM_TYPE, TERM_REMINDER_CUSTOM_TYPE, type TerminalsRegistry } from './terminal-core.ts';
|
|
14
|
+
import { TODO_REMINDER_CUSTOM_TYPE } from './todo-reminder-core.ts';
|
|
15
|
+
|
|
16
|
+
// Width handling lives in its own module so the ask_user_question UI can reuse it
|
|
17
|
+
// without pulling the todo/subagent/terminal cores into its import graph.
|
|
18
|
+
import { charWidth, styledWidth, truncateStyled } from './ansi-text.ts';
|
|
19
|
+
export { charWidth, styledWidth, truncateStyled };
|
|
20
|
+
|
|
21
|
+
/** Role manifest entry is written by index.ts; keep the literal here to avoid an import cycle. */
|
|
22
|
+
export const ROLE_MANIFEST_CUSTOM_TYPE = 'pi-herdr.role-manifest';
|
|
23
|
+
/** Soft-approval trace written by the worker manifest gate in index.ts. */
|
|
24
|
+
export const APPROVAL_NEEDED_CUSTOM_TYPE = 'pi-herdr.approval-needed';
|
|
25
|
+
|
|
26
|
+
/** Structural subset of pi's Theme that the builders actually use. */
|
|
27
|
+
export interface RenderTheme {
|
|
28
|
+
fg(color: string, text: string): string;
|
|
29
|
+
bold(text: string): string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Structural subset of pi's Component; enough for `registerEntryRenderer`. */
|
|
33
|
+
export interface RenderComponent {
|
|
34
|
+
render(width: number): string[];
|
|
35
|
+
invalidate(): void;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface RendererApi {
|
|
39
|
+
registerEntryRenderer?(
|
|
40
|
+
customType: string,
|
|
41
|
+
renderer: (entry: unknown, options: { expanded: boolean }, theme: RenderTheme) => RenderComponent | undefined,
|
|
42
|
+
): void;
|
|
43
|
+
registerMessageRenderer?(
|
|
44
|
+
customType: string,
|
|
45
|
+
renderer: (message: unknown, options: { expanded: boolean }, theme: RenderTheme) => RenderComponent | undefined,
|
|
46
|
+
): void;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/* ── Pure line builders ────────────────────────────────────────────── */
|
|
50
|
+
|
|
51
|
+
/** Component wrapper: render is a no-op transform except for width clipping. */
|
|
52
|
+
export function card(lines: readonly string[]): RenderComponent {
|
|
53
|
+
return {
|
|
54
|
+
render: (width: number) => lines.map((line) => truncateStyled(line, width)),
|
|
55
|
+
invalidate() { /* No cached state. */ },
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function clip(text: string, max: number): string {
|
|
60
|
+
const flat = text.replace(/\s+/g, ' ').trim();
|
|
61
|
+
return flat.length <= max ? flat : `${flat.slice(0, max - 1)}…`;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const EDIT_VERB: Record<string, string> = {
|
|
65
|
+
done: '✓ done',
|
|
66
|
+
drop: '■ dropped',
|
|
67
|
+
unblock: '○ unblocked',
|
|
68
|
+
rm: '✗ removed',
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
export function formatEditCounts(edits: ReadonlyArray<{ op: string }>): string {
|
|
72
|
+
const counts = new Map<string, number>();
|
|
73
|
+
for (const edit of edits) counts.set(edit.op, (counts.get(edit.op) ?? 0) + 1);
|
|
74
|
+
return [...counts.entries()]
|
|
75
|
+
.map(([op, n]) => `${EDIT_VERB[op] ?? op}${n > 1 ? ` ×${n}` : ''}`)
|
|
76
|
+
.join(' · ');
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function todoEditLines(data: unknown, theme: RenderTheme, expanded: boolean): string[] {
|
|
80
|
+
const payload = data as TodoEditPayload | undefined;
|
|
81
|
+
const edits = Array.isArray(payload?.edits) ? payload.edits : [];
|
|
82
|
+
if (edits.length === 0) return [theme.fg('dim', 'todo · no edits')];
|
|
83
|
+
const head = `${theme.fg('accent', theme.bold('todo'))} ${theme.fg('dim', `· ${edits.length} edit${edits.length > 1 ? 's' : ''}`)} ${formatEditCounts(edits)}`;
|
|
84
|
+
if (!expanded) return [head];
|
|
85
|
+
return [
|
|
86
|
+
head,
|
|
87
|
+
...edits.map((edit) => theme.fg('dim', ` ${EDIT_VERB[edit.op] ?? edit.op} — ${clip(String(edit.content ?? ''), 90)}`)),
|
|
88
|
+
];
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function subsLines(data: unknown, theme: RenderTheme, expanded: boolean): string[] {
|
|
92
|
+
const subs = (data as SubsRegistry | undefined)?.subs;
|
|
93
|
+
if (!Array.isArray(subs) || subs.length === 0) return [theme.fg('dim', 'subagents · none')];
|
|
94
|
+
const running = subs.filter((s) => s.status === 'running').length;
|
|
95
|
+
const head = `${theme.fg('accent', theme.bold('subagents'))} ${theme.fg('dim', `· ${subs.length} tracked`)}${running ? ` ${theme.fg('success', `${running} running`)}` : ''}`;
|
|
96
|
+
if (!expanded) return [head];
|
|
97
|
+
return [
|
|
98
|
+
head,
|
|
99
|
+
...subs.map((sub) => theme.fg(
|
|
100
|
+
'dim',
|
|
101
|
+
` ${sub.status === 'running' ? '●' : '○'} ${sub.paneId} · ${sub.status} · ${clip(String(sub.description ?? ''), 70)}`,
|
|
102
|
+
)),
|
|
103
|
+
];
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function terminalsLines(data: unknown, theme: RenderTheme, expanded: boolean): string[] {
|
|
107
|
+
const terminals = (data as TerminalsRegistry | undefined)?.terminals;
|
|
108
|
+
if (!Array.isArray(terminals) || terminals.length === 0) return [theme.fg('dim', 'terminals · none')];
|
|
109
|
+
const head = `${theme.fg('accent', theme.bold('terminals'))} ${theme.fg('dim', `· ${terminals.length} open`)}`;
|
|
110
|
+
if (!expanded) return [head];
|
|
111
|
+
return [
|
|
112
|
+
head,
|
|
113
|
+
...terminals.map((t) => theme.fg('dim', ` ${t.paneId} · ${clip(String(t.label ?? t.cwd ?? ''), 70)}`)),
|
|
114
|
+
];
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function roleManifestLines(data: unknown, theme: RenderTheme): string[] {
|
|
118
|
+
const rec = (data ?? {}) as { role?: unknown; manifestVersion?: unknown; tools?: unknown; permissions?: unknown };
|
|
119
|
+
const role = typeof rec.role === 'string' ? rec.role : '?';
|
|
120
|
+
const version = typeof rec.manifestVersion === 'string' || typeof rec.manifestVersion === 'number' ? String(rec.manifestVersion) : '?';
|
|
121
|
+
const tools = Array.isArray(rec.tools) ? rec.tools.length : 0;
|
|
122
|
+
const gates = Object.values((rec.permissions ?? {}) as Record<string, unknown>)
|
|
123
|
+
.filter((v) => v === 'deny' || v === 'ask').length;
|
|
124
|
+
const tail = gates > 0 ? theme.fg('warning', ` · ${gates} gated`) : '';
|
|
125
|
+
return [`${theme.fg('accent', theme.bold('role'))} ${role} ${theme.fg('dim', `v${version} · ${tools} tools`)}${tail}`];
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export function approvalLines(data: unknown, theme: RenderTheme): string[] {
|
|
129
|
+
const rec = (data ?? {}) as { role?: unknown; tool?: unknown };
|
|
130
|
+
const role = typeof rec.role === 'string' ? rec.role : '?';
|
|
131
|
+
const tool = typeof rec.tool === 'string' ? rec.tool : '?';
|
|
132
|
+
return [`${theme.fg('warning', '⚠ approval needed')} ${theme.fg('dim', `· ${tool} (${role})`)}`];
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Reminder custom messages carry the full injected text; restyle without altering it. */
|
|
136
|
+
export function reminderLines(message: unknown, theme: RenderTheme, label: string, expanded: boolean): string[] {
|
|
137
|
+
const content = typeof (message as { content?: unknown } | undefined)?.content === 'string'
|
|
138
|
+
? String((message as { content: string }).content)
|
|
139
|
+
: '';
|
|
140
|
+
const firstLine = content.split('\n').find((line) => line.trim().length > 0) ?? label;
|
|
141
|
+
const head = `${theme.fg('warning', `↻ ${label}`)}`;
|
|
142
|
+
if (!expanded) return [head, theme.fg('dim', ` ${clip(firstLine, 110)}`)];
|
|
143
|
+
return [head, ...content.split('\n').map((line) => theme.fg('dim', line))];
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/* ── Installation ──────────────────────────────────────────────────── */
|
|
147
|
+
|
|
148
|
+
function entryCard(
|
|
149
|
+
build: (data: unknown, theme: RenderTheme, expanded: boolean) => string[],
|
|
150
|
+
): (entry: unknown, options: { expanded: boolean }, theme: RenderTheme) => RenderComponent {
|
|
151
|
+
return (entry, options, theme) => card(build((entry as { data?: unknown } | undefined)?.data, theme, options.expanded));
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function messageCard(
|
|
155
|
+
build: (message: unknown, theme: RenderTheme, expanded: boolean) => string[],
|
|
156
|
+
): (message: unknown, options: { expanded: boolean }, theme: RenderTheme) => RenderComponent {
|
|
157
|
+
return (message, options, theme) => card(build(message, theme, options.expanded));
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Register pier's transcript renderers. Returns the custom types actually registered,
|
|
162
|
+
* so the caller can log coverage and tests can assert the degrade path.
|
|
163
|
+
*
|
|
164
|
+
* Why best-effort: entry/message renderers are display-only. A pi build without the
|
|
165
|
+
* API (pre-0.80.4) or a throwing renderer must never break tool registration, so each
|
|
166
|
+
* registration is individually guarded.
|
|
167
|
+
*/
|
|
168
|
+
export function installRenderers(pi: unknown): string[] {
|
|
169
|
+
const api = pi as RendererApi;
|
|
170
|
+
const registered: string[] = [];
|
|
171
|
+
const entry = (type: string, build: (data: unknown, theme: RenderTheme, expanded: boolean) => string[]): void => {
|
|
172
|
+
if (typeof api.registerEntryRenderer !== 'function') return;
|
|
173
|
+
try {
|
|
174
|
+
api.registerEntryRenderer(type, entryCard(build));
|
|
175
|
+
registered.push(type);
|
|
176
|
+
} catch {
|
|
177
|
+
/* Older pi or a conflicting renderer: keep the default rendering. */
|
|
178
|
+
}
|
|
179
|
+
};
|
|
180
|
+
const message = (type: string, build: (msg: unknown, theme: RenderTheme, expanded: boolean) => string[]): void => {
|
|
181
|
+
if (typeof api.registerMessageRenderer !== 'function') return;
|
|
182
|
+
try {
|
|
183
|
+
api.registerMessageRenderer(type, messageCard(build));
|
|
184
|
+
registered.push(type);
|
|
185
|
+
} catch {
|
|
186
|
+
/* Best effort; the message still reaches the model. */
|
|
187
|
+
}
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
entry(TODO_EDIT_CUSTOM_TYPE, (data, theme, expanded) => todoEditLines(data, theme, expanded));
|
|
191
|
+
entry(SUBS_CUSTOM_TYPE, (data, theme, expanded) => subsLines(data, theme, expanded));
|
|
192
|
+
entry(TERMINALS_CUSTOM_TYPE, (data, theme, expanded) => terminalsLines(data, theme, expanded));
|
|
193
|
+
entry(ROLE_MANIFEST_CUSTOM_TYPE, (data, theme) => roleManifestLines(data, theme));
|
|
194
|
+
entry(APPROVAL_NEEDED_CUSTOM_TYPE, (data, theme) => approvalLines(data, theme));
|
|
195
|
+
message(TODO_REMINDER_CUSTOM_TYPE, (msg, theme, expanded) => reminderLines(msg, theme, 'todo reminder', expanded));
|
|
196
|
+
message(TERM_REMINDER_CUSTOM_TYPE, (msg, theme, expanded) => reminderLines(msg, theme, 'terminal nudge', expanded));
|
|
197
|
+
return registered;
|
|
198
|
+
}
|
package/src/role-loader.ts
CHANGED
|
@@ -143,7 +143,8 @@ function parseAndValidate(name: string, fileName: string, text: string, layerLab
|
|
|
143
143
|
throw new RoleLoaderError('INVALID_ROLE_CONFIG', `role "${name}"(${layerLabel})不是合法 JSON: ${(err as Error).message}`);
|
|
144
144
|
}
|
|
145
145
|
const result = validateRoleManifest(parsed);
|
|
146
|
-
|
|
146
|
+
// `ok === false` (instead of `!ok`) is required to narrow without strictNullChecks.
|
|
147
|
+
if (result.ok === false) {
|
|
147
148
|
throw new RoleLoaderError(
|
|
148
149
|
'INVALID_ROLE_CONFIG',
|
|
149
150
|
`role "${name}"(${layerLabel})校验失败:\n${result.issues.map((i) => ` - ${i}`).join('\n')}`,
|
package/src/role-manifest.ts
CHANGED
|
@@ -58,10 +58,6 @@ function isPlainObject(v: unknown): v is Record<string, unknown> {
|
|
|
58
58
|
return typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
59
59
|
}
|
|
60
60
|
|
|
61
|
-
function isPosInt(v: unknown): boolean {
|
|
62
|
-
return typeof v === 'number' && Number.isInteger(v) && v > 0;
|
|
63
|
-
}
|
|
64
|
-
|
|
65
61
|
export function validateRoleManifest(input: unknown): ValidateResult {
|
|
66
62
|
const issues: string[] = [];
|
|
67
63
|
if (!isPlainObject(input)) {
|
package/src/runtime-policy.ts
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
* Why: Prevents drift between hardcoded literals and environment overrides;
|
|
5
5
|
* makes timing behavior explicit and testable.
|
|
6
6
|
*/
|
|
7
|
+
import { pierOption } from './pier-options.ts';
|
|
7
8
|
|
|
8
9
|
export interface RuntimePolicy {
|
|
9
10
|
/** Subagent overall timeout (ms) */
|
|
@@ -12,8 +13,6 @@ export interface RuntimePolicy {
|
|
|
12
13
|
readonly gcTickMs: number
|
|
13
14
|
/** Poll interval for subagent state observation (ms) */
|
|
14
15
|
readonly pollIntervalMs: number
|
|
15
|
-
/** Stale check cadence (turns) */
|
|
16
|
-
readonly staleCadenceTurns: number
|
|
17
16
|
/** Settlement notice window / machine-inject grace / takeover idle (ms) */
|
|
18
17
|
readonly settlementWindowMs: number
|
|
19
18
|
/** Post-settle observation window before auto-consume (ms) */
|
|
@@ -22,16 +21,15 @@ export interface RuntimePolicy {
|
|
|
22
21
|
readonly foregroundPatienceMs: number
|
|
23
22
|
/** Default session TTL (seconds) */
|
|
24
23
|
readonly sessionTtlSeconds: number
|
|
25
|
-
/** Terminal read maximum bytes */
|
|
26
|
-
readonly terminalReadMaxBytes: number
|
|
27
24
|
/** Git operation timeout (ms) */
|
|
28
25
|
readonly gitTimeoutMs: number
|
|
29
26
|
/** Subagent pane pipe readiness wait (ms) */
|
|
30
27
|
readonly readinessTimeoutMs: number
|
|
31
28
|
}
|
|
32
29
|
|
|
30
|
+
/** B10: read through the option catalog so the legacy `PI_HERDR_*` spelling keeps working. */
|
|
33
31
|
function parseEnvInt(key: string, defaultValue: number, min: number = 0): number {
|
|
34
|
-
const raw =
|
|
32
|
+
const raw = pierOption(key)
|
|
35
33
|
if (!raw) return defaultValue
|
|
36
34
|
|
|
37
35
|
const parsed = Number.parseInt(raw, 10)
|
|
@@ -48,14 +46,13 @@ export function createRuntimePolicy(overrides?: Partial<RuntimePolicy>): Runtime
|
|
|
48
46
|
subagentTimeoutMs: overrides?.subagentTimeoutMs ?? parseEnvInt('PIER_SUBAGENT_TIMEOUT_MS', 600_000, 1000),
|
|
49
47
|
gcTickMs: overrides?.gcTickMs ?? parseEnvInt('PIER_GC_TICK_MS', 30_000, 1000),
|
|
50
48
|
pollIntervalMs: overrides?.pollIntervalMs ?? parseEnvInt('PIER_POLL_INTERVAL_MS', 30_000, 1000),
|
|
51
|
-
staleCadenceTurns: overrides?.staleCadenceTurns ?? parseEnvInt('PIER_STALE_CADENCE_TURNS', 3, 1),
|
|
52
49
|
settlementWindowMs: overrides?.settlementWindowMs ?? parseEnvInt('PIER_SETTLEMENT_WINDOW_MS', 60_000, 0),
|
|
53
50
|
observationWindowMs: overrides?.observationWindowMs ?? parseEnvInt('PIER_OBSERVATION_WINDOW_MS', 30_000, 0),
|
|
54
51
|
foregroundPatienceMs: overrides?.foregroundPatienceMs ?? parseEnvInt('PIER_FOREGROUND_PATIENCE_MS', 300_000, 0),
|
|
55
52
|
sessionTtlSeconds: overrides?.sessionTtlSeconds ?? parseEnvInt('PIER_SESSION_TTL_SECONDS', 600, 0),
|
|
56
|
-
terminalReadMaxBytes: overrides?.terminalReadMaxBytes ?? parseEnvInt('TERM_READ_MAX', 4096, 1),
|
|
57
53
|
gitTimeoutMs: overrides?.gitTimeoutMs ?? parseEnvInt('PIER_GIT_TIMEOUT_MS', 10_000, 1),
|
|
58
|
-
|
|
54
|
+
// A14: concurrent heavy workers can take far longer than 30s to boot; PIER_READY_TIMEOUT_MS overrides.
|
|
55
|
+
readinessTimeoutMs: overrides?.readinessTimeoutMs ?? parseEnvInt('PIER_READY_TIMEOUT_MS', 90_000, 1000),
|
|
59
56
|
}
|
|
60
57
|
}
|
|
61
58
|
|