pi-pier 0.3.0 → 0.3.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 (96) hide show
  1. package/README.md +12 -11
  2. package/package.json +2 -2
  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 +8 -12
  9. package/src/compact-coordinator.ts +278 -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/dashboard-command.ts +38 -19
  14. package/src/efficiency-config-core.ts +287 -484
  15. package/src/efficiency-store.ts +16 -29
  16. package/src/focus-poller.ts +14 -35
  17. package/src/gc-core.ts +10 -36
  18. package/src/herdr-client.ts +82 -208
  19. package/src/history-store.ts +36 -23
  20. package/src/index-gates.ts +124 -0
  21. package/src/index-locks.ts +2 -7
  22. package/src/index-master.ts +24 -18
  23. package/src/index-notices.ts +28 -15
  24. package/src/index-pipe.ts +17 -12
  25. package/src/index-roles.ts +306 -0
  26. package/src/index-runtime.ts +2 -6
  27. package/src/index-worker.ts +4 -8
  28. package/src/index.ts +305 -564
  29. package/src/jev-client.ts +12 -17
  30. package/src/jev-core.ts +137 -87
  31. package/src/ledger.ts +13 -21
  32. package/src/lock-core.ts +28 -56
  33. package/src/manifest-compose.ts +35 -20
  34. package/src/observation-core.ts +52 -100
  35. package/src/pane-title.ts +19 -38
  36. package/src/pi-surface.ts +34 -22
  37. package/src/pier-options.ts +54 -50
  38. package/src/pipe-channel.ts +27 -59
  39. package/src/platform-paths.ts +19 -52
  40. package/src/{core → plugins}/grid-shape.ts +8 -10
  41. package/src/{core → plugins}/heat-plan.ts +20 -22
  42. package/src/plugins/observation.ts +475 -0
  43. package/src/plugins/subagent.ts +728 -0
  44. package/src/{core → plugins}/terminal.ts +167 -127
  45. package/src/{core → plugins}/todo.ts +61 -100
  46. package/src/progress-core.ts +16 -53
  47. package/src/reconcile-core.ts +24 -33
  48. package/src/reducer-core.ts +24 -60
  49. package/src/reducer-invoker.ts +266 -239
  50. package/src/renderers.ts +76 -87
  51. package/src/role-loader.ts +33 -28
  52. package/src/role-manifest.ts +23 -28
  53. package/src/role-state.ts +119 -0
  54. package/src/routing-telemetry.ts +136 -0
  55. package/src/runtime-policy.ts +25 -40
  56. package/src/session-tail.ts +65 -41
  57. package/src/settle-wake-core.ts +52 -21
  58. package/src/slim-frame.ts +25 -122
  59. package/src/stale-core.ts +17 -24
  60. package/src/storage-layout.ts +14 -43
  61. package/src/subagent-core.ts +253 -118
  62. package/src/subagent-gc.ts +109 -67
  63. package/src/subagent-poller.ts +367 -24
  64. package/src/subagent-session.ts +516 -0
  65. package/src/subagent-spawn.ts +530 -52
  66. package/src/swallow.ts +7 -17
  67. package/src/terminal-core.ts +125 -106
  68. package/src/todo-core.ts +57 -109
  69. package/src/todo-read-hook.ts +23 -39
  70. package/src/todo-reminder-core.ts +26 -51
  71. package/src/todo-window.ts +11 -12
  72. package/src/todos-service.ts +15 -35
  73. package/src/tool-error.ts +6 -13
  74. package/src/tool-gate.ts +38 -21
  75. package/src/vocab.ts +52 -25
  76. package/src/ask-multi.ts +0 -260
  77. package/src/config-guide.ts +0 -361
  78. package/src/core/observation.ts +0 -555
  79. package/src/core/subagent.ts +0 -633
  80. package/src/efficiency-config-io.ts +0 -53
  81. package/src/git-adapter.ts +0 -113
  82. package/src/notice-buffer.ts +0 -23
  83. package/src/subagent-execute.ts +0 -328
  84. package/src/subagent-git-io.ts +0 -80
  85. package/src/subagent-launch.ts +0 -114
  86. package/src/subagent-list-action.ts +0 -69
  87. package/src/subagent-output-action.ts +0 -104
  88. package/src/subagent-output-adapter.ts +0 -27
  89. package/src/subagent-output-core.ts +0 -263
  90. package/src/subagent-poll-loop.ts +0 -305
  91. package/src/subagent-port.ts +0 -27
  92. package/src/subagent-ready.ts +0 -89
  93. package/src/subagent-registry.ts +0 -70
  94. package/src/subagent-resolution.ts +0 -41
  95. package/src/subagent-scope.ts +0 -41
  96. 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,33 @@ 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 (token-cap class), skip compact decisions for a
