killeros 2.1.29 → 2.1.30

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,21 @@ All notable changes to KillerOS are documented here.
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [2.1.30] - 2026-09-21
8
+
9
+ ### Added
10
+
11
+ - Added measured assistant output-token throughput to settled worked-for receipts, excluding first-token latency, tool execution, and gaps between model calls.
12
+
13
+ ### Fixed
14
+
15
+ - Prevented competing stale-settings-lock recovery attempts from removing a new writer's lock and losing settings updates.
16
+ - Recorded change receipts in repositories using Git's reftable reference backend while still rejecting receipts when `HEAD` moves during a response.
17
+ - Serialized global `killeros.json` updates across Pi processes so concurrent automatic-compaction and notification changes no longer discard successful writes or fail during Windows file replacement.
18
+ - Accepted `/handoff` summaries with `## ` lines inside fenced code blocks instead of rejecting the complete summary for an extra section.
19
+ - Changed the footer's `ctx` percentage to show context remaining, matching `/auto-compact` threshold direction while preserving urgency colors and the `/compact` hint.
20
+ - Reported `/goal` objectives with unsupported terminal content separately from the 4,000-character limit.
21
+
7
22
  ## [2.1.29] - 2026-09-19
8
23
 
9
24
  ### Added
package/README.md CHANGED
@@ -32,7 +32,7 @@ Or from GitHub:
32
32
  pi install git:github.com/KyrosHendrix/pi-KillerOS
33
33
  ```
34
34
 
35
- Pin a release by appending its tag, for example `@v2.1.29`. Add `-l` to install only for the current project. Restart Pi after installing.
35
+ Pin a release by appending its tag, for example `@v2.1.30`. Add `-l` to install only for the current project. Restart Pi after installing.
36
36
 
37
37
  ## Commands
38
38
 
@@ -234,24 +234,18 @@ async function snapshotKnownPaths(repo: Repository, baseline: Snapshot, paths: r
234
234
  }
235
235
 
236
236
  async function currentHead(repo: Repository): Promise<string> {
237
- const head = (await readBoundedFile(path.join(repo.gitDirectory, "HEAD"), 4_096)).toString("ascii").trim();
238
- if (/^[0-9a-f]{40}(?:[0-9a-f]{24})?$/u.test(head)) return head;
239
- const match = /^ref: (refs\/[^\0\r\n]+)$/u.exec(head);
240
- const reference = match?.[1];
241
- if (!reference || reference.includes("\\") || reference.split("/").some((part) => part === "." || part === "..")) throw new GitFailure("error");
237
+ let resolved: Buffer;
242
238
  try {
243
- return (await readBoundedFile(path.join(repo.commonDirectory, ...reference.split("/")), 4_096)).toString("ascii").trim();
239
+ resolved = await runGit(repo.root, ["rev-parse", "--verify", "HEAD"], undefined, 4_096);
244
240
  } catch (error) {
245
- if (!missingFile(error)) throw error;
246
- }
247
- try {
248
- const packed = (await readBoundedFile(path.join(repo.commonDirectory, "packed-refs"), GIT_OUTPUT_LIMIT)).toString("ascii");
249
- const packedMatch = new RegExp(`^([0-9a-f]{40}(?:[0-9a-f]{24})?) ${reference.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&")}$`, "mu").exec(packed);
250
- return packedMatch?.[1] ?? "(initial)";
251
- } catch (error) {
252
- if (missingFile(error)) return "(initial)";
253
- throw error;
241
+ if (!(error instanceof GitFailure) || error.reason !== "error") throw error;
242
+ const reference = decode(await runGit(repo.root, ["symbolic-ref", "--quiet", "HEAD"], undefined, 4_096)).trim();
243
+ if (!/^refs\/[^\0\r\n]+$/u.test(reference)) throw new GitFailure("error");
244
+ return "(initial)";
254
245
  }
246
+ const head = decode(resolved).trim();
247
+ if (!/^[0-9a-f]{40}(?:[0-9a-f]{24})?$/u.test(head)) throw new GitFailure("error");
248
+ return head;
255
249
  }
