pi-pier 0.2.7 → 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 (85) hide show
  1. package/README.md +26 -12
  2. package/package.json +8 -3
  3. package/schemas/efficiency-config.schema.json +77 -0
  4. package/schemas/role-manifest.schema.json +7 -1
  5. package/skills/pi-herdr-workflow/SKILL.md +11 -0
  6. package/src/ansi-text.ts +134 -0
  7. package/src/ask-dialog.ts +308 -0
  8. package/src/ask-user.ts +139 -88
  9. package/src/bootstrap.ts +13 -14
  10. package/src/compact-coordinator.ts +471 -0
  11. package/src/compact-economics-core.ts +328 -0
  12. package/src/config-catalog-core.ts +332 -0
  13. package/src/config-command.ts +388 -0
  14. package/src/dashboard-command.ts +108 -0
  15. package/src/efficiency-config-core.ts +464 -0
  16. package/src/efficiency-store.ts +299 -0
  17. package/src/focus-poller.ts +223 -0
  18. package/src/gc-core.ts +77 -18
  19. package/src/herdr-client.ts +284 -154
  20. package/src/history-store.ts +36 -23
  21. package/src/index-gates.ts +124 -0
  22. package/src/index-locks.ts +29 -16
  23. package/src/index-master.ts +29 -19
  24. package/src/index-notices.ts +35 -11
  25. package/src/index-pipe.ts +17 -7
  26. package/src/index-roles.ts +306 -0
  27. package/src/index-runtime.ts +2 -6
  28. package/src/index-worker.ts +7 -9
  29. package/src/index.ts +425 -388
  30. package/src/jev-client.ts +141 -0
  31. package/src/jev-core.ts +431 -0
  32. package/src/ledger.ts +13 -21
  33. package/src/lock-core.ts +98 -37
  34. package/src/manifest-compose.ts +35 -20
  35. package/src/observation-core.ts +200 -0
  36. package/src/pane-title.ts +19 -38
  37. package/src/pi-surface.ts +34 -22
  38. package/src/pier-options.ts +100 -0
  39. package/src/pipe-channel.ts +65 -52
  40. package/src/platform-paths.ts +19 -52
  41. package/src/{core → plugins}/grid-shape.ts +12 -12
  42. package/src/plugins/heat-plan.ts +158 -0
  43. package/src/plugins/observation.ts +475 -0
  44. package/src/plugins/subagent.ts +728 -0
  45. package/src/{core → plugins}/terminal.ts +237 -123
  46. package/src/{core → plugins}/todo.ts +75 -100
  47. package/src/progress-core.ts +16 -53
  48. package/src/reconcile-core.ts +24 -33
  49. package/src/reducer-core.ts +322 -0
  50. package/src/reducer-invoker.ts +419 -0
  51. package/src/renderers.ts +187 -0
  52. package/src/role-loader.ts +35 -29
  53. package/src/role-manifest.ts +23 -32
  54. package/src/role-state.ts +119 -0
  55. package/src/routing-telemetry.ts +136 -0
  56. package/src/runtime-policy.ts +25 -43
  57. package/src/session-tail.ts +102 -23
  58. package/src/settle-wake-core.ts +55 -22
  59. package/src/slim-frame.ts +29 -124
  60. package/src/stale-core.ts +17 -24
  61. package/src/storage-layout.ts +24 -30
  62. package/src/subagent-core.ts +256 -119
  63. package/src/subagent-gc.ts +131 -69
  64. package/src/subagent-poller.ts +401 -12
  65. package/src/subagent-session.ts +516 -0
  66. package/src/subagent-spawn.ts +651 -28
  67. package/src/swallow.ts +65 -0
  68. package/src/terminal-core.ts +160 -69
  69. package/src/todo-core.ts +62 -113
  70. package/src/todo-read-hook.ts +23 -39
  71. package/src/todo-reminder-core.ts +30 -48
  72. package/src/todo-window.ts +11 -12
  73. package/src/todos-service.ts +27 -35
  74. package/src/tool-error.ts +19 -0
  75. package/src/tool-gate.ts +38 -21
  76. package/src/vocab.ts +55 -25
  77. package/src/core/subagent.ts +0 -839
  78. package/src/git-adapter.ts +0 -104
  79. package/src/notice-buffer.ts +0 -23
  80. package/src/subagent-git-io.ts +0 -80
  81. package/src/subagent-launch.ts +0 -110
  82. package/src/subagent-poll-loop.ts +0 -285
  83. package/src/subagent-port.ts +0 -27
  84. package/src/subagent-scope.ts +0 -39
  85. package/src/subagent-session-io.ts +0 -147
