pi-pier 0.4.1 → 0.4.3

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 (45) hide show
  1. package/README.md +1 -2
  2. package/package.json +1 -1
  3. package/schemas/efficiency-config.schema.json +25 -19
  4. package/schemas/role-manifest.schema.json +9 -9
  5. package/src/ask-dialog.ts +9 -2
  6. package/src/ask-user.ts +142 -74
  7. package/src/compact-coordinator.ts +204 -33
  8. package/src/compact-economics-core.ts +62 -29
  9. package/src/config-catalog-core.ts +1 -0
  10. package/src/delegation-core.ts +1 -1
  11. package/src/efficiency-config-core.ts +34 -20
  12. package/src/efficiency-store.ts +42 -5
  13. package/src/herdr-client.ts +15 -5
  14. package/src/index-notices.ts +1 -1
  15. package/src/index-pipe.ts +0 -8
  16. package/src/index.ts +71 -87
  17. package/src/jev-client.ts +32 -7
  18. package/src/jev-core.ts +70 -33
  19. package/src/manifest-compose.ts +4 -4
  20. package/src/observation-core.ts +13 -1
  21. package/src/pipe-channel.ts +0 -8
  22. package/src/plugins/heat-plan.ts +18 -3
  23. package/src/plugins/observation.ts +118 -46
  24. package/src/plugins/subagent.ts +53 -40
  25. package/src/plugins/terminal.ts +192 -47
  26. package/src/plugins/todo.ts +44 -22
  27. package/src/reducer-invoker.ts +28 -14
  28. package/src/renderers.ts +1 -13
  29. package/src/role-loader.ts +8 -8
  30. package/src/role-manifest.ts +22 -22
  31. package/src/roles/master.json +1 -1
  32. package/src/roles/worker-default.json +3 -3
  33. package/src/settle-wake-core.ts +8 -2
  34. package/src/subagent-core.ts +41 -3
  35. package/src/subagent-poller.ts +52 -18
  36. package/src/subagent-session.ts +67 -4
  37. package/src/subagent-spawn.ts +6 -5
  38. package/src/terminal-core.ts +111 -17
  39. package/src/todo-core.ts +47 -2
  40. package/src/todo-read-hook.ts +31 -17
  41. package/src/todo-reminder-core.ts +9 -5
  42. package/src/todo-window.ts +8 -4
  43. package/src/todos-service.ts +12 -0
  44. package/src/tree-view.ts +10 -10
  45. package/src/vocab.ts +7 -1
@@ -14,6 +14,10 @@ import { existsSync, readFileSync } from 'node:fs';
14
14
  import { homedir } from 'node:os';
15
15
  import { join } from 'node:path';
16
16
 
