killeros 2.0.1 → 2.0.3

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.
@@ -0,0 +1,167 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
3
+ import { basename, dirname, join } from "node:path";
4
+ import type { StopReason } from "@earendil-works/pi-ai";
5
+ import {
6
+ getAgentDir,
7
+ type ExtensionAPI,
8
+ type ExtensionContext,
9
+ } from "@earendil-works/pi-coding-agent";
10
+
11
+ export interface NotificationPreferenceStore {
12
+ load(): boolean;
13
+ save(enabled: boolean): void;
14
+ }
15
+
16
+ export interface CompletionNotificationDependencies {
17
+ store: NotificationPreferenceStore;
18
+ ring(): void;
19
+ }
20
+
21
+ export const COMPLETION_BELL_GLYPH = "󰂚";
22
+
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
+ export function createNotificationPreferenceStore(
41
+ settingsPath = defaultSettingsPath(),
42
+ ): NotificationPreferenceStore {
43
+ return {
44
+ load: () => readStoredSettings(settingsPath).completionSound === true,
45
+ 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
+ }
59
+ },
60
+ };
61
+ }
62
+
63
+ export function formatNotificationTitle(
64
+ cwd: string,
65
+ sessionName: string | undefined,
66
+ enabled: boolean,
67
+ ): string {
68
+ const directory = basename(cwd);
69
+ const base = sessionName ? `π - ${sessionName} - ${directory}` : `π - ${directory}`;
70
+ return enabled ? `${base} ${COMPLETION_BELL_GLYPH}` : base;
71
+ }
72
+
73
+ function errorMessage(error: unknown): string {
74
+ return error instanceof Error ? error.message : String(error);
75
+ }
76
+
77
+ export function registerCompletionNotifications(
78
+ pi: ExtensionAPI,
79
+ dependencies?: CompletionNotificationDependencies,
80
+ ): void {
81
+ const runtime = dependencies ?? {
82
+ store: createNotificationPreferenceStore(),
83
+ ring: () => { process.stdout.write("\x07"); },
84
+ };
85
+ let enabled = false;
86
+ let requestPending = false;
87
+ let lastStopReason: StopReason | undefined;
88
+
89
+ const applyTitle = (ctx: ExtensionContext): void => {
90
+ if (ctx.mode !== "tui") return;
91
+ ctx.ui.setTitle(formatNotificationTitle(ctx.cwd, pi.getSessionName(), enabled));
92
+ };
93
+
94
+ pi.registerCommand("notification", {
95
+ description: "Configure the completion sound",
96
+ handler: async (_args, ctx) => {
97
+ if (ctx.mode !== "tui") {
98
+ ctx.ui.notify("/notification requires TUI mode", "error");
99
+ return;
100
+ }
101
+ const selected = await ctx.ui.select("Completion sound", [
102
+ enabled ? "On ← current" : "On",
103
+ enabled ? "Off" : "Off ← current",
104
+ ]);
105
+ if (!selected) return;
106
+ const nextEnabled = selected.startsWith("On");
107
+ try {
108
+ runtime.store.save(nextEnabled);
109
+ } catch (error) {
110
+ ctx.ui.notify(`Completion sound setting could not be saved: ${errorMessage(error)}`, "error");
111
+ return;
112
+ }
113
+ enabled = nextEnabled;
114
+ applyTitle(ctx);
115
+ ctx.ui.notify(`Completion sound: ${enabled ? "On" : "Off"}`, "info");
116
+ },
117
+ });
118
+
119
+ pi.on("session_start", (_event, ctx) => {
120
+ requestPending = false;
121
+ lastStopReason = undefined;
122
+ if (ctx.mode !== "tui") return;
123
+ try {
124
+ enabled = runtime.store.load();
125
+ } catch (error) {
126
+ enabled = false;
127
+ ctx.ui.notify(`Completion sound settings could not be read: ${errorMessage(error)}`, "error");
128
+ }
129
+ applyTitle(ctx);
130
+ });
131
+
132
+ pi.on("session_info_changed", (_event, ctx) => applyTitle(ctx));
133
+
134
+ pi.on("agent_start", (_event, ctx) => {
135
+ if (ctx.mode !== "tui") return;
136
+ requestPending = true;
137
+ lastStopReason = undefined;
138
+ });
139
+
140
+ pi.on("agent_end", (event, ctx) => {
141
+ if (ctx.mode !== "tui" || !requestPending) return;
142
+ lastStopReason = undefined;
143
+ for (let index = event.messages.length - 1; index >= 0; index -= 1) {
144
+ const message = event.messages[index];
145
+ if (message?.role !== "assistant") continue;
146
+ lastStopReason = message.stopReason;
147
+ break;
148
+ }
149
+ });
150
+
151
+ pi.on("agent_settled", (_event, ctx) => {
152
+ if (ctx.mode !== "tui" || !requestPending) return;
153
+ if (!ctx.isIdle() || ctx.hasPendingMessages()) return;
154
+ requestPending = false;
155
+ if (!enabled || lastStopReason === "aborted") return;
156
+ try {
157
+ runtime.ring();
158
+ } catch (error) {
159
+ ctx.ui.notify(`Completion sound failed: ${errorMessage(error)}`, "error");
160
+ }
161
+ });
162
+
163
+ pi.on("session_shutdown", () => {
164
+ requestPending = false;
165
+ lastStopReason = undefined;
166
+ });
167
+ }
@@ -1,4 +1,4 @@
1
- import { keyHint, type ExtensionAPI, type ThemeColor } from "@earendil-works/pi-coding-agent";
1
+ import { type ExtensionAPI, type ThemeColor } from "@earendil-works/pi-coding-agent";
2
2
  import {
3
3
  decodeKittyPrintable,
4
4
  Editor,
@@ -174,6 +174,21 @@ export function registerQuestionTool(pi: ExtensionAPI): void {
174
174
  };
175
175
  finishFromAbort = () => finish({ kind: "aborted" });
176
176
 
177
+ const keyHint = (
178
+ keybinding: Parameters<typeof keybindings.getKeys>[0],
179
+ description: string,
180
+ ): string => {
181
+ const keyText = keybindings.getKeys(keybinding)
182
+ .join("/")
183
+ .split("/")
184
+ .map((key) => key
185
+ .split("+")
186
+ .map((part) => process.platform === "darwin" && part.toLocaleLowerCase() === "alt" ? "option" : part)
187
+ .join("+"))
188
+ .join("/");
189
+ return theme.fg("dim", keyText) + theme.fg("muted", ` ${description}`);
190
+ };
191
+
177
192
  const editorTheme: EditorTheme = {
178
193
  borderColor: (text) => theme.fg("accent", text),
179
194
  selectList: {
@@ -23,13 +23,13 @@ export interface GoalState {
23
23
  blockedAuditStartTurn: number;
24
24
  baselineTokens: number;
25
25
  result?: string;
26
+ resumeAfterManualCompaction?: true;
26
27
  }
27
28
 
28
29
  export interface GoalRuntime {
29
30
  state?: GoalState;
30
31
  continuationScheduled: boolean;
31
32
  continuationHeld: boolean;
32
- continuationHeldForCompaction: boolean;
33
33
  goalTurnInFlight: boolean;
34
34
  agentEndObserved: boolean;
35
35
  persistenceRetryNeeded: boolean;
@@ -46,7 +46,6 @@ export function createGoalRuntime(): GoalRuntime {
46
46
  return {
47
47
  continuationScheduled: false,
48
48
  continuationHeld: false,
49
- continuationHeldForCompaction: false,
50
49
  goalTurnInFlight: false,
51
50
  agentEndObserved: false,
52
51
  persistenceRetryNeeded: false,
@@ -61,26 +60,3 @@ export function resetInitRuntime(state: InitRuntime): void {
61
60
  state.projectRoot = undefined;
62
61
  state.activeTools = undefined;
63
62
  }
64
-
65
- export interface CompactionRuntime {
66
- compactionInFlight: boolean;
67
- automaticCompactionArmed: boolean;
68
- automaticCompactionAwaitingHook: boolean;
69
- automaticCompactionPending: boolean;
70
- compactionOperationId: number;
71
- sessionGeneration: number;
72
- lastCompactionAt?: number;
73
- thresholdPercent: number;
74
- }
75
-
76
- export function createCompactionRuntime(): CompactionRuntime {
77
- return {
78
- compactionInFlight: false,
79
- automaticCompactionArmed: true,
80
- automaticCompactionAwaitingHook: false,
81
- automaticCompactionPending: false,
82
- compactionOperationId: 0,
83
- sessionGeneration: 0,
84
- thresholdPercent: 40,
85
- };
86
- }
@@ -42,6 +42,7 @@ const STARTUP_TIPS = [
42
42
  "Press Shift+Enter to insert a line break without sending.",
43
43
  "Run /variants to tune the model's reasoning depth.",
44
44
  "Type / to browse every command available in this session.",
45
+ "Run /notification to enable a terminal bell when work settles.",
45
46
  ] as const;
46
47
 
47
48
  export function resolveGitBranch(cwd: string): Promise<string | undefined> {
@@ -369,8 +370,17 @@ class PiCodeEditor extends CustomEditor {
369
370
  }
370
371
  }
371
372
 
373
+ const ACTIVITY_FRAMES = [
374
+ "·", "✢", "✱", "✶", "✻", "✽",
375
+ "✽", "✻", "✶", "✱", "✢", "·",
376
+ ] as const;
377
+ const ACTIVITY_FRAME_INTERVAL_MS = 120;
372
378
  const ACTIVITY_WORDS = ["Brewing", "Pondering", "Tinkering", "Wrangling", "Noodling", "Cooking"] as const;
373
379
 
380
+ function formatActivityMessage(word: string, theme: Theme): string {
381
+ return `${theme.fg("accent", `${word}…`)} ${theme.fg("dim", `(${theme.bold("esc")} to interrupt · thinking)`)}`;
382
+ }
383
+
374
384
  export function registerShellUi(pi: ExtensionAPI): void {
375
385
  let activeHeader: PiStartupHeader | undefined;
376
386
  let activityDeck: string[] = [];
@@ -413,7 +423,10 @@ export function registerShellUi(pi: ExtensionAPI): void {
413
423
  return activeHeader;
414
424
  });
415
425
  clearActivityTimer();
416
- ctx.ui.setWorkingIndicator({ frames: [ctx.ui.theme.fg("accent", "✻")] });
426
+ ctx.ui.setWorkingIndicator({
427
+ frames: ACTIVITY_FRAMES.map((frame) => ctx.ui.theme.fg("accent", frame)),
428
+ intervalMs: ACTIVITY_FRAME_INTERVAL_MS,
429
+ });
417
430
  ctx.ui.setHiddenThinkingLabel("└ Thinking…");
418
431
  ctx.ui.setEditorComponent((tui, editorTheme, keybindings) =>
419
432
  new PiCodeEditor(tui, editorTheme, keybindings, ctx.ui.theme, () => availableCommandNames(pi)));
@@ -425,7 +438,7 @@ export function registerShellUi(pi: ExtensionAPI): void {
425
438
  pi.on("agent_start", (_event, ctx) => {
426
439
  if (ctx.mode !== "tui") return;
427
440
  clearActivityTimer();
428
- const updateWorkingWord = (): void => ctx.ui.setWorkingMessage(`${nextActivityWord()}…`);
441
+ const updateWorkingWord = (): void => ctx.ui.setWorkingMessage(formatActivityMessage(nextActivityWord(), ctx.ui.theme));
429
442
  updateWorkingWord();
430
443
  activityTimer = setInterval(updateWorkingWord, 2_500);
431
444
  activityTimer.unref?.();
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "killeros",
3
- "version": "2.0.1",
4
- "description": "A production-hardened TUI and workflow extension for the Pi coding agent.",
3
+ "version": "2.0.3",
4
+ "description": "TUI, goals, and workflow automation for the Pi coding agent",
5
5
  "type": "module",
6
6
  "keywords": [
7
7
  "pi-package",