killeros 3.0.4 → 3.0.6

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.
@@ -35,6 +35,7 @@ export interface AutoCompactionDependencies {
35
35
  loadPreference?: (ctx: ExtensionContext) => AutoCompactionPreference;
36
36
  getCompactionSettings?: (ctx: ExtensionContext) => CompactionSettings;
37
37
  settingsStore?: KillerosSettingsStore;
38
+ onPreferenceChange?: (preference: AutoCompactionPreference | undefined) => void;
38
39
  goal?: AutoCompactionGoalHandlers;
39
40
  }
40
41
 
@@ -142,10 +143,12 @@ export function registerAutoCompaction(
142
143
  if (!argument || argument === "status") {
143
144
  try {
144
145
  const preference = readAutoCompactionPreference(settingsStore.load());
146
+ dependencies.onPreferenceChange?.(preference);
145
147
  ctx.ui.notify(preference.enabled
146
148
  ? `Automatic compaction: on at ${preference.percentRemaining}% remaining`
147
149
  : `Automatic compaction: off (threshold ${preference.percentRemaining}%)`, "info");
148
150
  } catch (error) {
151
+ dependencies.onPreferenceChange?.(undefined);
149
152
  ctx.ui.notify(`Automatic compaction setting could not be read: ${errorMessage(error)}`, "error");
150
153
  }
151
154
  return;
@@ -167,6 +170,7 @@ export function registerAutoCompaction(
167
170
  return { autoCompaction: updated };
168
171
  });
169
172
  if (!updated) throw new Error("Settings update did not apply the preference");
173
+ dependencies.onPreferenceChange?.(updated);
170
174
  ctx.ui.notify(percent === undefined
171
175
  ? updated.enabled
172
176
  ? `Automatic compaction: on at ${updated.percentRemaining}% remaining`
@@ -269,7 +273,9 @@ export function registerAutoCompaction(
269
273
  try {
270
274
  preference = loadPreference(ctx);
271
275
  compactionSettings = getCompactionSettings(ctx);
276
+ dependencies.onPreferenceChange?.(preference);
272
277
  } catch (error) {
278
+ dependencies.onPreferenceChange?.(undefined);
273
279
  if (!settingsErrorReported) {
274
280
  settingsErrorReported = true;
275
281
  ctx.ui.notify(`Automatic compaction settings could not be read: ${errorMessage(error)}`, "error");
@@ -343,8 +349,14 @@ export function registerAutoCompaction(
343
349
  }
344
350
  });
345
351
 
346
- pi.on("agent_settled", (_event, ctx) => {
352
+ pi.on("agent_settled", (event, ctx) => {
347
353
  goalRequestOwned = false;
354
+ // Pi aborts the agent when our pending compaction starts. Its callback owns
355
+ // recovery, including cancellation; the settlement flag alone is ambiguous.
356
+ if ("aborted" in event && event.aborted === true && request?.phase !== "compacting") {
357
+ request = undefined;
358
+ return;
359
+ }
348
360
  if (request?.phase === "continuation-dispatched") {
349
361
  request = undefined;
350
362
  notifyFailure(ctx, new Error("continuation was not accepted"));
@@ -3,6 +3,8 @@ import { watch } from "node:fs";
3
3
  import path from "node:path";
4
4
  import { type ExtensionAPI, type ExtensionContext, type Theme, type ThemeColor } from "@earendil-works/pi-coding-agent";
5
5
  import { truncateToWidth, visibleWidth, type TUI } from "@earendil-works/pi-tui";
6
+ import { readAutoCompactionPreference, type AutoCompactionPreference } from "./auto-compaction.ts";
7
+ import { createKillerosSettingsStore, type KillerosSettingsStore } from "./settings.ts";
6
8
  import { isCodexFastEnabled, isCodexFastModel, subscribeCodexFast } from "./codex-fast-state.ts";
7
9
  import { formatCwd, formatTime, modelDisplayName, padRight } from "./display.ts";
8
10
  import { inspectWorktreeWithoutFilters, passiveGitCommand, passiveGitEnv, type PassiveGitRunner } from "./passive-git-status.ts";
@@ -255,14 +257,33 @@ export function contextPercentRemaining(ctx: ExtensionContext): number | null {
255
257
  return Math.round(Math.max(0, Math.min(100, percentRemaining)));
256
258
  }
257
259
 
258
- export function formatContextProgress(tokensUsed: number | null, contextWindow: number, theme: Theme): string {
259
- if (tokensUsed === null || !Number.isFinite(tokensUsed)) return theme.fg("dim", "ctx —%");
260
- const windowSize = Number.isFinite(contextWindow) && contextWindow > 0 ? contextWindow : 128_000;
261
- const used = Math.max(0, tokensUsed);
262
- const percentRemaining = Math.max(0, Math.min(100, Math.round(((windowSize - used) / windowSize) * 100)));
260
+ function validCapacity(value: number | undefined): value is number {
261
+ return value !== undefined && Number.isFinite(value) && value > 0;
262
+ }
263
+
264
+ function formatContextTokens(tokens: number): string {
265
+ const unit = tokens >= 1_000_000 ? 1_000_000 : tokens >= 1_000 ? 1_000 : 1;
266
+ // Floor labels so rounding never promises tokens that are not available.
267
+ const count = unit === 1 ? Math.floor(tokens) : Math.floor(tokens / (unit / 100)) / 100;
268
+ return `${count}${unit === 1_000_000 ? "M" : unit === 1_000 ? "K" : ""}`;
269
+ }
270
+
271
+ export function formatContextProgress(
272
+ tokensUsed: number | null,
273
+ contextWindow: number,
274
+ theme: Theme,
275
+ showManualHint = false,
276
+ detail: "full" | "capacity" | "percentage" = "full",
277
+ ): string {
278
+ if (!validCapacity(contextWindow)) return theme.fg("dim", "ctx —%");
279
+ const capacity = detail === "percentage" ? "" : ` (${formatContextTokens(contextWindow)})`;
280
+ if (tokensUsed === null || !Number.isFinite(tokensUsed)) return theme.fg("dim", `ctx —%${capacity}`);
281
+ const remaining = Math.max(0, contextWindow - Math.max(0, tokensUsed));
282
+ const percentRemaining = Math.round(remaining / contextWindow * 100);
263
283
  const color: ThemeColor = percentRemaining > 50 ? "success" : percentRemaining >= 20 ? "warning" : "error";
264
- const action = percentRemaining <= 15 ? " · /compact" : "";
265
- return theme.fg(color, `ctx ${percentRemaining}%${action}`);
284
+ const counts = detail === "full" ? ` (${formatContextTokens(remaining)}/${formatContextTokens(contextWindow)})` : capacity;
285
+ const action = showManualHint && percentRemaining <= 15 ? " · /compact" : "";
286
+ return theme.fg(color, `ctx ${percentRemaining}%${counts}${action}`);
266
287
  }
267
288
 
268
289
  function sumSessionCost(ctx: ExtensionContext): number {
@@ -367,7 +388,16 @@ function formatGitFileChanges(changes: GitFileChanges, theme: Theme): string {
367
388
  return `${theme.fg("dim", `±${total} [`)}${theme.fg("warning", `~${changes.modified}`)} ${theme.fg("success", `+${changes.added}`)} ${theme.fg("error", `−${changes.deleted}`)}${theme.fg("dim", "]")}`;
368
389
  }
369
390
 
370
- export function registerFooter(pi: ExtensionAPI, goalRuntime: GoalRuntime): void {
391
+ export function registerFooter(
392
+ pi: ExtensionAPI,
393
+ goalRuntime: GoalRuntime,
394
+ settingsStore: KillerosSettingsStore = createKillerosSettingsStore(),
395
+ ): (preference: AutoCompactionPreference | undefined) => void {
396
+ let autoCompactionEnabled: boolean | undefined;
397
+ const updatePreference = (preference: AutoCompactionPreference | undefined): void => {
398
+ autoCompactionEnabled = preference?.enabled;
399
+ activeTui?.requestRender();
400
+ };
371
401
  let currentModel: ExtensionContext["model"];
372
402
  let thinkingLevel: ThinkingLevel = "off";
373
403
  let activeTui: TUI | undefined;
@@ -394,6 +424,11 @@ export function registerFooter(pi: ExtensionAPI, goalRuntime: GoalRuntime): void
394
424
  pi.on("session_start", (_event, ctx) => {
395
425
  resetSessionCost();
396
426
  if (ctx.mode !== "tui") return;
427
+ try {
428
+ autoCompactionEnabled = readAutoCompactionPreference(settingsStore.load()).enabled;
429
+ } catch {
430
+ autoCompactionEnabled = undefined;
431
+ }
397
432
  unsubscribeCodexFast?.();
398
433
  unsubscribeCodexFast = subscribeCodexFast(() => activeTui?.requestRender());
399
434
  const sessionStart = Date.now();
@@ -437,31 +472,31 @@ export function registerFooter(pi: ExtensionAPI, goalRuntime: GoalRuntime): void
437
472
  } catch {
438
473
  usage = undefined;
439
474
  }
440
- const contextWindow = usage?.contextWindow ?? model?.contextWindow ?? 128_000;
441
- const context = formatContextProgress(usage?.tokens ?? null, contextWindow, theme);
475
+ const contextWindow = validCapacity(usage?.contextWindow)
476
+ ? usage.contextWindow : validCapacity(model?.contextWindow) ? model.contextWindow : Number.NaN;
477
+ const contexts = (["full", "capacity", "percentage"] as const).map((detail) =>
478
+ formatContextProgress(usage?.tokens ?? null, contextWindow, theme, autoCompactionEnabled === false, detail));
442
479
  const branch = footerData.getGitBranch();
443
480
  const signature = formatModel(model, theme, true, isCodexFastEnabled());
444
481
  const fullDirectory = colorDirectory(cwd);
445
482
  const focusedDirectory = colorDirectory(compactDirectory(cwd));
446
483
  const goal = formatGoalFooter(goalRuntime.state, theme);
447
- const primary = joinFooterParts([
448
- signature,
449
- level,
450
- context,
451
- ], theme);
452
- const primaryFocused = joinFooterParts([signature, context], theme);
484
+ const primary = contexts.map((context) => joinFooterParts([signature, level, context], theme));
485
+ const primaryFocused = contexts.map((context) => joinFooterParts([signature, context], theme));
453
486
  const session = joinFooterParts([
454
487
  theme.fg("dim", formatTime(Date.now() - sessionStart)),
455
488
  theme.fg("dim", formatCost(getSessionCost(ctx))),
456
489
  ], theme);
457
490
  const essentialModel = formatModel(model, theme, false, isCodexFastEnabled());
458
- const primaryRow = footerRowFits(primary, session, width)
459
- ? renderFooterRow(primary, session, width)
460
- : footerRowFits(primary, "", width)
461
- ? renderFooterRow(primary, "", width)
462
- : footerRowFits(primaryFocused, "", width)
463
- ? renderFooterRow(primaryFocused, "", width)
464
- : renderFooterRow(essentialModel, context, width);
491
+ const candidates = [
492
+ { left: primary[0] ?? "", right: session },
493
+ ...primary.map((left) => ({ left, right: "" })),
494
+ ...primaryFocused.map((left) => ({ left, right: "" })),
495
+ ...contexts.map((right) => ({ left: essentialModel, right })),
496
+ ];
497
+ const selected = candidates.find(({ left, right }) => footerRowFits(left, right, width))
498
+ ?? { left: essentialModel, right: contexts[2] ?? "" };
499
+ const primaryRow = renderFooterRow(selected.left, selected.right, width);
465
500
  const changes = gitFileChanges ? formatGitFileChanges(gitFileChanges, theme) : "";
466
501
  const branchLabel = branch
467
502
  ? `${theme.fg("dim", branch)}${changes ? `${theme.fg("dim", " · ")}${changes}` : ""}`
@@ -501,4 +536,5 @@ export function registerFooter(pi: ExtensionAPI, goalRuntime: GoalRuntime): void
501
536
  activeTui = undefined;
502
537
  goalRuntime.requestRender = undefined;
503
538
  });
539
+ return updatePreference;
504
540
  }
@@ -219,7 +219,7 @@ export function registerGoalSettlement(
219
219
  pi: ExtensionAPI,
220
220
  runtime: GoalRuntime,
221
221
  ): AutoCompactionGoalHandlers {
222
- pi.on("agent_settled", (_event, ctx) => {
222
+ pi.on("agent_settled", (event, ctx) => {
223
223
  runtime.blockedCompletion = undefined;
224
224
  syncGoalUpdateTool(pi, runtime);
225
225
  const wasGoalTurn = runtime.goalTurnInFlight;
@@ -230,6 +230,18 @@ export function registerGoalSettlement(
230
230
  runtime.agentEndObserved = false;
231
231
  runtime.continuationScheduled = false;
232
232
 
233
+ // ctx.compact() also aborts the request. An owned compaction must settle through
234
+ // its success/error callback; its cancellation cannot authorize recovery.
235
+ if ("aborted" in event && event.aborted === true && !runtime.automaticCompaction
236
+ && runtime.lastStopReason !== "aborted") {
237
+ runtime.lastStopReason = undefined;
238
+ runtime.lastError = undefined;
239
+ if (runtime.state?.status === "active") {
240
+ pauseGoalAfterFailure(pi, runtime, ctx, "the agent request was cancelled");
241
+ }
242
+ return;
243
+ }
244
+
233
245
  if (runtime.automaticCompaction) {
234
246
  const stopReason = runtime.lastStopReason;
235
247
  const error = safeTerminalText(runtime.lastError ?? "");
@@ -116,11 +116,11 @@ export function registerCompletionNotifications(
116
116
  }
117
117
  });
118
118
 
119
- pi.on("agent_settled", (_event, ctx) => {
119
+ pi.on("agent_settled", (event, ctx) => {
120
120
  if (!requestPending || ctx.mode !== "tui") return;
121
121
  if (!ctx.isIdle() || ctx.hasPendingMessages()) return;
122
122
  requestPending = false;
123
- if (!enabled || lastStopReason === "aborted") return;
123
+ if (!enabled || "aborted" in event && event.aborted === true || lastStopReason === "aborted") return;
124
124
  try {
125
125
  runtime.ring();
126
126
  } catch (error) {