231
+ // bounded window — each attempt aborts an in-flight turn, so a doomed retry burns turns.
232
+ if (this.state.compactBackoffTurnEnds > 0) {
233
+ this.state.compactBackoffTurnEnds--;
234
+ if (opts.config.logEnabled) {
235
+ this.logDecision(opts.ctx, { ...decision, compact: false, reason: 'failure_backoff' });
224
236
  }
237
+ return;
238
+ }
225
239
 
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);
240
+ const branch = opts.ctx.sessionManager?.getBranch?.() ?? [];
241
+ if (!nativeCompactionFeasible(branch, keepRecentTokens)) {
242
+ if (opts.config.logEnabled) {
243
+ this.logDecision(opts.ctx, { ...decision, compact: false, reason: 'native_not_compactable' });
244
+ }
245
+ return;
232
246
  }
247
+
248
+ this.selectedCompaction = decision;
249
+ this.intentionalAbort = true;
250
+ opts.cancelReminder?.();
251
+ opts.ctx.abort();
233
252
  }
234
253
 
235
254
  async onAgentSettled(opts: {
@@ -241,159 +260,157 @@ export class CompactCoordinator {
241
260
  onBeforeCompact?: (sessionRoot: string, ctx: ExtensionContext) => Promise<void>;
242
261
  }): Promise<void> {
243
262
  if (!this.selectedCompaction || !opts.ctx.isIdle()) return;
263
+
264
+ // Announce the compaction window before the summary request starts: the supervising master
265
+ // polls this file, and its observation window is shorter than a large-context compaction.
266
+ this.appendMarker(opts.pi, COMPACTION_INFLIGHT_TYPE, { at: Date.now() });
244
267
  const decision = this.selectedCompaction;
245
268
  this.selectedCompaction = null;
246
269
 
247
270
  opts.cancelReminder?.();
248
271
  this.compactionInFlight = true;
249
272
 
250
- const sessionDir = opts.ctx.sessionManager?.getSessionDir?.();
251
- const sessionId = opts.ctx.sessionManager?.getSessionId?.();
252
- const sessionRoot = resolveSessionRoot(sessionDir, sessionId);
253
-
273
+ const sessionRoot = resolveSessionRoot(
274
+ opts.ctx.sessionManager?.getSessionDir?.(),
275
+ opts.ctx.sessionManager?.getSessionId?.(),
276
+ );
254
277
  if (sessionRoot && opts.onBeforeCompact) {
255
278
  try {
256
279
  await opts.onBeforeCompact(sessionRoot, opts.ctx);
257
280
  } catch {
258
- /* ignore batch packing errors before compaction */
259
281
  }
260
282
  }
261
283
 
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
284
  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();
285
+ const settled = Promise.withResolvers<void>();
286
+ opts.ctx.compact({
287
+ customInstructions: compactionInstructions(opts.todos),
288
+ onComplete: (compaction) => {
289
+ try {
290
+ this.finishCompaction(opts, decision, startMs, compaction);
291
+ } finally {
292
+ settled.resolve();
281
293
  }
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
- },
294
+ },
295
+ onError: (error) => {
296
+ try {
297
+ this.failCompaction(opts, decision, startMs, error);
298
+ } finally {
299
+ settled.resolve();
300
+ }
301
+ },
302
+ });
303
+ await settled.promise;
304
+ }
305
+
306
+ private finishCompaction(
307
+ opts: { ctx: ExtensionContext; pi: ExtensionAPI; config: OnlineContextCompactConfig },
308
+ decision: CompactionDecision,
309
+ startMs: number,
310
+ compaction: { summary?: string } | undefined,
311
+ ): void {
312
+ this.state.priorCompactionCount++;
313
+ this.state.carriedDebtTokens = decision.writeTokens * (decision.incrementalCacheCostRatio ?? 0);
314
+ this.state.cacheDebtRepaymentTokens = Math.max(0, decision.archiveTokens - decision.memoTokens);
315
+ this.state.epoch++;
316
+ this.state.consecutiveCompactionFailures = 0;
317
+ this.state.compactBackoffTurnEnds = 0;
318
+ this.appendMarker(opts.pi, COMPACT_STATE_CUSTOM_TYPE, this.state);
319
+
320
+ if (opts.config.logEnabled) {
321
+ this.logCompact(opts.ctx, {
322
+ decision: decision.reason,
323
+ writeTokens: decision.writeTokens,
324
+ archiveTokens: decision.archiveTokens,
325
+ savedTokens: Math.max(0, decision.archiveTokens - decision.memoTokens),
326
+ breakevenRequests: decision.breakevenRequests,
327
+ expectedRemainingRequests: decision.expectedRemainingRequests,
328
+ carriedDebtTokens: this.state.carriedDebtTokens,
329
+ summaryTokens: typeof compaction?.summary === 'string' ? Math.ceil(compaction.summary.length / 4) : 0,
330
+ priorCompactionCount: this.state.priorCompactionCount,
331
+ durationMs: Date.now() - startMs,
395
332
  });
333
+ }
334
+
335
+ this.sendContinuation(
336
+ opts.pi,
337
+ COMPACTION_CONTINUE_TEXT,
338
+ false,
339
+ );
340
+ // Settled marker last: the master's poller releases its hold as soon as it appears.
341
+ this.appendMarker(opts.pi, COMPACTION_SETTLED_TYPE, { outcome: 'completed', at: Date.now() });
342
+ this.compactionInFlight = false;
343
+ this.intentionalAbort = false;
344
+ }
345
+
346
+ private failCompaction(
347
+ opts: { ctx: ExtensionContext; pi: ExtensionAPI; config: OnlineContextCompactConfig },
348
+ decision: CompactionDecision,
349
+ startMs: number,
350
+ error: unknown,
351
+ ): void {
352
+ this.compactionInFlight = false;
353
+ this.intentionalAbort = false;
354
+ const message = error instanceof Error ? error.message : String(error);
355
+ // 'Compaction cancelled' / AbortError mean the user (or pi) cancelled on purpose: telemetry keeps
356
+ // that apart from real failures, and a cancel must NOT trigger a continuation turn.
357
+ const cancelled =
358
+ error instanceof Error &&
359
+ (error.name === 'AbortError' || error.message === 'Compaction cancelled');
360
+
361
+ // Release the cross-session hold in every outcome — the cancel path returns before continuing.
362
+ this.appendMarker(opts.pi, COMPACTION_SETTLED_TYPE, {
363
+ outcome: cancelled ? 'cancelled' : 'failed',
364
+ at: Date.now(),
396
365
  });
366
+ if (opts.config.logEnabled) {
367
+ this.logCompact(opts.ctx, {
368
+ event: 'compaction-failed',
369
+ decision: decision.reason,
370
+ cancelled,
371
+ error: message,
372
+ consecutiveCompactionFailures: this.state.consecutiveCompactionFailures,
373
+ durationMs: Date.now() - startMs,
374
+ });
375
+ }
376
+ if (cancelled) return;
377
+
378
+ // P1-3: record the failure and back off exponentially (cap 4 skipped decisions).
379
+ this.state.consecutiveCompactionFailures++;
380
+ this.state.compactBackoffTurnEnds = Math.min(2 ** this.state.consecutiveCompactionFailures, 4);
381
+ // Persist the bumped counters: state is restored from this marker, so a restart without it
382
+ // would reset the P1-3 backoff window and retry the doomed compaction immediately.
383
+ this.appendMarker(opts.pi, COMPACT_STATE_CUSTOM_TYPE, this.state);
384
+
385
+ // OCC aborted the turn on purpose, so a failed compaction would leave the session parked on an
386
+ // aborted message; the notice must be visible rather than a silent failure.
387
+ const approxTokens = this.state.lastContextTokens;
388
+ this.sendContinuation(
389
+ opts.pi,
390
+ `Context compaction FAILED (${message}). `
391
+ + `Context ≈ ${approxTokens != null ? `${Math.round(approxTokens / 1000)}K tokens` : 'near the window limit'}; `
392
+ + `OCC backs off for the next ${this.state.compactBackoffTurnEnds} opportunities. `
393
+ + 'If this repeats, run /compact manually, prune large outputs, or restart the session.',
394
+ true,
395
+ );
396
+ }
397
+
398
+ /** Best-effort session entry write: onAgentSettled runs detached, so a full disk must not become an
399
+ * unhandled rejection (a missing marker only delays one settlement notice). */
400
+ private appendMarker(pi: ExtensionAPI, customType: string, data: unknown): void {
401
+ try {
402
+ pi.appendEntry(customType, data);
403
+ } catch (err) {
404
+ swallow(`compact.append-${customType}`, err);
405
+ }
406
+ }
407
+
408
+ /** Resume the task after compaction; must never throw into the detached settled handler. */
409
+ private sendContinuation(pi: ExtensionAPI, content: string, display: boolean): void {
410
+ try {
411
+ pi.sendMessage({ customType: COMPACTION_CONTINUE_TYPE, content, display }, { triggerTurn: true });
412
+ } catch {
413
+ }
397
414
  }
