killeros 2.0.9 → 2.0.10

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
@@ -4,6 +4,13 @@ All notable changes to KillerOS are documented here.
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [2.0.10] - 2026-08-16
8
+
9
+ ### Added
10
+
11
+ - Added proactive turn-boundary compaction with Pi-owned settings and safe continuation for ordinary prompts and active goals.
12
+ - Added reusable multi-activation decision-gated workflow registrations while preserving exact-match behavior.
13
+
7
14
  ## [2.0.9] - 2026-08-15
8
15
 
9
16
  ### Added
package/Killeros.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
2
  import { registerRequestActivity } from "./killeros/activity.ts";
3
+ import { registerAutoCompaction } from "./killeros/auto-compaction.ts";
3
4
  import {
4
5
  createSlashCommandResolver,
5
6
  registerAliases,
@@ -62,7 +63,8 @@ export default function Killeros(pi: ExtensionAPI, options: KillerosOptions = {}
62
63
  registerVariants(pi);
63
64
  registerInitCommand(pi, initRuntime, goalRuntime);
64
65
  registerLifecycleHooks(pi);
65
- registerGoalSettlement(pi, goalRuntime, initRuntime);
66
+ const goalCompaction = registerGoalSettlement(pi, goalRuntime, initRuntime);
67
+ registerAutoCompaction(pi, { goal: goalCompaction });
66
68
  registerInitSettlement(pi, initRuntime);
67
69
  registerRequestActivity(pi);
68
70
  registerCompletionNotifications(pi, options.completionNotifications);
package/README.md CHANGED
@@ -31,7 +31,7 @@ pi install git:github.com/KyrosHendrix/pi-KillerOS
31
31
  Pin an install to a release:
32
32
 
33
33
  ```bash
34
- pi install git:github.com/KyrosHendrix/pi-KillerOS@v2.0.9
34
+ pi install git:github.com/KyrosHendrix/pi-KillerOS@v2.0.10
35
35
  ```
36
36
 
37
37
  Add `-l` to either command for a project-only install. Restart Pi after installing.
@@ -44,7 +44,7 @@ Add `-l` to either command for a project-only install. Restart Pi after installi
44
44
  - Frameless multiline editor with one focus-aware `❯`, a shuffled session-stable empty-state suggestion, overflow-only scroll indicators, Shift+Enter support, and slash-command autocomplete; KillerOS preserves an editor factory configured by another extension
45
45
  - One compact TUI transcript line reporting truthful `Done`, `Stopped`, or `Failed` settlement with elapsed time while preserving older `✻ Worked for …` entries
46
46
  - Compact two-deck footer with session state above workspace state; model, context, and active goals stay prioritized as reasoning, time, cost, branch, and path reduce by available width
47
- - Pi-owned context compaction with active goals continuing from Pi's settled boundary after manual, threshold, and overflow compaction
47
+ - Proactive turn-boundary context compaction in TUI and RPC modes, with ordinary prompts and active goals continuing safely after a successful summary
48
48
  - Optional completion sounds after successful or failed settled requests, excluding manual aborts
49
49
  - `/variants` selector and direct reasoning-level arguments
50
50
  - Codex-style `/goal` with an interactive status/action panel, durable objectives, immediate pause and clear cancellation, automatic continuation, explicit completion, and durable blocker audits
@@ -81,7 +81,7 @@ The generated file uses the four behavioral sections adapted from `writing-great
81
81
 
82
82
  ### Decision-gated workflows
83
83
 
84
- Explicit `/skill:decision-gated-workflow` activation opens the shared question UI before Pi expands the skill. `Normal` allows only interview and read-only tools; `With docs` additionally permits agreed glossary, context-map, and ADR paths. The selected policy remains active until the workflow is explicitly finished or cancelled, and lifecycle changes clear it safely. Extensions can supply additional adapters through `KillerosOptions.decisionGatedWorkflows`.
84
+ Explicit `/skill:<name>` activation opens the shared question UI before Pi expands a registered skill. `WorkflowAdapter.activation` keeps the existing exact-match API, while `WorkflowAdapter.activations` lets one policy register multiple explicit skill names without duplicating the gate. `Normal` allows only interview and read-only tools; `With docs` additionally permits agreed glossary, context-map, and ADR paths. Unregistered names pass through unchanged, duplicate or ambiguous registrations fail at startup, and the selected policy remains active until the workflow is explicitly finished or cancelled. Extensions can supply additional adapters through `KillerosOptions.decisionGatedWorkflows`.
85
85
 
86
86
  ### Interactive questions
87
87
 
@@ -97,9 +97,22 @@ KillerOS activates its packaged `killeros` theme when a TUI session starts. Tool
97
97
 
98
98
  The completion sound is a global user preference stored in Pi's agent directory and is off by default. Run `/notification` in TUI mode to enable or disable it; enabling does not play a preview. Enabled TUI tabs append `󰂚`, which requires a Nerd Font in the terminal tab UI. An unsupported font may show a box without affecting sound. KillerOS uses the terminal's audible bell and cannot produce sound when the terminal disables it.
99
99
 
100
+ Automatic compaction is enabled by default when Pi's effective `compaction.enabled` setting is true. After each completed assistant turn, including its tool execution, KillerOS reads the active model's current context usage and triggers the public Pi compaction API when `remainingTokens <= max(contextWindow * percentRemaining / 100, reserveTokens)`. The default `percentRemaining` is `15`; `reserveTokens` and `keepRecentTokens` remain Pi-owned. KillerOS reads those effective settings with Pi's public `SettingsManager` using `getAgentDir()` and the current project trust state. The KillerOS-only preference lives in the same global `killeros.json` file:
101
+
102
+ ```json
103
+ {
104
+ "autoCompaction": {
105
+ "enabled": true,
106
+ "percentRemaining": 15
107
+ }
108
+ }
109
+ ```
110
+
111
+ Missing context readings skip the check. Successful ordinary-prompt compaction queues one hidden continuation; active `/goal` runs use the existing session-compaction and goal-continuation path. A failed compaction does not automatically retry. Manual `/compact` behavior is unchanged.
112
+
100
113
  KillerOS displays session costs in USD. The footer uses Pi's human-readable model name when available, keeps the provider visually secondary, and renders context as `percent left (tokens)` without a progress bar. An active goal replaces the right-side path with warning-yellow `/goal is active (...)` and keeps exact seconds in minute and hour formats. Paused and blocked goals retain their existing placement; completed goals remain in transcript history and `/goal` status rather than the footer. At narrow widths, context pressure and actionable goal state take priority.
101
114
 
102
- Pi decides when compaction runs and Pi writes the summary, applies manual focus instructions, tracks files, retries summarization, and handles overflow recovery. KillerOS does not add a second threshold or replace Pi's summary. Active `/goal` work continues from Pi's settled boundary, after Pi finishes retries, compaction, and queued work.
115
+ Pi writes the summary, applies manual focus instructions, tracks files, retries summarization, and handles overflow recovery. KillerOS owns only the proactive turn-boundary trigger and does not replace Pi's compaction implementation. Active `/goal` work continues from the settled compaction boundary, after Pi finishes retries, compaction, and queued work.
103
116
 
104
117
  Manual `/compact` aborts the current goal turn before summarization, so KillerOS records an honest temporary pause for that exact goal revision. After Pi saves the manual summary, KillerOS resumes that revision automatically. A failed or cancelled manual compaction stays paused; run `/goal pause` during the pause to cancel automatic recovery.
105
118
 
@@ -111,9 +124,9 @@ Lifecycle hooks are loaded from `.pi/killeros-hooks.json` at session start. Supp
111
124
 
112
125
  | Mode | Behavior |
113
126
  |---|---|
114
- | TUI | All features are available, including the completion sound and tab-title indicator |
115
- | RPC | Goal set/view/pause/resume/clear work; TUI components, `/goal edit`, `/init`, completion sounds, and the title indicator are disabled |
116
- | Print/JSON | Interactive questions, `/goal`, and `/init` fail explicitly; completion sounds and the title indicator are disabled |
127
+ | TUI | All features are available, including proactive compaction, the completion sound, and the tab-title indicator |
128
+ | RPC | Proactive compaction and goal set/view/pause/resume/clear work; TUI components, `/goal edit`, `/init`, completion sounds, and the title indicator are disabled |
129
+ | Print/JSON | Interactive questions, `/goal`, `/init`, and proactive compaction are disabled; completion sounds and the title indicator are disabled |
117
130
 
118
131
  ## Validation
119
132
 
@@ -0,0 +1,224 @@
1
+ import {
2
+ getAgentDir,
3
+ SettingsManager,
4
+ type CompactionSettings,
5
+ type ContextUsage,
6
+ type ExtensionAPI,
7
+ type ExtensionContext,
8
+ } from "@earendil-works/pi-coding-agent";
9
+ import { createKillerosSettingsStore } from "./settings.ts";
10
+
11
+ export const DEFAULT_AUTO_COMPACTION_PERCENT_REMAINING = 15;
12
+ export const AUTO_COMPACTION_MESSAGE_TYPE = "killeros-auto-compaction";
13
+ export const AUTO_COMPACTION_MESSAGE = "Continue the interrupted task from the compacted context.";
14
+
15
+ export interface AutoCompactionPreference {
16
+ enabled: boolean;
17
+ percentRemaining: number;
18
+ }
19
+
20
+ export interface AutoCompactionGoalHandlers {
21
+ isActive(ctx: ExtensionContext): boolean;
22
+ onRequested(): void;
23
+ onCompleted(ctx: ExtensionContext): void;
24
+ onFailed(ctx: ExtensionContext, error: unknown): void;
25
+ }
26
+
27
+ export interface AutoCompactionDependencies {
28
+ loadPreference?: (ctx: ExtensionContext) => AutoCompactionPreference;
29
+ getCompactionSettings?: (ctx: ExtensionContext) => CompactionSettings;
30
+ goal?: AutoCompactionGoalHandlers;
31
+ }
32
+
33
+ interface AutoCompactionRequest {
34
+ goal: boolean;
35
+ token: symbol;
36
+ }
37
+
38
+ function isRecord(value: unknown): value is Record<string, unknown> {
39
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value);
40
+ }
41
+
42
+ export function readAutoCompactionPreference(
43
+ settings: Readonly<Record<string, unknown>>,
44
+ ): AutoCompactionPreference {
45
+ const raw = isRecord(settings.autoCompaction) ? settings.autoCompaction : {};
46
+ const percentRemaining = typeof raw.percentRemaining === "number"
47
+ && Number.isFinite(raw.percentRemaining)
48
+ && raw.percentRemaining >= 0
49
+ && raw.percentRemaining <= 100
50
+ ? raw.percentRemaining
51
+ : DEFAULT_AUTO_COMPACTION_PERCENT_REMAINING;
52
+ return {
53
+ enabled: typeof raw.enabled === "boolean" ? raw.enabled : true,
54
+ percentRemaining,
55
+ };
56
+ }
57
+
58
+ function defaultPreference(): AutoCompactionPreference {
59
+ return readAutoCompactionPreference(createKillerosSettingsStore().load());
60
+ }
61
+
62
+ function defaultCompactionSettings(ctx: ExtensionContext): CompactionSettings {
63
+ return SettingsManager.create(ctx.cwd, getAgentDir(), {
64
+ projectTrusted: ctx.isProjectTrusted(),
65
+ }).getCompactionSettings();
66
+ }
67
+
68
+ function reserveTokens(settings: Pick<CompactionSettings, "reserveTokens">): number {
69
+ return typeof settings.reserveTokens === "number" && Number.isFinite(settings.reserveTokens)
70
+ ? Math.max(0, settings.reserveTokens)
71
+ : 0;
72
+ }
73
+
74
+ export function shouldTriggerAutoCompaction(
75
+ usage: Pick<ContextUsage, "tokens" | "contextWindow"> | undefined,
76
+ preference: AutoCompactionPreference,
77
+ compactionSettings: Pick<CompactionSettings, "enabled" | "reserveTokens">,
78
+ ): boolean {
79
+ if (!preference.enabled || !compactionSettings.enabled || usage?.tokens === undefined || usage.tokens === null) {
80
+ return false;
81
+ }
82
+ if (!Number.isFinite(usage.tokens) || !Number.isFinite(usage.contextWindow) || usage.contextWindow <= 0) {
83
+ return false;
84
+ }
85
+ const remainingTokens = usage.contextWindow - usage.tokens;
86
+ const threshold = Math.max(
87
+ usage.contextWindow * preference.percentRemaining / 100,
88
+ reserveTokens(compactionSettings),
89
+ );
90
+ return remainingTokens <= threshold;
91
+ }
92
+
93
+ function errorMessage(error: unknown): string {
94
+ return error instanceof Error ? error.message : String(error);
95
+ }
96
+
97
+ function supportedMode(ctx: ExtensionContext): boolean {
98
+ return ctx.mode === "tui" || ctx.mode === "rpc";
99
+ }
100
+
101
+ export function registerAutoCompaction(
102
+ pi: ExtensionAPI,
103
+ dependencies: AutoCompactionDependencies = {},
104
+ ): void {
105
+ const loadPreference = dependencies.loadPreference ?? (() => defaultPreference());
106
+ const getCompactionSettings = dependencies.getCompactionSettings ?? defaultCompactionSettings;
107
+ let request: AutoCompactionRequest | undefined;
108
+ let armed = true;
109
+ let settingsErrorReported = false;
110
+
111
+ const resetForLifecycle = (): void => {
112
+ request = undefined;
113
+ armed = true;
114
+ settingsErrorReported = false;
115
+ };
116
+
117
+ const notifyFailure = (ctx: ExtensionContext, error: unknown): void => {
118
+ ctx.ui.notify(`Automatic compaction failed: ${errorMessage(error)}`, "error");
119
+ };
120
+
121
+ const finishFailure = (
122
+ ctx: ExtensionContext,
123
+ token: symbol,
124
+ goal: boolean,
125
+ error: unknown,
126
+ ): void => {
127
+ if (!request || request.token !== token) return;
128
+ request = undefined;
129
+ if (goal && dependencies.goal) {
130
+ try {
131
+ dependencies.goal.onFailed(ctx, error);
132
+ } catch (callbackError) {
133
+ notifyFailure(ctx, callbackError);
134
+ }
135
+ return;
136
+ }
137
+ notifyFailure(ctx, error);
138
+ };
139
+
140
+ pi.on("turn_end", (_event, ctx) => {
141
+ if (!supportedMode(ctx) || request) return;
142
+
143
+ let preference: AutoCompactionPreference;
144
+ let compactionSettings: CompactionSettings;
145
+ let usage: ContextUsage | undefined;
146
+ try {
147
+ preference = loadPreference(ctx);
148
+ compactionSettings = getCompactionSettings(ctx);
149
+ } catch (error) {
150
+ if (!settingsErrorReported) {
151
+ settingsErrorReported = true;
152
+ ctx.ui.notify(`Automatic compaction settings could not be read: ${errorMessage(error)}`, "error");
153
+ }
154
+ return;
155
+ }
156
+ try {
157
+ usage = ctx.getContextUsage();
158
+ } catch {
159
+ return;
160
+ }
161
+ if (!usage) return;
162
+ if (usage.tokens !== null && usage.tokens !== undefined && Number.isFinite(usage.tokens)
163
+ && Number.isFinite(usage.contextWindow) && usage.contextWindow > 0) {
164
+ const remainingTokens = usage.contextWindow - usage.tokens;
165
+ const threshold = Math.max(
166
+ usage.contextWindow * preference.percentRemaining / 100,
167
+ reserveTokens(compactionSettings),
168
+ );
169
+ if (remainingTokens > threshold) {
170
+ armed = true;
171
+ }
172
+ }
173
+ if (!armed) return;
174
+ if (!shouldTriggerAutoCompaction(usage, preference, compactionSettings)) return;
175
+
176
+ armed = false;
177
+ const token = Symbol();
178
+ const goal = dependencies.goal?.isActive(ctx) === true;
179
+ request = { goal, token };
180
+ if (goal && dependencies.goal) {
181
+ try {
182
+ dependencies.goal.onRequested();
183
+ } catch (error) {
184
+ finishFailure(ctx, token, true, error);
185
+ return;
186
+ }
187
+ }
188
+
189
+ try {
190
+ ctx.compact({
191
+ onComplete: () => {
192
+ if (!request || request.token !== token) return;
193
+ request = undefined;
194
+ if (goal && dependencies.goal) {
195
+ try {
196
+ dependencies.goal.onCompleted(ctx);
197
+ } catch (error) {
198
+ notifyFailure(ctx, error);
199
+ }
200
+ return;
201
+ }
202
+ try {
203
+ pi.sendMessage({
204
+ customType: AUTO_COMPACTION_MESSAGE_TYPE,
205
+ content: AUTO_COMPACTION_MESSAGE,
206
+ display: false,
207
+ }, { triggerTurn: true, deliverAs: "followUp" });
208
+ } catch (error) {
209
+ notifyFailure(ctx, error);
210
+ }
211
+ },
212
+ onError: (error) => finishFailure(ctx, token, goal, error),
213
+ });
214
+ } catch (error) {
215
+ finishFailure(ctx, token, goal, error);
216
+ }
217
+ });
218
+
219
+ pi.on("session_start", resetForLifecycle);
220
+ pi.on("session_shutdown", resetForLifecycle);
221
+ pi.on("session_tree", resetForLifecycle);
222
+ pi.on("session_before_switch", resetForLifecycle);
223
+ pi.on("session_before_fork", resetForLifecycle);
224
+ }
package/killeros/goals.ts CHANGED
@@ -264,7 +264,10 @@ function transitionGoal(
264
264
  resumeAfterManualCompaction: options.resumeAfterManualCompaction,
265
265
  };
266
266
  persistGoalState(pi, runtime, event, next);
267
- if (status !== "active") runtime.continuationScheduled = false;
267
+ if (status !== "active") {
268
+ runtime.continuationScheduled = false;
269
+ runtime.automaticCompaction = undefined;
270
+ }
268
271
  return next;
269
272
  }
270
273
 
@@ -272,6 +275,7 @@ function clearGoalExecutionFlags(runtime: GoalRuntime): void {
272
275
  runtime.continuationScheduled = false;
273
276
  runtime.goalTurnInFlight = false;
274
277
  runtime.agentEndObserved = false;
278
+ runtime.automaticCompaction = undefined;
275
279
  runtime.lastStopReason = undefined;
276
280
  runtime.lastError = undefined;
277
281
  }
@@ -333,6 +337,7 @@ export function pauseGoalAfterFailure(
333
337
  syncGoalUpdateTool(pi, runtime);
334
338
  runtime.persistenceRetryNeeded = true;
335
339
  runtime.continuationScheduled = false;
340
+ runtime.automaticCompaction = undefined;
336
341
  runtime.requestRender?.();
337
342
  }
338
343
  if (notify) ctx.ui.notify(`Goal paused: ${reason}\n${recoveryInstruction}`, "error");
@@ -359,6 +364,7 @@ function pauseGoalForPossibleManualCompaction(
359
364
  syncGoalUpdateTool(pi, runtime);
360
365
  runtime.persistenceRetryNeeded = true;
361
366
  runtime.continuationScheduled = false;
367
+ runtime.automaticCompaction = undefined;
362
368
  runtime.requestRender?.();
363
369
  }
364
370
  ctx.ui.notify(
@@ -384,6 +390,7 @@ function recoverGoalAfterManualCompaction(
384
390
  return false;
385
391
  }
386
392
  runtime.continuationScheduled = false;
393
+ runtime.automaticCompaction = undefined;
387
394
  ctx.ui.notify("Manual compaction complete. Goal resumed.", "info");
388
395
  setImmediate(() => scheduleGoalContinuation(pi, runtime, initState, ctx));
389
396
  return true;
@@ -450,6 +457,42 @@ function scheduleGoalContinuation(
450
457
  }
451
458
  }
452
459
 
460
+ function completeAutomaticCompaction(
461
+ pi: ExtensionAPI,
462
+ runtime: GoalRuntime,
463
+ initState: InitRuntime,
464
+ ctx: ExtensionContext,
465
+ ): void {
466
+ if (runtime.automaticCompaction === undefined) return;
467
+ if (runtime.state?.status !== "active" || initState.active) {
468
+ runtime.automaticCompaction = undefined;
469
+ return;
470
+ }
471
+ runtime.automaticCompaction = "completed";
472
+ if (runtime.goalTurnInFlight || !ctx.isIdle()) return;
473
+ runtime.automaticCompaction = undefined;
474
+ setImmediate(() => scheduleGoalContinuation(pi, runtime, initState, ctx));
475
+ }
476
+
477
+ function failAutomaticCompaction(
478
+ pi: ExtensionAPI,
479
+ runtime: GoalRuntime,
480
+ ctx: ExtensionContext,
481
+ error: unknown,
482
+ ): void {
483
+ if (runtime.automaticCompaction === undefined) return;
484
+ runtime.automaticCompaction = undefined;
485
+ if (runtime.state?.status !== "active") return;
486
+ const reason = error instanceof Error ? error.message : String(error);
487
+ pauseGoalAfterFailure(
488
+ pi,
489
+ runtime,
490
+ ctx,
491
+ `automatic compaction failed: ${reason}`,
492
+ "Automatic continuation is stopped. Run /goal resume after resolving the compaction problem.",
493
+ );
494
+ }
495
+
453
496
  function goalInstructions(state: GoalState, heading: string): string {
454
497
  return [
455
498
  `# ${heading}`,
@@ -598,6 +641,7 @@ export function registerGoal(
598
641
  runtime.continuationHeld = false;
599
642
  runtime.goalTurnInFlight = false;
600
643
  runtime.agentEndObserved = false;
644
+ runtime.automaticCompaction = undefined;
601
645
  runtime.persistenceRetryNeeded = false;
602
646
  runtime.lastStopReason = undefined;
603
647
  runtime.lastError = undefined;
@@ -630,6 +674,7 @@ export function registerGoal(
630
674
  runtime.continuationHeld = false;
631
675
  runtime.goalTurnInFlight = false;
632
676
  runtime.agentEndObserved = false;
677
+ runtime.automaticCompaction = undefined;
633
678
  runtime.persistenceRetryNeeded = false;
634
679
  runtime.lastStopReason = undefined;
635
680
  runtime.lastError = undefined;
@@ -983,7 +1028,12 @@ export function registerGoalSettlement(
983
1028
  pi: ExtensionAPI,
984
1029
  runtime: GoalRuntime,
985
1030
  initState: InitRuntime,
986
- ): void {
1031
+ ): {
1032
+ isActive(ctx: ExtensionContext): boolean;
1033
+ onRequested(): void;
1034
+ onCompleted(ctx: ExtensionContext): void;
1035
+ onFailed(ctx: ExtensionContext, error: unknown): void;
1036
+ } {
987
1037
  pi.on("agent_settled", (_event, ctx) => {
988
1038
  const wasGoalTurn = runtime.goalTurnInFlight;
989
1039
  const continuationWasScheduled = runtime.continuationScheduled;
@@ -1000,6 +1050,7 @@ export function registerGoalSettlement(
1000
1050
  return;
1001
1051
  }
1002
1052
  if (!agentEndObserved) {
1053
+ if (runtime.automaticCompaction !== undefined) return;
1003
1054
  pauseGoalAfterFailure(pi, runtime, ctx, "the goal turn ended without an agent result");
1004
1055
  return;
1005
1056
  }
@@ -1007,6 +1058,12 @@ export function registerGoalSettlement(
1007
1058
  const reason = runtime.lastError || "the agent turn was aborted";
1008
1059
  runtime.lastStopReason = undefined;
1009
1060
  runtime.lastError = undefined;
1061
+ if (runtime.automaticCompaction !== undefined) {
1062
+ if (runtime.automaticCompaction === "completed") {
1063
+ completeAutomaticCompaction(pi, runtime, initState, ctx);
1064
+ }
1065
+ return;
1066
+ }
1010
1067
  pauseGoalForPossibleManualCompaction(pi, runtime, ctx, reason);
1011
1068
  return;
1012
1069
  }
@@ -1023,7 +1080,23 @@ export function registerGoalSettlement(
1023
1080
  });
1024
1081
 
1025
1082
  pi.on("session_compact", (event, ctx) => {
1083
+ if (runtime.automaticCompaction !== undefined) {
1084
+ completeAutomaticCompaction(pi, runtime, initState, ctx);
1085
+ return;
1086
+ }
1026
1087
  if (event.reason !== "manual") return;
1027
1088
  recoverGoalAfterManualCompaction(pi, runtime, initState, ctx);
1028
1089
  });
1090
+
1091
+ return {
1092
+ isActive: (ctx: ExtensionContext): boolean => isGoalModeSupported(ctx)
1093
+ && isSavedSession(ctx)
1094
+ && runtime.state?.status === "active"
1095
+ && !initState.active,
1096
+ onRequested: (): void => {
1097
+ if (runtime.state?.status === "active") runtime.automaticCompaction = "pending";
1098
+ },
1099
+ onCompleted: (ctx: ExtensionContext): void => completeAutomaticCompaction(pi, runtime, initState, ctx),
1100
+ onFailed: (ctx: ExtensionContext, error: unknown): void => failAutomaticCompaction(pi, runtime, ctx, error),
1101
+ };
1029
1102
  }
@@ -1,12 +1,10 @@
1
- import { randomUUID } from "node:crypto";
2
- import { mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
3
- import { basename, dirname, join } from "node:path";
1
+ import { basename } from "node:path";
4
2
  import type { StopReason } from "@earendil-works/pi-ai";
5
3
  import {
6
- getAgentDir,
7
4
  type ExtensionAPI,
8
5
  type ExtensionContext,
9
6
  } from "@earendil-works/pi-coding-agent";
7
+ import { createKillerosSettingsStore } from "./settings.ts";
10
8
 
11
9
  export interface NotificationPreferenceStore {
12
10
  load(): boolean;
@@ -20,42 +18,14 @@ export interface CompletionNotificationDependencies {
20
18
 
21
19
  export const COMPLETION_BELL_GLYPH = "󰂚";
22
20
 
23
- const defaultSettingsPath = (): string => join(getAgentDir(), "killeros.json");
24
-
25
- type StoredSettings = Record<string, unknown>;
26
-
27
- function readStoredSettings(settingsPath: string): StoredSettings {
28
- try {
29
- const parsed: unknown = JSON.parse(readFileSync(settingsPath, "utf8"));
30
- if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
31
- throw new Error("KillerOS settings must contain a JSON object");
32
- }
33
- return parsed as StoredSettings;
34
- } catch (error) {
35
- if ((error as NodeJS.ErrnoException).code === "ENOENT") return {};
36
- throw error;
37
- }
38
- }
39
-
40
21
  export function createNotificationPreferenceStore(
41
- settingsPath = defaultSettingsPath(),
22
+ settingsPath?: string,
42
23
  ): NotificationPreferenceStore {
24
+ const settings = createKillerosSettingsStore(settingsPath);
43
25
  return {
44
- load: () => readStoredSettings(settingsPath).completionSound === true,
26
+ load: () => settings.load().completionSound === true,
45
27
  save: (enabled) => {
46
- const current = readStoredSettings(settingsPath);
47
- mkdirSync(dirname(settingsPath), { recursive: true });
48
- const temporaryPath = `${settingsPath}.${process.pid}.${randomUUID()}.tmp`;
49
- try {
50
- writeFileSync(
51
- temporaryPath,
52
- `${JSON.stringify({ ...current, completionSound: enabled }, null, 2)}\n`,
53
- { encoding: "utf8", mode: 0o600 },
54
- );
55
- renameSync(temporaryPath, settingsPath);
56
- } finally {
57
- rmSync(temporaryPath, { force: true });
58
- }
28
+ settings.update({ completionSound: enabled });
59
29
  },
60
30
  };
61
31
  }
@@ -57,6 +57,7 @@ export interface GoalRuntime {
57
57
  continuationHeld: boolean;
58
58
  goalTurnInFlight: boolean;
59
59
  agentEndObserved: boolean;
60
+ automaticCompaction?: "pending" | "completed";
60
61
  persistenceRetryNeeded: boolean;
61
62
  lastStopReason?: string;
62
63
  lastError?: string;
@@ -73,6 +74,7 @@ export function createGoalRuntime(): GoalRuntime {
73
74
  continuationHeld: false,
74
75
  goalTurnInFlight: false,
75
76
  agentEndObserved: false,
77
+ automaticCompaction: undefined,
76
78
  persistenceRetryNeeded: false,
77
79
  };
78
80
  }
@@ -0,0 +1,47 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
3
+ import { dirname, join } from "node:path";
4
+ import { getAgentDir } from "@earendil-works/pi-coding-agent";
5
+
6
+ export type KillerosSettings = Record<string, unknown>;
7
+
8
+ export interface KillerosSettingsStore {
9
+ load(): KillerosSettings;
10
+ update(patch: Readonly<Record<string, unknown>>): void;
11
+ }
12
+
13
+ function readStoredSettings(settingsPath: string): KillerosSettings {
14
+ try {
15
+ const parsed: unknown = JSON.parse(readFileSync(settingsPath, "utf8"));
16
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
17
+ throw new Error("KillerOS settings must contain a JSON object");
18
+ }
19
+ return parsed as KillerosSettings;
20
+ } catch (error) {
21
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return {};
22
+ throw error;
23
+ }
24
+ }
25
+
26
+ export function createKillerosSettingsStore(
27
+ settingsPath = join(getAgentDir(), "killeros.json"),
28
+ ): KillerosSettingsStore {
29
+ return {
30
+ load: () => readStoredSettings(settingsPath),
31
+ update: (patch) => {
32
+ const current = readStoredSettings(settingsPath);
33
+ mkdirSync(dirname(settingsPath), { recursive: true });
34
+ const temporaryPath = `${settingsPath}.${process.pid}.${randomUUID()}.tmp`;
35
+ try {
36
+ writeFileSync(
37
+ temporaryPath,
38
+ `${JSON.stringify({ ...current, ...patch }, null, 2)}\n`,
39
+ { encoding: "utf8", mode: 0o600 },
40
+ );
41
+ renameSync(temporaryPath, settingsPath);
42
+ } finally {
43
+ rmSync(temporaryPath, { force: true });
44
+ }
45
+ },
46
+ };
47
+ }
@@ -19,7 +19,10 @@ export interface WorkflowPolicy {
19
19
 
20
20
  export interface WorkflowAdapter {
21
21
  id: string;
22
- activation: string;
22
+ /** The existing single explicit skill activation API. */
23
+ activation?: string;
24
+ /** Additional explicit skill activations that share this adapter policy. */
25
+ activations?: readonly string[];
23
26
  question: QuestionParamsValue;
24
27
  policies: readonly WorkflowPolicy[];
25
28
  selectPolicy: (details: QuestionDetails) => WorkflowPolicy | undefined;
@@ -48,11 +51,14 @@ type InternalState =
48
51
  | {
49
52
  kind: "pending_decision";
50
53
  adapter: WorkflowAdapter;
54
+ activation: string;
51
55
  abortController: AbortController;
52
56
  token: symbol;
53
57
  }
54
- | { kind: "active"; adapter: WorkflowAdapter; policy: WorkflowPolicy; token: symbol }
55
- | { kind: "terminal_cleanup"; adapter: WorkflowAdapter; reason: WorkflowTerminalReason; token: symbol };
58
+ | { kind: "active"; adapter: WorkflowAdapter; activation: string; policy: WorkflowPolicy; token: symbol }
59
+ | { kind: "terminal_cleanup"; adapter: WorkflowAdapter; activation: string; reason: WorkflowTerminalReason; token: symbol };
60
+
61
+ const ACTIVATION_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]*$/u;
56
62
 
57
63
  function explicitSkillActivation(text: string): string | undefined {
58
64
  if (!text.startsWith("/skill:")) return;
@@ -76,21 +82,21 @@ function publicState(state: InternalState): WorkflowGateState {
76
82
  return {
77
83
  kind: state.kind,
78
84
  adapterId: state.adapter.id,
79
- activation: state.adapter.activation,
85
+ activation: state.activation,
80
86
  };
81
87
  }
82
88
  if (state.kind === "terminal_cleanup") {
83
89
  return {
84
90
  kind: state.kind,
85
91
  adapterId: state.adapter.id,
86
- activation: state.adapter.activation,
92
+ activation: state.activation,
87
93
  reason: state.reason,
88
94
  };
89
95
  }
90
96
  return {
91
97
  kind: state.kind,
92
98
  adapterId: state.adapter.id,
93
- activation: state.adapter.activation,
99
+ activation: state.activation,
94
100
  policyId: state.policy.id,
95
101
  };
96
102
  }
@@ -141,14 +147,26 @@ export function registerWorkflowGate(
141
147
  const adaptersByActivation = new Map<string, WorkflowAdapter>();
142
148
  for (const adapter of adapters) {
143
149
  if (!adapter.id.trim()) throw new Error("Decision-gated workflow adapters require an id");
144
- if (!/^[-A-Za-z0-9._]+$/u.test(adapter.activation)) {
145
- throw new Error(`Invalid decision-gated workflow activation: ${adapter.activation}`);
150
+ if (adapter.activations !== undefined && !Array.isArray(adapter.activations)) {
151
+ throw new Error(`Workflow adapter ${adapter.id} activations must be an array`);
146
152
  }
147
- if (adaptersByActivation.has(adapter.activation)) {
148
- throw new Error(`Duplicate decision-gated workflow activation: ${adapter.activation}`);
153
+ const activations = [
154
+ ...(adapter.activation === undefined ? [] : [adapter.activation]),
155
+ ...(adapter.activations ?? []),
156
+ ];
157
+ if (activations.length === 0) {
158
+ throw new Error(`Workflow adapter ${adapter.id} must register at least one explicit skill activation`);
159
+ }
160
+ for (const activation of activations) {
161
+ if (typeof activation !== "string" || !ACTIVATION_PATTERN.test(activation)) {
162
+ throw new Error(`Invalid decision-gated workflow activation: ${activation}`);
163
+ }
164
+ if (adaptersByActivation.has(activation)) {
165
+ throw new Error(`Duplicate decision-gated workflow activation: ${activation}`);
166
+ }
167
+ adaptersByActivation.set(activation, adapter);
149
168
  }
150
169
  if (adapter.policies.length === 0) throw new Error(`Workflow adapter ${adapter.id} has no policies`);
151
- adaptersByActivation.set(adapter.activation, adapter);
152
170
  }
153
171
 
154
172
  let state: InternalState = { kind: "inactive" };
@@ -160,6 +178,7 @@ export function registerWorkflowGate(
160
178
  state = {
161
179
  kind: "terminal_cleanup",
162
180
  adapter: current.adapter,
181
+ activation: current.activation,
163
182
  reason,
164
183
  token: current.token,
165
184
  };
@@ -204,13 +223,12 @@ export function registerWorkflowGate(
204
223
  };
205
224
 
206
225
  pi.on("input", async (event, ctx) => {
207
- const activation = explicitSkillActivation(event.text);
208
- if (!activation) return;
209
-
210
- if (state.kind === "pending_decision" || state.kind === "terminal_cleanup") {
226
+ if ((state.kind === "pending_decision" || state.kind === "terminal_cleanup") && event.text.startsWith("/skill:")) {
211
227
  notify(ctx, "A decision-gated workflow is waiting for its structured question; skill routing is blocked", "warning");
212
228
  return { action: "handled" };
213
229
  }
230
+ const activation = explicitSkillActivation(event.text);
231
+ if (!activation) return;
214
232
 
215
233
  const adapter = adaptersByActivation.get(activation);
216
234
  if (!adapter) return;
@@ -230,7 +248,7 @@ export function registerWorkflowGate(
230
248
 
231
249
  const abortController = new AbortController();
232
250
  const token = Symbol();
233
- state = { kind: "pending_decision", adapter, abortController, token };
251
+ state = { kind: "pending_decision", adapter, activation, abortController, token };
234
252
  let details: QuestionDetails;
235
253
  try {
236
254
  details = await questionRunner.ask(adapter.question, abortController.signal, ctx);
@@ -275,7 +293,7 @@ export function registerWorkflowGate(
275
293
  if (state.kind !== "pending_decision" || state.adapter !== adapter || state.token !== token) {
276
294
  return { action: "handled" };
277
295
  }
278
- state = { kind: "active", adapter, policy, token };
296
+ state = { kind: "active", adapter, activation, policy, token };
279
297
  return { action: "continue" };
280
298
  });
281
299
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "killeros",
3
- "version": "2.0.9",
3
+ "version": "2.0.10",
4
4
  "description": "TUI, goals, and workflow automation for the Pi coding agent",
5
5
  "type": "module",
6
6
  "keywords": [