17
+ /** pi's thinking levels in ascending order, plus `inherit` (summarize at the session's own level). */
18
+ export const SUMMARY_THINKING_LEVELS = ['inherit', 'off', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max'] as const;
19
+ export type SummaryThinkingLevel = (typeof SUMMARY_THINKING_LEVELS)[number];
20
+
17
21
  export interface OnlineContextCompactConfig {
18
22
  enabled: boolean;
19
23
  logEnabled: boolean;
@@ -21,6 +25,8 @@ export interface OnlineContextCompactConfig {
21
25
  keepRecentTokens?: number;
22
26
  firstCompactionRequestScale: number;
23
27
  subsequentCompactionMargin: number;
28
+ /** Cap on the summary request's thinking level (never raised above the session's). */
29
+ summaryThinkingLevel: SummaryThinkingLevel;
24
30
  }
25
31
 
26
32
  export interface ObservationPackConfig {
@@ -74,7 +80,7 @@ export const DEFAULT_EFFICIENCY_CONFIG: EfficiencyConfig = Object.freeze({
74
80
  version: 1,
75
81
  onlineContextCompact: Object.freeze({
76
82
  enabled: false, logEnabled: false, cacheWriteReadRatio: 'auto', keepRecentTokens: 20000,
77
- firstCompactionRequestScale: 2.0, subsequentCompactionMargin: 1.5,
83
+ firstCompactionRequestScale: 2.0, subsequentCompactionMargin: 1.5, summaryThinkingLevel: 'low',
78
84
  }),
79
85
  observationPack: Object.freeze({
80
86
  enabled: false, logEnabled: false, thresholdBytes: 10240, fullSends: 2,
@@ -87,7 +93,7 @@ export const DEFAULT_EFFICIENCY_CONFIG: EfficiencyConfig = Object.freeze({
87
93
  jev: Object.freeze({ enabled: false, logEnabled: false, model: 'jev-1.13.0', timeoutMs: 2000, minConfidence: 0.6 }),
88
94
  });
89
95
 
90
- type FieldKind = 'bool' | 'int' | 'number' | 'ratio' | 'string' | 'optionalString';
96
+ type FieldKind = 'bool' | 'int' | 'number' | 'ratio' | 'string' | 'optionalString' | 'enum';
91
97
 
92
98
  interface FieldSpec {
93
99
  readonly key: string;
@@ -97,6 +103,8 @@ interface FieldSpec {
97
103
  readonly max?: number;
98
104
  /** Issue text for a bound that must not be printed as a plain JS number ("1.0"). */
99
105
  readonly minText?: string;
106
+ /** Accepted values for `enum`. */
107
+ readonly values?: readonly string[];
100
108
  }
101
109
 
102
110
  /** A section is `{enabled, ...fields}`; field order fixes the order of reported issues. */
@@ -113,6 +121,7 @@ const SECTIONS: readonly SectionSpec[] = [
113
121
  { key: 'keepRecentTokens', kind: 'int', min: 1000 },
114
122
  { key: 'firstCompactionRequestScale', kind: 'number', min: 1, minText: '1.0' },
115
123
  { key: 'subsequentCompactionMargin', kind: 'number', min: 1, minText: '1.0' },
124
+ { key: 'summaryThinkingLevel', kind: 'enum', values: SUMMARY_THINKING_LEVELS },
116
125
  ] },
117
126
  { name: 'observationPack', fields: [
118
127
  { key: 'enabled', kind: 'bool' },
@@ -147,17 +156,19 @@ function issueFor(section: string, field: FieldSpec): string {
147
156
  const path = `${section}.${field.key}`;
148
157
  switch (field.kind) {
149
158
  case 'bool':
150
- return `${path} 必须是 boolean`;
159
+ return `${path} must be a boolean`;
151
160
  case 'int':
152
- return `${path} 必须是 >= ${field.min} 的整数`;
161
+ return `${path} must be an integer >= ${field.min}`;
153
162
  case 'number':
154
163
  return field.max === undefined
155
- ? `${path} 必须是 >= ${field.minText ?? field.min} 的数字`
156
- : `${path} 必须是 ${field.min} 到 ${field.max} 之间的数字`;
164
+ ? `${path} must be a number >= ${field.minText ?? field.min}`
165
+ : `${path} must be a number between ${field.min} and ${field.max}`;
157
166
  case 'ratio':
158
- return `${path} 必须是 "auto" 或非负有限数`;
167
+ return `${path} must be "auto" or a non-negative finite number`;
168
+ case 'enum':
169
+ return `${path} must be one of ${(field.values ?? []).join(' | ')}`;
159
170
  default:
160
- return `${path} 必须是非空字符串`;
171
+ return `${path} must be a non-empty string`;
161
172
  }
162
173
  }
163
174
 
@@ -190,6 +201,9 @@ function applyField(target: Record<string, unknown>, field: FieldSpec, raw: unkn
190
201
  if (typeof raw !== 'string' || !raw.trim()) return false;
191
202
  target[field.key] = raw.trim();
192
203
  return true;
204
+ case 'enum':
205
+ if (typeof raw !== 'string' || !(field.values ?? []).includes(raw)) return false;
206
+ break;
193
207
  }
194
208
  target[field.key] = raw;
195
209
  return true;
@@ -215,15 +229,15 @@ export function validateEfficiencyConfig(raw: unknown): ValidateConfigResult {
215
229
  const config = defaults();
216
230
  if (!isPlainObject(raw)) {
217
231
  for (const section of SECTIONS) config[section.name].enabled = false;
218
- return { ok: false, config, issues: ['efficiency config 必须是 JSON 对象'] };
232
+ return { ok: false, config, issues: ['efficiency config must be a JSON object'] };
219
233
  }
220
234
 
221
235
  const issues: string[] = [];
222
236
  for (const key of Object.keys(raw)) {
223
- if (key !== 'version' && !SECTIONS.some((s) => s.name === key)) issues.push(`未知顶层配置项: "${key}"`);
237
+ if (key !== 'version' && !SECTIONS.some((s) => s.name === key)) issues.push(`unknown top-level config key: "${key}"`);
224
238
  }
225
239
  if (raw.version !== undefined && raw.version !== 1) {
226
- issues.push(`version 必须是 1 (当前: ${JSON.stringify(raw.version)})`);
240
+ issues.push(`version must be 1 (got: ${JSON.stringify(raw.version)})`);
227
241
  }
228
242
  const topLevelInvalid = issues.length > 0;
229
243
 
@@ -233,10 +247,10 @@ export function validateEfficiencyConfig(raw: unknown): ValidateConfigResult {
233
247
  const rawSection = raw[section.name];
234
248
  if (rawSection !== undefined) {
235
249
  if (!isPlainObject(rawSection)) {
236
- issues.push(`${section.name} 必须是 JSON 对象`);
250
+ issues.push(`${section.name} must be a JSON object`);
237
251
  } else {
238
252
  for (const key of Object.keys(rawSection)) {
239
- if (!section.fields.some((f) => f.key === key)) issues.push(`${section.name} 未知配置项: "${key}"`);
253
+ if (!section.fields.some((f) => f.key === key)) issues.push(`${section.name} unknown config key: "${key}"`);
240
254
  }
241
255
  for (const field of section.fields) {
242
256
  if (!applyField(target, field, rawSection[field.key])) issues.push(issueFor(section.name, field));
@@ -323,10 +337,10 @@ function applyEnvOverrides(
323
337
  continue;
324
338
  }
325
339
  const requirement =
326
- override.kind === 'unit' ? '(需 0 到 1)'
327
- : override.kind === 'int' ? `(需 >= ${override.min} 的整数)`
340
+ override.kind === 'unit' ? ' (must be 0 to 1)'
341
+ : override.kind === 'int' ? ` (must be an integer >= ${override.min})`
328
342
  : '';
329
- warn(`无效环境变量 ${override.env}="${raw}"${requirement},忽略`);
343
+ warn(`invalid environment variable ${override.env}="${raw}"${requirement}, ignored`);
330
344
  }
331
345
  }
332
346
 
@@ -347,13 +361,13 @@ export function resolveEfficiencyConfig(opts: ResolveConfigOptions = {}): Effici
347
361
  let baseRaw: unknown = opts.userConfig;
348
362
  if (opts.workspaceConfig !== undefined) {
349
363
  if (isTrusted) baseRaw = opts.workspaceConfig;
350
- else warn('工作区能效配置未被信任(isProjectTrusted=false),已安全忽略工作区配置');
364
+ else warn('workspace efficiency config is not trusted (isProjectTrusted=false); workspace config safely ignored');
351
365
  }
352
366
 
353
367
  let resolved = defaults();
354
368
  if (baseRaw !== undefined) {
355
369
  const validated = validateEfficiencyConfig(baseRaw);
356
- if (!validated.ok) warn(`配置存在校验问题,已安全回退默认值: ${validated.issues.join('; ')}`);
370
+ if (!validated.ok) warn(`config has validation issues; safely fell back to defaults: ${validated.issues.join('; ')}`);
357
371
  resolved = validated.config;
358
372
  }
359
373
 
@@ -426,8 +440,8 @@ export function loadEfficiencyConfigFromDisk(opts: {
426
440
  const warnFile = (label: string, filePath: string, error: unknown): void => {
427
441
  opts.onWarning?.(`${label} ${filePath}: ${error instanceof Error ? error.message : String(error)}`);
428
442
  };
429
- const workspaceConfig = readJsonConfig(wsPath, (error) => warnFile('无法解析工作区能效配置文件', wsPath, error));
430
- const userConfig = readJsonConfig(userPath, (error) => warnFile('无法解析用户能效配置文件', userPath, error));
443
+ const workspaceConfig = readJsonConfig(wsPath, (error) => warnFile('cannot parse workspace efficiency config file', wsPath, error));
444
+ const userConfig = readJsonConfig(userPath, (error) => warnFile('cannot parse user efficiency config file', userPath, error));
431
445
  const resolved = resolveEfficiencyConfig({
432
446
  workspaceConfig,
433
447
  userConfig,
@@ -5,7 +5,7 @@
5
5
  */
6
6
 
7
7
  import { constants } from 'node:fs';
8
- import { lstat, mkdir, open, readdir, realpath, rename, rm, stat } from 'node:fs/promises';
8
+ import { lstat, mkdir, open, readdir, realpath, rename, rm, stat, type FileHandle } from 'node:fs/promises';
9
9
  import { tmpdir } from 'node:os';
10
10
  import { basename, dirname, isAbsolute, join, relative, resolve } from 'node:path';
11
11
  import {
@@ -52,6 +52,21 @@ export function observationObjectPath(root: string, obsId: string): string {
52
52
  return join(root, 'observation-pack', 'objects', `${obsId}.txt`);
53
53
  }
54
54
 
55
+ /** Cross-process idempotence probe: a resumed process has no memo but the content-addressed object
56
+ * is still on disk. A wrong-sized file is treated as absent so the writer re-verifies it. */
57
+ export async function observationObjectExists(
58
+ root: string,
59
+ obsId: string,
60
+ expectedBytes?: number,
61
+ ): Promise<boolean> {
62
+ try {
63
+ const stats = await lstat(observationObjectPath(root, obsId));
64
+ return stats.isFile() && (expectedBytes === undefined || stats.size === expectedBytes);
65
+ } catch {
66
+ return false;
67
+ }
68
+ }
69
+
55
70
  export function reducerObjectPath(root: string, sha256: string): string {
56
71
  return join(root, 'evidence-preserving-reducer', 'objects', `${sha256}.txt`);
57
72
  }
@@ -202,6 +217,27 @@ export async function storeContentAddressedObject(
202
217
  };
203
218
  }
204
219
 
220
+ const ALIGN_SCAN_BYTES = 64 * 1024;
221
+
222
+ /** Advance a mid-line offset to the next line start so a recall page never opens inside a line.
223
+ * A single-line tail (no newline left) keeps the requested offset: skipping to EOF would make the
224
+ * rest of the object unreadable. */
225
+ async function alignOffsetToLineStart(handle: FileHandle, offset: number, size: number): Promise<number> {
226
+ if (offset <= 0 || offset >= size) return offset;
227
+ const prev = Buffer.alloc(1);
228
+ const { bytesRead: prevRead } = await handle.read(prev, 0, 1, offset - 1);
229
+ if (prevRead === 0 || prev[0] === 0x0a) return offset;
230
+
231
+ const scan = Buffer.alloc(Math.min(ALIGN_SCAN_BYTES, size - offset));
232
+ for (let pos = offset; pos < size; pos += scan.length) {
233
+ const { bytesRead } = await handle.read(scan, 0, Math.min(scan.length, size - pos), pos);
234
+ if (bytesRead === 0) break;
235
+ const newline = scan.subarray(0, bytesRead).indexOf(0x0a);
236
+ if (newline !== -1) return pos + newline + 1;
237
+ }
238
+ return offset;
239
+ }
240
+
205
241
  export async function readStoredObjectChunk(
206
242
  filePath: string,
207
243
  offset: number,
@@ -218,14 +254,15 @@ export async function readStoredObjectChunk(
218
254
  throw new Error(`Offset ${offset} exceeds object size ${fileStat.size}`);
219
255
  }
220
256
 
221
- const available = Math.max(0, fileStat.size - offset);
257
+ const start = await alignOffsetToLineStart(handle, offset, fileStat.size);
258
+ const available = Math.max(0, fileStat.size - start);
222
259
  const buffer = Buffer.alloc(Math.min(available, limits.maxBytes + 4));
223
- const { bytesRead } = await handle.read(buffer, 0, buffer.length, offset);
260
+ const { bytesRead } = await handle.read(buffer, 0, buffer.length, start);
224
261
  const sliced = sliceBufferChunk(buffer.subarray(0, bytesRead), 0, limits);
225
262
  // sliceBufferChunk's nextOffset is relative to the window it was given (offset 0). Callers page
226
263
  // with the absolute file offset from the header, so add the seek back.
227
- const nextOffset = offset + sliced.nextOffset;
228
- return { ...sliced, nextOffset, eof: nextOffset >= fileStat.size };
264
+ const nextOffset = start + sliced.nextOffset;
265
+ return { ...sliced, offset: start, nextOffset, eof: nextOffset >= fileStat.size };
229
266
  } finally {
230
267
  await handle?.close();
231
268
  }
@@ -147,9 +147,20 @@ export function herdrUnavailableHint(err: unknown): string | null {
147
147
  /** Wire protocol this client is written against (herdr 0.9.5, contract fixture regenerated 2026-10-20). */
148
148
  export const HERDR_PROTOCOL_EXPECTED = 22;
149
149
 
150
+ export type WaitFailureReason = 'timeout' | 'unavailable';
151
+
150
152
  export type WaitForOutputResult =
151
153
  | { matched: true }
152
- | { matched: false; reason: 'timeout' | 'unavailable' };
154
+ | { matched: false; reason: WaitFailureReason };
155
+
156
+ /** Classify a failed `pane.wait_for_output`: null means the error is not a wait outcome and should
157
+ * surface. herdr may report its own server-side timeout under an `unavailable` code or as
158
+ * "timed out"; a wait that consumed its budget is a timeout regardless of the label it chose. */
159
+ export function classifyWaitFailure(message: string, elapsedMs: number, timeoutMs: number): WaitFailureReason | null {
160
+ if (/timeout|timed out|deadline exceeded/i.test(message) || elapsedMs >= timeoutMs * 0.95) return 'timeout';
161
+ if (/not supported|not implemented|unknown method|unavailable|method not found/i.test(message)) return 'unavailable';
162
+ return null;
163
+ }
153
164
 
154
165
  /** pane.read / agent.read selection: `recent` keeps ANSI so T6 fullscreen detection still sees it. */
155
166
  export interface ReadOptions {
@@ -783,6 +794,7 @@ export class HerdrClient implements HerdrClientLike {
783
794
  }
784
795
 
785
796
  async waitForOutput(paneId: string, match: { type: 'substring' | 'regex'; value: string }, timeoutMs: number): Promise<WaitForOutputResult> {
797
+ const started = Date.now();
786
798
  try {
787
799
  await this.request('pane.wait_for_output', {
788
800
  pane_id: paneId,
@@ -793,10 +805,8 @@ export class HerdrClient implements HerdrClientLike {
793
805
  return { matched: true };
794
806
  } catch (err) {
795
807
  const msg = String((err as Error)?.message ?? err);
796
- if (/timeout/i.test(msg)) return { matched: false, reason: 'timeout' };
797
- if (/not supported|not implemented|unknown method|unavailable|method not found/i.test(msg)) {
798
- return { matched: false, reason: 'unavailable' };
799
- }
808
+ const reason = classifyWaitFailure(msg, Date.now() - started, timeoutMs);
809
+ if (reason != null) return { matched: false, reason };
800
810
  throw err;
801
811
  }
802
812
  }
@@ -14,7 +14,7 @@ export function collapseNotices(contents: readonly string[], max = NOTICE_MAX_SH
14
14
  if (contents.length === 0) return null;
15
15
  if (contents.length <= max) return contents.join('\n\n');
16
16
  const hidden = contents.length - max;
17
- const tail = `…另有 ${hidden} 条结算未逐条展示。全量结果看 history 台账(路径公式见 subagent resume 工具描述);在跑代理用 subagent list 查看。`;
17
+ const tail = `…${hidden} more settlement(s) not shown individually. See the history ledger for full results (path formula in the subagent resume tool description); use subagent list for running agents.`;
18
18
  return [...contents.slice(0, max), tail].join('\n\n');
19
19
  }
20
20
 
package/src/index-pipe.ts CHANGED
@@ -21,8 +21,6 @@ export interface PipeHandlerSession {
21
21
  setPendingMachineRequest: (req: MachineRequest | null) => void;
22
22
  /** P0 (RFC §4.4): apply a role switch requested over the pipe (master → worker). */
23
23
  applyRoleSwitch: (role: string, switchedBy: string) => Promise<{ ok: boolean; message: string }>;
24
- /** Phase 2 (RFC rfc-delegation-identity §3.2): worker → master progress note (display-less custom message). */
25
- deliverProgress: (workerPaneId: string, text: string) => Promise<void>;
26
24
  /** D113 (RFC rfc-unified-widget): worker → master full todo push; updates the widget cache. */
27
25
  applyWorkerTodos: (workerPaneId: string, items: Array<{ content: string; status: string; blocker?: string }>, summary: string) => void;
28
26
  }
@@ -58,12 +56,6 @@ export async function handlePipeRequest(
58
56
  ? { type: 'ok', id: req.id, detail: res.message }
59
57
  : { type: 'error', id: req.id, message: res.message };
60
58
  }
61
- case 'progress': {
62
- // Never triggers a master turn and never floods the visible chat (RFC §3.2); the
63
- // display-less custom message rides context only, rendered as a compact card.
64
- await s.deliverProgress(req.paneId, req.text);
65
- return { type: 'ok', id: req.id };
66
- }
67
59
  case 'todo_sync': {
68
60
  s.applyWorkerTodos(req.paneId, req.items, req.summary);
69
61
  return { type: 'ok', id: req.id };
package/src/index.ts CHANGED
@@ -10,7 +10,6 @@
10
10
  * details persist in session JSONL and getBranch() replay implements branch rollback.
11
11
  */
12
12
  import type { ExtensionAPI } from '@earendil-works/pi-coding-agent';
13
- import { Type } from 'typebox';
14
13
  import {
15
14
  ASK_PARAMETERS,
16
15
  ASK_PROMPT_GUIDELINES,
@@ -20,6 +19,7 @@ import {
20
19
  hasAskUi,
21
20
  noUiResult,
22
21
  prepareAsk,
22
+ prepareAskArguments,
23
23
  runAsk,
24
24
  } from './ask-user.ts';
25
25
  import { pipeNameFor, pipeRequest, startPipeServer } from './pipe-channel.ts';
@@ -42,7 +42,7 @@ import { parseRuntimeManifest } from './tool-gate.ts';
42
42
  import { formatPaneTitle } from './pane-title.ts';
43
43
  import { registerSlimFrame, updateSlimFrame } from './slim-frame.ts';
44
44
  import { applyRestoredWorkerMode, planIndexMode } from './index-runtime.ts';
45
- import { buildResumeArgv, emptySubagentPortBox, PROGRESS_CUSTOM_TYPE } from './subagent-core.ts';
45
+ import { buildResumeArgv, emptySubagentPortBox, sleepWhileSubsRunningNote } from './subagent-core.ts';
46
46
  import { hasWorkerMarker, writeWorkerMarker, readWorkerMarker } from './worker-marker.ts';
47
47
  import {
48
48
  DELEGATION_CUSTOM_TYPE,
@@ -58,18 +58,16 @@ import { handlePipeRequest, type MachineRequest } from './index-pipe.ts';
58
58
  import { installWriteLocks } from './index-locks.ts';
59
59
  import { installDashboardCommand } from './dashboard-command.ts';
60
60
  import { openPierPanel } from './panel-open.ts';
61
- import { registerObservationPack, createCompactionBatchPackHook, type PickMiddleExcerpt } from './plugins/observation.ts';
61
+ import { registerObservationPack, createCompactionBatchPackHook, type PickMiddleExcerpts } from './plugins/observation.ts';
62
62
  import { createJevRuntime } from './jev-client.ts';
63
63
  import {
64
64
  NOTICE_RANK_MIN_CONFIDENCE,
65
- buildExcerptWindows,
66
65
  composeNoticeRanking,
67
66
  evaluateExcerptPick,
68
- excerptAskIsSafe,
69
67
  excerptPickRequest,
70
68
  noticeRankRequest,
69
+ planExcerptAsks,
71
70
  } from './jev-core.ts';
72
- import { completeLineExcerpt } from './observation-core.ts';
73
71
  import { installConfigCommand } from './config-command.ts';
74
72
  import { CompactCoordinator } from './compact-coordinator.ts';
75
73
  import { handleReducerToolResult, type ToolResultEventLike } from './reducer-invoker.ts';
@@ -113,30 +111,37 @@ export default async function (pi: ExtensionAPI) {
113
111
  /* ── Jev decision layer (RFC docs/rfc-jev-integration.md): direct HTTP, total-budget
114
112
  * abort, fail-open at every site. Disabled or keyless → behavior identical to before. ── */
115
113
  const jevRuntime = createJevRuntime(() => effConfig.jev, { getSessionRoot: () => sessionRoot, getSessionId: () => sessionId || null });
116
- /** P0-3: candidate windows are generated in code; jev only picks (rerank pattern). */
117
- const pickMiddleExcerpt: PickMiddleExcerpt = async (text, excerptBudgetBytes) => {
118
- if (!jevRuntime.available) return null;
119
- const halfBudget = Math.floor(excerptBudgetBytes / 2);
120
- const windows = buildExcerptWindows(text, halfBudget);
121
- if (windows.length === 0) return null; // degenerate budget only
122
- const headExcerpt = completeLineExcerpt(text, halfBudget, false);
123
- const tailExcerpt = completeLineExcerpt(text, excerptBudgetBytes - halfBudget, true);
124
- // Credential-shaped text never leaves the process (§8 privacy boundary).
125
- if (!excerptAskIsSafe(windows, headExcerpt, tailExcerpt)) return null;
126
- const request = excerptPickRequest(windows, headExcerpt, tailExcerpt);
127
- if (!request) return null;
114
+ /** P0-3: candidate windows are generated in code; jev only picks (rerank pattern), one request per batch. */
115
+ const pickMiddleExcerpts: PickMiddleExcerpts = async (items, excerptBudgetBytes) => {
116
+ const picks: Array<{ text: string; label: string } | null> = items.map(() => null);
117
+ if (!jevRuntime.available) return picks;
118
+ const asked = planExcerptAsks(items, excerptBudgetBytes);
119
+ const request = excerptPickRequest(asked);
120
+ if (!request) return picks;
128
121
  const result = await jevRuntime.ask(request, {
129
122
  questionId: 'obs-excerpt-window',
130
- extra: { site: 'obs-excerpt', candidates: windows.map((w) => w.id) },
123
+ extra: {
124
+ site: 'obs-excerpt',
125
+ items: asked.map((a) => ({ obsId: a.obsId, windows: a.windows.map((w) => ({ id: w.id, startLine: w.startLine })) })),
126
+ },
131
127
  enrich: ({ answers }) => {
132
- if (!answers || answers.window === undefined || answers.window.type !== 'choice') return {};
133
- return { picked: answers.window.choice, confidence: answers.window.confidence };
128
+ if (!answers) return {};
129
+ return {
130
+ picked: asked.map((_, i) => {
131
+ const answer = answers[`excerpt_${i}_window`];
132
+ return answer?.type === 'choice' ? { choice: answer.choice, confidence: answer.confidence } : null;
133
+ }),
134
+ };
134
135
  },
135
136
  });
136
- if (!result.ok) return null;
137
- const picked = evaluateExcerptPick(result.answers, effConfig.jev.minConfidence);
138
- const window = picked === null ? undefined : windows.find((w) => w.id === picked);
139
- return window ? { text: window.text, label: window.label } : null;
137
+ if (!result.ok) return picks;
138
+ for (let i = 0; i < asked.length; i++) {
139
+ const { index, windows } = asked[i]!;
140
+ const picked = evaluateExcerptPick(result.answers, i, effConfig.jev.minConfidence);
141
+ const window = picked === null ? undefined : windows.find((w) => w.id === picked);
142
+ if (window) picks[index] = { text: window.text, label: window.label };
143
+ }
144
+ return picks;
140
145
  };
141
146
 
142
147
  const rendererTypes = installRenderers(pi);
@@ -290,6 +295,8 @@ export default async function (pi: ExtensionAPI) {
290
295
  }
291
296
  todos.applyEdits(plan.edits, { source: 'reconcile' });
292
297
  mirrorTodos();
298
+ // Reconcile is not a tool call, so nothing else refreshes the resident widget after an edit.
299
+ todoUi.rerenderWidget?.();
293
300
  }
294
301
  return plan.noteLines;
295
302
  } catch {
@@ -353,6 +360,18 @@ export default async function (pi: ExtensionAPI) {
353
360
  });
354
361
  });
355
362
 
363
+ /* ── Masters sleep-polled background workers (sleep 120…840) instead of trusting the settlement notice. ── */
364
+ if (!isSubagent) {
365
+ pi.on('tool_result', async (event: unknown) => {
366
+ const rec = (event ?? {}) as { toolName?: unknown; input?: { command?: unknown }; content?: unknown };
367
+ if (rec.toolName !== 'bash' || typeof rec.input?.command !== 'string') return;
368
+ const note = sleepWhileSubsRunningNote(rec.input.command, subagentPort.current?.listRunningSubs() ?? []);
369
+ if (!note) return;
370
+ const content = Array.isArray(rec.content) ? rec.content as Array<{ type: 'text'; text: string }> : [];
371
+ return { content: [...content, { type: 'text' as const, text: note }] };
372
+ });
373
+ }
374
+
356
375
  let locksHandle = { getHeldLocks: () => [] as readonly string[] };
357
376
  if (env) {
358
377
  locksHandle = installWriteLocks(pi, { client, env, hard: process.env[WRITE_LOCK_ENV] === '1' });
@@ -365,19 +384,11 @@ export default async function (pi: ExtensionAPI) {
365
384
  return Promise.resolve();
366
385
  };
367
386
 
368
- /** Phase 2 (RFC rfc-delegation-identity §3.2): a worker's progress note enters master context as
369
- * a display-less custom message — no turn, no visible chat line; renderers.ts gives it a card. */
370
- const deliverProgress = (workerPaneId: string, text: string): Promise<void> => {
371
- (pi as unknown as {
372
- sendMessage?: (msg: { customType: string; content: string; display: boolean; details?: Record<string, unknown> }, opts?: { triggerTurn?: boolean }) => void;
373
- }).sendMessage?.(
374
- { customType: PROGRESS_CUSTOM_TYPE, content: text.slice(0, 2000), display: false, details: { paneId: workerPaneId } },
375
- { triggerTurn: false },
376
- );
377
- return Promise.resolve();
378
- };
379
387
  const notices = createNoticeBuffer({
380
- isBusy: () => agentActive || lastStopReason === ABORT_STOP_REASON,
388
+ // pi core rejects prompts while it is compacting (and drops fire-and-forget sends), so a
389
+ // settlement landing inside the compaction window must stay buffered until the continuation
390
+ // run settles — otherwise the result is lost with nobody left to retry it.
391
+ isBusy: () => agentActive || lastStopReason === ABORT_STOP_REASON || coordinator.compactionInFlight,
381
392
  send: sendUserMessageAs,
382
393
  // P0-2: rank collapsed batches by relevance to the master's in-progress work.
383
394
  rank: async (contents) => {
@@ -443,7 +454,6 @@ export default async function (pi: ExtensionAPI) {
443
454
  abort: () => { latestCtx?.abort?.(); },
444
455
  setPendingMachineRequest: (next) => { pendingMachineRequest = next; },
445
456
  applyRoleSwitch: roles.applyRoleSwitch,
446
- deliverProgress,
447
457
  applyWorkerTodos,
448
458
  }));
449
459
  } catch {
@@ -589,6 +599,9 @@ export default async function (pi: ExtensionAPI) {
589
599
  // Safety fallback: pi core prohibits prompts while compaction is active.
590
600
  coordinator.compactionInFlight = false;
591
601
  coordinator.intentionalAbort = false;
602
+ // A selection that never became startable must not latch every later decision (and the
603
+ // settle-wake hold) until the next human input.
604
+ coordinator.selectedCompaction = null;
592
605
  gates.report('working', currentActivity(todos.items));
593
606
  });
594
607
 
@@ -612,15 +625,20 @@ export default async function (pi: ExtensionAPI) {
612
625
  // flush ran regardless. The merged handler keeps both semantics.
613
626
  const msg = event !== null && typeof event === 'object' && 'message' in event ? (event as { message: unknown }).message : null;
614
627
  if (msg !== null && typeof msg === 'object') {
615
- const { role, stopReason } = msg as { role?: unknown; stopReason?: unknown };
628
+ const { role, stopReason, usage } = msg as { role?: unknown; stopReason?: unknown; usage?: unknown };
616
629
  if (role === 'assistant' && typeof stopReason === 'string') {
617
630
  lastStopReason = stopReason;
618
631
  }
632
+ if (role === 'assistant' && usage !== null && typeof usage === 'object') {
633
+ coordinator.onAssistantUsage(usage as { input?: number; cacheRead?: number; cacheWrite?: number });
634
+ }
619
635
  if (ctx && typeof ctx === 'object') {
620
636
  coordinator.onTurnEnd({
621
637
  ctx,
622
638
  todos: todos.items,
623
639
  config: effConfig.onlineContextCompact,
640
+ pi,
641
+ lastStopReason: typeof stopReason === 'string' ? stopReason : null,
624
642
  cancelReminder: todoUi.cancelReminder,
625
643
  });
626
644
  }
@@ -649,11 +667,18 @@ export default async function (pi: ExtensionAPI) {
649
667
  // D96: master settled while background subagents still run → remind (worker port is unbound).
650
668
  if (plan.wake && plan.notice && !isSubagent) {
651
669
  const running = subagentPort.current?.listRunningSubs() ?? [];
652
- const brief = running.map((s) => `${s.paneId} (${s.description})`).join('、');
653
- const notice = `注意:仍有 ${running.length} 个后台 subagent 在运行:${brief}。若你的任务依赖它们,请等待其结算(subagent list 查看状态);若不等待,请说明放弃原因。`;
670
+ const brief = running.map((s) => `${s.paneId} (${s.description})`).join(', ');
671
+ const notice = `Note: ${running.length} background subagent(s) still running: ${brief}. If your task depends on them, wait for their settlement (subagent list to check status); if you will not wait, state why you are abandoning them.`;
654
672
  void sendUserMessageAs(notice, 'followUp');
655
673
  }
656
674
  if (plan.compact && ctx && typeof ctx === 'object') {
675
+ // Settlement notices that land inside the compaction window stay buffered (isBusy includes
676
+ // compactionInFlight). The continuation run flushes them at its own agent_settled; a cancelled
677
+ // or rejected compaction sends no continuation, so release the buffer once the coordinator is
678
+ // done and no run has started.
679
+ const flushAfterCompaction = (): void => {
680
+ if (!coordinator.compactionInFlight && !agentActive) void notices.flush('followUp');
681
+ };
657
682
  void coordinator.onAgentSettled({
658
683
  ctx,
659
684
  todos: todos.items,
@@ -664,9 +689,9 @@ export default async function (pi: ExtensionAPI) {
664
689
  getObsConfig: () => effConfig.observationPack,
665
690
  getManifest: () => roles.state.manifest,
666
691
  getSessionId: () => sessionId,
667
- pickMiddleExcerpt,
692
+ pickMiddleExcerpts,
668
693
  }),
669
- });
694
+ }).then(flushAfterCompaction, flushAfterCompaction);
670
695
  }
671
696
  if (lastStopReason !== ABORT_STOP_REASON && !aborted) void notices.flush('followUp');
672
697
  void pushSettleReply(aborted);
@@ -677,7 +702,7 @@ export default async function (pi: ExtensionAPI) {
677
702
  pi,
678
703
  getConfig: () => effConfig,
679
704
  getRuntimeManifest: () => roles.state.manifest,
680
- pickMiddleExcerpt,
705
+ pickMiddleExcerpts,
681
706
  getRemainingHorizon: () => {
682
707
  const usage = (latestCtx as { getContextUsage?: () => { contextWindow?: number } })?.getContextUsage?.();
683
708
  return coordinator.getRemainingHorizon(3, usage?.contextWindow ?? null);
@@ -701,6 +726,8 @@ export default async function (pi: ExtensionAPI) {
701
726
  description: ASK_TOOL_DESCRIPTION,
702
727
  promptGuidelines: ASK_PROMPT_GUIDELINES,
703
728
  parameters: ASK_PARAMETERS,
729
+ // Models sometimes send options/questions as a JSON string; repair them before pi validates.
730
+ prepareArguments: prepareAskArguments,
704
731
  async execute(_tc, params, signal, _upd, ctx) {
705
732
  const prepared = prepareAsk(params);
706
733
  if (!prepared.ok) return prepared.result;
@@ -717,49 +744,6 @@ export default async function (pi: ExtensionAPI) {
717
744
  },
718
745
  });
719
746
 
720
- /* ── report_progress (Phase 2, RFC rfc-delegation-identity §3.2): worker → master checkpoint ── */
721
- if (isSubagent) {
722
- pi.registerTool({
723
- name: 'report_progress',
724
- label: 'Report Progress',
725
- description:
726
- 'Send the delegating master a short progress update without finishing the task. Use at meaningful checkpoints (a module done, a test went green, a blocker found) — the master sees it in its context but is not interrupted.',
727
- promptSnippet: 'report_progress: mid-task checkpoint to the delegating master (does not end the task).',
728
- promptGuidelines: [
729
- 'Call at meaningful checkpoints only — a milestone reached, a risk discovered, a decision the master should know early — not after every tool call.',
730
- 'One or two sentences per call; the final answer still goes in your normal closing message when the task finishes.',
731
- ],
732
- parameters: Type.Object({
733
- message: Type.String({ description: 'Short checkpoint update for the delegating master.' }),
734
- }),
735
- async execute(_tc, params) {
736
- const parentPipe = delegation?.parentPipe;
737
- const message = typeof (params as { message?: unknown })?.message === 'string'
738
- ? (params as { message: string }).message.trim()
739
- : '';
740
- if (!message) {
741
- return { content: [{ type: 'text', text: 'Error: `message` is required (one or two sentences).' }], details: {} };
742
- }
743
- if (!parentPipe) {
744
- return { content: [{ type: 'text', text: 'Error: no delegating master pipe is known to this session; progress cannot be delivered.' }], details: {} };
745
- }
746
- try {
747
- const res = await pipeRequest(parentPipe, {
748
- type: 'progress',
749
- id: `prog-${Date.now()}`,
750
- paneId: env?.paneId ?? '',
751
- text: message,
752
- }, 5000);
753
- return res.type === 'ok'
754
- ? { content: [{ type: 'text', text: 'Progress delivered to the master (visible in its context, no interruption).' }], details: {} }
755
- : { content: [{ type: 'text', text: `Error: master rejected progress report: ${res.message}` }], details: {} };
756
- } catch (err) {
757
- return { content: [{ type: 'text', text: `Error: could not reach the master pipe: ${(err as Error)?.message ?? err}` }], details: {} };
758
- }
759
- },
760
- });
761
- }
762
-
763
747
  pi.on('session_shutdown', async () => {
764
748
  coordinator.compactionInFlight = false;
765
749
  coordinator.intentionalAbort = false;