pi-pier 0.3.0 → 0.4.1

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 (103) hide show
  1. package/README.md +16 -12
  2. package/package.json +3 -3
  3. package/schemas/role-manifest.schema.json +7 -1
  4. package/skills/pi-herdr-workflow/SKILL.md +4 -5
  5. package/src/ansi-text.ts +62 -11
  6. package/src/ask-dialog.ts +308 -0
  7. package/src/ask-user.ts +113 -112
  8. package/src/bootstrap.ts +10 -13
  9. package/src/compact-coordinator.ts +279 -261
  10. package/src/compact-economics-core.ts +77 -145
  11. package/src/config-catalog-core.ts +77 -197
  12. package/src/config-command.ts +271 -64
  13. package/src/cordis-services.ts +64 -0
  14. package/src/dashboard-command.ts +77 -33
  15. package/src/delegation-core.ts +115 -0
  16. package/src/efficiency-config-core.ts +286 -486
  17. package/src/efficiency-store.ts +16 -29
  18. package/src/gc-core.ts +10 -36
  19. package/src/guards.ts +6 -0
  20. package/src/herdr-client.ts +202 -233
  21. package/src/history-store.ts +38 -23
  22. package/src/index-gates.ts +118 -0
  23. package/src/index-locks.ts +2 -7
  24. package/src/index-master.ts +31 -29
  25. package/src/index-notices.ts +28 -15
  26. package/src/index-pipe.ts +31 -12
  27. package/src/index-roles.ts +306 -0
  28. package/src/index-runtime.ts +16 -6
  29. package/src/index-worker.ts +8 -18
  30. package/src/index.ts +561 -570
  31. package/src/jev-client.ts +12 -17
  32. package/src/jev-core.ts +138 -90
  33. package/src/ledger.ts +13 -21
  34. package/src/lock-core.ts +28 -56
  35. package/src/manifest-compose.ts +35 -20
  36. package/src/observation-core.ts +52 -100
  37. package/src/pane-title.ts +19 -38
  38. package/src/panel-open.ts +47 -0
  39. package/src/pi-surface.ts +34 -22
  40. package/src/pier-options.ts +53 -50
  41. package/src/pipe-channel.ts +44 -59
  42. package/src/platform-paths.ts +19 -52
  43. package/src/{core → plugins}/grid-shape.ts +8 -10
  44. package/src/{core → plugins}/heat-plan.ts +20 -22
  45. package/src/plugins/observation.ts +475 -0
  46. package/src/plugins/subagent.ts +766 -0
  47. package/src/{core → plugins}/terminal.ts +198 -139
  48. package/src/{core → plugins}/todo.ts +73 -134
  49. package/src/progress-core.ts +16 -53
  50. package/src/reconcile-core.ts +24 -33
  51. package/src/reducer-core.ts +26 -64
  52. package/src/reducer-invoker.ts +266 -239
  53. package/src/renderers.ts +108 -84
  54. package/src/role-loader.ts +33 -28
  55. package/src/role-manifest.ts +25 -31
  56. package/src/role-state.ts +119 -0
  57. package/src/roles/worker-default.json +3 -3
  58. package/src/routing-telemetry.ts +136 -0
  59. package/src/runtime-policy.ts +25 -40
  60. package/src/session-tail.ts +65 -41
  61. package/src/settle-wake-core.ts +61 -22
  62. package/src/slim-frame.ts +25 -122
  63. package/src/stale-core.ts +17 -24
  64. package/src/storage-layout.ts +14 -43
  65. package/src/subagent-core.ts +423 -120
  66. package/src/subagent-gc.ts +109 -67
  67. package/src/subagent-poller.ts +383 -24
  68. package/src/subagent-session.ts +516 -0
  69. package/src/subagent-spawn.ts +577 -52
  70. package/src/swallow.ts +7 -17
  71. package/src/terminal-core.ts +187 -134
  72. package/src/todo-core.ts +57 -109
  73. package/src/todo-read-hook.ts +46 -39
  74. package/src/todo-reminder-core.ts +26 -51
  75. package/src/todo-window.ts +11 -12
  76. package/src/todos-service.ts +15 -35
  77. package/src/tool-error.ts +6 -13
  78. package/src/tool-gate.ts +38 -21
  79. package/src/tree-view.ts +162 -0
  80. package/src/vocab.ts +52 -25
  81. package/src/worker-marker.ts +76 -0
  82. package/src/ask-multi.ts +0 -260
  83. package/src/config-guide.ts +0 -361
  84. package/src/core/observation.ts +0 -555
  85. package/src/core/subagent.ts +0 -633
  86. package/src/efficiency-config-io.ts +0 -53
  87. package/src/focus-poller.ts +0 -244
  88. package/src/git-adapter.ts +0 -113
  89. package/src/notice-buffer.ts +0 -23
  90. package/src/subagent-execute.ts +0 -328
  91. package/src/subagent-git-io.ts +0 -80
  92. package/src/subagent-launch.ts +0 -114
  93. package/src/subagent-list-action.ts +0 -69
  94. package/src/subagent-output-action.ts +0 -104
  95. package/src/subagent-output-adapter.ts +0 -27
  96. package/src/subagent-output-core.ts +0 -263
  97. package/src/subagent-poll-loop.ts +0 -305
  98. package/src/subagent-port.ts +0 -27
  99. package/src/subagent-ready.ts +0 -89
  100. package/src/subagent-registry.ts +0 -70
  101. package/src/subagent-resolution.ts +0 -41
  102. package/src/subagent-scope.ts +0 -41
  103. package/src/subagent-session-io.ts +0 -230
