pi-condense 2.9.1 → 2.9.2

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.
package/CHANGELOG.md CHANGED
@@ -7,6 +7,10 @@ Published to npm as [`pi-condense`](https://www.npmjs.com/package/pi-condense) (
7
7
  Pushing a `vX.Y.Z` tag triggers `.github/workflows/release.yml`, which runs the tests and
8
8
  publishes via OIDC trusted publishing. See `.agents/skills/release/SKILL.md`.
9
9
 
10
+ ## [2.9.2] - 2026-08-31
11
+
12
+ - **Context metrics removed from the footer status line.** The ` · think Nk · gap Nk · chain P%` suffix rendered on nearly every non-idle session and crowded the footer for no actionable signal. The metrics stay on `/pruner status` (`--- context ---`) and in the `context-prune-flush-metrics` session entries; the footer is back to prune state, reclaim, and `diag`. The snapshot cache that existed only to feed the widget is gone - both remaining consumers compute on demand.
13
+
10
14
  ## [2.9.1] - 2026-08-18
11
15
 
12
16
  - **Orphan sweep: any foreign message is now a barrier ([#11](https://github.com/jjuraszek/pi-condense/issues/11)).** `sweepOrphanToolResults` only reset its open-call set on `assistant` messages, while pi-ai flushes synthetic tool results at both `assistant` and `user` boundaries (`convertToLlm` maps `custom`/`branchSummary`/`compactionSummary`/`bashExecution` to `user`). A foreign message spliced between a toolCall and its toolResult (observed: a pi-cohort control notice) let the real result through alongside pi-ai's synthetic - a duplicate `tool_use_id` the provider rejects permanently (Anthropic 400, branch bricked). Now any message that is neither `assistant` nor `toolResult` clears the open set, so the interleaved result is swept and pi-ai's repairable synthetic stands alone; already-broken branches un-brick on the next render. Deliberate conservative over-sweep (unknown roles, `excludeFromContext` bashExecution) - no role allowlist. Test helper `expectNoOrphanToolResults` carries the identical rule. Spec: `doc/specs/2026-08-18-gh-11-orphan-sweep-barrier.md` (partially supersedes the 2026-08-12 spec's section C).
package/PRUNING.md CHANGED
@@ -995,7 +995,7 @@ Phase 1 (per-batch summarization) is unaffected by chain closure and remains the
995
995
 
996
996
  Neither knob makes a chain close; they just keep Phase 1 flushing on schedule so raw toolResults do not pile up unsummarized for the whole run.
997
997
 
998
- **Observability metrics** (`src/context-metrics.ts`, `computeContextMetrics`) exist precisely to make this shape of session visible instead of silently reporting `calls: 1` the way the triggering incident did. All three are chars/4 token estimates (`Math.round`, same convention as the reclaim footer) and surface on `/pruner status` (a `--- context ---` block), the footer status line (a compact suffix, shown only when the frontier gap is non-zero), and a `context-prune-flush-metrics` session entry written once per flush attempt regardless of outcome:
998
+ **Observability metrics** (`src/context-metrics.ts`, `computeContextMetrics`) exist precisely to make this shape of session visible instead of silently reporting `calls: 1` the way the triggering incident did. All three are chars/4 token estimates (`Math.round`, same convention as the reclaim footer) and surface on `/pruner status` (a `--- context ---` block) and a `context-prune-flush-metrics` session entry written once per flush attempt regardless of outcome:
999
999
 
1000
1000
  | Metric | Definition |
1001
1001
  |---|---|
package/README.md CHANGED
@@ -69,6 +69,8 @@ pi install npm:pi-condense
69
69
  | `agent-message` (default) | When the agent sends a final text-only reply | ~1 cache rewrite per task batch |
70
70
  | `on-demand` | Only when you run `/pruner now` | None until you ask |
71
71
 
72
+ With the default `agent-message` trigger (and `autoBudgetThreshold`/`budgetTurnDelta` unset), a non-interactive (`pi -p`) session sees its first flush only at the final reply - set `autoBudgetThreshold` (e.g. `0.8`) so flushes also fire mid-run. This is a property of single-prompt sessions, not a defect in the default.
73
+
72
74
  Before any summarizer call, a pre-flush pipeline can drop or redirect a batch at zero LLM cost: protected tools/paths are never touched, content-hash duplicates are aliased to the original, batches too small to be worth summarizing are skipped outright, and oversized single results are spilled straight to a sidecar file. Closed tool-call chains older than a rolling window are additionally range-compressed. Full pipeline and each safeguard: [PRUNING.md § Pre-flush Pipeline & Safeguards](PRUNING.md#pre-flush-pipeline--safeguards), [§ Chain Compression](PRUNING.md#chain-compression).
73
75
 
74
76
  ### External cost channel
@@ -84,7 +86,7 @@ Every summarizer cost update is emitted on the shared `pi.events` channel `cost:
84
86
  | Batch vs chain | A batch is one flush's worth of tool calls; a chain is a longer closed sequence eligible for range compression |
85
87
  | Prune frontier | The last attempted prune boundary - advances even on a skip, so nothing is reconsidered twice |
86
88
  | Diagnostics (`diag u/m/o/b`) | A self-hiding status-line segment surfacing prune-time degradations: `u` = unresolved chain range, `m` = detection/render id mismatch (informational, does not change what's dropped), `o` = orphan tool-result sweep, `b` = a zero-coverage chain with nothing left to backfill (genuine span mismatch, see below). Each letter's count is omitted when zero; the whole segment disappears when all four are zero. Backing session entries are `context-prune-diagnostic` - see below |
87
- | Context metrics (`think`/`gap`/`chain`) | Open-cycle thinking tokens, largest-chain share, frontier gap - what the pruner cannot (yet) reclaim, notably in single-chain sessions. See below and [PRUNING.md § Single-chain sessions](PRUNING.md#single-chain-sessions) |
89
+ | Context metrics (`thinking`/`chain share`/`frontier gap`) | Open-cycle thinking tokens, largest-chain share, frontier gap - what the pruner cannot (yet) reclaim, notably in single-chain sessions. Shown on `/pruner status`, never on the footer. See below and [PRUNING.md § Single-chain sessions](PRUNING.md#single-chain-sessions) |
88
90
  | Prompt-cache interaction | Why batching (not per-turn pruning) is the default - see [PRUNING.md](PRUNING.md#how-prefix-caching-works) |
89
91
  | `cost:external` | The shared cost-reporting channel pi-condense emits on (see above) |
90
92
 
@@ -100,10 +102,9 @@ The status-line `diag u<N>/m<N>/o<N>/b<N>` segment above is backed by `context-p
100
102
 
101
103
  ### Context metrics (`context-prune-flush-metrics`)
102
104
 
103
- Three metrics the pruner cannot yet reclaim - open-cycle thinking tokens, largest-chain share (%), frontier gap tokens - surface in three places, all backed by `computeContextMetrics` (`src/context-metrics.ts`):
105
+ Three metrics the pruner cannot yet reclaim - open-cycle thinking tokens, largest-chain share (%), frontier gap tokens - surface in two places, both backed by `computeContextMetrics` (`src/context-metrics.ts`). They are deliberately kept off the footer status line, which stays limited to prune state, reclaim, and diagnostics:
104
106
 
105
107
  - `/pruner status` prints a `--- context ---` block: `thinking:`, `chain share:`, `frontier gap:`, plus a `rearmed: yes` line while a reload-rearm probe (below) has recoverable work armed.
106
- - The footer status line appends `· think Nk · gap Nk · chain P%` - only when the frontier gap is non-zero, so an idle session's footer is unchanged.
107
108
  - Each flush attempt (every outcome, including empty/error) writes one `context-prune-flush-metrics` session entry with the pre-flush snapshot - session-log-only, never added to what the model sees, and not reconstructed on reload.
108
109
 
109
110
  These are most informative for long single-chain sessions where Phase 3 (chain compression) never gets a closed chain to act on - see [PRUNING.md § Single-chain sessions](PRUNING.md#single-chain-sessions) for the limitation and config guidance, and [PRUNING.md § Reload rearm](PRUNING.md#reload-rearm) for how a reload with recoverable pending work re-arms the automatic flush trigger.
@@ -180,6 +181,10 @@ pi-condense is the context-economy layer: it has no code dependency on the other
180
181
 
181
182
  No committed roadmap beyond what's already tracked in [CHANGELOG.md](CHANGELOG.md); proposals and in-progress work show up there and in repo issues first.
182
183
 
184
+ ## Contributing
185
+
186
+ See [CONTRIBUTING.md](CONTRIBUTING.md) - issues follow a Context / Problem / Idea / Acceptance Criteria template; PRs run the [pi-gauntlet](https://github.com/jjuraszek/pi-gauntlet) workflow (one-liners exempt from ceremony, never from keeping docs truthful).
187
+
183
188
  ## Support
184
189
 
185
190
  If this saves you tokens, [buy me a coffee](https://buymeacoffee.com/jjurasszek).
package/index.ts CHANGED
@@ -93,10 +93,6 @@ export default function (pi: ExtensionAPI) {
93
93
  // Cleared on every non-concurrent flushPending invocation.
94
94
  let rearmedPending = false;
95
95
 
96
- // Latest ContextMetricsSnapshot, recomputed at reload probes, batch capture,
97
- // and flush entry. Cached (rather than recomputed on every widget refresh)
98
- // because computeContextMetrics walks the full branch.
99
- let metricsCache: ContextMetricsSnapshot | undefined;
100
96
  const computeMetricsSnapshot = (ctx: any): ContextMetricsSnapshot | undefined => {
101
97
  try {
102
98
  // Includes persisted custom_message entries (e.g. this extension's own
@@ -114,7 +110,7 @@ export default function (pi: ExtensionAPI) {
114
110
  ? { role: "custom", customType: e.customType, content: e.content, display: e.display, details: e.details, timestamp: new Date(e.timestamp).getTime() }
115
111
  : e.message,
116
112
  );
117
- metricsCache = computeContextMetrics(
113
+ return computeContextMetrics(
118
114
  branch,
119
115
  frontier.get(),
120
116
  (k: string) => indexer.isSummarized(k),
@@ -122,8 +118,8 @@ export default function (pi: ExtensionAPI) {
122
118
  );
123
119
  } catch (err) {
124
120
  console.error("pi-condense: context metrics computation failed", err);
121
+ return undefined;
125
122
  }
126
- return metricsCache;
127
123
  };
128
124
 
129
125
  type FlushResult =
@@ -566,7 +562,7 @@ export default function (pi: ExtensionAPI) {
566
562
 
567
563
  if (processedBatches.length === 0) {
568
564
  // Nothing was persisted (all calls failed or first call failed)
569
- setPruneStatusWidget(ctx, currentConfig.value, statsAccum.getLiveReclaim(), diagnostics.counts(), metricsCache);
565
+ setPruneStatusWidget(ctx, currentConfig.value, statsAccum.getLiveReclaim(), diagnostics.counts());
570
566
  outcome = "error";
571
567
  return { ok: false, reason: "summarizer-failed" };
572
568
  }
@@ -644,7 +640,7 @@ export default function (pi: ExtensionAPI) {
644
640
  return { ok: false, reason: isStaleContextError(err) ? "stale-context" : "failed", error: errorMessage(err) };
645
641
  }
646
642
 
647
- setPruneStatusWidget(ctx, currentConfig.value, statsAccum.getLiveReclaim(), diagnostics.counts(), metricsCache);
643
+ setPruneStatusWidget(ctx, currentConfig.value, statsAccum.getLiveReclaim(), diagnostics.counts());
648
644
  emitExternalCost(pi, statsAccum);
649
645
 
650
646
  // Chain compression — compress closed chains beyond the rolling window.
@@ -771,7 +767,7 @@ export default function (pi: ExtensionAPI) {
771
767
  // When the abort signal fired, summarizeBatch rethrows rather than
772
768
  // swallowing the error. Don't show a UI error — the user intended this.
773
769
  if (options.signal?.aborted) {
774
- setPruneStatusWidget(ctx, currentConfig.value, statsAccum.getLiveReclaim(), diagnostics.counts(), metricsCache);
770
+ setPruneStatusWidget(ctx, currentConfig.value, statsAccum.getLiveReclaim(), diagnostics.counts());
775
771
  return { ok: false, reason: "aborted" };
776
772
  }
777
773
  if (isStaleContextError(err)) {
@@ -816,10 +812,8 @@ export default function (pi: ExtensionAPI) {
816
812
  }
817
813
  }
818
814
 
819
- computeMetricsSnapshot(ctx);
820
-
821
815
  // Update footer status
822
- setPruneStatusWidget(ctx, currentConfig.value, statsAccum.getLiveReclaim(), diagnostics.counts(), metricsCache);
816
+ setPruneStatusWidget(ctx, currentConfig.value, statsAccum.getLiveReclaim(), diagnostics.counts());
823
817
 
824
818
  ctx.ui.setWidget(
825
819
  "pruner-boot",
@@ -856,8 +850,7 @@ export default function (pi: ExtensionAPI) {
856
850
  }
857
851
  }
858
852
 
859
- computeMetricsSnapshot(ctx);
860
- setPruneStatusWidget(ctx, currentConfig.value, statsAccum.getLiveReclaim(), diagnostics.counts(), metricsCache);
853
+ setPruneStatusWidget(ctx, currentConfig.value, statsAccum.getLiveReclaim(), diagnostics.counts());
861
854
  });
862
855
 
863
856
  // ── turn_end: capture batch, flush immediately or queue ──────────────────
@@ -934,13 +927,6 @@ export default function (pi: ExtensionAPI) {
934
927
  }
935
928
  }
936
929
 
937
- // Recompute regardless of whether trim produced a batch: a turn whose
938
- // toolResults are all protected/spilled/summarized/trimmed-empty still
939
- // changes the branch (thinking, open-segment size), so the cache must not
940
- // go stale on it. Placed before the pushedBatch/rearmedPending early
941
- // return below — a cache write is not gate evaluation.
942
- if (hasToolResults) computeMetricsSnapshot(ctx);
943
-
944
930
  // Mirrors main's `if (!batch) return;`: no freshly pushed batch this turn
945
931
  // means no gate evaluation, regardless of leftover pendingBatches from an
946
932
  // earlier turn — UNLESS a reload probe armed rearmedPending, in which case
@@ -1026,7 +1012,7 @@ export default function (pi: ExtensionAPI) {
1026
1012
  changed = true;
1027
1013
  statsAccum.setLiveReclaim(result.beforeChars, result.afterChars);
1028
1014
  }
1029
- setPruneStatusWidget(ctx, currentConfig.value, statsAccum.getLiveReclaim(), diagnostics.counts(), metricsCache);
1015
+ setPruneStatusWidget(ctx, currentConfig.value, statsAccum.getLiveReclaim(), diagnostics.counts());
1030
1016
 
1031
1017
  if (!changed) return undefined;
1032
1018
  return { messages };
@@ -1082,7 +1068,6 @@ export default function (pi: ExtensionAPI) {
1082
1068
  compactChains,
1083
1069
  () => diagnostics.counts(),
1084
1070
  (ctx: any) => computeMetricsSnapshot(ctx) ?? EMPTY_METRICS_SNAPSHOT,
1085
- () => metricsCache,
1086
1071
  () => rearmedPending,
1087
1072
  );
1088
1073
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-condense",
3
- "version": "2.9.1",
3
+ "version": "2.9.2",
4
4
  "description": "Pi coding-agent extension that summarizes completed tool-call batches, replaces raw outputs with short stubs, compresses closed tool-call chains, and recovers any original on demand via context_tree_query.",
5
5
  "author": "Jacek Juraszek",
6
6
  "license": "MIT",
@@ -11,10 +11,9 @@ function captureStatus(
11
11
  config: ContextPruneConfig,
12
12
  value?: Parameters<typeof setPruneStatusWidget>[2],
13
13
  diagnostics?: Parameters<typeof setPruneStatusWidget>[3],
14
- metrics?: Parameters<typeof setPruneStatusWidget>[4],
15
14
  ): string | undefined {
16
15
  let captured: string | undefined;
17
- setPruneStatusWidget({ ui: { setStatus: (_id, text) => { captured = text; } } }, config, value, diagnostics, metrics);
16
+ setPruneStatusWidget({ ui: { setStatus: (_id, text) => { captured = text; } } }, config, value, diagnostics);
18
17
  return captured;
19
18
  }
20
19
 
@@ -59,7 +58,6 @@ function setupPrunerCommand(overrides: {
59
58
  async () => ({ compressedEntries: [], skipped: 0 }),
60
59
  undefined,
61
60
  overrides.getContextMetrics,
62
- undefined,
63
61
  overrides.getRearmed,
64
62
  );
65
63
 
@@ -213,28 +211,3 @@ describe("diagnostic counters on the status line", () => {
213
211
  });
214
212
  });
215
213
 
216
- describe("context metrics suffix on the status line", () => {
217
- const metrics: ContextMetricsSnapshot = {
218
- openCycleThinkingTokens: 12000,
219
- largestChainSharePct: 62,
220
- frontierGapTokens: 195000,
221
- };
222
-
223
- it("appends a compact think/gap/chain segment when frontierGapTokens > 0", () => {
224
- const text = pruneStatusText(cfg(true), undefined, undefined, metrics);
225
- expect(text).toContain("\u00b7 think 12.0k \u00b7 gap 195.0k \u00b7 chain 62%");
226
- });
227
-
228
- it("omits the suffix when frontierGapTokens is 0", () => {
229
- const withZeroGap = { ...metrics, frontierGapTokens: 0 };
230
- expect(pruneStatusText(cfg(true), undefined, undefined, withZeroGap)).toBe(
231
- pruneStatusText(cfg(true)),
232
- );
233
- });
234
-
235
- it("composes after the diag suffix when both are present", () => {
236
- const mixedDiag = { "unresolved-range": 2, "range-id-mismatch": 0, "orphan-sweep": 1 } as const;
237
- const text = pruneStatusText(cfg(true), undefined, mixedDiag, metrics);
238
- expect(text).toBe("prune: ON \u00b7 diag u2/o1 \u00b7 think 12.0k \u00b7 gap 195.0k \u00b7 chain 62%");
239
- });
240
- });
package/src/commands.ts CHANGED
@@ -64,7 +64,6 @@ export function pruneStatusText(
64
64
  config: ContextPruneConfig,
65
65
  reclaim?: LiveReclaim,
66
66
  diagnostics?: Record<DiagnosticKind, number>,
67
- metrics?: ContextMetricsSnapshot,
68
67
  ): string {
69
68
  if (!config.enabled) return "prune: OFF";
70
69
  const diag = diagnostics
@@ -76,14 +75,11 @@ export function pruneStatusText(
76
75
  ].filter(Boolean)
77
76
  : [];
78
77
  const suffix = diag.length > 0 ? ` \u00b7 diag ${diag.join("/")}` : "";
79
- const metricsSuffix = metrics && metrics.frontierGapTokens > 0
80
- ? ` \u00b7 think ${formatCompactCount(metrics.openCycleThinkingTokens)} \u00b7 gap ${formatCompactCount(metrics.frontierGapTokens)} \u00b7 chain ${metrics.largestChainSharePct}%`
81
- : "";
82
- if (!reclaim || reclaim.beforeChars <= 0) return `prune: ON${suffix}${metricsSuffix}`;
78
+ if (!reclaim || reclaim.beforeChars <= 0) return `prune: ON${suffix}`;
83
79
  const beforeTok = Math.round(reclaim.beforeChars / 4);
84
80
  const afterTok = Math.round(reclaim.afterChars / 4);
85
81
  const reduction = Math.max(0, Math.round((1 - afterTok / beforeTok) * 100));
86
- return `prune: ON \u00b7 ${formatCompactCount(beforeTok)}->${formatCompactCount(afterTok)} (-${reduction}%)${suffix}${metricsSuffix}`;
82
+ return `prune: ON \u00b7 ${formatCompactCount(beforeTok)}->${formatCompactCount(afterTok)} (-${reduction}%)${suffix}`;
87
83
  }
88
84
 
89
85
  export function setPruneStatusWidget(
@@ -91,13 +87,12 @@ export function setPruneStatusWidget(
91
87
  config: ContextPruneConfig,
92
88
  value?: LiveReclaim | string,
93
89
  diagnostics?: Record<DiagnosticKind, number>,
94
- metrics?: ContextMetricsSnapshot,
95
90
  ): void {
96
91
  if (!config.showPruneStatusLine) {
97
92
  ctx.ui.setStatus(STATUS_WIDGET_ID, undefined);
98
93
  return;
99
94
  }
100
- const text = typeof value === "string" ? value : pruneStatusText(config, value, diagnostics, metrics);
95
+ const text = typeof value === "string" ? value : pruneStatusText(config, value, diagnostics);
101
96
  // Leading-only separator: the footer joins extension status segments with a
102
97
  // single space, so a trailing divider collides with the next segment's leading
103
98
  // one and renders doubled. One leading bar yields single dividers between
@@ -481,7 +476,6 @@ export function registerCommands(
481
476
  compactChains: (ctx: ExtensionCommandContext) => Promise<{ compressedEntries: ChainCompressionEntry[]; skipped: number }>,
482
477
  getDiagnosticCounts?: () => Record<DiagnosticKind, number>,
483
478
  getContextMetrics?: (ctx: ExtensionCommandContext) => ContextMetricsSnapshot,
484
- getCachedMetrics?: () => ContextMetricsSnapshot | undefined,
485
479
  getRearmed?: () => boolean,
486
480
  ): void {
487
481
  // Register the /pruner command
@@ -829,7 +823,7 @@ export function registerCommands(
829
823
  }
830
824
  currentConfig.value = newConfig;
831
825
  saveConfig(newConfig);
832
- setPruneStatusWidget(ctx, newConfig, getLiveReclaim(), getDiagnosticCounts?.(), getCachedMetrics?.());
826
+ setPruneStatusWidget(ctx, newConfig, getLiveReclaim(), getDiagnosticCounts?.());
833
827
  settingsList?.invalidate();
834
828
  };
835
829
 
@@ -864,7 +858,7 @@ export function registerCommands(
864
858
  currentConfig.value = { ...currentConfig.value, enabled: true };
865
859
  saveConfig(currentConfig.value);
866
860
  ctx.ui.notify("Context pruning enabled.");
867
- setPruneStatusWidget(ctx, currentConfig.value, getLiveReclaim(), getDiagnosticCounts?.(), getCachedMetrics?.());
861
+ setPruneStatusWidget(ctx, currentConfig.value, getLiveReclaim(), getDiagnosticCounts?.());
868
862
  break;
869
863
  }
870
864
 
@@ -873,7 +867,7 @@ export function registerCommands(
873
867
  currentConfig.value = { ...currentConfig.value, enabled: false };
874
868
  saveConfig(currentConfig.value);
875
869
  ctx.ui.notify("Context pruning disabled.");
876
- setPruneStatusWidget(ctx, currentConfig.value, getLiveReclaim(), getDiagnosticCounts?.(), getCachedMetrics?.());
870
+ setPruneStatusWidget(ctx, currentConfig.value, getLiveReclaim(), getDiagnosticCounts?.());
877
871
  break;
878
872
  }
879
873
 
@@ -996,7 +990,7 @@ export function registerCommands(
996
990
  currentConfig.value = { ...currentConfig.value, pruneOn: modeArg as ContextPruneConfig["pruneOn"] };
997
991
  }
998
992
  saveConfig(currentConfig.value);
999
- setPruneStatusWidget(ctx, currentConfig.value, getLiveReclaim(), getDiagnosticCounts?.(), getCachedMetrics?.());
993
+ setPruneStatusWidget(ctx, currentConfig.value, getLiveReclaim(), getDiagnosticCounts?.());
1000
994
  break;
1001
995
  }
1002
996
 
@@ -1098,7 +1092,7 @@ export function registerCommands(
1098
1092
 
1099
1093
  // Remove the widget and restore the normal footer status.
1100
1094
  clearWidget();
1101
- setPruneStatusWidget(ctx, currentConfig.value, getLiveReclaim(), getDiagnosticCounts?.(), getCachedMetrics?.());
1095
+ setPruneStatusWidget(ctx, currentConfig.value, getLiveReclaim(), getDiagnosticCounts?.());
1102
1096
 
1103
1097
  if (!result.ok) {
1104
1098
  const suffix = "error" in result && result.error ? ` (${result.error})` : "";
@@ -173,6 +173,8 @@ function bootExtension(
173
173
  const piAppended: AppendedEntry[] = options.separatePiAppended ? [] : appended;
174
174
  const sessionAppended: AppendedEntry[] = appended;
175
175
  const handlers = new Map<string, (event: any, ctx: any) => any>();
176
+ const commands = new Map<string, (args: string, ctx: any) => Promise<void>>();
177
+ const notifications: string[] = [];
176
178
 
177
179
  const pushPi = (type: string, data?: unknown) => {
178
180
  piAppended.push({ type, data });
@@ -187,7 +189,9 @@ function bootExtension(
187
189
  },
188
190
  appendEntry: options.piAppendEntry ? options.piAppendEntry(pushPi) : pushPi,
189
191
  sendMessage() {},
190
- registerCommand() {},
192
+ registerCommand(name: string, spec: { handler: (args: string, ctx: any) => Promise<void> }) {
193
+ commands.set(name, spec.handler);
194
+ },
191
195
  registerTool() {},
192
196
  registerMessageRenderer() {},
193
197
  events: { emit() {} },
@@ -221,12 +225,14 @@ function bootExtension(
221
225
  ui: {
222
226
  setStatus() {},
223
227
  setWidget() {},
224
- notify() {},
228
+ notify(message: string) {
229
+ notifications.push(message);
230
+ },
225
231
  select: async () => undefined,
226
232
  },
227
233
  };
228
234
 
229
- return { handlers, ctx, pi, piAppended, sessionAppended, appended, branch };
235
+ return { handlers, commands, notifications, ctx, pi, piAppended, sessionAppended, appended, branch };
230
236
  }
231
237
 
232
238
  async function boot(options?: Parameters<typeof bootExtension>[0]) {
@@ -526,75 +532,6 @@ describe("reload rearm (issue #6)", () => {
526
532
  expect(piAppended.some((e) => e.type === "context-prune-flush-metrics")).toBe(false);
527
533
  });
528
534
 
529
- it("recomputes the cached metrics snapshot on a turn_end whose toolResults produce no pushed batch (G3)", async () => {
530
- // Component 4 (spec): the snapshot cache recomputes at every enabled
531
- // turn_end carrying toolResults, unconditional on whether trim yields a
532
- // batch to push. Observed via the footer widget suffix (commands.ts's
533
- // pruneStatusText), which is rendered from the cache, not recomputed
534
- // itself — the honest seam here since the harness's pi.registerCommand
535
- // is a no-op stub and the registerCommands getCachedMetrics callback is
536
- // therefore unreachable from a test.
537
- const { handlers, ctx, branch } = await boot({ protectedTools: ["secret_tool"] });
538
-
539
- // Neutralize the budget/delta gate so this test only observes the
540
- // recompute, not a side-effect flush (harness default usage is 0.6,
541
- // above the fixture's 0.5 autoBudgetThreshold).
542
- ctx.getContextUsage = () => ({ tokens: 10, contextWindow: 1000000 });
543
-
544
- await handlers.get("session_start")!({}, ctx);
545
-
546
- const statusCalls: unknown[] = [];
547
- ctx.ui.setStatus = (_id: string, text?: string) => statusCalls.push(text);
548
-
549
- // Force a render of the current (session_start-computed) cache.
550
- const rawBefore = ctx.sessionManager.getBranch().filter((e: any) => e.type === "message").map((e: any) => e.message);
551
- await handlers.get("context")!({ messages: rawBefore }, ctx);
552
- const textBefore = statusCalls[statusCalls.length - 1];
553
-
554
- // Grow the branch as Pi would before firing turn_end: a new assistant
555
- // turn with a large thinking block and a protected tool call, plus its
556
- // toolResult. Protected content is excluded from frontierGapTokens by
557
- // design, but NOT from openCycleThinkingTokens or largestChainSharePct —
558
- // so this turn still moves the cache if recomputed.
559
- const newAssistant = {
560
- type: "message",
561
- message: {
562
- role: "assistant",
563
- content: [
564
- { type: "thinking", text: "t".repeat(4000) },
565
- { type: "toolCall", id: "tc2", name: "secret_tool", arguments: {} },
566
- ],
567
- },
568
- };
569
- const newToolResult = {
570
- type: "message",
571
- message: {
572
- role: "toolResult",
573
- toolCallId: "tc2",
574
- toolName: "secret_tool",
575
- content: [{ type: "text", text: "s".repeat(400) }],
576
- timestamp: Date.now(),
577
- },
578
- };
579
- branch.push(newAssistant, newToolResult);
580
-
581
- // This turn's toolResults are entirely protected, so trimBatchToPendingRange
582
- // returns null and no batch is pushed — the case this fix targets.
583
- await handlers.get("turn_end")!(
584
- { message: newAssistant.message, toolResults: [newToolResult.message], turnIndex: 3 },
585
- ctx,
586
- );
587
-
588
- const rawAfter = ctx.sessionManager.getBranch().filter((e: any) => e.type === "message").map((e: any) => e.message);
589
- await handlers.get("context")!({ messages: rawAfter }, ctx);
590
- const textAfter = statusCalls[statusCalls.length - 1];
591
-
592
- // Pre-fix, the cache is stale (computed once at session_start, on the
593
- // pre-growth branch) — the widget text does not move. Post-fix, the
594
- // turn_end recompute picks up the larger open segment/thinking.
595
- expect(textAfter).not.toBe(textBefore);
596
- });
597
-
598
535
  it("includes a persisted summary custom_message entry in the largest-chain-share denominator (G1)", async () => {
599
536
  // Component 1 (spec): denominator = per-message chars over the entire
600
537
  // branch projection, INCLUDING retained custom_message summary entries.
@@ -620,17 +557,13 @@ describe("reload rearm (issue #6)", () => {
620
557
  };
621
558
  const branch = [...closedChain, summaryEntry, closer];
622
559
 
623
- const { handlers, ctx } = await boot({ branch });
560
+ const { handlers, commands, notifications, ctx } = await boot({ branch });
624
561
  ctx.getContextUsage = () => ({ tokens: 10, contextWindow: 1000000 });
625
562
 
626
- const statusCalls: unknown[] = [];
627
- ctx.ui.setStatus = (_id: string, text?: string) => statusCalls.push(text);
628
-
629
563
  await handlers.get("session_start")!({}, ctx);
630
564
 
631
- const rawMessages = ctx.sessionManager.getBranch().filter((e: any) => e.type === "message").map((e: any) => e.message);
632
- await handlers.get("context")!({ messages: rawMessages }, ctx);
633
- const text = statusCalls[statusCalls.length - 1] as string;
565
+ await commands.get("pruner")!("status", ctx);
566
+ const text = notifications[notifications.length - 1] as string;
634
567
 
635
568
  const chainChars = closedChain.map((e: any) => JSON.stringify(e.message).length).reduce((a, b) => a + b, 0);
636
569
  const totalWithSummary = [...closedChain.map((e: any) => e.message), summaryEntry, closer.message]
@@ -641,7 +574,7 @@ describe("reload rearm (issue #6)", () => {
641
574
  (100 * chainChars) / closedChain.map((e: any) => JSON.stringify(e.message).length).reduce((a, b) => a + b, 0),
642
575
  );
643
576
 
644
- expect(text).toContain(`chain ${expectedPct}%`);
577
+ expect(text).toContain(`chain share: ${expectedPct}%`);
645
578
  expect(expectedPct).toBeLessThan(inflatedPct);
646
579
  });
647
580
  });