killeros 2.0.22 → 2.1.23

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.
@@ -70,7 +70,7 @@ const BUILTIN_COMMANDS: ReadonlyArray<{ name: string; description: string }> = [
70
70
  ];
71
71
 
72
72
  const COMMAND_SYNTAX_HINTS: Readonly<Record<string, string>> = {
73
- goal: "/goal [objective|clear|edit|pause|resume]",
73
+ goal: "/goal [objective|pause|resume|clear]",
74
74
  handoff: "/handoff [next-session focus]",
75
75
  variants: "/variants [level]",
76
76
  model: "/model [provider/model]",
@@ -40,12 +40,14 @@ export function formatTime(milliseconds: number): string {
40
40
 
41
41
  export function formatTokens(value: number): string {
42
42
  if (!Number.isFinite(value)) return "0";
43
- const amount = Math.max(0, value);
44
- if (amount < 1_000) return `${Math.round(amount)}`;
45
- if (amount >= 1_000_000) {
46
- const precision = amount >= 10_000_000 ? 0 : 1;
47
- return `${Number((amount / 1_000_000).toFixed(precision))}M`;
43
+ const rounded = Math.round(Math.max(0, value));
44
+ if (rounded < 1_000) return `${rounded}`;
45
+ if (rounded >= 1_000_000) {
46
+ const precision = rounded >= 10_000_000 ? 0 : 1;
47
+ return `${Number((rounded / 1_000_000).toFixed(precision))}M`;
48
48
  }
49
- const precision = amount >= 100_000 ? 0 : 1;
50
- return `${Number((amount / 1_000).toFixed(precision))}k`;
49
+ const precision = rounded >= 100_000 ? 0 : 1;
50
+ const inK = Number((rounded / 1_000).toFixed(precision));
51
+ if (inK >= 1_000) return `${Number((rounded / 1_000_000).toFixed(1))}M`;
52
+ return `${inK}k`;
51
53
  }
@@ -1,4 +1,5 @@
1
1
  import { execFile } from "node:child_process";
2
+ import { watch } from "node:fs";
2
3
  import { type ExtensionAPI, type ExtensionContext, type Theme, type ThemeColor } from "@earendil-works/pi-coding-agent";
3
4
  import { truncateToWidth, visibleWidth, type TUI } from "@earendil-works/pi-tui";
4
5
  import { isCodexFastEnabled, subscribeCodexFast } from "./codex-fast-state.ts";
@@ -9,24 +10,45 @@ import { safeTerminalText } from "./safe-terminal-text.ts";
9
10
  import { LEVEL_COLORS, type ThinkingLevel } from "./variants.ts";
10
11
 
11
12
  const GIT_STATUS_REFRESH_INTERVAL_MS = 30_000;
13
+ const GIT_STATUS_TIMEOUT_MS = 5_000;
14
+ const GIT_STATUS_WATCH_DEBOUNCE_MS = 250;
15
+ const GIT_STATUS_WATCH_INTERVAL_MS = 5_000;
12
16
  const CODEX_PROVIDER = "openai-codex";
13
17
  const colorDirectory = (text: string): string => `\x1B[38;2;240;248;154m${text}\x1B[39m`;
14
18
 
15
- interface GitFileChanges {
19
+ export interface GitFileChanges {
16
20
  modified: number;
17
21
  added: number;
18
22
  deleted: number;
19
23
  }
20
24
 
21
- function resolveGitFileChanges(cwd: string): Promise<GitFileChanges | undefined> {
25
+ type GitStatusExecutor = (
26
+ file: string,
27
+ args: string[],
28
+ options: {
29
+ encoding: "utf8";
30
+ env: NodeJS.ProcessEnv;
31
+ maxBuffer: number;
32
+ timeout: number;
33
+ windowsHide: true;
34
+ },
35
+ callback: (error: Error | null, stdout: string) => void,
36
+ ) => unknown;
37
+
38
+ /** Resolves changed-file counts with a bounded asynchronous Git status process. */
39
+ export function resolveGitFileChanges(
40
+ cwd: string,
41
+ execute: GitStatusExecutor = execFile,
42
+ ): Promise<GitFileChanges | undefined> {
22
43
  return new Promise((resolve) => {
23
- execFile(
44
+ execute(
24
45
  "git",
25
46
  ["-C", cwd, "status", "--porcelain=v1", "-z", "--untracked-files=all"],
26
47
  {
27
48
  encoding: "utf8",
49
+ env: { ...process.env, GIT_OPTIONAL_LOCKS: "0" },
28
50
  maxBuffer: 4 * 1024 * 1024,
29
- timeout: 1_000,
51
+ timeout: GIT_STATUS_TIMEOUT_MS,
30
52
  windowsHide: true,
31
53
  },
32
54
  (error, stdout) => {
@@ -72,9 +94,16 @@ function createGitRefresh<T>(
72
94
  return;
73
95
  }
74
96
  pending = true;
75
- void resolveResult(cwd).then((result) => {
76
- if (!disposed) onResult(result);
77
- }).finally(() => {
97
+ let result: Promise<T | undefined>;
98
+ try {
99
+ result = resolveResult(cwd);
100
+ } catch {
101
+ result = Promise.resolve(undefined);
102
+ }
103
+ void result.then(
104
+ (value) => { if (!disposed) onResult(value); },
105
+ () => { if (!disposed) onResult(undefined); },
106
+ ).finally(() => {
78
107
  pending = false;
79
108
  if (!disposed && queued) {
80
109
  queued = false;
@@ -100,14 +129,20 @@ export function createGitStatusRefresh(
100
129
  return createGitRefresh(cwd, onCount, resolveCount);
101
130
  }
102
131
 
103
- function createGitFileChangesRefresh(
132
+ /** Coalesces file-change scans and emits only successful results. */
133
+ export function createGitFileChangesRefresh(
104
134
  cwd: string,
105
- onChanges: (changes: GitFileChanges | undefined) => void,
135
+ onChanges: (changes: GitFileChanges) => void,
136
+ resolveChanges: (cwd: string) => Promise<GitFileChanges | undefined> = resolveGitFileChanges,
106
137
  ): { request: () => void; dispose: () => void } {
107
- return createGitRefresh(cwd, onChanges, resolveGitFileChanges);
138
+ return createGitRefresh(cwd, (changes) => {
139
+ if (changes !== undefined) onChanges(changes);
140
+ }, resolveChanges);
108
141
  }
109
142
 
110
143
  type ScheduleFallback = (refresh: () => void, intervalMs: number) => () => void;
144
+ type WatchDirectory = (cwd: string, onChange: () => void, onError: () => void) => () => void;
145
+ type ScheduleDelay = (refresh: () => void, delayMs: number) => () => void;
111
146
 
112
147
  const scheduleFallback: ScheduleFallback = (refresh, intervalMs) => {
113
148
  const timer = setInterval(refresh, intervalMs);
@@ -115,6 +150,18 @@ const scheduleFallback: ScheduleFallback = (refresh, intervalMs) => {
115
150
  return () => clearInterval(timer);
116
151
  };
117
152
 
153
+ const watchDirectory: WatchDirectory = (cwd, onChange, onError) => {
154
+ const watcher = watch(cwd, { recursive: true }, onChange);
155
+ watcher.on("error", onError);
156
+ return () => watcher.close();
157
+ };
158
+
159
+ const scheduleDelay: ScheduleDelay = (refresh, delayMs) => {
160
+ const timer = setTimeout(refresh, delayMs);
161
+ timer.unref?.();
162
+ return () => clearTimeout(timer);
163
+ };
164
+
118
165
  /** Schedules the fallback Git scan independently from footer rendering. */
119
166
  export function scheduleGitStatusFallback(
120
167
  refresh: () => void,
@@ -123,6 +170,55 @@ export function scheduleGitStatusFallback(
123
170
  return schedule(refresh, GIT_STATUS_REFRESH_INTERVAL_MS);
124
171
  }
125
172
 
173
+ /** Throttles filesystem changes and falls back silently when watching is unavailable. */
174
+ export function scheduleGitStatusWatch(
175
+ cwd: string,
176
+ refresh: () => void,
177
+ startWatching: WatchDirectory = watchDirectory,
178
+ schedule: ScheduleDelay = scheduleDelay,
179
+ ): () => void {
180
+ let disposed = false;
181
+ let coolingDown = false;
182
+ let queued = false;
183
+ let stopTimer: (() => void) | undefined;
184
+ let stopWatching: (() => void) | undefined;
185
+ const stop = (): void => {
186
+ if (disposed) return;
187
+ disposed = true;
188
+ stopTimer?.();
189
+ stopTimer = undefined;
190
+ stopWatching?.();
191
+ stopWatching = undefined;
192
+ };
193
+ const changed = (): void => {
194
+ if (disposed) return;
195
+ if (stopTimer) {
196
+ if (coolingDown) queued = true;
197
+ return;
198
+ }
199
+ stopTimer = schedule(() => {
200
+ stopTimer = undefined;
201
+ if (disposed) return;
202
+ refresh();
203
+ coolingDown = true;
204
+ stopTimer = schedule(() => {
205
+ stopTimer = undefined;
206
+ coolingDown = false;
207
+ if (queued) {
208
+ queued = false;
209
+ changed();
210
+ }
211
+ }, GIT_STATUS_WATCH_INTERVAL_MS);
212
+ }, GIT_STATUS_WATCH_DEBOUNCE_MS);
213
+ };
214
+ try {
215
+ stopWatching = startWatching(cwd, changed, stop);
216
+ } catch {
217
+ disposed = true;
218
+ }
219
+ return stop;
220
+ }
221
+
126
222
  export function formatCost(usd: number): string {
127
223
  if (!Number.isFinite(usd)) return "$—";
128
224
  return `$${usd.toFixed(2)}`;
@@ -258,7 +354,8 @@ function renderFooter(rows: string[], width: number, theme: Theme): string[] {
258
354
  function formatGoalFooter(state: GoalState | undefined, theme: Theme): string {
259
355
  if (!state) return "";
260
356
  if (state.status === "active") {
261
- return theme.fg("warning", `/goal is active (${formatTime(goalElapsedMilliseconds(state, Date.now()))})`);
357
+ const turns = state.maxTurns === undefined ? "" : ` ${state.turns}/${state.maxTurns}`;
358
+ return theme.fg("warning", `/goal is active${turns} (${formatTime(goalElapsedMilliseconds(state, Date.now()))})`);
262
359
  }
263
360
  if (state.status === "paused") return theme.fg("warning", "/goal is paused");
264
361
  if (state.status === "blocked") return theme.fg("error", "/goal is blocked");
@@ -278,7 +375,6 @@ export function registerFooter(pi: ExtensionAPI, goalRuntime: GoalRuntime): void
278
375
  let cachedSessionCost = 0;
279
376
  let sessionCostDirty = true;
280
377
  let unsubscribeCodexFast: (() => void) | undefined;
281
- let requestGitStatusRefresh: (() => void) | undefined;
282
378
  const resetSessionCost = (): void => {
283
379
  cachedSessionCost = 0;
284
380
  sessionCostDirty = true;
@@ -314,19 +410,19 @@ export function registerFooter(pi: ExtensionAPI, goalRuntime: GoalRuntime): void
314
410
  gitFileChanges = changes;
315
411
  tui.requestRender();
316
412
  });
317
- requestGitStatusRefresh = gitStatus.request;
318
413
  const unsubscribe = footerData.onBranchChange(() => {
319
414
  gitStatus.request();
320
415
  tui.requestRender();
321
416
  });
322
417
  gitStatus.request();
418
+ const stopWatch = scheduleGitStatusWatch(ctx.cwd, gitStatus.request);
323
419
  const stopFallback = scheduleGitStatusFallback(gitStatus.request);
324
420
  return {
325
421
  dispose() {
326
422
  unsubscribe();
423
+ stopWatch();
327
424
  stopFallback();
328
425
  gitStatus.dispose();
329
- if (requestGitStatusRefresh === gitStatus.request) requestGitStatusRefresh = undefined;
330
426
  if (activeTui === tui) activeTui = undefined;
331
427
  },
332
428
  invalidate() {},
@@ -393,12 +489,8 @@ export function registerFooter(pi: ExtensionAPI, goalRuntime: GoalRuntime): void
393
489
  thinkingLevel = event.level;
394
490
  activeTui?.requestRender();
395
491
  });
396
- const refreshAfterActivity = (): void => {
397
- invalidateSessionCost();
398
- requestGitStatusRefresh?.();
399
- };
400
- pi.on("turn_end", refreshAfterActivity);
401
- pi.on("session_compact", refreshAfterActivity);
492
+ pi.on("turn_end", invalidateSessionCost);
493
+ pi.on("session_compact", invalidateSessionCost);
402
494
  pi.on("session_tree", () => {
403
495
  resetSessionCost();
404
496
  activeTui?.requestRender();
@@ -408,7 +500,6 @@ export function registerFooter(pi: ExtensionAPI, goalRuntime: GoalRuntime): void
408
500
  unsubscribeCodexFast = undefined;
409
501
  resetSessionCost();
410
502
  activeTui = undefined;
411
- requestGitStatusRefresh = undefined;
412
503
  goalRuntime.requestRender = undefined;
413
504
  });
414
505
  }
@@ -0,0 +1,23 @@
1
+ import { validateGoalObjective } from "./goal-state.ts";
2
+
3
+ export type GoalCommand =
4
+ | { kind: "status" }
5
+ | { kind: "objective"; objective: string }
6
+ | { kind: "pause" }
7
+ | { kind: "resume" }
8
+ | { kind: "clear" }
9
+ | { kind: "invalid"; message: string };
10
+
11
+ /** Parses one raw /goal argument string into a closed command variant. */
12
+ export function parseGoalCommand(args: string): GoalCommand {
13
+ const input = args.trim();
14
+ if (!input) return { kind: "status" };
15
+ const lowered = input.toLowerCase();
16
+ if (lowered === "pause") return { kind: "pause" };
17
+ if (lowered === "resume") return { kind: "resume" };
18
+ if (lowered === "clear") return { kind: "clear" };
19
+ const objective = validateGoalObjective(input);
20
+ return objective
21
+ ? { kind: "objective", objective }
22
+ : { kind: "invalid", message: "A goal objective may not exceed 4,000 characters" };
23
+ }