killeros 2.0.2 → 2.0.4

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: {
@@ -1,11 +1,21 @@
1
+ import type { InitEvidenceIndex } from "./init-evidence.ts";
2
+ import type { InitTargetBaseline } from "./init-target.ts";
3
+
4
+ export type InitOutcome =
5
+ | { kind: "pending" }
6
+ | { kind: "written" }
7
+ | { kind: "policy-conflict"; reason: string }
8
+ | { kind: "no-outcome" };
9
+
1
10
  export interface InitRuntime {
2
11
  active: boolean;
3
12
  targetPath?: string;
4
- writeAttempted: boolean;
5
- writeSucceeded: boolean;
6
13
  projectRoot?: string;
7
14
  activeTools?: string[];
8
- settle?: (writeSucceeded: boolean) => void;
15
+ evidence?: InitEvidenceIndex;
16
+ baseline?: InitTargetBaseline;
17
+ outcome: InitOutcome;
18
+ settle?: (outcome: InitOutcome) => void;
9
19
  }
10
20
 
11
21
  export type GoalStatus = "active" | "paused" | "blocked" | "complete";
@@ -23,13 +33,13 @@ export interface GoalState {
23
33
  blockedAuditStartTurn: number;
24
34
  baselineTokens: number;
25
35
  result?: string;
36
+ resumeAfterManualCompaction?: true;
26
37
  }
27
38
 
28
39
  export interface GoalRuntime {
29
40
  state?: GoalState;
30
41
  continuationScheduled: boolean;
31
42
  continuationHeld: boolean;
32
- continuationHeldForCompaction: boolean;
33
43
  goalTurnInFlight: boolean;
34
44
  agentEndObserved: boolean;
35
45
  persistenceRetryNeeded: boolean;
@@ -39,14 +49,13 @@ export interface GoalRuntime {
39
49
  }
40
50
 
41
51
  export function createInitRuntime(): InitRuntime {
42
- return { active: false, writeAttempted: false, writeSucceeded: false };
52
+ return { active: false, outcome: { kind: "pending" } };
43
53
  }
44
54
 
45
55
  export function createGoalRuntime(): GoalRuntime {
46
56
  return {
47
57
  continuationScheduled: false,
48
58
  continuationHeld: false,
49
- continuationHeldForCompaction: false,
50
59
  goalTurnInFlight: false,
51
60
  agentEndObserved: false,
52
61
  persistenceRetryNeeded: false,
@@ -56,31 +65,10 @@ export function createGoalRuntime(): GoalRuntime {
56
65
  export function resetInitRuntime(state: InitRuntime): void {
57
66
  state.active = false;
58
67
  state.targetPath = undefined;
59
- state.writeAttempted = false;
60
- state.writeSucceeded = false;
61
68
  state.projectRoot = undefined;
62
69
  state.activeTools = undefined;
63
- }
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
- };
70
+ state.evidence = undefined;
71
+ state.baseline = undefined;
72
+ state.outcome = { kind: "pending" };
73
+ state.settle = undefined;
86
74
  }
@@ -11,7 +11,6 @@ import {
11
11
  } from "@earendil-works/pi-coding-agent";
12
12
  import {
13
13
  Container,
14
- CURSOR_MARKER,
15
14
  Text,
16
15
  truncateToWidth,
17
16
  visibleWidth,
@@ -19,7 +18,6 @@ import {
19
18
  type EditorTheme,
20
19
  type TUI,
21
20
  } from "@earendil-works/pi-tui";
22
- import { availableCommandNames } from "./commands.ts";
23
21
  import { formatCwd, padRight } from "./display.ts";
24
22
  import { reportError } from "./errors.ts";
25
23
  import { formatModel } from "./footer.ts";
@@ -42,6 +40,7 @@ const STARTUP_TIPS = [
42
40
  "Press Shift+Enter to insert a line break without sending.",
43
41
  "Run /variants to tune the model's reasoning depth.",
44
42
  "Type / to browse every command available in this session.",
43
+ "Run /notification to enable a terminal bell when work settles.",
45
44
  ] as const;
46
45
 
47
46
  export function resolveGitBranch(cwd: string): Promise<string | undefined> {
@@ -150,113 +149,6 @@ class PiStartupHeader {
150
149
  }
151
150
 
152
151
  const ANSI_REGEX = /\x1b(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])/g;
153
- const ANSI_SEQUENCE_AT_START = /^\x1b\[[0-?]*[ -/]*[@-~]/u;
154
- const COMMAND_TOKEN_PATTERN = /(^|[ \t])(\/[A-Za-z0-9:_-]*)/gu;
155
-
156
- function controlSequenceAt(text: string, index: number): string | undefined {
157
- if (text.startsWith(CURSOR_MARKER, index)) return CURSOR_MARKER;
158
- return text.slice(index).match(ANSI_SEQUENCE_AT_START)?.[0];
159
- }
160
-
161
- interface CommandToken {
162
- text: string;
163
- start: number;
164
- end: number;
165
- valid: boolean;
166
- }
167
-
168
- interface EditorVisualLine {
169
- logicalLine: number;
170
- startCol: number;
171
- length: number;
172
- }
173
-
174
- function commandTokens(text: string, normalizedNames: readonly string[]): CommandToken[] {
175
- return [...text.matchAll(COMMAND_TOKEN_PATTERN)].map((match) => {
176
- const token = match[2] ?? "";
177
- const prefix = token.slice(1).toLocaleLowerCase();
178
- const start = (match.index ?? 0) + (match[1]?.length ?? 0);
179
- return {
180
- text: token,
181
- start,
182
- end: start + token.length,
183
- valid: normalizedNames.some((name) => name.startsWith(prefix)),
184
- };
185
- });
186
- }
187
-
188
- function highlightTextRanges(
189
- text: string,
190
- ranges: Array<{ start: number; end: number }>,
191
- color: (value: string) => string,
192
- ): string {
193
- if (ranges.length === 0) return text;
194
-
195
- let output = "";
196
- let buffer = "";
197
- let bufferHighlighted: boolean | undefined;
198
- let plainIndex = 0;
199
- const flush = (): void => {
200
- if (!buffer) return;
201
- output += bufferHighlighted ? color(buffer) : buffer;
202
- buffer = "";
203
- };
204
-
205
- for (let index = 0; index < text.length;) {
206
- const control = controlSequenceAt(text, index);
207
- if (control) {
208
- flush();
209
- output += control;
210
- index += control.length;
211
- continue;
212
- }
213
-
214
- const highlighted = ranges.some((range) => plainIndex >= range.start && plainIndex < range.end);
215
- if (bufferHighlighted !== highlighted) {
216
- flush();
217
- bufferHighlighted = highlighted;
218
- }
219
- buffer += text[index];
220
- plainIndex += 1;
221
- index += 1;
222
- }
223
- flush();
224
- return output;
225
- }
226
-
227
- function highlightEditorLines(
228
- lines: string[],
229
- sourceLines: string[],
230
- visualLines: EditorVisualLine[],
231
- scrollOffset: number,
232
- commandNames: ReadonlySet<string>,
233
- color: (value: string) => string,
234
- ): { lines: string[]; bottomBorderIndex: number } {
235
- let bottomBorderIndex = -1;
236
- for (let index = lines.length - 1; index >= 1; index -= 1) {
237
- if (isBorderLine(lines[index] ?? "")) {
238
- bottomBorderIndex = index;
239
- break;
240
- }
241
- }
242
- if (bottomBorderIndex < 0) bottomBorderIndex = lines.length - 1;
243
-
244
- const normalizedNames = [...commandNames].map((name) => name.toLocaleLowerCase());
245
- for (let index = 1; index < bottomBorderIndex; index += 1) {
246
- const visualLine = visualLines[scrollOffset + index - 1];
247
- if (!visualLine) continue;
248
- const visibleStart = visualLine.startCol;
249
- const visibleEnd = visibleStart + visualLine.length;
250
- const ranges = commandTokens(sourceLines[visualLine.logicalLine] ?? "", normalizedNames)
251
- .filter((token) => token.valid && token.start < visibleEnd && token.end > visibleStart)
252
- .map((token) => ({
253
- start: Math.max(token.start, visibleStart) - visibleStart,
254
- end: Math.min(token.end, visibleEnd) - visibleStart,
255
- }));
256
- lines[index] = highlightTextRanges(lines[index] ?? "", ranges, color);
257
- }
258
- return { lines, bottomBorderIndex };
259
- }
260
152
 
261
153
  function stripAnsi(text: string): string {
262
154
  return text.replace(ANSI_REGEX, "").trim();
@@ -275,19 +167,16 @@ function isScrolledTopBorder(line: string): boolean {
275
167
  class PiCodeEditor extends CustomEditor {
276
168
  private readonly appKeybindings: KeybindingsManager;
277
169
  private readonly runtimeTheme: Theme;
278
- private readonly getCommandNames: () => ReadonlySet<string>;
279
170
 
280
171
  constructor(
281
172
  tui: TUI,
282
173
  theme: EditorTheme,
283
174
  appKeybindings: KeybindingsManager,
284
175
  runtimeTheme: Theme,
285
- getCommandNames: () => ReadonlySet<string>,
286
176
  ) {
287
177
  super(tui, theme, appKeybindings);
288
178
  this.appKeybindings = appKeybindings;
289
179
  this.runtimeTheme = runtimeTheme;
290
- this.getCommandNames = getCommandNames;
291
180
  }
292
181
 
293
182
  override handleInput(data: string): void {
@@ -304,37 +193,19 @@ class PiCodeEditor extends CustomEditor {
304
193
  super.handleInput(data);
305
194
  }
306
195
 
307
- private renderWithCommandHighlighting(
308
- width: number,
309
- color: (value: string) => string,
310
- ): { lines: string[]; bottomBorderIndex: number } {
311
- const lines = super.render(width);
312
- const internals = this as unknown as {
313
- lastWidth: number;
314
- scrollOffset: number;
315
- buildVisualLineMap: (layoutWidth: number) => EditorVisualLine[];
316
- };
317
- return highlightEditorLines(
318
- lines,
319
- this.getLines(),
320
- internals.buildVisualLineMap(internals.lastWidth),
321
- internals.scrollOffset,
322
- this.getCommandNames(),
323
- color,
324
- );
325
- }
326
-
327
196
  override render(width: number): string[] {
328
197
  if (width <= 0) return [];
329
- const colorCommand = (value: string): string => this.runtimeTheme.fg("mdLink", value);
330
- if (width < 4) {
331
- return this.renderWithCommandHighlighting(width, colorCommand)
332
- .lines.map((line) => truncateToWidth(line, width, ""));
333
- }
198
+ if (width < 4) return super.render(width).map((line) => truncateToWidth(line, width, ""));
334
199
  const innerWidth = width - 2;
335
- const highlighted = this.renderWithCommandHighlighting(innerWidth, colorCommand);
336
- const { lines, bottomBorderIndex } = highlighted;
200
+ const lines = super.render(innerWidth);
337
201
  if (lines.length < 2) return lines.map((line) => truncateToWidth(line, width, ""));
202
+ let bottomBorderIndex = lines.length - 1;
203
+ for (let index = lines.length - 1; index >= 1; index -= 1) {
204
+ if (isBorderLine(lines[index] ?? "")) {
205
+ bottomBorderIndex = index;
206
+ break;
207
+ }
208
+ }
338
209
 
339
210
  const gray = (text: string): string => this.runtimeTheme.fg("dim", text);
340
211
  const framed: string[] = [];
@@ -380,6 +251,8 @@ function formatActivityMessage(word: string, theme: Theme): string {
380
251
  return `${theme.fg("accent", `${word}…`)} ${theme.fg("dim", `(${theme.bold("esc")} to interrupt · thinking)`)}`;
381
252
  }
382
253
 
254
+ let killerosEditorFactory: ReturnType<ExtensionContext["ui"]["getEditorComponent"]>;
255
+
383
256
  export function registerShellUi(pi: ExtensionAPI): void {
384
257
  let activeHeader: PiStartupHeader | undefined;
385
258
  let activityDeck: string[] = [];
@@ -427,8 +300,12 @@ export function registerShellUi(pi: ExtensionAPI): void {
427
300
  intervalMs: ACTIVITY_FRAME_INTERVAL_MS,
428
301
  });
429
302
  ctx.ui.setHiddenThinkingLabel("└ Thinking…");
430
- ctx.ui.setEditorComponent((tui, editorTheme, keybindings) =>
431
- new PiCodeEditor(tui, editorTheme, keybindings, ctx.ui.theme, () => availableCommandNames(pi)));
303
+ const existingEditorFactory = ctx.ui.getEditorComponent?.();
304
+ if (!existingEditorFactory || existingEditorFactory === killerosEditorFactory) {
305
+ killerosEditorFactory = (tui, editorTheme, keybindings) =>
306
+ new PiCodeEditor(tui, editorTheme, keybindings, ctx.ui.theme);
307
+ ctx.ui.setEditorComponent(killerosEditorFactory);
308
+ }
432
309
  } catch (error) {
433
310
  reportError(ctx, "Killeros UI failed to initialize", error);
434
311
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "killeros",
3
- "version": "2.0.2",
4
- "description": "A production-hardened TUI and workflow extension for the Pi coding agent.",
3
+ "version": "2.0.4",
4
+ "description": "TUI, goals, and workflow automation for the Pi coding agent",
5
5
  "type": "module",
6
6
  "keywords": [
7
7
  "pi-package",