@@ -1,40 +1,34 @@
1
1
  /**
2
- * D100 Online Context Compact Coordinator.
3
- *
4
- * Orchestrates:
5
- * - Tracking todo completion boundaries (filtered to source === 'tool').
6
- * - Evaluating KV-cache economics & window protection on turn_end.
7
- * - Guarding against ejecting queued messages (hasPendingMessages check).
8
- * - Aborting turn and executing Pi native compaction with custom instructions.
9
- * - Feeding remaining tasks forward and injecting continuation turn.
10
- * - Persisting state to session entries across branches and restores.
2
+ * D100 Online Context Compact Coordinator: tracks tool-sourced todo completion boundaries, evaluates
3
+ * KV-cache economics and window protection on turn_end, aborts the in-flight turn, runs pi's native
4
+ * compaction, then continues the task. State persists to session entries (branch rebuilds restore it).
11
5
  */
12
6
 
13
7
  import type { ExtensionAPI, ExtensionContext } from '@earendil-works/pi-coding-agent';
14
- import {
15
- decideCompaction,
16
- DEFAULT_COMPACTION_ECONOMICS,
17
- estimateRemainingRequests,
18
- MAX_BOUNDARY_SAMPLES,
19
- nativeCompactionFeasible,
20
- resolveCacheRatioFromCost,
21
- type CompactionDecision,
22
- } from './compact-economics-core.ts';
8
+ import { decideCompaction, DEFAULT_COMPACTION_ECONOMICS, estimateRemainingRequests, MAX_BOUNDARY_SAMPLES, nativeCompactionFeasible, resolveCacheRatioFromCost, type CompactionDecision } from './compact-economics-core.ts';
23
9
  import type { TodoItem } from './todo-core.ts';
24
10
  import type { TodoCompletionSource } from './todos-service.ts';
25
11
  import type { OnlineContextCompactConfig } from './efficiency-config-core.ts';
26
- import {
27
- appendEfficiencyLog,
28
- efficiencyLogPath,
29
- resolveSessionRoot,
30
- } from './efficiency-store.ts';
12
+ import { appendEfficiencyLog, efficiencyLogPath, resolveSessionRoot } from './efficiency-store.ts';
13
+ import { swallow } from './swallow.ts';
31
14
 
32
15
  export const COMPACT_STATE_CUSTOM_TYPE = 'pi-herdr.efficiency-state';
33
16
  export const COMPACTION_CONTINUE_TYPE = 'pi-herdr.compaction-continue';
34
- export const DEFAULT_KEEP_RECENT_TOKENS = 20_000;
35
- export const DEFAULT_MEMO_TOKENS = 1_000;
17
+ /** Shown to the model after a successful OCC. Names the abort so it is not retried as a failure. */
18
+ export const COMPACTION_CONTINUE_TEXT =
19
+ 'Online context compaction finished. The preceding abort ("This operation was aborted" / "Operation aborted") was this compaction aborting the in-flight turn on purpose — not a model failure, not a user cancel, and not a reason to retry that call. Active tasks are preserved. Continue the remaining work.';
20
+ /**
21
+ * Cross-session compaction markers: OCC's abort lands in the child transcript as an assistant
22
+ * message with stopReason 'error', which a master's poller would read as a settled turn. The
23
+ * inflight/settled pair gives it a "do not settle" window pane state cannot provide (pane shows idle).
24
+ */
25
+ export const COMPACTION_INFLIGHT_TYPE = 'pi-herdr.compaction-inflight';
26
+ export const COMPACTION_SETTLED_TYPE = 'pi-herdr.compaction-settled';
27
+ const DEFAULT_KEEP_RECENT_TOKENS = 20_000;
28
+ const DEFAULT_MEMO_TOKENS = 1_000;
36
29
 
