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.
Files changed (72) hide show
  1. package/README.md +20 -7
  2. package/package.json +7 -2
  3. package/schemas/efficiency-config.schema.json +77 -0
  4. package/skills/pi-herdr-workflow/SKILL.md +13 -1
  5. package/src/ansi-text.ts +83 -0
  6. package/src/ask-multi.ts +260 -0
  7. package/src/ask-user.ts +458 -0
  8. package/src/bootstrap.ts +5 -2
  9. package/src/compact-coordinator.ts +454 -0
  10. package/src/compact-economics-core.ts +396 -0
  11. package/src/config-catalog-core.ts +452 -0
  12. package/src/config-command.ts +181 -0
  13. package/src/config-guide.ts +361 -0
  14. package/src/core/grid-shape.ts +4 -2
  15. package/src/core/heat-plan.ts +160 -0
  16. package/src/core/observation.ts +555 -0
  17. package/src/core/subagent.ts +187 -393
  18. package/src/core/terminal.ts +270 -47
  19. package/src/core/todo.ts +49 -14
  20. package/src/dashboard-command.ts +89 -0
  21. package/src/efficiency-config-core.ts +661 -0
  22. package/src/efficiency-config-io.ts +53 -0
  23. package/src/efficiency-store.ts +312 -0
  24. package/src/focus-poller.ts +244 -0
  25. package/src/gc-core.ts +85 -0
  26. package/src/git-adapter.ts +12 -3
  27. package/src/herdr-client.ts +292 -36
  28. package/src/index-locks.ts +27 -9
  29. package/src/index-master.ts +5 -0
  30. package/src/index-notices.ts +13 -2
  31. package/src/index-pipe.ts +5 -0
  32. package/src/index-worker.ts +5 -0
  33. package/src/index.ts +360 -60
  34. package/src/jev-client.ts +146 -0
  35. package/src/jev-core.ts +381 -0
  36. package/src/lock-core.ts +105 -16
  37. package/src/observation-core.ts +248 -0
  38. package/src/pier-options.ts +96 -0
  39. package/src/pipe-channel.ts +52 -7
  40. package/src/reducer-core.ts +358 -0
  41. package/src/reducer-invoker.ts +392 -0
  42. package/src/renderers.ts +198 -0
  43. package/src/role-loader.ts +2 -1
  44. package/src/role-manifest.ts +0 -4
  45. package/src/runtime-policy.ts +5 -8
  46. package/src/session-tail.ts +60 -5
  47. package/src/settle-wake-core.ts +3 -1
  48. package/src/slim-frame.ts +60 -3
  49. package/src/storage-layout.ts +23 -0
  50. package/src/subagent-core.ts +3 -1
  51. package/src/subagent-execute.ts +328 -0
  52. package/src/subagent-gc.ts +33 -13
  53. package/src/subagent-launch.ts +4 -0
  54. package/src/subagent-list-action.ts +69 -0
  55. package/src/subagent-output-action.ts +104 -0
  56. package/src/subagent-output-adapter.ts +27 -0
  57. package/src/subagent-output-core.ts +263 -0
  58. package/src/subagent-poll-loop.ts +59 -39
  59. package/src/subagent-poller.ts +46 -0
  60. package/src/subagent-ready.ts +89 -0
  61. package/src/subagent-registry.ts +70 -0
  62. package/src/subagent-resolution.ts +41 -0
  63. package/src/subagent-scope.ts +3 -1
  64. package/src/subagent-session-io.ts +115 -32
  65. package/src/subagent-spawn.ts +153 -8
  66. package/src/swallow.ts +75 -0
  67. package/src/terminal-core.ts +161 -10
  68. package/src/todo-core.ts +5 -4
  69. package/src/todo-reminder-core.ts +8 -1
  70. package/src/todos-service.ts +18 -6
  71. package/src/tool-error.ts +26 -0
  72. package/src/vocab.ts +7 -4
@@ -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
+ }
@@ -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
+ }
@@ -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
- if (!result.ok) {
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')}`,
@@ -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)) {
@@ -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 = process.env[key]
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
- readinessTimeoutMs: overrides?.readinessTimeoutMs ?? parseEnvInt('PIER_READY_TIMEOUT_MS', 30_000, 1000),
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