package/src/bootstrap.ts CHANGED
@@ -1,13 +1,10 @@
1
1
  /**
2
2
  * Bootstrap the master Cordis tree (D78 mount topology, D80 ecosystem adoption, D81 split).
3
3
  *
4
- * Load the group, timer, and HMR plugins in order so declarations resolve and HMR stays a
5
- * development-only path (`--expose-internals` plus PI_HERDR_HMR=1); production has no watcher.
6
- *
7
- * Workers bypass this module (C3/D81) because short-lived processes mount manually without
8
- * loader or HMR; this module and subagent-scope.ts are master-only dynamic imports.
9
- *
10
- * Core modules will gradually become loader entries; manual mounts remain non-hot-reloaded (D80③).
4
+ * Loads group, timer and HMR plugins in order so declarations resolve, with HMR as a development-only
5
+ * path (`--expose-internals` plus PIER_HMR=1); production has no watcher. Workers bypass this module
6
+ * (C3/D81): short-lived processes mount manually, and this module plus subagent-poller.ts are the
7
+ * master-only dynamic imports.
11
8
  */
12
9
  import { Context } from '@deepseek-ai/cordis';
13
10
  import Loader from '@deepseek-ai/cordis-plugin-loader';
@@ -15,6 +12,7 @@ import Group from '@deepseek-ai/cordis-plugin-group';
15
12
  import { pathToFileURL, fileURLToPath } from 'node:url';
16
13
  import * as path from 'node:path';
17
14
  import { DisposeLedger } from './ledger.ts';
15
+ import { pierOption } from './pier-options.ts';
18
16
 