37
- export interface CoordinatorState {
30
+ /** Fields persisted in the state marker; `version: 1` gates restores. */
31
+ interface CoordinatorState {
38
32
  version: 1;
39
33
  epoch: number;
40
34
  completedBoundaryRequestCounts: number[];
@@ -45,9 +39,24 @@ export interface CoordinatorState {
45
39
  positiveContextDeltaCount: number;
46
40
  lastContextTokens: number | null;
47
41
  currentBoundaryRequestCount: number;
42
+ consecutiveCompactionFailures: number;
43
+ /** P1-3: turn-end compact decisions to skip after a failure (each abort costs an in-flight turn). */
44
+ compactBackoffTurnEnds: number;
48
45
  }
49
46
 
50
- export function initialCoordinatorState(): CoordinatorState {
47
+ const NUMERIC_STATE_FIELDS = [
48
+ 'epoch',
49
+ 'carriedDebtTokens',
50
+ 'cacheDebtRepaymentTokens',
51
+ 'priorCompactionCount',
52
+ 'positiveContextDeltaTotal',
53
+ 'positiveContextDeltaCount',
54
+ 'currentBoundaryRequestCount',
55
+ 'consecutiveCompactionFailures',
56
+ 'compactBackoffTurnEnds',
57
+ ] as const satisfies readonly (keyof CoordinatorState)[];
58
+
59
+ function initialCoordinatorState(): CoordinatorState {
51
60
  return {
52
61
  version: 1,
53
62
  epoch: 0,
@@ -59,32 +68,52 @@ export function initialCoordinatorState(): CoordinatorState {
59
68
  positiveContextDeltaCount: 0,
60
69
  lastContextTokens: null,
61
70
  currentBoundaryRequestCount: 0,
71
+ consecutiveCompactionFailures: 0,
72
+ compactBackoffTurnEnds: 0,
62
73
  };
63
74
  }
64
75
 
65
76
  export function restoreCoordinatorState(entries: readonly unknown[]): CoordinatorState {
66
- if (!Array.isArray(entries)) return initialCoordinatorState();
77
+ const base = initialCoordinatorState();
78
+ if (!Array.isArray(entries)) return base;
67
79
  for (let i = entries.length - 1; i >= 0; i--) {
68
80
  const entry = entries[i] as { type?: string; customType?: string; data?: unknown };
69
- if (entry && entry.type === 'custom' && entry.customType === COMPACT_STATE_CUSTOM_TYPE) {
70
- const d = entry.data as Partial<CoordinatorState>;
71
- if (d && d.version === 1 && Array.isArray(d.completedBoundaryRequestCounts)) {
72
- return {
73
- version: 1,
74
- epoch: typeof d.epoch === 'number' ? d.epoch : 0,
75
- completedBoundaryRequestCounts: [...d.completedBoundaryRequestCounts],
76
- carriedDebtTokens: typeof d.carriedDebtTokens === 'number' ? d.carriedDebtTokens : 0,
77
- cacheDebtRepaymentTokens: typeof d.cacheDebtRepaymentTokens === 'number' ? d.cacheDebtRepaymentTokens : 0,
78
- priorCompactionCount: typeof d.priorCompactionCount === 'number' ? d.priorCompactionCount : 0,
79
- positiveContextDeltaTotal: typeof d.positiveContextDeltaTotal === 'number' ? d.positiveContextDeltaTotal : 0,
80
- positiveContextDeltaCount: typeof d.positiveContextDeltaCount === 'number' ? d.positiveContextDeltaCount : 0,
81
- lastContextTokens: typeof d.lastContextTokens === 'number' ? d.lastContextTokens : null,
82
- currentBoundaryRequestCount: typeof d.currentBoundaryRequestCount === 'number' ? d.currentBoundaryRequestCount : 0,
83
- };
84
- }
81
+ if (entry?.type !== 'custom' || entry.customType !== COMPACT_STATE_CUSTOM_TYPE) continue;
82
+ const d = entry.data as Partial<CoordinatorState> | null | undefined;
83
+ if (!d || d.version !== 1 || !Array.isArray(d.completedBoundaryRequestCounts)) continue;
84
+ for (const field of NUMERIC_STATE_FIELDS) {
85
+ const value = d[field];
86
+ if (typeof value === 'number') base[field] = value;
85
87
  }
88
+ if (typeof d.lastContextTokens === 'number') base.lastContextTokens = d.lastContextTokens;
89
+ base.completedBoundaryRequestCounts = [...d.completedBoundaryRequestCounts];
90
+ return base;
86
91
  }
87
- return initialCoordinatorState();
92
+ return base;
93
+ }
94
+
95
+ function averageContextIncrement(state: CoordinatorState): number | null {
96
+ return state.positiveContextDeltaCount > 0
97
+ ? state.positiveContextDeltaTotal / state.positiveContextDeltaCount
98
+ : null;
99
+ }
100
+
101
+ function remainingTodos(todos: readonly TodoItem[]): readonly TodoItem[] {
102
+ return todos.filter((it) => it.status !== 'completed' && it.status !== 'abandoned');
103
+ }
104
+
105
+ /** P1-3: bound the payload — a huge open-task list inflates the summary request itself. */
106
+ function compactionInstructions(todos: readonly TodoItem[]): string {
107
+ const remaining = remainingTodos(todos);
108
+ const taskLines = remaining
109
+ .slice(0, 25)
110
+ .map((it) => `- [${it.status}] ${it.content.slice(0, 160)}${it.blocker ? ` (waiting on: ${it.blocker.slice(0, 80)})` : ''}`)
111
+ .join('\n');
112
+ const elided = remaining.length > 25 ? `\n… (+${remaining.length - 25} more, elided)` : '';
113
+ return [
114
+ 'Preserve completed work, verification results, important decisions, and remaining work.',
115
+ remaining.length > 0 ? `Active/remaining tasks to preserve:\n${taskLines}${elided}` : 'All listed tasks are completed.',
116
+ ].join('\n\n');
88
117
  }
89
118
 
90
119
  export class CompactCoordinator {
@@ -95,19 +124,18 @@ export class CompactCoordinator {
95
124
  pendingBoundaryCompleted = false;
96
125
 
97
126
  recordBoundaryCompleted(count: number, source?: TodoCompletionSource): void {
98
- // Only count completed transitions originating from model tool calls
127
+ // Only model tool calls count, and the sample is the requests spent reaching this boundary.
99
128
  if (source !== 'tool' || count <= 0) return;
100
129
  this.pendingBoundaryCompleted = true;
101
- if (this.state.currentBoundaryRequestCount > 0) {
102
- this.state.completedBoundaryRequestCounts.push(this.state.currentBoundaryRequestCount);
103
- if (this.state.completedBoundaryRequestCounts.length > MAX_BOUNDARY_SAMPLES) {
104
- this.state.completedBoundaryRequestCounts.splice(
105
- 0,
106
- this.state.completedBoundaryRequestCounts.length - MAX_BOUNDARY_SAMPLES,
107
- );
108
- }
109
- this.state.currentBoundaryRequestCount = 0;
130
+ if (this.state.currentBoundaryRequestCount <= 0) return;
131
+ this.state.completedBoundaryRequestCounts.push(this.state.currentBoundaryRequestCount);
132
+ if (this.state.completedBoundaryRequestCounts.length > MAX_BOUNDARY_SAMPLES) {
133
+ this.state.completedBoundaryRequestCounts.splice(
134
+ 0,
135
+ this.state.completedBoundaryRequestCounts.length - MAX_BOUNDARY_SAMPLES,
136
+ );
110
137
  }
138
+ this.state.currentBoundaryRequestCount = 0;
111
139
  }
112
140
 
113
141
  onBeforeProviderRequest(contextTokens: number): void {
@@ -117,9 +145,7 @@ export class CompactCoordinator {
117
145
  0,
118
146
  this.state.carriedDebtTokens - this.state.cacheDebtRepaymentTokens,
119
147
  );
120
- if (this.state.carriedDebtTokens === 0) {
121
- this.state.cacheDebtRepaymentTokens = 0;
122
- }
148
+ if (this.state.carriedDebtTokens === 0) this.state.cacheDebtRepaymentTokens = 0;
123
149
  }
124
150
  if (this.state.lastContextTokens !== null && contextTokens > this.state.lastContextTokens) {
125
151
  this.state.positiveContextDeltaTotal += contextTokens - this.state.lastContextTokens;
@@ -129,29 +155,22 @@ export class CompactCoordinator {
129
155
  }
130
156
 
131
157
  onInput(event: { text?: string; source?: string; streamingBehavior?: string }): void {
132
- // Ignore internal messages dispatched by extensions (D96 reminders, pipe injections, followUps)
133
- if (event.source === 'extension' || event.text?.startsWith('CORRECTION:')) {
134
- return;
135
- }
158
+ // Ignore messages dispatched by extensions (D96 reminders, pipe injections, followUps).
159
+ if (event.source === 'extension' || event.text?.startsWith('CORRECTION:')) return;
136
160
 
137
161
  const fromHuman =
138
162
  event.source === 'interactive' ||
139
163
  event.source === 'rpc' ||
140
164
  event.source === undefined;
165
+ if (!fromHuman) return;
141
166
 
142
- // If human typed a new prompt or steer directive, reset expectations (correction)
143
- if (fromHuman) {
144
- this.selectedCompaction = null;
145
- this.intentionalAbort = false;
146
- this.pendingBoundaryCompleted = false;
147
- // completedBoundaryRequestCounts deliberately survives: it measures
148
- // requests-per-boundary pacing, which holds across human turns. Clearing it
149
- // zeroed the mean and pinned expectedRemainingRequests at 1 after every
150
- // prompt, so OCC never saw a horizon worth compacting under (2026-09-17
151
- // review: compact.jsonl epoch kept climbing 1→9 with zero compactions).
152
- this.state.carriedDebtTokens = 0;
153
- this.state.epoch++;
154
- }
167
+ this.selectedCompaction = null;
168
+ this.intentionalAbort = false;
169
+ this.pendingBoundaryCompleted = false;
170
+ // completedBoundaryRequestCounts deliberately survives: it measures requests-per-boundary pacing,
171
+ // which holds across human turns — clearing it pinned expectedRemainingRequests at 1 every prompt.
172
+ this.state.carriedDebtTokens = 0;
173
+ this.state.epoch++;
155
174
  }
156
175
 
157
176
  onTurnEnd(opts: {
@@ -165,8 +184,7 @@ export class CompactCoordinator {
165
184
 
166
185
  if (!boundary || this.selectedCompaction) return;
167
186
  if (!opts.config.enabled) return;
168
-
169
- // Safety guard: if user typed something that is queued, don't abort!
187
+ // Safety guard: never abort while the user has a queued message.
170
188
  if (opts.ctx.hasPendingMessages?.()) return;
171
189
 
172
190
  const usage = opts.ctx.getContextUsage?.();
@@ -175,22 +193,10 @@ export class CompactCoordinator {
175
193
  ? usage.tokens
176
194
  : this.state.lastContextTokens ?? 0;
177
195
  const contextWindowTokens = usage?.contextWindow ?? opts.ctx.model?.contextWindow ?? null;
196
+ // System prompt is re-sent verbatim, so it is never part of the compressible archive; ~4 chars/token.
178
197
  const fixedTokens = Math.ceil(Buffer.byteLength(opts.ctx.getSystemPrompt?.() ?? '') / 4);
179
198
  const keepRecentTokens = opts.config.keepRecentTokens ?? DEFAULT_KEEP_RECENT_TOKENS;
180
199
  const archiveTokens = Math.max(0, contextTokens - fixedTokens - keepRecentTokens);
181
- const remainingBoundaries = opts.todos.filter(
182
- (it) => it.status !== 'completed' && it.status !== 'abandoned',
183
- ).length;
184
-
185
- const averageContextTokenIncrement =
186
- this.state.positiveContextDeltaCount > 0
187
- ? this.state.positiveContextDeltaTotal / this.state.positiveContextDeltaCount
188
- : null;
189
- const cacheRatio = resolveCacheRatioFromCost(
190
- opts.config.cacheWriteReadRatio,
191
- opts.ctx.model?.cost,
192
- { provider: opts.ctx.model?.provider, modelId: opts.ctx.model?.id },
193
- );
194
200
 
195
201
  const decision = decideCompaction({
196
202
  writeTokens: contextTokens,
@@ -198,13 +204,17 @@ export class CompactCoordinator {
198
204
  memoTokens: DEFAULT_MEMO_TOKENS,
199
205
  contextTokens,
200
206
  completedBoundaryRequestCounts: this.state.completedBoundaryRequestCounts,
201
- remainingBoundaries,
202
- averageContextTokenIncrement,
207
+ remainingBoundaries: remainingTodos(opts.todos).length,
208
+ averageContextTokenIncrement: averageContextIncrement(this.state),
203
209
  contextWindowTokens,
204
210
  priorCompactionCount: this.state.priorCompactionCount,
205
211
  carriedDebtTokens: this.state.carriedDebtTokens,
206
212
  cacheDebtRepaymentTokens: this.state.cacheDebtRepaymentTokens,
207
- cacheWriteReadRatio: cacheRatio,
213
+ cacheWriteReadRatio: resolveCacheRatioFromCost(
214
+ opts.config.cacheWriteReadRatio,
215
+ opts.ctx.model?.cost,
216
+ { provider: opts.ctx.model?.provider, modelId: opts.ctx.model?.id },
217
+ ),
208
218
  economics: {
209
219
  ...DEFAULT_COMPACTION_ECONOMICS,
210
220
  firstCompactionRequestScale: opts.config.firstCompactionRequestScale,
@@ -212,24 +222,34 @@ export class CompactCoordinator {
212
222
  },
213
223
  });
214
224
 
215
- if (decision.compact) {
216
- const branch = opts.ctx.sessionManager?.getBranch?.() ?? [];
217
- const keepRecentTokens = opts.config.keepRecentTokens ?? DEFAULT_KEEP_RECENT_TOKENS;
218
- const feasible = nativeCompactionFeasible(branch, keepRecentTokens);
219
- if (!feasible) {
220
- if (opts.config.logEnabled) {
221
- this.logDecision(opts.ctx, { ...decision, compact: false, reason: 'native_not_compactable' });
222
- }
223
- return;
225
+ if (!decision.compact) {
226
+ if (opts.config.logEnabled) this.logDecision(opts.ctx, decision);
227
+ return;
228
+ }
229
+
230
+ // P1-3 backoff: after a failed summarization, skip compact decisions for a bounded window —
231
+ // each attempt aborts an in-flight turn, so a doomed retry burns turns. (The token-cap failure
232
+ // class is gone since pi 1.4.0 uncapped summary output; transient timeouts/aborts remain.)
233
+ if (this.state.compactBackoffTurnEnds > 0) {
234
+ this.state.compactBackoffTurnEnds--;
235
+ if (opts.config.logEnabled) {
236
+ this.logDecision(opts.ctx, { ...decision, compact: false, reason: 'failure_backoff' });
224
237
  }
238
+ return;
239
+ }
225
240
 
226
- this.selectedCompaction = decision;
227
- this.intentionalAbort = true;
228
- opts.cancelReminder?.();
229
- opts.ctx.abort();
230
- } else if (opts.config.logEnabled) {
231
- this.logDecision(opts.ctx, decision);
241
+ const branch = opts.ctx.sessionManager?.getBranch?.() ?? [];
242
+ if (!nativeCompactionFeasible(branch, keepRecentTokens)) {
243
+ if (opts.config.logEnabled) {
244
+ this.logDecision(opts.ctx, { ...decision, compact: false, reason: 'native_not_compactable' });
245
+ }
246
+ return;
232
247
  }
248
+
249
+ this.selectedCompaction = decision;
250
+ this.intentionalAbort = true;
251
+ opts.cancelReminder?.();
252
+ opts.ctx.abort();
233
253
  }
234
254
 
235
255
  async onAgentSettled(opts: {
@@ -241,159 +261,157 @@ export class CompactCoordinator {
241
261
  onBeforeCompact?: (sessionRoot: string, ctx: ExtensionContext) => Promise<void>;
242
262
  }): Promise<void> {
243
263
  if (!this.selectedCompaction || !opts.ctx.isIdle()) return;
264
+
265
+ // Announce the compaction window before the summary request starts: the supervising master
266
+ // polls this file, and its observation window is shorter than a large-context compaction.
267
+ this.appendMarker(opts.pi, COMPACTION_INFLIGHT_TYPE, { at: Date.now() });
244
268
  const decision = this.selectedCompaction;
245
269
  this.selectedCompaction = null;
246
270
 
247
271
  opts.cancelReminder?.();
248
272
  this.compactionInFlight = true;
249
273
 
250
- const sessionDir = opts.ctx.sessionManager?.getSessionDir?.();
251
- const sessionId = opts.ctx.sessionManager?.getSessionId?.();
252
- const sessionRoot = resolveSessionRoot(sessionDir, sessionId);
253
-
274
+ const sessionRoot = resolveSessionRoot(
275
+ opts.ctx.sessionManager?.getSessionDir?.(),
276
+ opts.ctx.sessionManager?.getSessionId?.(),
277
+ );
254
278
  if (sessionRoot && opts.onBeforeCompact) {
255
279
  try {
256
280
  await opts.onBeforeCompact(sessionRoot, opts.ctx);
257
281
  } catch {
258
- /* ignore batch packing errors before compaction */
259
282
  }
260
283
  }
261
284
 
262
- const remaining = opts.todos.filter(
263
- (it) => it.status !== 'completed' && it.status !== 'abandoned',
264
- );
265
- const taskLines = remaining
266
- .map((it) => `- [${it.status}] ${it.content}${it.blocker ? ` (waiting on: ${it.blocker})` : ''}`)
267
- .join('\n');
268
-
269
- const customInstructions = [
270
- 'Preserve completed work, verification results, important decisions, and remaining work.',
271
- remaining.length > 0 ? `Active/remaining tasks to preserve:\n${taskLines}` : 'All listed tasks are completed.',
272
- ].join('\n\n');
273
-
274
285
  const startMs = Date.now();
275
- await new Promise<void>((resolve) => {
276
- let settled = false;
277
- const finish = () => {
278
- if (!settled) {
279
- settled = true;
280
- resolve();
286
+ const settled = Promise.withResolvers<void>();
287
+ opts.ctx.compact({
288
+ customInstructions: compactionInstructions(opts.todos),
289
+ onComplete: (compaction) => {
290
+ try {
291
+ this.finishCompaction(opts, decision, startMs, compaction);
292
+ } finally {
293
+ settled.resolve();
281
294
  }
282
- };
283
-
284
- opts.ctx.compact({
285
- customInstructions,
286
- onComplete: (compaction) => {
287
- try {
288
- this.state.priorCompactionCount++;
289
- const incrementalRatio = decision.incrementalCacheCostRatio ?? 0;
290
- this.state.carriedDebtTokens = decision.writeTokens * incrementalRatio;
291
- this.state.cacheDebtRepaymentTokens = Math.max(0, decision.archiveTokens - decision.memoTokens);
292
- this.state.epoch++;
293
-
294
- opts.pi.appendEntry(COMPACT_STATE_CUSTOM_TYPE, this.state);
295
-
296
- if (opts.config.logEnabled) {
297
- const sessionDir = opts.ctx.sessionManager?.getSessionDir?.();
298
- const sessionId = opts.ctx.sessionManager?.getSessionId?.();
299
- const root = resolveSessionRoot(sessionDir, sessionId);
300
- if (root) {
301
- const logPath = efficiencyLogPath(root, 'compact');
302
- void appendEfficiencyLog(logPath, {
303
- schema: 'pier-efficiency/1',
304
- mechanism: 'onlineContextCompact',
305
- ts: new Date().toISOString(),
306
- sessionId: sessionId ?? 'unknown',
307
- epoch: this.state.epoch,
308
- decision: decision.reason,
309
- writeTokens: decision.writeTokens,
310
- archiveTokens: decision.archiveTokens,
311
- savedTokens: Math.max(0, decision.archiveTokens - decision.memoTokens),
312
- breakevenRequests: decision.breakevenRequests,
313
- expectedRemainingRequests: decision.expectedRemainingRequests,
314
- carriedDebtTokens: this.state.carriedDebtTokens,
315
- summaryTokens: typeof compaction?.summary === 'string' ? Math.ceil(compaction.summary.length / 4) : 0,
316
- priorCompactionCount: this.state.priorCompactionCount,
317
- durationMs: Date.now() - startMs,
318
- }).catch(() => {});
319
- }
320
- }
321
-
322
- try {
323
- opts.pi.sendMessage(
324
- {
325
- customType: COMPACTION_CONTINUE_TYPE,
326
- content: 'Online context compaction finished. Active tasks preserved. Continue working on remaining tasks.',
327
- display: false,
328
- },
329
- { triggerTurn: true },
330
- );
331
- } catch {
332
- /* ignore continuation failure */
333
- }
334
- } finally {
335
- this.compactionInFlight = false;
336
- this.intentionalAbort = false;
337
- finish();
338
- }
339
- },
340
- onError: (error) => {
341
- this.compactionInFlight = false;
342
- this.intentionalAbort = false;
343
-
344
- // `Compaction cancelled` / AbortError mean the user (or pi) cancelled on purpose.
345
- // Telemetry must keep those apart from real failures, and a cancelled compaction
346
- // must NOT be answered with a turn-triggering continuation.
347
- const cancelled =
348
- error instanceof Error &&
349
- (error.name === 'AbortError' || error.message === 'Compaction cancelled');
350
-
351
- if (opts.config.logEnabled) {
352
- const sessionDir = opts.ctx.sessionManager?.getSessionDir?.();
353
- const sessionId = opts.ctx.sessionManager?.getSessionId?.();
354
- const root = resolveSessionRoot(sessionDir, sessionId);
355
- if (root) {
356
- const logPath = efficiencyLogPath(root, 'compact');
357
- void appendEfficiencyLog(logPath, {
358
- schema: 'pier-efficiency/1',
359
- mechanism: 'onlineContextCompact',
360
- event: 'compaction-failed',
361
- ts: new Date().toISOString(),
362
- sessionId: sessionId ?? 'unknown',
363
- epoch: this.state.epoch,
364
- decision: decision.reason,
365
- cancelled,
366
- error: error instanceof Error ? error.message : String(error),
367
- durationMs: Date.now() - startMs,
368
- }).catch(() => {});
369
- }
370
- }
371
-
372
- if (cancelled) {
373
- finish();
374
- return;
375
- }
376
-
377
- // Resume the task: OCC aborted the turn on purpose, so a failed compaction would
378
- // otherwise leave the session parked on an aborted assistant message. Pi's own
379
- // threshold compaction stays available as the safety net.
380
- try {
381
- opts.pi.sendMessage(
382
- {
383
- customType: COMPACTION_CONTINUE_TYPE,
384
- content: 'Context compaction failed. Continuing with the current context.',
385
- display: false,
386
- },
387
- { triggerTurn: true },
388
- );
389
- } catch {
390
- /* ignore continuation failure */
391
- }
392
-
393
- finish();
394
- },
295
+ },
296
+ onError: (error) => {
297
+ try {
298
+ this.failCompaction(opts, decision, startMs, error);
299
+ } finally {
300
+ settled.resolve();
301
+ }
302
+ },
303
+ });
304
+ await settled.promise;
305
+ }
306
+
307
+ private finishCompaction(
308
+ opts: { ctx: ExtensionContext; pi: ExtensionAPI; config: OnlineContextCompactConfig },
309
+ decision: CompactionDecision,
310
+ startMs: number,
311
+ compaction: { summary?: string } | undefined,
312
+ ): void {
313
+ this.state.priorCompactionCount++;
314
+ this.state.carriedDebtTokens = decision.writeTokens * (decision.incrementalCacheCostRatio ?? 0);
315
+ this.state.cacheDebtRepaymentTokens = Math.max(0, decision.archiveTokens - decision.memoTokens);
316
+ this.state.epoch++;
317
+ this.state.consecutiveCompactionFailures = 0;
318
+ this.state.compactBackoffTurnEnds = 0;
319
+ this.appendMarker(opts.pi, COMPACT_STATE_CUSTOM_TYPE, this.state);
320
+
321
+ if (opts.config.logEnabled) {
322
+ this.logCompact(opts.ctx, {
323
+ decision: decision.reason,
324
+ writeTokens: decision.writeTokens,
325
+ archiveTokens: decision.archiveTokens,
326
+ savedTokens: Math.max(0, decision.archiveTokens - decision.memoTokens),
327
+ breakevenRequests: decision.breakevenRequests,
328
+ expectedRemainingRequests: decision.expectedRemainingRequests,
329
+ carriedDebtTokens: this.state.carriedDebtTokens,
330
+ summaryTokens: typeof compaction?.summary === 'string' ? Math.ceil(compaction.summary.length / 4) : 0,
331
+ priorCompactionCount: this.state.priorCompactionCount,
332
+ durationMs: Date.now() - startMs,
395
333
  });
334
+ }
335
+
336
+ this.sendContinuation(
337
+ opts.pi,
338
+ COMPACTION_CONTINUE_TEXT,
339
+ false,
340
+ );
341
+ // Settled marker last: the master's poller releases its hold as soon as it appears.
342
+ this.appendMarker(opts.pi, COMPACTION_SETTLED_TYPE, { outcome: 'completed', at: Date.now() });
343
+ this.compactionInFlight = false;
344
+ this.intentionalAbort = false;
345
+ }
346
+
347
+ private failCompaction(
348
+ opts: { ctx: ExtensionContext; pi: ExtensionAPI; config: OnlineContextCompactConfig },
349
+ decision: CompactionDecision,
350
+ startMs: number,
351
+ error: unknown,
352
+ ): void {
353
+ this.compactionInFlight = false;
354
+ this.intentionalAbort = false;
355
+ const message = error instanceof Error ? error.message : String(error);
356
+ // 'Compaction cancelled' / AbortError mean the user (or pi) cancelled on purpose: telemetry keeps
357
+ // that apart from real failures, and a cancel must NOT trigger a continuation turn.
358
+ const cancelled =
359
+ error instanceof Error &&
360
+ (error.name === 'AbortError' || error.message === 'Compaction cancelled');
361
+
362
+ // Release the cross-session hold in every outcome — the cancel path returns before continuing.
363
+ this.appendMarker(opts.pi, COMPACTION_SETTLED_TYPE, {
364
+ outcome: cancelled ? 'cancelled' : 'failed',
365
+ at: Date.now(),
396
366
  });
367
+ if (opts.config.logEnabled) {
368
+ this.logCompact(opts.ctx, {
369
+ event: 'compaction-failed',
370
+ decision: decision.reason,
371
+ cancelled,
372
+ error: message,
373
+ consecutiveCompactionFailures: this.state.consecutiveCompactionFailures,
374
+ durationMs: Date.now() - startMs,
375
+ });
376
+ }
377
+ if (cancelled) return;
378
+
379
+ // P1-3: record the failure and back off exponentially (cap 4 skipped decisions).
380
+ this.state.consecutiveCompactionFailures++;
381
+ this.state.compactBackoffTurnEnds = Math.min(2 ** this.state.consecutiveCompactionFailures, 4);
382
+ // Persist the bumped counters: state is restored from this marker, so a restart without it
383
+ // would reset the P1-3 backoff window and retry the doomed compaction immediately.
384
+ this.appendMarker(opts.pi, COMPACT_STATE_CUSTOM_TYPE, this.state);
385
+
386
+ // OCC aborted the turn on purpose, so a failed compaction would leave the session parked on an
387
+ // aborted message; the notice must be visible rather than a silent failure.
388
+ const approxTokens = this.state.lastContextTokens;
389
+ this.sendContinuation(
390
+ opts.pi,
391
+ `Context compaction FAILED (${message}). `
392
+ + `Context ≈ ${approxTokens != null ? `${Math.round(approxTokens / 1000)}K tokens` : 'near the window limit'}; `
393
+ + `OCC backs off for the next ${this.state.compactBackoffTurnEnds} opportunities. `
394
+ + 'If this repeats, run /compact manually, prune large outputs, or restart the session.',
395
+ true,
396
+ );
397
+ }
398
+
399
+ /** Best-effort session entry write: onAgentSettled runs detached, so a full disk must not become an
400
+ * unhandled rejection (a missing marker only delays one settlement notice). */
401
+ private appendMarker(pi: ExtensionAPI, customType: string, data: unknown): void {
402
+ try {
403
+ pi.appendEntry(customType, data);
404
+ } catch (err) {
405
+ swallow(`compact.append-${customType}`, err);
406
+ }
407
+ }
408
+
409
+ /** Resume the task after compaction; must never throw into the detached settled handler. */
410
+ private sendContinuation(pi: ExtensionAPI, content: string, display: boolean): void {
411
+ try {
412
+ pi.sendMessage({ customType: COMPACTION_CONTINUE_TYPE, content, display }, { triggerTurn: true });
413
+ } catch {
414
+ }
397
415
  }
398
416
 
399
417
  rebuildFromBranch(entries: readonly unknown[]): void {
@@ -406,49 +424,49 @@ export class CompactCoordinator {
406
424
 
407
425
  getRemainingHorizon(remainingBoundaries = 3, contextWindowTokens: number | null = null): number {
408
426
  const counts = this.state.completedBoundaryRequestCounts;
409
- if (counts.length === 0) return 4;
410
-
427
+ if (counts.length === 0) return 4; // No samples yet: conservative default horizon.
411
428
  const est = estimateRemainingRequests({
412
429
  completedBoundaryRequestCounts: counts,
413
430
  remainingBoundaries,
414
- scale: 1.0,
415
- standardDeviationK: 0.0,
431
+ scale: DEFAULT_COMPACTION_ECONOMICS.remainingRequestScale,
432
+ standardDeviationK: DEFAULT_COMPACTION_ECONOMICS.remainingRequestStddevK,
416
433
  contextTokens: this.state.lastContextTokens ?? 0,
417
434
  contextWindowTokens,
418
- averageContextTokenIncrement:
419
- this.state.positiveContextDeltaCount > 0
420
- ? this.state.positiveContextDeltaTotal / this.state.positiveContextDeltaCount
421
- : null,
435
+ averageContextTokenIncrement: averageContextIncrement(this.state),
422
436
  });
423
437
  return Math.max(1, est.expectedRemainingRequests);
424
438
  }
425
439
 
426
- private logDecision(ctx: ExtensionContext, decision: CompactionDecision): void {
427
- const sessionDir = ctx.sessionManager?.getSessionDir?.();
440
+ /** Append one compact.jsonl record (shared envelope); logging is always best-effort. */
441
+ private logCompact(ctx: ExtensionContext, fields: Record<string, unknown>): void {
428
442
  const sessionId = ctx.sessionManager?.getSessionId?.();
429
- const root = resolveSessionRoot(sessionDir, sessionId);
443
+ const root = resolveSessionRoot(ctx.sessionManager?.getSessionDir?.(), sessionId);
430
444
  if (!root) return;
431
- const logPath = efficiencyLogPath(root, 'compact');
432
- void appendEfficiencyLog(logPath, {
445
+ void appendEfficiencyLog(efficiencyLogPath(root, 'compact'), {
433
446
  schema: 'pier-efficiency/1',
434
447
  mechanism: 'onlineContextCompact',
435
- event: 'decision',
436
448
  ts: new Date().toISOString(),
437
449
  sessionId: sessionId ?? 'unknown',
438
450
  epoch: this.state.epoch,
451
+ ...fields,
452
+ }).catch(() => {});
453
+ }
454
+
455
+ private logDecision(ctx: ExtensionContext, decision: CompactionDecision): void {
456
+ this.logCompact(ctx, {
457
+ event: 'decision',
439
458
  decision: decision.reason,
440
459
  compact: decision.compact,
441
460
  writeTokens: decision.writeTokens,
442
461
  archiveTokens: decision.archiveTokens,
443
462
  breakevenRequests: decision.breakevenRequests,
444
463
  expectedRemainingRequests: decision.expectedRemainingRequests,
445
- // 2026-09-17: without resolvedRatio/model the 61x cache_ratio_unavailable
446
- // run was un-diagnosable from logs alone (which price table? which family?).
464
+ // Without resolvedRatio/model a cache_ratio_unavailable run is undiagnosable from logs alone.
447
465
  resolvedRatio: decision.cacheWriteReadRatio,
448
466
  incrementalCacheCostRatio: decision.incrementalCacheCostRatio,
449
467
  remainingBoundaries: decision.remainingBoundaries,
450
468
  model: ctx.model?.id ?? undefined,
451
469
  provider: ctx.model?.provider ?? undefined,
452
- }).catch(() => {});
470
+ });
453
471
  }
454
472
  }