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 +7 -0
- package/Killeros.ts +3 -1
- package/README.md +20 -7
- package/killeros/auto-compaction.ts +224 -0
- package/killeros/goals.ts +75 -2
- package/killeros/notifications.ts +6 -36
- package/killeros/runtime.ts +2 -0
- package/killeros/settings.ts +47 -0
- package/killeros/workflow-gate.ts +35 -17
- package/package.json +1 -1
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.
|
|
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
|
-
-
|
|
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
|
|
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
|
|
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 |
|
|
116
|
-
| Print/JSON | Interactive questions, `/goal`,
|
|
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")
|
|
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
|
-
):
|
|
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 {
|
|
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
|
|
22
|
+
settingsPath?: string,
|
|
42
23
|
): NotificationPreferenceStore {
|
|
24
|
+
const settings = createKillerosSettingsStore(settingsPath);
|
|
43
25
|
return {
|
|
44
|
-
load: () =>
|
|
26
|
+
load: () => settings.load().completionSound === true,
|
|
45
27
|
save: (enabled) => {
|
|
46
|
-
|
|
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
|
}
|
package/killeros/runtime.ts
CHANGED
|
@@ -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
|
|
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.
|
|
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.
|
|
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.
|
|
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 (
|
|
145
|
-
throw new Error(`
|
|
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
|
-
|
|
148
|
-
|
|
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
|
-
|
|
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
|
|