killeros 2.1.28 → 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 +29 -0
- package/README.md +6 -2
- package/killeros/auto-compaction.ts +58 -7
- package/killeros/change-receipt.ts +46 -145
- package/killeros/commands.ts +1 -0
- package/killeros/footer.ts +47 -78
- package/killeros/goal-command.ts +6 -3
- package/killeros/goal-runtime.ts +1 -1
- package/killeros/handoff.ts +37 -1
- package/killeros/passive-git-status.ts +212 -72
- package/killeros/settings.ts +94 -3
- package/killeros/worked-for.ts +76 -13
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,35 @@ 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
|
+
|
|
22
|
+
## [2.1.29] - 2026-09-19
|
|
23
|
+
|
|
24
|
+
### Added
|
|
25
|
+
|
|
26
|
+
- Added `/auto-compact [status|on|off|<percent>]` to inspect and update the global automatic compaction preference without restarting Pi.
|
|
27
|
+
|
|
28
|
+
### Fixed
|
|
29
|
+
|
|
30
|
+
- Kept normalized CRLF checkouts clean in passive Git scans while respecting text attributes and binary files without running filters.
|
|
31
|
+
- Detected same-size tracked-file rewrites even when tools restore the indexed modification time.
|
|
32
|
+
- Resolved change-receipt repository ownership at the start of every response so nested or replaced repositories cannot reuse a stale monitor.
|
|
33
|
+
- Kept the 100% automatic-compaction threshold active for later user requests and goal turns without compacting the generated ordinary continuation.
|
|
34
|
+
- Prevented automatic footer and change-receipt scans from starting clean or process filters, including filters added and removed while a scan is running.
|
|
35
|
+
|
|
7
36
|
## [2.1.28] - 2026-09-15
|
|
8
37
|
|
|
9
38
|
### Changed
|
package/README.md
CHANGED
|
@@ -8,7 +8,7 @@ A TypeScript extension for the [Pi coding agent](https://github.com/earendil-wor
|
|
|
8
8
|
- `/goal`: set an objective and Pi keeps working toward it across turns, compaction, reloads, and branch navigation. Each turn must record `continue` with evidence and one next action, `complete`, or the existing blocker decision; otherwise the goal pauses. New goals pause after 20 turns; `/goal resume` grants another 20.
|
|
9
9
|
- `/codex-fast`: toggles the `priority` service tier on Codex requests or reports its status.
|
|
10
10
|
- `/handoff`: starts a fresh linked session carrying visible continuation context.
|
|
11
|
-
-
|
|
11
|
+
- `/auto-compact`: reports, enables, disables, or tunes automatic context compaction.
|
|
12
12
|
- A `question` tool with single-select and multi-select modes.
|
|
13
13
|
- Lifecycle hooks (`tool_call`, `tool_result`, `agent_settled`) from `.pi/killeros-hooks.json`, plus `AGENTS.local.md` loading for trusted projects.
|
|
14
14
|
- Optional completion sounds for settled requests.
|
|
@@ -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.
|
|
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
|
|
|
@@ -43,6 +43,8 @@ Pin a release by appending its tag, for example `@v2.1.28`. Add `-l` to install
|
|
|
43
43
|
/goal resume Resume automatic continuation
|
|
44
44
|
/goal clear Remove the current goal
|
|
45
45
|
/codex-fast [status] Toggle or report Codex fast mode
|
|
46
|
+
/auto-compact [status|on|off|<percent>]
|
|
47
|
+
Inspect or change automatic compaction
|
|
46
48
|
/notification Configure the completion sound
|
|
47
49
|
/handoff [focus] Fresh session with continuation context
|
|
48
50
|
/clear New session after confirmation
|
|
@@ -71,6 +73,8 @@ The packaged `killeros` theme activates on TUI start. Compaction triggers by def
|
|
|
71
73
|
}
|
|
72
74
|
```
|
|
73
75
|
|
|
76
|
+
Use `/auto-compact status` to inspect the effective KillerOS preference, `/auto-compact on` or `/auto-compact off` to toggle it, and `/auto-compact <percent>` to set an integer threshold from 0 through 100. A threshold of 0 does not disable Pi's own token reserve.
|
|
77
|
+
|
|
74
78
|
`handoffMaxTokens` caps the `/handoff` summary output at 8192 tokens by default; raise it when long sessions truncate the summary.
|
|
75
79
|
|
|
76
80
|
State proof in the objective so the agent can verify it with its normal tools:
|
|
@@ -7,7 +7,8 @@ import {
|
|
|
7
7
|
type ExtensionContext,
|
|
8
8
|
} from "@earendil-works/pi-coding-agent";
|
|
9
9
|
import { errorMessage } from "./errors.ts";
|
|
10
|
-
import {
|
|
10
|
+
import { GOAL_CONTINUATION_TYPE } from "./goal-runtime.ts";
|
|
11
|
+
import { createKillerosSettingsStore, type KillerosSettingsStore } from "./settings.ts";
|
|
11
12
|
|
|
12
13
|
export const DEFAULT_AUTO_COMPACTION_PERCENT_REMAINING = 15;
|
|
13
14
|
export const AUTO_COMPACTION_MESSAGE_TYPE = "killeros-auto-compaction";
|
|
@@ -32,6 +33,7 @@ export interface AutoCompactionGoalHandlers {
|
|
|
32
33
|
export interface AutoCompactionDependencies {
|
|
33
34
|
loadPreference?: (ctx: ExtensionContext) => AutoCompactionPreference;
|
|
34
35
|
getCompactionSettings?: (ctx: ExtensionContext) => CompactionSettings;
|
|
36
|
+
settingsStore?: KillerosSettingsStore;
|
|
35
37
|
goal?: AutoCompactionGoalHandlers;
|
|
36
38
|
}
|
|
37
39
|
|
|
@@ -66,8 +68,8 @@ export function readAutoCompactionPreference(
|
|
|
66
68
|
};
|
|
67
69
|
}
|
|
68
70
|
|
|
69
|
-
function defaultPreference(): AutoCompactionPreference {
|
|
70
|
-
return readAutoCompactionPreference(
|
|
71
|
+
function defaultPreference(store: KillerosSettingsStore): AutoCompactionPreference {
|
|
72
|
+
return readAutoCompactionPreference(store.load());
|
|
71
73
|
}
|
|
72
74
|
|
|
73
75
|
function defaultCompactionSettings(ctx: ExtensionContext): CompactionSettings {
|
|
@@ -115,18 +117,60 @@ export function registerAutoCompaction(
|
|
|
115
117
|
pi: ExtensionAPI,
|
|
116
118
|
dependencies: AutoCompactionDependencies = {},
|
|
117
119
|
): void {
|
|
118
|
-
const
|
|
120
|
+
const settingsStore = dependencies.settingsStore ?? createKillerosSettingsStore();
|
|
121
|
+
const loadPreference = dependencies.loadPreference ?? (() => defaultPreference(settingsStore));
|
|
119
122
|
const getCompactionSettings = dependencies.getCompactionSettings ?? defaultCompactionSettings;
|
|
120
123
|
let request: AutoCompactionRequest | undefined;
|
|
121
124
|
let armed = true;
|
|
125
|
+
let rearmOnNextLogicalRequest = false;
|
|
122
126
|
let settingsErrorReported = false;
|
|
123
127
|
|
|
124
128
|
const resetForLifecycle = (): void => {
|
|
125
129
|
request = undefined;
|
|
126
130
|
armed = true;
|
|
131
|
+
rearmOnNextLogicalRequest = false;
|
|
127
132
|
settingsErrorReported = false;
|
|
128
133
|
};
|
|
129
134
|
|
|
135
|
+
pi.registerCommand("auto-compact", {
|
|
136
|
+
description: "Inspect or change automatic compaction",
|
|
137
|
+
handler: async (args, ctx) => {
|
|
138
|
+
const argument = args.trim();
|
|
139
|
+
if (!argument || argument === "status") {
|
|
140
|
+
try {
|
|
141
|
+
const preference = readAutoCompactionPreference(settingsStore.load());
|
|
142
|
+
ctx.ui.notify(preference.enabled
|
|
143
|
+
? `Automatic compaction: on at ${preference.percentRemaining}% remaining`
|
|
144
|
+
: `Automatic compaction: off (threshold ${preference.percentRemaining}%)`, "info");
|
|
145
|
+
} catch (error) {
|
|
146
|
+
ctx.ui.notify(`Automatic compaction setting could not be read: ${errorMessage(error)}`, "error");
|
|
147
|
+
}
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const percent = /^\d+$/u.test(argument) ? Number(argument) : undefined;
|
|
152
|
+
if (argument !== "on" && argument !== "off" && (percent === undefined || percent > 100)) {
|
|
153
|
+
ctx.ui.notify("Usage: /auto-compact [status|on|off|<percent 0-100>]", "error");
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
try {
|
|
158
|
+
const preference = readAutoCompactionPreference(settingsStore.load());
|
|
159
|
+
const updated = percent === undefined
|
|
160
|
+
? { ...preference, enabled: argument === "on" }
|
|
161
|
+
: { ...preference, percentRemaining: percent };
|
|
162
|
+
settingsStore.update({ autoCompaction: updated });
|
|
163
|
+
ctx.ui.notify(percent === undefined
|
|
164
|
+
? updated.enabled
|
|
165
|
+
? `Automatic compaction: on at ${updated.percentRemaining}% remaining`
|
|
166
|
+
: `Automatic compaction: off (threshold ${updated.percentRemaining}%)`
|
|
167
|
+
: `Automatic compaction threshold: ${percent}% remaining (${updated.enabled ? "on" : "off"})`, "info");
|
|
168
|
+
} catch (error) {
|
|
169
|
+
ctx.ui.notify(`Automatic compaction setting could not be saved: ${errorMessage(error)}`, "error");
|
|
170
|
+
}
|
|
171
|
+
},
|
|
172
|
+
});
|
|
173
|
+
|
|
130
174
|
const notifyFailure = (ctx: ExtensionContext, error: unknown): void => {
|
|
131
175
|
ctx.ui.notify(`Automatic compaction failed: ${errorMessage(error)}`, "error");
|
|
132
176
|
};
|
|
@@ -250,6 +294,7 @@ export function registerAutoCompaction(
|
|
|
250
294
|
ctx.compact({
|
|
251
295
|
onComplete: () => {
|
|
252
296
|
if (request?.phase !== "compacting" || request.token !== token) return;
|
|
297
|
+
rearmOnNextLogicalRequest = preference.percentRemaining === 100;
|
|
253
298
|
if (goal && dependencies.goal) {
|
|
254
299
|
request = undefined;
|
|
255
300
|
try {
|
|
@@ -280,9 +325,15 @@ export function registerAutoCompaction(
|
|
|
280
325
|
finalizeOrdinaryContinuation(ctx, request.token);
|
|
281
326
|
});
|
|
282
327
|
pi.on("message_start", (event) => {
|
|
283
|
-
|
|
284
|
-
&& event.message.
|
|
285
|
-
|
|
328
|
+
const isOrdinaryContinuation = event.message.role === "custom"
|
|
329
|
+
&& event.message.customType === AUTO_COMPACTION_MESSAGE_TYPE;
|
|
330
|
+
if (request?.phase === "continuation-dispatched" && isOrdinaryContinuation) request = undefined;
|
|
331
|
+
const startsNewLogicalRequest = event.message.role === "user"
|
|
332
|
+
|| (event.message.role === "custom" && event.message.customType === GOAL_CONTINUATION_TYPE);
|
|
333
|
+
if (rearmOnNextLogicalRequest && startsNewLogicalRequest) {
|
|
334
|
+
armed = true;
|
|
335
|
+
rearmOnNextLogicalRequest = false;
|
|
336
|
+
}
|
|
286
337
|
});
|
|
287
338
|
|
|
288
339
|
pi.on("session_start", resetForLifecycle);
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
2
|
import { createReadStream, watch } from "node:fs";
|
|
3
|
-
import {
|
|
3
|
+
import { inspectWorktreeWithoutFilters, passiveGitCommand, passiveGitEnv, type PassiveWorktreeFile as DirtyFile } from "./passive-git-status.ts";
|
|
4
4
|
import { lstat, open, readlink } from "node:fs/promises";
|
|
5
5
|
import path from "node:path";
|
|
6
6
|
import { createInflate } from "node:zlib";
|
|
@@ -140,44 +140,22 @@ type Repository = {
|
|
|
140
140
|
blobCache: Map<string, Buffer>;
|
|
141
141
|
blobCacheBytes: number;
|
|
142
142
|
};
|
|
143
|
-
const repositoryCache = new Map<string, Promise<Repository>>();
|
|
144
|
-
|
|
145
143
|
async function repository(cwd: string): Promise<Repository> {
|
|
146
|
-
const
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
};
|
|
160
|
-
})();
|
|
161
|
-
repositoryCache.set(cwd, pending);
|
|
162
|
-
try {
|
|
163
|
-
return await pending;
|
|
164
|
-
} catch (error) {
|
|
165
|
-
repositoryCache.delete(cwd);
|
|
166
|
-
throw error;
|
|
167
|
-
}
|
|
144
|
+
const result = decode(await runGit(cwd, [
|
|
145
|
+
"rev-parse", "--path-format=absolute", "--show-toplevel", "--git-dir", "--git-common-dir", "--git-path", "objects",
|
|
146
|
+
])).trimEnd().split(/\r?\n/u);
|
|
147
|
+
const [root, gitDirectory, commonDirectory, objectDirectory] = result;
|
|
148
|
+
if (!root || !gitDirectory || !commonDirectory || !objectDirectory) throw new GitFailure("error");
|
|
149
|
+
return {
|
|
150
|
+
root,
|
|
151
|
+
gitDirectory,
|
|
152
|
+
commonDirectory,
|
|
153
|
+
objectDirectory,
|
|
154
|
+
blobCache: new Map(),
|
|
155
|
+
blobCacheBytes: 0,
|
|
156
|
+
};
|
|
168
157
|
}
|
|
169
158
|
|
|
170
|
-
type DirtyFile = {
|
|
171
|
-
path: string;
|
|
172
|
-
headMode?: string;
|
|
173
|
-
headObjectId?: string;
|
|
174
|
-
indexMode?: string;
|
|
175
|
-
indexObjectId?: string;
|
|
176
|
-
mode?: string;
|
|
177
|
-
content?: Buffer;
|
|
178
|
-
contentObjectId?: string;
|
|
179
|
-
};
|
|
180
|
-
|
|
181
159
|
async function readBoundedFile(filePath: string, limit: number): Promise<Buffer> {
|
|
182
160
|
const handle = await open(filePath, "r");
|
|
183
161
|
try {
|
|
@@ -198,47 +176,6 @@ async function readBoundedFile(filePath: string, limit: number): Promise<Buffer>
|
|
|
198
176
|
}
|
|
199
177
|
}
|
|
200
178
|
|
|
201
|
-
async function readSnapshotFiles(repo: Repository, files: Map<string, DirtyFile>): Promise<void> {
|
|
202
|
-
const pending = [...files.values()].filter((file) => file.mode && !file.contentObjectId);
|
|
203
|
-
let totalBytes = 0;
|
|
204
|
-
let nextIndex = 0;
|
|
205
|
-
const readNext = async (): Promise<void> => {
|
|
206
|
-
while (nextIndex < pending.length) {
|
|
207
|
-
const file = pending[nextIndex];
|
|
208
|
-
nextIndex += 1;
|
|
209
|
-
if (!file?.mode) continue;
|
|
210
|
-
const absolutePath = path.join(repo.root, ...file.path.split("/"));
|
|
211
|
-
if (file.mode === "120000") {
|
|
212
|
-
const content = Buffer.from(await readlink(absolutePath));
|
|
213
|
-
totalBytes += content.length;
|
|
214
|
-
if (totalBytes > SNAPSHOT_CONTENT_LIMIT) throw new GitFailure("too-large");
|
|
215
|
-
file.content = content;
|
|
216
|
-
continue;
|
|
217
|
-
}
|
|
218
|
-
const handle = await open(absolutePath, "r");
|
|
219
|
-
try {
|
|
220
|
-
const stats = await handle.stat();
|
|
221
|
-
if (!stats.isFile()) throw new GitFailure("error");
|
|
222
|
-
totalBytes += stats.size;
|
|
223
|
-
if (totalBytes > SNAPSHOT_CONTENT_LIMIT) throw new GitFailure("too-large");
|
|
224
|
-
const content = Buffer.alloc(stats.size + 1);
|
|
225
|
-
let length = 0;
|
|
226
|
-
while (length < content.length) {
|
|
227
|
-
const { bytesRead } = await handle.read(content, length, content.length - length, length);
|
|
228
|
-
if (bytesRead === 0) break;
|
|
229
|
-
length += bytesRead;
|
|
230
|
-
}
|
|
231
|
-
if (length > stats.size) throw new GitFailure("error");
|
|
232
|
-
totalBytes -= stats.size - length;
|
|
233
|
-
file.content = content.subarray(0, length);
|
|
234
|
-
} finally {
|
|
235
|
-
await handle.close();
|
|
236
|
-
}
|
|
237
|
-
}
|
|
238
|
-
};
|
|
239
|
-
await Promise.all(Array.from({ length: Math.min(8, pending.length) }, readNext));
|
|
240
|
-
}
|
|
241
|
-
|
|
242
179
|
type Snapshot = { head: string; files: Map<string, DirtyFile> };
|
|
243
180
|
type RepositoryMonitor = {
|
|
244
181
|
changedPaths: Set<string>;
|
|
@@ -253,59 +190,23 @@ const repositoryMonitors = new Map<string, RepositoryMonitor>();
|
|
|
253
190
|
|
|
254
191
|
function discardMonitor(monitor: RepositoryMonitor): void {
|
|
255
192
|
for (const watcher of monitor.watchers) watcher.close();
|
|
256
|
-
repositoryMonitors.delete(monitor.repo.root);
|
|
193
|
+
if (repositoryMonitors.get(monitor.repo.root) === monitor) repositoryMonitors.delete(monitor.repo.root);
|
|
257
194
|
}
|
|
258
195
|
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
const files = new Map<string, DirtyFile>();
|
|
273
|
-
for (const record of records) {
|
|
274
|
-
if (record.startsWith("# ")) continue;
|
|
275
|
-
if (record.startsWith("u ")) throw new Error("unmerged index");
|
|
276
|
-
if (record.startsWith("? ")) {
|
|
277
|
-
const filePath = record.slice(2);
|
|
278
|
-
if (!filePath || filePath.endsWith("/")) continue;
|
|
279
|
-
const stats = await lstat(path.join(repo.root, ...filePath.split("/")));
|
|
280
|
-
files.set(filePath, {
|
|
281
|
-
...files.get(filePath),
|
|
282
|
-
path: filePath,
|
|
283
|
-
indexMode: undefined,
|
|
284
|
-
indexObjectId: undefined,
|
|
285
|
-
mode: stats.isSymbolicLink() ? "120000" : stats.mode & 0o111 ? "100755" : "100644",
|
|
286
|
-
contentObjectId: undefined,
|
|
287
|
-
});
|
|
288
|
-
continue;
|
|
289
|
-
}
|
|
290
|
-
const match = /^1 (\S{2}) \S+ ([0-7]{6}) ([0-7]{6}) ([0-7]{6}) ([0-9a-f]+) ([0-9a-f]+) (.*)$/su.exec(record);
|
|
291
|
-
if (!match) throw new Error("invalid status record");
|
|
292
|
-
const [, status, headMode, indexMode, worktreeMode, headObjectId, indexObjectId, filePath] = match;
|
|
293
|
-
if (!status || !headMode || !indexMode || !worktreeMode || !headObjectId || !indexObjectId || !filePath) throw new Error("incomplete status record");
|
|
294
|
-
const worktreeChanged = status[1] !== ".";
|
|
295
|
-
const selectedMode = worktreeChanged ? worktreeMode : indexMode;
|
|
296
|
-
files.set(filePath, {
|
|
297
|
-
...files.get(filePath),
|
|
298
|
-
path: filePath,
|
|
299
|
-
headMode: headMode === "000000" ? undefined : headMode,
|
|
300
|
-
headObjectId: /^0+$/u.test(headObjectId) ? undefined : headObjectId,
|
|
301
|
-
indexMode: indexMode === "000000" ? undefined : indexMode,
|
|
302
|
-
indexObjectId: /^0+$/u.test(indexObjectId) ? undefined : indexObjectId,
|
|
303
|
-
mode: selectedMode === "000000" ? undefined : selectedMode,
|
|
304
|
-
contentObjectId: worktreeChanged || /^0+$/u.test(indexObjectId) ? undefined : indexObjectId,
|
|
305
|
-
});
|
|
196
|
+
function sameRepository(left: Repository, right: Repository): boolean {
|
|
197
|
+
return left.root === right.root
|
|
198
|
+
&& left.gitDirectory === right.gitDirectory
|
|
199
|
+
&& left.commonDirectory === right.commonDirectory
|
|
200
|
+
&& left.objectDirectory === right.objectDirectory;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
async function snapshot(repo: Repository): Promise<Snapshot> {
|
|
204
|
+
try {
|
|
205
|
+
return await inspectWorktreeWithoutFilters(repo.root, (args) => runGit(repo.root, args));
|
|
206
|
+
} catch (error) {
|
|
207
|
+
if (error instanceof Error && error.message === "worktree content limit exceeded") throw new GitFailure("too-large");
|
|
208
|
+
throw error;
|
|
306
209
|
}
|
|
307
|
-
await readSnapshotFiles(repo, files);
|
|
308
|
-
return { head: headRecord.slice("# branch.oid ".length), files };
|
|
309
210
|
}
|
|
310
211
|
|
|
311
212
|
async function snapshotKnownPaths(repo: Repository, baseline: Snapshot, paths: readonly string[]): Promise<Snapshot | undefined> {
|
|
@@ -333,24 +234,18 @@ async function snapshotKnownPaths(repo: Repository, baseline: Snapshot, paths: r
|
|
|
333
234
|
}
|
|
334
235
|
|
|
335
236
|
async function currentHead(repo: Repository): Promise<string> {
|
|
336
|
-
|
|
337
|
-
if (/^[0-9a-f]{40}(?:[0-9a-f]{24})?$/u.test(head)) return head;
|
|
338
|
-
const match = /^ref: (refs\/[^\0\r\n]+)$/u.exec(head);
|
|
339
|
-
const reference = match?.[1];
|
|
340
|
-
if (!reference || reference.includes("\\") || reference.split("/").some((part) => part === "." || part === "..")) throw new GitFailure("error");
|
|
341
|
-
try {
|
|
342
|
-
return (await readBoundedFile(path.join(repo.commonDirectory, ...reference.split("/")), 4_096)).toString("ascii").trim();
|
|
343
|
-
} catch (error) {
|
|
344
|
-
if (!missingFile(error)) throw error;
|
|
345
|
-
}
|
|
237
|
+
let resolved: Buffer;
|
|
346
238
|
try {
|
|
347
|
-
|
|
348
|
-
const packedMatch = new RegExp(`^([0-9a-f]{40}(?:[0-9a-f]{24})?) ${reference.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&")}$`, "mu").exec(packed);
|
|
349
|
-
return packedMatch?.[1] ?? "(initial)";
|
|
239
|
+
resolved = await runGit(repo.root, ["rev-parse", "--verify", "HEAD"], undefined, 4_096);
|
|
350
240
|
} catch (error) {
|
|
351
|
-
if (
|
|
352
|
-
|
|
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)";
|
|
353
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;
|
|
354
249
|
}
|
|
355
250
|
|
|
356
251
|
function observes(filePath: string, observedPaths: readonly string[]): boolean {
|
|
@@ -360,7 +255,8 @@ function observes(filePath: string, observedPaths: readonly string[]): boolean {
|
|
|
360
255
|
async function refreshSnapshot(monitor: RepositoryMonitor, observedPaths: readonly string[], current = monitor.snapshot): Promise<Snapshot> {
|
|
361
256
|
if (monitor.watchFailed || observedPaths.length > 200) return snapshot(monitor.repo);
|
|
362
257
|
if (observedPaths.length === 0) return current;
|
|
363
|
-
const scoped = await snapshotKnownPaths(monitor.repo, current, observedPaths)
|
|
258
|
+
const scoped = await snapshotKnownPaths(monitor.repo, current, observedPaths);
|
|
259
|
+
if (!scoped) return snapshot(monitor.repo);
|
|
364
260
|
const files = new Map(current.files);
|
|
365
261
|
for (const filePath of files.keys()) {
|
|
366
262
|
if (observes(filePath, observedPaths)) files.delete(filePath);
|
|
@@ -714,11 +610,17 @@ export async function beginChangeReceipt(cwd: string, trusted = true): Promise<C
|
|
|
714
610
|
};
|
|
715
611
|
}
|
|
716
612
|
try {
|
|
717
|
-
|
|
613
|
+
let repo = await repository(cwd);
|
|
718
614
|
let monitor = repositoryMonitors.get(repo.root);
|
|
615
|
+
if (monitor && !sameRepository(monitor.repo, repo)) {
|
|
616
|
+
discardMonitor(monitor);
|
|
617
|
+
monitor = undefined;
|
|
618
|
+
}
|
|
719
619
|
if (!monitor) {
|
|
720
620
|
monitor = createMonitor(repo, await snapshot(repo));
|
|
721
621
|
repositoryMonitors.set(repo.root, monitor);
|
|
622
|
+
} else {
|
|
623
|
+
repo = monitor.repo;
|
|
722
624
|
}
|
|
723
625
|
if (monitor.inUse) throw new Error("change collection already active");
|
|
724
626
|
const idleChanges = [...monitor.changedPaths];
|
|
@@ -771,5 +673,4 @@ export async function beginChangeReceipt(cwd: string, trusted = true): Promise<C
|
|
|
771
673
|
export function disposeChangeReceipts(): void {
|
|
772
674
|
for (const monitor of repositoryMonitors.values()) discardMonitor(monitor);
|
|
773
675
|
repositoryMonitors.clear();
|
|
774
|
-
repositoryCache.clear();
|
|
775
676
|
}
|
package/killeros/commands.ts
CHANGED
|
@@ -70,6 +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
|
+
"auto-compact": "/auto-compact [status|on|off|<percent>]",
|
|
73
74
|
goal: "/goal [objective|pause|resume|clear]",
|
|
74
75
|
handoff: "/handoff [next-session focus]",
|
|
75
76
|
model: "/model [provider/model]",
|
package/killeros/footer.ts
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { execFile } from "node:child_process";
|
|
2
2
|
import { watch } from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
3
4
|
import { type ExtensionAPI, type ExtensionContext, type Theme, type ThemeColor } from "@earendil-works/pi-coding-agent";
|
|
4
5
|
import { truncateToWidth, visibleWidth, type TUI } from "@earendil-works/pi-tui";
|
|
5
6
|
import { isCodexFastEnabled, subscribeCodexFast } from "./codex-fast-state.ts";
|
|
6
7
|
import { formatCwd, formatTime, modelDisplayName, padRight } from "./display.ts";
|
|
7
|
-
import {
|
|
8
|
+
import { inspectWorktreeWithoutFilters, passiveGitCommand, passiveGitEnv, type PassiveGitRunner } from "./passive-git-status.ts";
|
|
8
9
|
import { goalElapsedMilliseconds } from "./goal-state.ts";
|
|
9
10
|
import type { GoalRuntime, GoalState } from "./runtime.ts";
|
|
10
11
|
import { safeTerminalText } from "./safe-terminal-text.ts";
|
|
@@ -36,26 +37,16 @@ type GitStatusExecutor = (
|
|
|
36
37
|
callback: (error: Error | null, stdout: string) => void,
|
|
37
38
|
) => unknown;
|
|
38
39
|
|
|
39
|
-
/** Resolves changed-file counts
|
|
40
|
-
export function resolveGitFileChanges(
|
|
40
|
+
/** Resolves changed-file counts without asking Git to inspect worktree content. */
|
|
41
|
+
export async function resolveGitFileChanges(
|
|
41
42
|
cwd: string,
|
|
42
43
|
execute: GitStatusExecutor = execFile,
|
|
43
44
|
trusted = true,
|
|
44
45
|
): Promise<GitFileChanges | undefined> {
|
|
45
|
-
if (!trusted) return
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
const found = passiveGitCommand(cwd);
|
|
50
|
-
if (!found) {
|
|
51
|
-
resolve(undefined);
|
|
52
|
-
return;
|
|
53
|
-
}
|
|
54
|
-
gitCommand = found;
|
|
55
|
-
} catch {
|
|
56
|
-
resolve(undefined);
|
|
57
|
-
return;
|
|
58
|
-
}
|
|
46
|
+
if (!trusted) return undefined;
|
|
47
|
+
try {
|
|
48
|
+
const gitCommand = passiveGitCommand(cwd);
|
|
49
|
+
if (!gitCommand) return undefined;
|
|
59
50
|
const options = {
|
|
60
51
|
encoding: "utf8" as const,
|
|
61
52
|
env: passiveGitEnv(),
|
|
@@ -63,64 +54,42 @@ export function resolveGitFileChanges(
|
|
|
63
54
|
timeout: GIT_STATUS_TIMEOUT_MS,
|
|
64
55
|
windowsHide: true as const,
|
|
65
56
|
};
|
|
66
|
-
const
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
});
|
|
71
|
-
} catch {
|
|
72
|
-
done(false);
|
|
73
|
-
}
|
|
74
|
-
};
|
|
75
|
-
const runStatus = (config: string, args: string[]): void => {
|
|
76
|
-
try {
|
|
77
|
-
execute(gitCommand, args, options, (error, stdout) => {
|
|
78
|
-
if (error) {
|
|
79
|
-
resolve(undefined);
|
|
80
|
-
return;
|
|
81
|
-
}
|
|
82
|
-
verifyFiltersUnchanged(config, (unchanged) => {
|
|
83
|
-
if (!unchanged) {
|
|
84
|
-
resolve(undefined);
|
|
85
|
-
return;
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
const changes: GitFileChanges = { modified: 0, added: 0, deleted: 0 };
|
|
89
|
-
const entries = stdout.split("\0");
|
|
90
|
-
for (let index = 0; index < entries.length; index += 1) {
|
|
91
|
-
const entry = entries[index];
|
|
92
|
-
if (!entry) continue;
|
|
93
|
-
const status = entry.slice(0, 2);
|
|
94
|
-
if (status.includes("D")) changes.deleted += 1;
|
|
95
|
-
else if (status === "??" || status.includes("A")) changes.added += 1;
|
|
96
|
-
else changes.modified += 1;
|
|
97
|
-
if (status.includes("R") || status.includes("C")) index += 1;
|
|
98
|
-
}
|
|
99
|
-
resolve(changes);
|
|
100
|
-
});
|
|
101
|
-
});
|
|
102
|
-
} catch {
|
|
103
|
-
resolve(undefined);
|
|
104
|
-
}
|
|
105
|
-
};
|
|
106
|
-
|
|
107
|
-
try {
|
|
108
|
-
execute(gitCommand, ["-C", cwd, ...PASSIVE_GIT_CONFIG_ARGS], options, (error, config) => {
|
|
109
|
-
if (error) {
|
|
110
|
-
resolve(undefined);
|
|
111
|
-
return;
|
|
112
|
-
}
|
|
113
|
-
const safetyArgs = passiveStatusSafetyArgs(config);
|
|
114
|
-
if (!safetyArgs) {
|
|
115
|
-
resolve(undefined);
|
|
116
|
-
return;
|
|
117
|
-
}
|
|
118
|
-
runStatus(config, ["-C", cwd, ...safetyArgs, "status", "--porcelain=v1", "-z", "--untracked-files=all"]);
|
|
57
|
+
const runAt = (directory: string): PassiveGitRunner => (args) => new Promise((resolve, reject) => {
|
|
58
|
+
execute(gitCommand, ["-C", directory, ...args], options, (error, stdout) => {
|
|
59
|
+
if (error) reject(error);
|
|
60
|
+
else resolve(Buffer.from(stdout));
|
|
119
61
|
});
|
|
120
|
-
}
|
|
121
|
-
|
|
62
|
+
});
|
|
63
|
+
const initialRunner = runAt(cwd);
|
|
64
|
+
const root = new TextDecoder("utf-8", { fatal: true }).decode(
|
|
65
|
+
await initialRunner(["rev-parse", "--path-format=absolute", "--show-toplevel"]),
|
|
66
|
+
).trim();
|
|
67
|
+
if (!path.isAbsolute(root)) return undefined;
|
|
68
|
+
const snapshot = await inspectWorktreeWithoutFilters(root, runAt(root));
|
|
69
|
+
const changes: GitFileChanges = { modified: 0, added: 0, deleted: 0 };
|
|
70
|
+
const deletedObjectIds: string[] = [];
|
|
71
|
+
const addedObjectIds: string[] = [];
|
|
72
|
+
for (const file of snapshot.files.values()) {
|
|
73
|
+
if (!file.mode) {
|
|
74
|
+
changes.deleted += 1;
|
|
75
|
+
if (!file.indexMode && file.headObjectId) deletedObjectIds.push(file.headObjectId);
|
|
76
|
+
} else if (!file.headMode) {
|
|
77
|
+
changes.added += 1;
|
|
78
|
+
if (file.indexObjectId) addedObjectIds.push(file.indexObjectId);
|
|
79
|
+
} else changes.modified += 1;
|
|
122
80
|
}
|
|
123
|
-
|
|
81
|
+
for (const objectId of deletedObjectIds) {
|
|
82
|
+
const match = addedObjectIds.indexOf(objectId);
|
|
83
|
+
if (match < 0) continue;
|
|
84
|
+
addedObjectIds.splice(match, 1);
|
|
85
|
+
changes.deleted -= 1;
|
|
86
|
+
changes.added -= 1;
|
|
87
|
+
changes.modified += 1;
|
|
88
|
+
}
|
|
89
|
+
return changes;
|
|
90
|
+
} catch {
|
|
91
|
+
return undefined;
|
|
92
|
+
}
|
|
124
93
|
}
|
|
125
94
|
|
|
126
95
|
async function resolveUncommittedFileCount(cwd: string): Promise<number | undefined> {
|
|
@@ -290,11 +259,11 @@ export function contextPercentRemaining(ctx: ExtensionContext): number | null {
|
|
|
290
259
|
export function formatContextProgress(tokensUsed: number | null, contextWindow: number, theme: Theme): string {
|
|
291
260
|
if (tokensUsed === null || !Number.isFinite(tokensUsed)) return theme.fg("dim", "ctx —%");
|
|
292
261
|
const windowSize = Number.isFinite(contextWindow) && contextWindow > 0 ? contextWindow : 128_000;
|
|
293
|
-
const used = Math.max(0,
|
|
294
|
-
const
|
|
295
|
-
const color: ThemeColor =
|
|
296
|
-
const action =
|
|
297
|
-
return theme.fg(color, `ctx ${
|
|
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}`);
|
|
298
267
|
}
|
|
299
268
|
|
|
300
269
|
function sumSessionCost(ctx: ExtensionContext): number {
|
package/killeros/goal-command.ts
CHANGED
|
@@ -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
|
-
|
|
22
|
-
|
|
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
|
}
|
package/killeros/goal-runtime.ts
CHANGED
|
@@ -15,7 +15,7 @@ import type { GoalRuntime, GoalState, GoalStatus } from "./runtime.ts";
|
|
|
15
15
|
import { safeTerminalText } from "./safe-terminal-text.ts";
|
|
16
16
|
|
|
17
17
|
export const GOAL_ENTRY_TYPE = "killeros-goal";
|
|
18
|
-
const GOAL_CONTINUATION_TYPE = "killeros-goal-continuation";
|
|
18
|
+
export const GOAL_CONTINUATION_TYPE = "killeros-goal-continuation";
|
|
19
19
|
export const GOAL_UPDATE_TOOL = "killeros_goal_update";
|
|
20
20
|
|
|
21
21
|
export type GoalEntryEvent = "set" | "replace" | "limit" | "turn" | "continue" | "pause" | "resume" | "blocked" | "complete" | "error" | "clear" | "checkpoint" | "blocker-audit";
|
package/killeros/handoff.ts
CHANGED
|
@@ -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
|
|
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,21 +1,40 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
1
2
|
import { accessSync, constants, existsSync, realpathSync, statSync } from "node:fs";
|
|
3
|
+
import { lstat, open, readlink } from "node:fs/promises";
|
|
2
4
|
import path from "node:path";
|
|
3
5
|
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
6
|
+
const WORKTREE_CONTENT_LIMIT = 128 * 1024 * 1024;
|
|
7
|
+
const PASSIVE_METADATA_ARGS = ["-c", "core.fsmonitor=false"] as const;
|
|
8
|
+
|
|
9
|
+
export type PassiveGitRunner = (args: readonly string[]) => Promise<Buffer>;
|
|
10
|
+
|
|
11
|
+
export type PassiveWorktreeFile = {
|
|
12
|
+
path: string;
|
|
13
|
+
headMode?: string;
|
|
14
|
+
headObjectId?: string;
|
|
15
|
+
indexMode?: string;
|
|
16
|
+
indexObjectId?: string;
|
|
17
|
+
mode?: string;
|
|
18
|
+
content?: Buffer;
|
|
19
|
+
contentObjectId?: string;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export type PassiveWorktreeSnapshot = {
|
|
23
|
+
head: string;
|
|
24
|
+
files: Map<string, PassiveWorktreeFile>;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
type IndexEntry = {
|
|
28
|
+
mode: string;
|
|
29
|
+
objectId: string;
|
|
30
|
+
eolAttributes: string;
|
|
31
|
+
ctimeNanoseconds: bigint;
|
|
32
|
+
mtimeNanoseconds: bigint;
|
|
33
|
+
size: bigint;
|
|
34
|
+
skipWorktree: boolean;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
type TreeEntry = { mode: string; objectId: string };
|
|
19
38
|
|
|
20
39
|
function searchPathEntries(env: NodeJS.ProcessEnv): string[] {
|
|
21
40
|
const values: string[] = [];
|
|
@@ -101,13 +120,7 @@ function insideInspected(candidate: string, root: string): boolean {
|
|
|
101
120
|
return normalizedCandidate.startsWith(`${trimmed}${path.sep}`);
|
|
102
121
|
}
|
|
103
122
|
|
|
104
|
-
|
|
105
|
-
// no safe candidate exists. Never starts a helper process and never
|
|
106
|
-
// returns a bare command name, so opening a repository cannot execute a
|
|
107
|
-
// repository-local locator or Git executable. Empty and relative
|
|
108
|
-
// search-path entries are ignored because they can resolve against the
|
|
109
|
-
// current directory. A candidate that resolves through a link returns its
|
|
110
|
-
// final path only when that path is also outside the repository.
|
|
123
|
+
/** Returns an absolute Git binary outside the inspected repository without starting a locator process. */
|
|
111
124
|
export function passiveGitCommand(cwd: string, env: NodeJS.ProcessEnv = process.env): string | undefined {
|
|
112
125
|
const root = inspectedRoot(cwd);
|
|
113
126
|
if (!root) return undefined;
|
|
@@ -118,8 +131,7 @@ export function passiveGitCommand(cwd: string, env: NodeJS.ProcessEnv = process.
|
|
|
118
131
|
for (const raw of entries) {
|
|
119
132
|
if (raw === "" || raw.trim() === "") continue;
|
|
120
133
|
const directory = unquoted(raw);
|
|
121
|
-
if (directory === "" || directory.trim() === "") continue;
|
|
122
|
-
if (!path.isAbsolute(directory)) continue;
|
|
134
|
+
if (directory === "" || directory.trim() === "" || !path.isAbsolute(directory)) continue;
|
|
123
135
|
for (const base of baseNames) {
|
|
124
136
|
const candidate = path.join(directory, base);
|
|
125
137
|
try {
|
|
@@ -140,60 +152,15 @@ export function passiveGitCommand(cwd: string, env: NodeJS.ProcessEnv = process.
|
|
|
140
152
|
} catch {
|
|
141
153
|
continue;
|
|
142
154
|
}
|
|
143
|
-
if (
|
|
144
|
-
if (insideInspected(resolved, root)) continue;
|
|
145
|
-
return resolved;
|
|
155
|
+
if (path.isAbsolute(resolved) && !insideInspected(resolved, root)) return resolved;
|
|
146
156
|
}
|
|
147
157
|
}
|
|
148
158
|
return undefined;
|
|
149
159
|
}
|
|
150
160
|
|
|
151
|
-
|
|
152
|
-
// undefined when discovery output is incomplete or names a driver the
|
|
153
|
-
// safety overrides cannot represent.
|
|
154
|
-
export function passiveFilterNames(config: string): string[] | undefined {
|
|
155
|
-
const records = config.split("\0");
|
|
156
|
-
if (records.at(-1) !== "") return undefined;
|
|
157
|
-
const names = new Set<string>();
|
|
158
|
-
for (const key of records) {
|
|
159
|
-
if (!key) continue;
|
|
160
|
-
const name = /^filter\.(.*)\.(?:clean|process)$/us.exec(key)?.[1];
|
|
161
|
-
if (name === undefined) continue;
|
|
162
|
-
if (!SAFE_FILTER_NAME.test(name)) return undefined;
|
|
163
|
-
names.add(name);
|
|
164
|
-
}
|
|
165
|
-
return [...names];
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
// Builds safe overrides from null-delimited `git config --name-only --list`
|
|
169
|
-
// output. Returns undefined when output is incomplete or names an unsafe
|
|
170
|
-
// filter, so the caller skips the status call.
|
|
171
|
-
export function passiveStatusSafetyArgs(config: string): string[] | undefined {
|
|
172
|
-
const names = passiveFilterNames(config);
|
|
173
|
-
if (!names) return undefined;
|
|
174
|
-
return ["-c", "core.fsmonitor=false", ...names.flatMap((name) => ["-c", `filter.${name}.clean=`, "-c", `filter.${name}.process=`, "-c", `filter.${name}.required=false`])];
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
export function samePassiveFilters(before: string, after: string): boolean {
|
|
178
|
-
const earlier = passiveFilterNames(before);
|
|
179
|
-
const later = passiveFilterNames(after);
|
|
180
|
-
if (!earlier || !later) return false;
|
|
181
|
-
if (earlier.length !== later.length) return false;
|
|
182
|
-
const ordered = [...later].sort();
|
|
183
|
-
return [...earlier].sort().every((name, index) => name === ordered[index]);
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
// Environment for automatic Git children: no optional locks, no lazy fetch
|
|
187
|
-
// from a promisor remote, and no PATH so a filter command that becomes
|
|
188
|
-
// effective after discovery cannot resolve a bare command name. Absolute
|
|
189
|
-
// filter paths are still possible; trusted-project callers detect mid-scan
|
|
190
|
-
// config changes with samePassiveFilters and skip those results.
|
|
161
|
+
/** Environment for automatic Git children, with locks, lazy fetches, and PATH command resolution disabled. */
|
|
191
162
|
export function passiveGitEnv(base: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv {
|
|
192
|
-
const env: NodeJS.ProcessEnv = {
|
|
193
|
-
...base,
|
|
194
|
-
GIT_OPTIONAL_LOCKS: "0",
|
|
195
|
-
GIT_NO_LAZY_FETCH: "1",
|
|
196
|
-
};
|
|
163
|
+
const env: NodeJS.ProcessEnv = { ...base, GIT_OPTIONAL_LOCKS: "0", GIT_NO_LAZY_FETCH: "1" };
|
|
197
164
|
let hasPath = false;
|
|
198
165
|
for (const key of Object.keys(env)) {
|
|
199
166
|
if (key.toLowerCase() === "path") {
|
|
@@ -204,3 +171,176 @@ export function passiveGitEnv(base: NodeJS.ProcessEnv = process.env): NodeJS.Pro
|
|
|
204
171
|
if (!hasPath) env.PATH = "";
|
|
205
172
|
return env;
|
|
206
173
|
}
|
|
174
|
+
|
|
175
|
+
function decode(buffer: Buffer): string {
|
|
176
|
+
return new TextDecoder("utf-8", { fatal: true }).decode(buffer);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function parseIndex(output: Buffer): Map<string, IndexEntry> {
|
|
180
|
+
const text = decode(output);
|
|
181
|
+
const pattern = /([0-7]{6}) ([0-9a-f]{40}(?:[0-9a-f]{24})?) ([0-3])\t([^\t]*)\t([^\0]*)\0 ctime: (\d+):(\d+)\n mtime: (\d+):(\d+)\n dev: \d+\tino: \d+\n uid: \d+\tgid: \d+\n size: (\d+)\tflags: ([0-9a-f]+)\n/gu;
|
|
182
|
+
const entries = new Map<string, IndexEntry>();
|
|
183
|
+
let end = 0;
|
|
184
|
+
for (const match of text.matchAll(pattern)) {
|
|
185
|
+
if (match.index !== end) throw new Error("invalid index metadata");
|
|
186
|
+
end = match.index + match[0].length;
|
|
187
|
+
const [, mode, objectId, stage, eolAttributes, filePath, ctimeSeconds, ctimeNanoseconds, mtimeSeconds, mtimeNanoseconds, size, flags] = match;
|
|
188
|
+
if (!mode || !objectId || !stage || eolAttributes === undefined || filePath === undefined || !ctimeSeconds || !ctimeNanoseconds || !mtimeSeconds || !mtimeNanoseconds || !size || !flags || stage !== "0") {
|
|
189
|
+
throw new Error("unsupported index entry");
|
|
190
|
+
}
|
|
191
|
+
entries.set(filePath, {
|
|
192
|
+
mode,
|
|
193
|
+
objectId,
|
|
194
|
+
eolAttributes: eolAttributes.trim(),
|
|
195
|
+
ctimeNanoseconds: BigInt(ctimeSeconds) * 1_000_000_000n + BigInt(ctimeNanoseconds),
|
|
196
|
+
mtimeNanoseconds: BigInt(mtimeSeconds) * 1_000_000_000n + BigInt(mtimeNanoseconds),
|
|
197
|
+
size: BigInt(size),
|
|
198
|
+
skipWorktree: (Number.parseInt(flags, 16) & 0x40000000) !== 0,
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
if (end !== text.length) throw new Error("incomplete index metadata");
|
|
202
|
+
return entries;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function parseTree(output: Buffer): Map<string, TreeEntry> {
|
|
206
|
+
const entries = new Map<string, TreeEntry>();
|
|
207
|
+
for (const record of decode(output).split("\0")) {
|
|
208
|
+
if (!record) continue;
|
|
209
|
+
const match = /^([0-7]{6}) (?:blob|commit) ([0-9a-f]{40}(?:[0-9a-f]{24})?)\t([^\0]+)$/u.exec(record);
|
|
210
|
+
if (!match?.[1] || !match[2] || !match[3]) throw new Error("invalid tree entry");
|
|
211
|
+
entries.set(match[3], { mode: match[1], objectId: match[2] });
|
|
212
|
+
}
|
|
213
|
+
return entries;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function parsePaths(output: Buffer): string[] {
|
|
217
|
+
const records = decode(output).split("\0");
|
|
218
|
+
if (records.at(-1) !== "") throw new Error("incomplete path list");
|
|
219
|
+
return records.slice(0, -1);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
async function readFileState(root: string, entry: IndexEntry | undefined, filePath: string, remaining: number): Promise<{ mode: string; content?: Buffer; statsMatch: boolean }> {
|
|
223
|
+
const absolutePath = path.join(root, ...filePath.split("/"));
|
|
224
|
+
const stats = await lstat(absolutePath, { bigint: true });
|
|
225
|
+
const mode = stats.isSymbolicLink() ? "120000" : stats.isFile() ? stats.mode & 0o111n ? "100755" : "100644" : "";
|
|
226
|
+
if (!mode) throw new Error("unsupported worktree entry");
|
|
227
|
+
const statsMatch = entry !== undefined
|
|
228
|
+
&& entry.mode === mode
|
|
229
|
+
&& entry.size === stats.size
|
|
230
|
+
&& entry.ctimeNanoseconds === stats.ctimeNs
|
|
231
|
+
&& entry.mtimeNanoseconds === stats.mtimeNs;
|
|
232
|
+
if (statsMatch) return { mode, statsMatch };
|
|
233
|
+
if (!stats.isSymbolicLink() && stats.size > BigInt(remaining)) throw new Error("worktree content limit exceeded");
|
|
234
|
+
const content = stats.isSymbolicLink() ? Buffer.from(await readlink(absolutePath)) : await readBounded(absolutePath, remaining);
|
|
235
|
+
return { mode, content, statsMatch };
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
async function readBounded(filePath: string, limit: number): Promise<Buffer> {
|
|
239
|
+
const handle = await open(filePath, "r");
|
|
240
|
+
try {
|
|
241
|
+
const stats = await handle.stat();
|
|
242
|
+
if (!stats.isFile() || stats.size > limit) throw new Error("worktree content limit exceeded");
|
|
243
|
+
const content = Buffer.alloc(stats.size + 1);
|
|
244
|
+
let length = 0;
|
|
245
|
+
while (length < content.length) {
|
|
246
|
+
const { bytesRead } = await handle.read(content, length, content.length - length, length);
|
|
247
|
+
if (bytesRead === 0) break;
|
|
248
|
+
length += bytesRead;
|
|
249
|
+
}
|
|
250
|
+
if (length > stats.size) throw new Error("worktree changed while reading");
|
|
251
|
+
return content.subarray(0, length);
|
|
252
|
+
} finally {
|
|
253
|
+
await handle.close();
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function blobObjectId(content: Buffer, length: number): string {
|
|
258
|
+
const algorithm = length === 64 ? "sha256" : "sha1";
|
|
259
|
+
return createHash(algorithm).update(`blob ${content.length}\0`).update(content).digest("hex");
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function matchesIndex(content: Buffer, entry: IndexEntry, autoCrlf: string): boolean {
|
|
263
|
+
if (blobObjectId(content, entry.objectId.length) === entry.objectId) return true;
|
|
264
|
+
if (entry.mode === "120000" || !content.includes(Buffer.from("\r\n"))) return false;
|
|
265
|
+
const attributes = entry.eolAttributes;
|
|
266
|
+
if (attributes === "-text") return false;
|
|
267
|
+
const automatic = attributes.startsWith("text=auto") || !attributes;
|
|
268
|
+
if (!attributes && !["true", "input"].includes(autoCrlf)) return false;
|
|
269
|
+
if (automatic) {
|
|
270
|
+
// Match Git's convert_is_binary heuristic, including its terminal EOF exception.
|
|
271
|
+
let printable = 0;
|
|
272
|
+
let nonprintable = 0;
|
|
273
|
+
for (let index = 0; index < content.length; index += 1) {
|
|
274
|
+
const byte = content[index];
|
|
275
|
+
if (byte === 0 || byte === 13 && content[index + 1] !== 10) return false;
|
|
276
|
+
if (byte === 13 || byte === 10 || byte === 26 && index === content.length - 1) continue;
|
|
277
|
+
if (byte === 127 || byte < 32 && ![8, 9, 12, 27].includes(byte)) nonprintable += 1;
|
|
278
|
+
else printable += 1;
|
|
279
|
+
}
|
|
280
|
+
if (Math.floor(printable / 128) < nonprintable) return false;
|
|
281
|
+
}
|
|
282
|
+
const normalized = Buffer.from(content.toString("latin1").replaceAll("\r\n", "\n"), "latin1");
|
|
283
|
+
return blobObjectId(normalized, entry.objectId.length) === entry.objectId;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* Reads index and tree metadata with Git, then inspects worktree files with Node.
|
|
288
|
+
* No Git command in this path asks Git to convert worktree content, so clean and process filters cannot start.
|
|
289
|
+
*/
|
|
290
|
+
export async function inspectWorktreeWithoutFilters(root: string, runGit: PassiveGitRunner): Promise<PassiveWorktreeSnapshot> {
|
|
291
|
+
const [indexOutput, untrackedOutput, resolvedHead, autoCrlfOutput] = await Promise.all([
|
|
292
|
+
runGit([...PASSIVE_METADATA_ARGS, "ls-files", "--cached", "--debug", "-z", "--full-name",
|
|
293
|
+
"--format=%(objectmode) %(objectname) %(stage)%x09%(eolattr)%x09%(path)"]),
|
|
294
|
+
runGit([...PASSIVE_METADATA_ARGS, "ls-files", "--others", "--exclude-standard", "-z", "--full-name"]),
|
|
295
|
+
runGit(["rev-parse", "--verify", "HEAD"]).then(decode, () => ""),
|
|
296
|
+
runGit(["config", "--type=bool-or-str", "--default", "false", "--get", "core.autocrlf"]),
|
|
297
|
+
]);
|
|
298
|
+
const autoCrlf = decode(autoCrlfOutput).trim().toLowerCase();
|
|
299
|
+
const head = resolvedHead.trim();
|
|
300
|
+
if (head && !/^[0-9a-f]{40}(?:[0-9a-f]{24})?$/u.test(head)) throw new Error("invalid HEAD");
|
|
301
|
+
const index = parseIndex(indexOutput);
|
|
302
|
+
const tree = head ? parseTree(await runGit(["ls-tree", "-r", "-z", "--full-tree", head])) : new Map<string, TreeEntry>();
|
|
303
|
+
const files = new Map<string, PassiveWorktreeFile>();
|
|
304
|
+
let contentBytes = 0;
|
|
305
|
+
|
|
306
|
+
for (const filePath of new Set([...tree.keys(), ...index.keys()])) {
|
|
307
|
+
const headEntry = tree.get(filePath);
|
|
308
|
+
const indexEntry = index.get(filePath);
|
|
309
|
+
if (headEntry?.mode === "160000" || indexEntry?.mode === "160000") continue;
|
|
310
|
+
const staged = headEntry?.mode !== indexEntry?.mode || headEntry?.objectId !== indexEntry?.objectId;
|
|
311
|
+
let mode: string | undefined;
|
|
312
|
+
let content: Buffer | undefined;
|
|
313
|
+
let worktreeChanged = false;
|
|
314
|
+
if (indexEntry && !indexEntry.skipWorktree) {
|
|
315
|
+
try {
|
|
316
|
+
const state = await readFileState(root, indexEntry, filePath, WORKTREE_CONTENT_LIMIT - contentBytes);
|
|
317
|
+
mode = process.platform === "win32" && indexEntry.mode !== "120000" ? indexEntry.mode : state.mode;
|
|
318
|
+
content = state.content;
|
|
319
|
+
if (content) contentBytes += content.length;
|
|
320
|
+
worktreeChanged = !state.statsMatch && (mode !== indexEntry.mode || !content || !matchesIndex(content, indexEntry, autoCrlf));
|
|
321
|
+
} catch (error) {
|
|
322
|
+
if (error !== null && typeof error === "object" && "code" in error && error.code === "ENOENT") worktreeChanged = true;
|
|
323
|
+
else throw error;
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
if (!staged && !worktreeChanged) continue;
|
|
327
|
+
files.set(filePath, {
|
|
328
|
+
path: filePath,
|
|
329
|
+
headMode: headEntry?.mode,
|
|
330
|
+
headObjectId: headEntry?.objectId,
|
|
331
|
+
indexMode: indexEntry?.mode,
|
|
332
|
+
indexObjectId: indexEntry?.objectId,
|
|
333
|
+
mode: worktreeChanged ? mode : indexEntry?.mode,
|
|
334
|
+
...(worktreeChanged && content ? { content } : indexEntry ? { contentObjectId: indexEntry.objectId } : {}),
|
|
335
|
+
});
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
for (const filePath of parsePaths(untrackedOutput)) {
|
|
339
|
+
const state = await readFileState(root, undefined, filePath, WORKTREE_CONTENT_LIMIT - contentBytes);
|
|
340
|
+
if (!state.content) throw new Error("missing untracked content");
|
|
341
|
+
contentBytes += state.content.length;
|
|
342
|
+
const previous = files.get(filePath);
|
|
343
|
+
files.set(filePath, { ...previous, path: filePath, mode: state.mode, content: state.content, contentObjectId: undefined });
|
|
344
|
+
}
|
|
345
|
+
return { head: head || "(initial)", files };
|
|
346
|
+
}
|
package/killeros/settings.ts
CHANGED
|
@@ -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
|
-
|
|
133
|
+
try {
|
|
134
|
+
rmSync(temporaryPath, { force: true });
|
|
135
|
+
} finally {
|
|
136
|
+
releaseLock();
|
|
137
|
+
}
|
|
47
138
|
}
|
|
48
139
|
},
|
|
49
140
|
};
|
package/killeros/worked-for.ts
CHANGED
|
@@ -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
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
active.modelProvider
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
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);
|