19
17
  export interface BootstrapHooks {
20
18
  onDispose?: () => void;
@@ -23,9 +21,8 @@ export interface BootstrapHooks {
23
21
  export interface CordisApp {
24
22
  /** Root owns subagent scopes and lifecycle effects. */
25
23
  root: Context;
26
- /** False preserves the bare-tree fallback, matching the baseline behavior. */
24
+ /** False means the loader was not mounted (bare-tree fallback). */
27
25
  loaderReady: boolean;
28
- /** HMR is enabled only in the development posture. */
29
26
  hmrActive: boolean;
30
27
  /** Shared ledger compensates HMR disposal and handles session_shutdown cleanup. */
31
28
  ledger: DisposeLedger;
@@ -44,7 +41,7 @@ export async function createCordisApp(hooks: BootstrapHooks = {}): Promise<Cordi
44
41
  if (hooks.onDispose) {
45
42
  root.effect(() => () => { hooks.onDispose?.(); }, 'session-root');
46
43
  }
47
- // Resolve relative entry names (./core/...) from this module's directory.
44
+ // Resolve relative entry names (./plugins/...) from this module's directory.
48
45
  root.baseUrl = pathToFileURL(path.dirname(fileURLToPath(import.meta.url))).href + '/';
49
46
 
50
47
  let loaderReady = false;
@@ -60,7 +57,8 @@ export async function createCordisApp(hooks: BootstrapHooks = {}): Promise<Cordi
60
57
  console.error(`[pi-herdr] cordis loader degraded (bare tree): ${err instanceof Error ? err.message : String(err)}`);
61
58
  }
62
59
 
63
- if (loaderReady && detectExposeInternals() && process.env.PI_HERDR_HMR === '1') {
60
+ // B10: canonical PIER_HMR, legacy PI_HERDR_HMR.
61
+ if (loaderReady && detectExposeInternals() && pierOption('PIER_HMR') === '1') {
64
62
  try {
65
63
  const withLoader = root as Context & {
66
64
  loader?: { create: (o: { name: string; config?: unknown }) => Promise<unknown> };
@@ -76,9 +74,10 @@ export async function createCordisApp(hooks: BootstrapHooks = {}): Promise<Cordi
76
74
  });
77
75
  hmrActive = true;
78
76
  console.error('[pi-herdr] hmr active (dev posture: --expose-internals + PI_HERDR_HMR=1)');
79
- // D80⑤: HMR already disposes old fiber effects; this hook additionally retires old pi-surface generations.
80
- // d87 keeps generations registered after the reload boundary alive; disposeKey retires only older entries.
81
- root.on('hmr/reload', (reloads: unknown) => {
77
+ // HMR already disposes old fiber effects; disposeKey additionally retires the older pi-surface
78
+ // generations while keeping generations registered after the reload boundary alive (D80⑤/d87).
79
+ // Cordis' own HMR boundary event (not part of pi's typed Events map).
80
+ (root as unknown as { on(name: string, fn: (reloads: unknown) => void): void }).on('hmr/reload', (reloads: unknown) => {
82
81
  try {
83
82
  const files: string[] = [];
84
83
  for (const r of (reloads as Map<unknown, { filename?: string }>).values()) {
@@ -0,0 +1,471 @@
1
+ /**
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).
5
+ */
6
+
7
+ import type { ExtensionAPI, ExtensionContext } from '@earendil-works/pi-coding-agent';
8
+ import { decideCompaction, DEFAULT_COMPACTION_ECONOMICS, estimateRemainingRequests, MAX_BOUNDARY_SAMPLES, nativeCompactionFeasible, resolveCacheRatioFromCost, type CompactionDecision } from './compact-economics-core.ts';
9
+ import type { TodoItem } from './todo-core.ts';
10
+ import type { TodoCompletionSource } from './todos-service.ts';
11
+ import type { OnlineContextCompactConfig } from './efficiency-config-core.ts';
12
+ import { appendEfficiencyLog, efficiencyLogPath, resolveSessionRoot } from './efficiency-store.ts';
13
+ import { swallow } from './swallow.ts';
14
+
15
+ export const COMPACT_STATE_CUSTOM_TYPE = 'pi-herdr.efficiency-state';
16
+ export const COMPACTION_CONTINUE_TYPE = 'pi-herdr.compaction-continue';
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;
29
+
30
+ /** Fields persisted in the state marker; `version: 1` gates restores. */
31
+ interface CoordinatorState {
32
+ version: 1;
33
+ epoch: number;
34
+ completedBoundaryRequestCounts: number[];
35
+ carriedDebtTokens: number;
36
+ cacheDebtRepaymentTokens: number;
37
+ priorCompactionCount: number;
38
+ positiveContextDeltaTotal: number;
39
+ positiveContextDeltaCount: number;
40
+ lastContextTokens: number | null;
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;
45
+ }
46
+
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 {
60
+ return {
61
+ version: 1,
62
+ epoch: 0,
63
+ completedBoundaryRequestCounts: [],
64
+ carriedDebtTokens: 0,
65
+ cacheDebtRepaymentTokens: 0,
66
+ priorCompactionCount: 0,
67
+ positiveContextDeltaTotal: 0,
68
+ positiveContextDeltaCount: 0,
69
+ lastContextTokens: null,
70
+ currentBoundaryRequestCount: 0,
71
+ consecutiveCompactionFailures: 0,
72
+ compactBackoffTurnEnds: 0,
73
+ };
74
+ }
75
+
76
+ export function restoreCoordinatorState(entries: readonly unknown[]): CoordinatorState {
77
+ const base = initialCoordinatorState();
78
+ if (!Array.isArray(entries)) return base;
79
+ for (let i = entries.length - 1; i >= 0; i--) {
80
+ const entry = entries[i] as { type?: string; customType?: string; data?: unknown };
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;
87
+ }
88
+ if (typeof d.lastContextTokens === 'number') base.lastContextTokens = d.lastContextTokens;
89
+ base.completedBoundaryRequestCounts = [...d.completedBoundaryRequestCounts];
90
+ return base;
91
+ }
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');
117
+ }
118
+
119
+ export class CompactCoordinator {
120
+ state: CoordinatorState = initialCoordinatorState();
121
+ compactionInFlight = false;
122
+ intentionalAbort = false;
123
+ selectedCompaction: CompactionDecision | null = null;
124
+ pendingBoundaryCompleted = false;
125
+
126
+ recordBoundaryCompleted(count: number, source?: TodoCompletionSource): void {
127
+ // Only model tool calls count, and the sample is the requests spent reaching this boundary.
128
+ if (source !== 'tool' || count <= 0) return;
129
+ this.pendingBoundaryCompleted = true;
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
+ );
137
+ }
138
+ this.state.currentBoundaryRequestCount = 0;
139
+ }
140
+
141
+ onBeforeProviderRequest(contextTokens: number): void {
142
+ this.state.currentBoundaryRequestCount++;
143
+ if (this.state.carriedDebtTokens > 0 && this.state.cacheDebtRepaymentTokens > 0) {
144
+ this.state.carriedDebtTokens = Math.max(
145
+ 0,
146
+ this.state.carriedDebtTokens - this.state.cacheDebtRepaymentTokens,
147
+ );
148
+ if (this.state.carriedDebtTokens === 0) this.state.cacheDebtRepaymentTokens = 0;
149
+ }
150
+ if (this.state.lastContextTokens !== null && contextTokens > this.state.lastContextTokens) {
151
+ this.state.positiveContextDeltaTotal += contextTokens - this.state.lastContextTokens;
152
+ this.state.positiveContextDeltaCount++;
153
+ }
154
+ this.state.lastContextTokens = contextTokens;
155
+ }
156
+
157
+ onInput(event: { text?: string; source?: string; streamingBehavior?: string }): void {
158
+ // Ignore messages dispatched by extensions (D96 reminders, pipe injections, followUps).
159
+ if (event.source === 'extension' || event.text?.startsWith('CORRECTION:')) return;
160
+
161
+ const fromHuman =
162
+ event.source === 'interactive' ||
163
+ event.source === 'rpc' ||
164
+ event.source === undefined;
165
+ if (!fromHuman) return;
166
+
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++;
174
+ }
175
+
176
+ onTurnEnd(opts: {
177
+ ctx: ExtensionContext;
178
+ todos: readonly TodoItem[];
179
+ config: OnlineContextCompactConfig;
180
+ cancelReminder?: () => void;
181
+ }): void {
182
+ const boundary = this.pendingBoundaryCompleted;
183
+ this.pendingBoundaryCompleted = false;
184
+
185
+ if (!boundary || this.selectedCompaction) return;
186
+ if (!opts.config.enabled) return;
187
+ // Safety guard: never abort while the user has a queued message.
188
+ if (opts.ctx.hasPendingMessages?.()) return;
189
+
190
+ const usage = opts.ctx.getContextUsage?.();
191
+ const contextTokens =
192
+ typeof usage?.tokens === 'number' && usage.tokens > 0
193
+ ? usage.tokens
194
+ : this.state.lastContextTokens ?? 0;
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.
197
+ const fixedTokens = Math.ceil(Buffer.byteLength(opts.ctx.getSystemPrompt?.() ?? '') / 4);
198
+ const keepRecentTokens = opts.config.keepRecentTokens ?? DEFAULT_KEEP_RECENT_TOKENS;
199
+ const archiveTokens = Math.max(0, contextTokens - fixedTokens - keepRecentTokens);
200
+
201
+ const decision = decideCompaction({
202
+ writeTokens: contextTokens,
203
+ archiveTokens,
204
+ memoTokens: DEFAULT_MEMO_TOKENS,
205
+ contextTokens,
206
+ completedBoundaryRequestCounts: this.state.completedBoundaryRequestCounts,
207
+ remainingBoundaries: remainingTodos(opts.todos).length,
208
+ averageContextTokenIncrement: averageContextIncrement(this.state),
209
+ contextWindowTokens,
210
+ priorCompactionCount: this.state.priorCompactionCount,
211
+ carriedDebtTokens: this.state.carriedDebtTokens,
212
+ cacheDebtRepaymentTokens: this.state.cacheDebtRepaymentTokens,
213
+ cacheWriteReadRatio: resolveCacheRatioFromCost(
214
+ opts.config.cacheWriteReadRatio,
215
+ opts.ctx.model?.cost,
216
+ { provider: opts.ctx.model?.provider, modelId: opts.ctx.model?.id },
217
+ ),
218
+ economics: {
219
+ ...DEFAULT_COMPACTION_ECONOMICS,
220
+ firstCompactionRequestScale: opts.config.firstCompactionRequestScale,
221
+ subsequentCompactionMargin: opts.config.subsequentCompactionMargin,
222
+ },
223
+ });
224
+
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' });
236
+ }
237
+ return;
238
+ }
239
+
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;
246
+ }
247
+
248
+ this.selectedCompaction = decision;
249
+ this.intentionalAbort = true;
250
+ opts.cancelReminder?.();
251
+ opts.ctx.abort();
252
+ }
253
+
254
+ async onAgentSettled(opts: {
255
+ ctx: ExtensionContext;
256
+ todos: readonly TodoItem[];
257
+ pi: ExtensionAPI;
258
+ config: OnlineContextCompactConfig;
259
+ cancelReminder?: () => void;
260
+ onBeforeCompact?: (sessionRoot: string, ctx: ExtensionContext) => Promise<void>;
261
+ }): Promise<void> {
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() });
267
+ const decision = this.selectedCompaction;
268
+ this.selectedCompaction = null;
269
+
270
+ opts.cancelReminder?.();
271
+ this.compactionInFlight = true;
272
+
273
+ const sessionRoot = resolveSessionRoot(
274
+ opts.ctx.sessionManager?.getSessionDir?.(),
275
+ opts.ctx.sessionManager?.getSessionId?.(),
276
+ );
277
+ if (sessionRoot && opts.onBeforeCompact) {
278
+ try {
279
+ await opts.onBeforeCompact(sessionRoot, opts.ctx);
280
+ } catch {
281
+ }
282
+ }
283
+
284
+ const startMs = Date.now();
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();
293
+ }
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,
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(),
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
+ }
414
+ }
415
+
416
+ rebuildFromBranch(entries: readonly unknown[]): void {
417
+ this.state = restoreCoordinatorState(entries);
418
+ this.selectedCompaction = null;
419
+ this.compactionInFlight = false;
420
+ this.intentionalAbort = false;
421
+ this.pendingBoundaryCompleted = false;
422
+ }
423
+
424
+ getRemainingHorizon(remainingBoundaries = 3, contextWindowTokens: number | null = null): number {
425
+ const counts = this.state.completedBoundaryRequestCounts;
426
+ if (counts.length === 0) return 4; // No samples yet: conservative default horizon.
427
+ const est = estimateRemainingRequests({
428
+ completedBoundaryRequestCounts: counts,
429
+ remainingBoundaries,
430
+ scale: DEFAULT_COMPACTION_ECONOMICS.remainingRequestScale,
431
+ standardDeviationK: DEFAULT_COMPACTION_ECONOMICS.remainingRequestStddevK,
432
+ contextTokens: this.state.lastContextTokens ?? 0,
433
+ contextWindowTokens,
434
+ averageContextTokenIncrement: averageContextIncrement(this.state),
435
+ });
436
+ return Math.max(1, est.expectedRemainingRequests);
437
+ }
438
+
439
+ /** Append one compact.jsonl record (shared envelope); logging is always best-effort. */
440
+ private logCompact(ctx: ExtensionContext, fields: Record<string, unknown>): void {
441
+ const sessionId = ctx.sessionManager?.getSessionId?.();
442
+ const root = resolveSessionRoot(ctx.sessionManager?.getSessionDir?.(), sessionId);
443
+ if (!root) return;
444
+ void appendEfficiencyLog(efficiencyLogPath(root, 'compact'), {
445
+ schema: 'pier-efficiency/1',
446
+ mechanism: 'onlineContextCompact',
447
+ ts: new Date().toISOString(),
448
+ sessionId: sessionId ?? 'unknown',
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',
457
+ decision: decision.reason,
458
+ compact: decision.compact,
459
+ writeTokens: decision.writeTokens,
460
+ archiveTokens: decision.archiveTokens,
461
+ breakevenRequests: decision.breakevenRequests,
462
+ expectedRemainingRequests: decision.expectedRemainingRequests,
463
+ // Without resolvedRatio/model a cache_ratio_unavailable run is undiagnosable from logs alone.
464
+ resolvedRatio: decision.cacheWriteReadRatio,
465
+ incrementalCacheCostRatio: decision.incrementalCacheCostRatio,
466
+ remainingBoundaries: decision.remainingBoundaries,
467
+ model: ctx.model?.id ?? undefined,
468
+ provider: ctx.model?.provider ?? undefined,
469
+ });
470
+ }
471
+ }