398
415
 
399
416
  rebuildFromBranch(entries: readonly unknown[]): void {
@@ -406,49 +423,49 @@ export class CompactCoordinator {
406
423
 
407
424
  getRemainingHorizon(remainingBoundaries = 3, contextWindowTokens: number | null = null): number {
408
425
  const counts = this.state.completedBoundaryRequestCounts;
409
- if (counts.length === 0) return 4;
410
-
426
+ if (counts.length === 0) return 4; // No samples yet: conservative default horizon.
411
427
  const est = estimateRemainingRequests({
412
428
  completedBoundaryRequestCounts: counts,
413
429
  remainingBoundaries,
414
- scale: 1.0,
415
- standardDeviationK: 0.0,
430
+ scale: DEFAULT_COMPACTION_ECONOMICS.remainingRequestScale,
431
+ standardDeviationK: DEFAULT_COMPACTION_ECONOMICS.remainingRequestStddevK,
416
432
  contextTokens: this.state.lastContextTokens ?? 0,
417
433
  contextWindowTokens,
418
- averageContextTokenIncrement:
419
- this.state.positiveContextDeltaCount > 0
420
- ? this.state.positiveContextDeltaTotal / this.state.positiveContextDeltaCount
421
- : null,
434
+ averageContextTokenIncrement: averageContextIncrement(this.state),
422
435
  });
423
436
  return Math.max(1, est.expectedRemainingRequests);
424
437
  }
425
438
 
426
- private logDecision(ctx: ExtensionContext, decision: CompactionDecision): void {
427
- const sessionDir = ctx.sessionManager?.getSessionDir?.();
439
+ /** Append one compact.jsonl record (shared envelope); logging is always best-effort. */
440
+ private logCompact(ctx: ExtensionContext, fields: Record<string, unknown>): void {
428
441
  const sessionId = ctx.sessionManager?.getSessionId?.();
429
- const root = resolveSessionRoot(sessionDir, sessionId);
442
+ const root = resolveSessionRoot(ctx.sessionManager?.getSessionDir?.(), sessionId);
430
443
  if (!root) return;
431
- const logPath = efficiencyLogPath(root, 'compact');
432
- void appendEfficiencyLog(logPath, {
444
+ void appendEfficiencyLog(efficiencyLogPath(root, 'compact'), {
433
445
  schema: 'pier-efficiency/1',
434
446
  mechanism: 'onlineContextCompact',
435
- event: 'decision',
436
447
  ts: new Date().toISOString(),
437
448
  sessionId: sessionId ?? 'unknown',
438
449
  epoch: this.state.epoch,
450
+ ...fields,
451
+ }).catch(() => {});
452
+ }
453
+
454
+ private logDecision(ctx: ExtensionContext, decision: CompactionDecision): void {
455
+ this.logCompact(ctx, {
456
+ event: 'decision',
439
457
  decision: decision.reason,
440
458
  compact: decision.compact,
441
459
  writeTokens: decision.writeTokens,
442
460
  archiveTokens: decision.archiveTokens,
443
461
  breakevenRequests: decision.breakevenRequests,
444
462
  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?).
463
+ // Without resolvedRatio/model a cache_ratio_unavailable run is undiagnosable from logs alone.
447
464
  resolvedRatio: decision.cacheWriteReadRatio,
448
465
  incrementalCacheCostRatio: decision.incrementalCacheCostRatio,
449
466
  remainingBoundaries: decision.remainingBoundaries,
450
467
  model: ctx.model?.id ?? undefined,
451
468
  provider: ctx.model?.provider ?? undefined,
452
- }).catch(() => {});
469
+ });
453
470
  }
454
471
  }