256
250
 
257
251
  function observes(filePath: string, observedPaths: readonly string[]): boolean {
@@ -259,11 +259,11 @@ export function contextPercentRemaining(ctx: ExtensionContext): number | null {
259
259
  export function formatContextProgress(tokensUsed: number | null, contextWindow: number, theme: Theme): string {
260
260
  if (tokensUsed === null || !Number.isFinite(tokensUsed)) return theme.fg("dim", "ctx —%");
261
261
  const windowSize = Number.isFinite(contextWindow) && contextWindow > 0 ? contextWindow : 128_000;
262
- const used = Math.max(0, Math.min(windowSize, Math.max(0, tokensUsed)));
263
- const percentUsed = Math.max(0, Math.min(100, Math.round((used / windowSize) * 100)));
264
- const color: ThemeColor = percentUsed > 80 ? "error" : percentUsed >= 50 ? "warning" : "success";
265
- const action = percentUsed >= 85 ? " · /compact" : "";
266
- return theme.fg(color, `ctx ${percentUsed}%${action}`);
262
+ const used = Math.max(0, tokensUsed);
263
+ const percentRemaining = Math.max(0, Math.min(100, Math.round(((windowSize - used) / windowSize) * 100)));
264
+ const color: ThemeColor = percentRemaining > 50 ? "success" : percentRemaining >= 20 ? "warning" : "error";
265
+ const action = percentRemaining <= 15 ? " · /compact" : "";
266
+ return theme.fg(color, `ctx ${percentRemaining}%${action}`);
267
267
  }
268
268
 
269
269
  function sumSessionCost(ctx: ExtensionContext): number {
@@ -1,4 +1,5 @@
1
1
  import { validateGoalObjective } from "./goal-state.ts";
2
+ import { safeTerminalText } from "./safe-terminal-text.ts";
2
3
 
3
4
  export type GoalCommand =
4
5
  | { kind: "status" }
@@ -17,7 +18,9 @@ export function parseGoalCommand(args: string): GoalCommand {
17
18
  if (lowered === "resume") return { kind: "resume" };
18
19
  if (lowered === "clear") return { kind: "clear" };
19
20
  const objective = validateGoalObjective(input);
20
- return objective
21
- ? { kind: "objective", objective }
22
- : { kind: "invalid", message: "A goal objective may not exceed 4,000 characters" };
21
+ if (objective) return { kind: "objective", objective };
22
+ if (safeTerminalText(input) !== input) {
23
+ return { kind: "invalid", message: "A goal objective contains unsupported content" };
24
+ }
25
+ return { kind: "invalid", message: "A goal objective may not exceed 4,000 characters" };
23
26
  }
@@ -91,10 +91,46 @@ export function resolveHandoffMaxTokens(settings: Readonly<KillerosSettings>, ov
91
91
  return positiveIntOr(override, positiveIntOr(settings.handoffMaxTokens, DEFAULT_HANDOFF_MAX_TOKENS));
92
92
  }
93
93
 
94
+ /** Finds fenced code spans so `## ` lines inside them are not treated as headings. */
95
+ function fencedCodeRanges(document: string): Array<{ start: number; end: number }> {
96
+ const ranges: Array<{ start: number; end: number }> = [];
97
+ let fenceStart: number | null = null;
98
+ let fenceChar = "";
99
+ let fenceLength = 0;
100
+ for (const line of document.matchAll(/[^\r\n]*(?:\r\n|\n|$)/g)) {
101
+ if (line[0].length === 0) break;
102
+ const lineStart = line.index ?? 0;
103
+ const lineEnd = lineStart + line[0].length;
104
+ const text = line[0].replace(/\r?\n$/, "");
105
+ const fence = /^[ \t]{0,3}(`{3,}|~{3,})/.exec(text);
106
+ if (!fence) continue;
107
+ const marker: string = fence[1];
108
+ const rest = text.slice(fence[0].length);
109
+ if (fenceStart === null) {
110
+ if (marker.startsWith("`") && rest.includes("`")) continue;
111
+ fenceStart = lineStart;
112
+ fenceChar = marker[0];
113
+ fenceLength = marker.length;
114
+ } else if (marker[0] === fenceChar && marker.length >= fenceLength && /^[ \t]*$/.test(rest)) {
115
+ ranges.push({ start: fenceStart, end: lineEnd });
116
+ fenceStart = null;
117
+ fenceChar = "";
118
+ fenceLength = 0;
119
+ }
120
+ }
121
+ if (fenceStart !== null) ranges.push({ start: fenceStart, end: document.length });
122
+ return ranges;
123
+ }
124
+
94
125
  /** Checks that the model returned every section needed to continue safely. */
95
126
  function hasRequiredHandoffContent(document: string, focus: string): boolean {
96
127
  if (focus && !document.includes(focus)) return false;
97
- const headings = [...document.matchAll(/^## ([^\r\n]+?)[ \t]*\r?$/gmu)];
128
+ const ranges = fencedCodeRanges(document);
129
+ const headings = [...document.matchAll(/^## ([^\r\n]+?)[ \t]*\r?$/gmu)]
130
+ .filter((heading) => {
131
+ const index = heading.index ?? 0;
132
+ return !ranges.some((range) => index >= range.start && index < range.end);
133
+ });
98
134
  if (headings.length !== HANDOFF_SECTIONS.length) return false;
99
135
  return headings.every((heading, index) => {
100
136
  if (heading[1] !== HANDOFF_SECTIONS[index]) return false;
@@ -1,5 +1,5 @@
1
1
  import { randomUUID } from "node:crypto";
2
- import { mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
2
+ import { linkSync, mkdirSync, readFileSync, renameSync, rmdirSync, rmSync, writeFileSync } from "node:fs";
3
3
  import { dirname, join } from "node:path";
4
4
  import { getAgentDir } from "@earendil-works/pi-coding-agent";
5
5
  import { hasErrorCode } from "./errors.ts";
@@ -26,16 +26,103 @@ function readStoredSettings(settingsPath: string): KillerosSettings {
26
26
  }
27
27
  }
28
28
 
29
+ const LOCK_RETRY_MILLISECONDS = 10;
30
+ const LOCK_TIMEOUT_MILLISECONDS = 10_000;
31
+
32
+ function readLockOwnerPid(lockPath: string): number | undefined {
33
+ try {
34
+ const value: unknown = JSON.parse(readFileSync(lockPath, "utf8"));
35
+ if (!isSettings(value) || typeof value.pid !== "number" || !Number.isSafeInteger(value.pid) || value.pid <= 0) return undefined;
36
+ return value.pid;
37
+ } catch {
38
+ return undefined;
39
+ }
40
+ }
41
+
42
+ function isProcessRunning(pid: number): boolean {
43
+ try {
44
+ process.kill(pid, 0);
45
+ return true;
46
+ } catch (error) {
47
+ return !hasErrorCode(error, "ESRCH");
48
+ }
49
+ }
50
+
51
+ function moveLockAside(lockPath: string): boolean {
52
+ const stalePath = `${lockPath}.${process.pid}.${randomUUID()}.stale`;
53
+ const deadline = Date.now() + LOCK_TIMEOUT_MILLISECONDS;
54
+ while (true) {
55
+ try {
56
+ renameSync(lockPath, stalePath);
57
+ break;
58
+ } catch (error) {
59
+ if (hasErrorCode(error, "ENOENT")) return false;
60
+ if (
61
+ Date.now() >= deadline
62
+ || !["EACCES", "EBUSY", "EPERM"].some((code) => hasErrorCode(error, code))
63
+ ) throw error;
64
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, LOCK_RETRY_MILLISECONDS);
65
+ }
66
+ }
67
+ rmSync(stalePath, { force: true });
68
+ return true;
69
+ }
70
+
71
+ function recoverSettingsLock(lockPath: string, expectedPid: number): boolean {
72
+ const recoveryPath = `${lockPath}.recovery`;
73
+ try {
74
+ mkdirSync(recoveryPath);
75
+ } catch (error) {
76
+ if (hasErrorCode(error, "EEXIST")) return false;
77
+ throw error;
78
+ }
79
+ try {
80
+ // Recheck under exclusive recovery ownership; another waiter may have replaced the lock.
81
+ return readLockOwnerPid(lockPath) === expectedPid && !isProcessRunning(expectedPid) && moveLockAside(lockPath);
82
+ } finally {
83
+ // ponytail: a crash during recovery leaves .recovery for manual removal; never steal it and risk lost writes.
84
+ rmdirSync(recoveryPath);
85
+ }
86
+ }
87
+
88
+ function acquireSettingsLock(settingsPath: string): () => void {
89
+ const lockPath = `${settingsPath}.lock`;
90
+ const ownerPath = `${lockPath}.${process.pid}.${randomUUID()}.tmp`;
91
+ const deadline = Date.now() + LOCK_TIMEOUT_MILLISECONDS;
92
+ writeFileSync(ownerPath, JSON.stringify({ pid: process.pid }), { encoding: "utf8", mode: 0o600, flag: "wx" });
93
+ try {
94
+ while (true) {
95
+ try {
96
+ linkSync(ownerPath, lockPath);
97
+ break;
98
+ } catch (error) {
99
+ if (!hasErrorCode(error, "EEXIST")) throw error;
100
+ const currentOwnerPid = readLockOwnerPid(lockPath);
101
+ if (currentOwnerPid && !isProcessRunning(currentOwnerPid) && recoverSettingsLock(lockPath, currentOwnerPid)) continue;
102
+ if (Date.now() >= deadline) throw new Error(`Timed out waiting for KillerOS settings lock: ${lockPath}`);
103
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, LOCK_RETRY_MILLISECONDS);
104
+ }
105
+ }
106
+ } finally {
107
+ rmSync(ownerPath, { force: true });
108
+ }
109
+
110
+ return () => {
111
+ moveLockAside(lockPath);
112
+ };
113
+ }
114
+
29
115
  export function createKillerosSettingsStore(
30
116
  settingsPath = join(getAgentDir(), "killeros.json"),
31
117
  ): KillerosSettingsStore {
32
118
  return {
33
119
  load: () => readStoredSettings(settingsPath),
34
120
  update: (patch) => {
35
- const current = readStoredSettings(settingsPath);
36
121
  mkdirSync(dirname(settingsPath), { recursive: true });
122
+ const releaseLock = acquireSettingsLock(settingsPath);
37
123
  const temporaryPath = `${settingsPath}.${process.pid}.${randomUUID()}.tmp`;
38
124
  try {
125
+ const current = readStoredSettings(settingsPath);
39
126
  writeFileSync(
40
127
  temporaryPath,
41
128
  `${JSON.stringify({ ...current, ...patch }, null, 2)}\n`,
@@ -43,7 +130,11 @@ export function createKillerosSettingsStore(
43
130
  );
44
131
  renameSync(temporaryPath, settingsPath);
45
132
  } finally {
46
- rmSync(temporaryPath, { force: true });
133
+ try {
134
+ rmSync(temporaryPath, { force: true });
135
+ } finally {
136
+ releaseLock();
137
+ }
47
138
  }
48
139
  },
49
140
  };
@@ -48,6 +48,8 @@ export interface WorkedForEntryDataV4 {
48
48
  checks: CheckAttempt[];
49
49
  omittedChecks: { passed: number; failed: number };
50
50
  model?: string;
51
+ outputTokens?: number;
52
+ decodeMilliseconds?: number;
51
53
  }
52
54
 
53
55
  type WorkedForEntryData = WorkedForEntryDataV1 | WorkedForEntryDataV2 | WorkedForEntryDataV3 | WorkedForEntryDataV4;
@@ -66,6 +68,10 @@ function integer(value: unknown): value is number {
66
68
  return typeof value === "number" && Number.isFinite(value) && Number.isInteger(value) && value >= 0;
67
69
  }
68
70
 
71
+ function positiveInteger(value: unknown): value is number {
72
+ return integer(value) && value > 0;
73
+ }
74
+
69
75
  function record(value: unknown): value is Record<string, unknown> {
70
76
  return typeof value === "object" && value !== null && !Array.isArray(value);
71
77
  }
@@ -148,6 +154,9 @@ function parseV4(data: Record<string, unknown>): WorkedForEntryDataV4 | undefine
148
154
  checks.push({ label, outcome: check.outcome });
149
155
  }
150
156
  const model = parseModelName(data.model);
157
+ const throughput = positiveInteger(data.outputTokens) && positiveInteger(data.decodeMilliseconds)
158
+ ? { outputTokens: data.outputTokens, decodeMilliseconds: data.decodeMilliseconds }
159
+ : {};
151
160
  return {
152
161
  version: 4,
153
162
  milliseconds: data.milliseconds,
@@ -157,6 +166,7 @@ function parseV4(data: Record<string, unknown>): WorkedForEntryDataV4 | undefine
157
166
  checks,
158
167
  omittedChecks: { passed: data.omittedChecks.passed, failed: data.omittedChecks.failed },
159
168
  ...(model ? { model } : {}),
169
+ ...throughput,
160
170
  };
161
171
  }
162
172
 
@@ -221,9 +231,15 @@ class WorkedForV4Component implements Component {
221
231
  const { data, theme } = this;
222
232
  const outcome = OUTCOMES[data.outcome];
223
233
  const headline = theme.fg(outcome.color, outcome.label);
234
+ const tokensPerSecond = data.outputTokens !== undefined && data.decodeMilliseconds !== undefined
235
+ ? data.outputTokens / (data.decodeMilliseconds / 1_000)
236
+ : undefined;
237
+ const speedSuffix = tokensPerSecond !== undefined && Number.isFinite(tokensPerSecond) && tokensPerSecond > 0
238
+ ? ` · ${tokensPerSecond.toFixed(1)} tok/s`
239
+ : "";
224
240
  const modelSuffix = data.model ? ` · ${data.model}` : "";
225
241
  const lines = [
226
- `${headline}${theme.fg("dim", ` · ${formatWorkedForDuration(data.milliseconds)} · ↑ ${formatTokens(data.tokens)} tokens${modelSuffix}`)}`,
242
+ `${headline}${theme.fg("dim", ` · ${formatWorkedForDuration(data.milliseconds)} · ↑ ${formatTokens(data.tokens)} tokens${speedSuffix}${modelSuffix}`)}`,
227
243
  ];
228
244
  if (data.changes.state === "unavailable") lines.push(theme.fg("dim", "Changes unavailable"));
229
245
  else if (data.changes.totalFiles === 0) lines.push(theme.fg("dim", "No files changed"));
@@ -287,8 +303,34 @@ type ActiveReceipt = {
287
303
  modelProvider: string | undefined;
288
304
  modelId: string | undefined;
289
305
  modelMismatch: boolean;
306
+ outputTokens: number;
307
+ decodeMilliseconds: number;
308
+ decodeStartedAt: number | undefined;
309
+ throughputInvalid: boolean;
290
310
  };
291
311
 
312
+ function finishAssistantStream(
313
+ receipt: ActiveReceipt,
314
+ outputTokens: number,
315
+ endedAt: number,
316
+ failed: boolean,
317
+ ): void {
318
+ if (failed) {
319
+ if (receipt.decodeStartedAt !== undefined) receipt.throughputInvalid = true;
320
+ } else if (receipt.decodeStartedAt !== undefined) {
321
+ const decodeMilliseconds = endedAt - receipt.decodeStartedAt;
322
+ if (positiveInteger(outputTokens) && positiveInteger(decodeMilliseconds)) {
323
+ receipt.outputTokens += outputTokens;
324
+ receipt.decodeMilliseconds += decodeMilliseconds;
325
+ } else {
326
+ receipt.throughputInvalid = true;
327
+ }
328
+ } else if (outputTokens !== 0) {
329
+ receipt.throughputInvalid = true;
330
+ }
331
+ receipt.decodeStartedAt = undefined;
332
+ }
333
+
292
334
  function receiptModelName(
293
335
  settled: ActiveReceipt,
294
336
  model: ExtensionContext["model"],
@@ -349,30 +391,46 @@ export function registerWorkedFor(
349
391
  modelProvider: undefined,
350
392
  modelId: undefined,
351
393
  modelMismatch: false,
394
+ outputTokens: 0,
395
+ decodeMilliseconds: 0,
396
+ decodeStartedAt: undefined,
397
+ throughputInvalid: false,
352
398
  };
353
399
  active = state;
354
400
  const collection = await state.collection;
355
401
  if (active !== state) await collection.dispose();
356
402
  });
357
403
 
404
+ pi.on("message_update", (event, ctx) => {
405
+ if (ctx.mode !== "tui" || !active) return;
406
+ const update = event.assistantMessageEvent;
407
+ if ((update.type === "text_delta" || update.type === "thinking_delta" || update.type === "toolcall_delta")
408
+ && update.delta.length > 0) active.decodeStartedAt ??= now();
409
+ });
410
+
358
411
  pi.on("message_end", (event, ctx) => {
359
412
  if (ctx.mode !== "tui" || !active) return;
360
413
  if (event.message.role !== "assistant") return;
361
414
  const provider: unknown = event.message.provider;
362
415
  const modelId: unknown = event.message.model;
363
- if (typeof provider !== "string" || typeof modelId !== "string") {
364
- active.modelMismatch = true;
365
- return;
366
- }
367
- if (active.modelProvider === undefined || active.modelId === undefined) {
368
- active.modelProvider = provider;
369
- active.modelId = modelId;
370
- } else if (active.modelProvider !== provider || active.modelId !== modelId) {
371
- active.modelMismatch = true;
372
- }
373
- if (event.message.responseModel !== undefined && event.message.responseModel !== modelId) {
374
- active.modelMismatch = true;
416
+ if (typeof provider !== "string" || typeof modelId !== "string") active.modelMismatch = true;
417
+ else {
418
+ if (active.modelProvider === undefined || active.modelId === undefined) {
419
+ active.modelProvider = provider;
420
+ active.modelId = modelId;
421
+ } else if (active.modelProvider !== provider || active.modelId !== modelId) {
422
+ active.modelMismatch = true;
423
+ }
424
+ if (event.message.responseModel !== undefined && event.message.responseModel !== modelId) {
425
+ active.modelMismatch = true;
426
+ }
375
427
  }
428
+ finishAssistantStream(
429
+ active,
430
+ event.message.usage.output,
431
+ now(),
432
+ event.message.stopReason === "error" || event.message.stopReason === "aborted",
433
+ );
376
434
  });
377
435
 
378
436
  pi.on("tool_result", (event: ToolResultEvent, ctx) => {
@@ -404,6 +462,10 @@ export function registerWorkedFor(
404
462
  ctx.ui.notify(`Change receipt unavailable: ${changes.reason}`, "warning");
405
463
  }
406
464
  const settledTokens = sessionTokenTotal(ctx);
465
+ const throughput = !settled.throughputInvalid && settled.decodeStartedAt === undefined && !settled.modelMismatch
466
+ && positiveInteger(settled.outputTokens) && positiveInteger(settled.decodeMilliseconds)
467
+ ? { outputTokens: settled.outputTokens, decodeMilliseconds: settled.decodeMilliseconds }
468
+ : {};
407
469
  const data = fitPayload({
408
470
  version: 4,
409
471
  milliseconds: Math.max(0, now() - settled.startedAt),
@@ -413,6 +475,7 @@ export function registerWorkedFor(
413
475
  checks: settled.checks,
414
476
  omittedChecks: settled.omittedChecks,
415
477
  ...(model ? { model } : {}),
478
+ ...throughput,
416
479
  });
417
480
  try {
418
481
  pi.appendEntry<WorkedForEntryDataV4>(WORKED_FOR_ENTRY_TYPE, data);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "killeros",
3
- "version": "2.1.29",
3
+ "version": "2.1.30",
4
4
  "description": "TUI, goals, and workflow automation for the Pi coding agent",
5
5
  "type": "module",
6
6
  "keywords": [