killeros 2.1.25 → 2.1.27
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 +28 -0
- package/Killeros.ts +5 -12
- package/README.md +6 -8
- package/killeros/auto-compaction.ts +0 -2
- package/killeros/change-receipt.ts +111 -63
- package/killeros/codex-fast.ts +8 -3
- package/killeros/footer.ts +76 -27
- package/killeros/goal-interface.ts +8 -15
- package/killeros/goal-runtime.ts +3 -6
- package/killeros/goal-settlement.ts +17 -29
- package/killeros/goal-state.ts +55 -3
- package/killeros/handoff.ts +36 -4
- package/killeros/passive-git-status.ts +206 -0
- package/killeros/personal-instructions.ts +2 -3
- package/killeros/runtime.ts +0 -38
- package/killeros/shell-ui.ts +13 -4
- package/killeros/worked-for.ts +3 -2
- package/package.json +1 -1
- package/killeros/init-evidence.ts +0 -291
- package/killeros/init-target.ts +0 -298
- package/killeros/init.ts +0 -281
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,34 @@ All notable changes to KillerOS are documented here.
|
|
|
4
4
|
|
|
5
5
|
## [Unreleased]
|
|
6
6
|
|
|
7
|
+
## [2.1.27] - 2026-09-12
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- Added `/codex-fast status` to report whether Codex fast mode is enabled without changing it.
|
|
12
|
+
|
|
13
|
+
### Fixed
|
|
14
|
+
|
|
15
|
+
- Restricted automatic startup, footer, and change-receipt Git scans to trusted projects, preventing repository executables and configuration races from launching commands before trust is granted.
|
|
16
|
+
- Resolved passive Git executable discovery without starting a locator or repository executable. Footer and change-receipt scans now use an absolute Git path outside the inspected repository and report unavailable when none exists.
|
|
17
|
+
- Closed the filter-configuration race in automatic Git inspection. Footer and change-receipt scans now run Git without `PATH` resolution, re-check effective filters after status, and skip results the scan cannot produce without an extension process or remote fetch. Change receipts no longer fetch missing objects from partial-clone remotes.
|
|
18
|
+
- Stopped attaching single-file verification to goals that name several files. Multi-file goals now complete through the model-reported path.
|
|
19
|
+
- Shared one passive Git status policy between footer telemetry and change receipts. Both scans now disable filesystem monitors and clean/process filters, and skip status when filter discovery is unavailable or unsafe.
|
|
20
|
+
- Recorded focused Node test runs in response receipts without persisting their test paths.
|
|
21
|
+
- Kept passive footer Git status from running configured filters, bounded loose and packed receipt blobs, and stopped stale `/handoff` summaries from replacing sessions with new work.
|
|
22
|
+
|
|
23
|
+
### Removed
|
|
24
|
+
|
|
25
|
+
- Removed `/init` and its `killeros_init_*` tools, prompts, workflow APIs, runtime state, and lifecycle handlers. Upgrading leaves existing `AGENTS.md` and recovery files untouched, and commands from other providers named `/init` pass through KillerOS unhandled.
|
|
26
|
+
|
|
27
|
+
## [2.1.26] - 2026-09-08
|
|
28
|
+
|
|
29
|
+
### Fixed
|
|
30
|
+
|
|
31
|
+
- Restored Pi lifecycle loading, blocked footer Git status from invoking configured filesystem monitors, and kept replaced `/init` guidance at a disclosed recovery path so late writes remain recoverable.
|
|
32
|
+
- Preserved Unicode blocker evidence when restoring goals, rejected cancelled goal updates before completion is saved, and read untracked symlink destinations instead of their targets in change receipts.
|
|
33
|
+
- Kept `/init` isolated from proactive compaction, refreshed Git filter safeguards for each receipt scan, and preserved compaction recovery after cancelled session replacements.
|
|
34
|
+
|
|
7
35
|
## [2.1.25] - 2026-09-06
|
|
8
36
|
|
|
9
37
|
### Fixed
|
package/Killeros.ts
CHANGED
|
@@ -12,7 +12,6 @@ import { registerGoalRuntime } from "./killeros/goal-runtime.ts";
|
|
|
12
12
|
import { registerGoalSettlement } from "./killeros/goal-settlement.ts";
|
|
13
13
|
import { registerHandoff } from "./killeros/handoff.ts";
|
|
14
14
|
import { registerLifecycleHooks } from "./killeros/hooks.ts";
|
|
15
|
-
import { registerInitCommand, registerInitSettlement } from "./killeros/init.ts";
|
|
16
15
|
import {
|
|
17
16
|
registerCompletionNotifications,
|
|
18
17
|
type CompletionNotificationDependencies,
|
|
@@ -20,15 +19,12 @@ import {
|
|
|
20
19
|
import { registerCodexFastMode } from "./killeros/codex-fast.ts";
|
|
21
20
|
import { registerPersonalInstructions } from "./killeros/personal-instructions.ts";
|
|
22
21
|
import { registerQuestionTool } from "./killeros/question.ts";
|
|
23
|
-
import { createGoalRuntime
|
|
22
|
+
import { createGoalRuntime } from "./killeros/runtime.ts";
|
|
24
23
|
import { registerShellUi } from "./killeros/shell-ui.ts";
|
|
25
24
|
import { registerWorkedFor } from "./killeros/worked-for.ts";
|
|
26
25
|
|
|
27
26
|
export { contextPercentRemaining, formatCost, formatContextProgress } from "./killeros/footer.ts";
|
|
28
27
|
export { executeHook } from "./killeros/hooks.ts";
|
|
29
|
-
export { INIT_WORKFLOW_PROMPT } from "./killeros/init.ts";
|
|
30
|
-
export { buildInitEvidence, listInitEvidence, readInitEvidence } from "./killeros/init-evidence.ts";
|
|
31
|
-
export { captureInitTargetBaseline, installInitAgentsFile, validateGeneratedGuidance, writeInitAgentsFile } from "./killeros/init-target.ts";
|
|
32
28
|
export interface KillerosOptions {
|
|
33
29
|
completionNotifications?: CompletionNotificationDependencies;
|
|
34
30
|
/** Output-token budget for /handoff summaries; invalid values fall back to killeros.json, then the default. */
|
|
@@ -36,25 +32,22 @@ export interface KillerosOptions {
|
|
|
36
32
|
}
|
|
37
33
|
|
|
38
34
|
export default function Killeros(pi: ExtensionAPI, options: KillerosOptions = {}): void {
|
|
39
|
-
const initRuntime = createInitRuntime();
|
|
40
35
|
const goalRuntime = createGoalRuntime();
|
|
41
36
|
const commandResolver = createSlashCommandResolver(pi);
|
|
42
37
|
registerShellUi(pi, commandResolver);
|
|
43
|
-
registerGoalInterface(pi, goalRuntime
|
|
44
|
-
registerGoalRuntime(pi, goalRuntime
|
|
45
|
-
registerPersonalInstructions(pi
|
|
38
|
+
registerGoalInterface(pi, goalRuntime);
|
|
39
|
+
registerGoalRuntime(pi, goalRuntime);
|
|
40
|
+
registerPersonalInstructions(pi);
|
|
46
41
|
registerQuestionTool(pi);
|
|
47
42
|
registerAliases(pi);
|
|
48
43
|
registerHandoff(pi, goalRuntime, options.handoffMaxTokens);
|
|
49
44
|
registerSlashAutocomplete(pi, commandResolver);
|
|
50
45
|
registerFooter(pi, goalRuntime);
|
|
51
46
|
registerCodexFastMode(pi);
|
|
52
|
-
registerInitCommand(pi, initRuntime, goalRuntime);
|
|
53
47
|
registerLifecycleHooks(pi);
|
|
54
48
|
registerWorkedFor(pi);
|
|
55
|
-
const goalCompaction = registerGoalSettlement(pi, goalRuntime
|
|
49
|
+
const goalCompaction = registerGoalSettlement(pi, goalRuntime);
|
|
56
50
|
registerAutoCompaction(pi, { goal: goalCompaction });
|
|
57
|
-
registerInitSettlement(pi, initRuntime);
|
|
58
51
|
registerRequestActivity(pi);
|
|
59
52
|
registerCompletionNotifications(pi, options.completionNotifications);
|
|
60
53
|
}
|
package/README.md
CHANGED
|
@@ -6,8 +6,7 @@ A TypeScript extension for the [Pi coding agent](https://github.com/earendil-wor
|
|
|
6
6
|
|
|
7
7
|
- A custom TUI: startup masthead with versions, model, working directory, and Git branch; a dark theme with coral accents; a multiline editor with slash-command completion; a footer that tracks model, context, and goal state; settled task receipts with duration and token usage.
|
|
8
8
|
- `/goal`: set an objective and Pi keeps working toward it across turns, compaction, reloads, and branch navigation. New goals pause after 20 turns; `/goal resume` grants another 20.
|
|
9
|
-
- `/
|
|
10
|
-
- `/codex-fast`: toggles the `priority` service tier on Codex requests.
|
|
9
|
+
- `/codex-fast`: toggles the `priority` service tier on Codex requests or reports its status.
|
|
11
10
|
- `/handoff`: starts a fresh linked session carrying visible continuation context.
|
|
12
11
|
- Automatic context compaction when remaining tokens drop below 15% of the window (configurable).
|
|
13
12
|
- A `question` tool with single-select and multi-select modes.
|
|
@@ -19,7 +18,7 @@ A TypeScript extension for the [Pi coding agent](https://github.com/earendil-wor
|
|
|
19
18
|
|
|
20
19
|
- Node.js 22.19.0+
|
|
21
20
|
- Pi 0.85.0 or later within the 0.x release line
|
|
22
|
-
- An interactive TUI session for the custom header, editor, footer,
|
|
21
|
+
- An interactive TUI session for the custom header, editor, footer, and `question`
|
|
23
22
|
|
|
24
23
|
## Install
|
|
25
24
|
|
|
@@ -33,18 +32,17 @@ Or from GitHub:
|
|
|
33
32
|
pi install git:github.com/KyrosHendrix/pi-KillerOS
|
|
34
33
|
```
|
|
35
34
|
|
|
36
|
-
Pin a release by appending its tag, for example `@v2.1.
|
|
35
|
+
Pin a release by appending its tag, for example `@v2.1.27`. Add `-l` to install only for the current project. Restart Pi after installing.
|
|
37
36
|
|
|
38
37
|
## Commands
|
|
39
38
|
|
|
40
39
|
```text
|
|
41
|
-
/init Generate root AGENTS.md from repository evidence
|
|
42
40
|
/goal View the current goal
|
|
43
41
|
/goal <objective> Set an objective
|
|
44
42
|
/goal pause Stop automatic continuation
|
|
45
43
|
/goal resume Resume automatic continuation
|
|
46
44
|
/goal clear Remove the current goal
|
|
47
|
-
/codex-fast
|
|
45
|
+
/codex-fast [status] Toggle or report Codex fast mode
|
|
48
46
|
/notification Configure the completion sound
|
|
49
47
|
/handoff [focus] Fresh session with continuation context
|
|
50
48
|
/clear New session after confirmation
|
|
@@ -56,8 +54,8 @@ Pin a release by appending its tag, for example `@v2.1.25`. Add `-l` to install
|
|
|
56
54
|
| Mode | What works |
|
|
57
55
|
| --- | --- |
|
|
58
56
|
| TUI | Everything |
|
|
59
|
-
| RPC | Goals, proactive compaction; no TUI components,
|
|
60
|
-
| Print/JSON | No interactive questions, `/goal`,
|
|
57
|
+
| RPC | Goals, proactive compaction; no TUI components, sounds, title indicator |
|
|
58
|
+
| Print/JSON | No interactive questions, `/goal`, or proactive compaction |
|
|
61
59
|
|
|
62
60
|
## Configuration
|
|
63
61
|
|
|
@@ -288,6 +288,4 @@ export function registerAutoCompaction(
|
|
|
288
288
|
pi.on("session_start", resetForLifecycle);
|
|
289
289
|
pi.on("session_shutdown", resetForLifecycle);
|
|
290
290
|
pi.on("session_tree", resetForLifecycle);
|
|
291
|
-
pi.on("session_before_switch", resetForLifecycle);
|
|
292
|
-
pi.on("session_before_fork", resetForLifecycle);
|
|
293
291
|
}
|
|
@@ -1,16 +1,16 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
|
-
import { watch } from "node:fs";
|
|
3
|
-
import {
|
|
2
|
+
import { createReadStream, watch } from "node:fs";
|
|
3
|
+
import { PASSIVE_GIT_CONFIG_ARGS, passiveGitCommand, passiveGitEnv, passiveStatusSafetyArgs, samePassiveFilters } from "./passive-git-status.ts";
|
|
4
|
+
import { lstat, open, readlink } from "node:fs/promises";
|
|
4
5
|
import path from "node:path";
|
|
5
|
-
import {
|
|
6
|
-
import { inflate } from "node:zlib";
|
|
6
|
+
import { createInflate } from "node:zlib";
|
|
7
7
|
|
|
8
8
|
const GIT_TIMEOUT_MS = 5_000;
|
|
9
9
|
const GIT_OUTPUT_LIMIT = 16 * 1024 * 1024;
|
|
10
10
|
const SNAPSHOT_CONTENT_LIMIT = 128 * 1024 * 1024;
|
|
11
11
|
const MAX_DIFF_OPERATIONS = 500_000;
|
|
12
12
|
const MAX_FILES = 20;
|
|
13
|
-
const
|
|
13
|
+
const GIT_OBJECT_HEADER_LIMIT = 1_024;
|
|
14
14
|
|
|
15
15
|
export type ChangeUnavailableReason = "not-git" | "timeout" | "too-large" | "error";
|
|
16
16
|
|
|
@@ -43,7 +43,12 @@ export const CHECK_LABELS = [
|
|
|
43
43
|
...GRADLE_CHECK_LABELS,
|
|
44
44
|
] as const;
|
|
45
45
|
|
|
46
|
-
|
|
46
|
+
const FOCUSED_CHECK_LABEL = "node --test (focused)";
|
|
47
|
+
const FOCUSED_TEST_UNSAFE_CHARACTERS = [
|
|
48
|
+
'"', "'", "`", "$", ";", "&", "|", "<", ">", "*", "?", "[", "]", "{", "}", "(", ")", "#", "!",
|
|
49
|
+
] as const;
|
|
50
|
+
|
|
51
|
+
export type CheckLabel = typeof CHECK_LABELS[number] | typeof FOCUSED_CHECK_LABEL;
|
|
47
52
|
|
|
48
53
|
export interface ChangeReceiptCollection {
|
|
49
54
|
finish(): Promise<ChangeSummary>;
|
|
@@ -61,11 +66,19 @@ class GitFailure extends Error {
|
|
|
61
66
|
}
|
|
62
67
|
}
|
|
63
68
|
|
|
64
|
-
function runGit(cwd: string, args: readonly string[], input?: Buffer): Promise<Buffer> {
|
|
69
|
+
function runGit(cwd: string, args: readonly string[], input?: Buffer, stdoutLimit = GIT_OUTPUT_LIMIT): Promise<Buffer> {
|
|
70
|
+
let gitCommand: string;
|
|
71
|
+
try {
|
|
72
|
+
const found = passiveGitCommand(cwd);
|
|
73
|
+
if (!found) return Promise.reject(new GitFailure("error"));
|
|
74
|
+
gitCommand = found;
|
|
75
|
+
} catch {
|
|
76
|
+
return Promise.reject(new GitFailure("error"));
|
|
77
|
+
}
|
|
65
78
|
return new Promise((resolve, reject) => {
|
|
66
|
-
const child = spawn(
|
|
79
|
+
const child = spawn(gitCommand, args, {
|
|
67
80
|
cwd,
|
|
68
|
-
env:
|
|
81
|
+
env: passiveGitEnv(),
|
|
69
82
|
stdio: [input ? "pipe" : "ignore", "pipe", "pipe"],
|
|
70
83
|
windowsHide: true,
|
|
71
84
|
});
|
|
@@ -82,7 +95,7 @@ function runGit(cwd: string, args: readonly string[], input?: Buffer): Promise<B
|
|
|
82
95
|
timer.unref();
|
|
83
96
|
const capture = (chunks: Buffer[], isStdout: boolean) => (chunk: Buffer): void => {
|
|
84
97
|
const nextBytes = (isStdout ? stdoutBytes : stderrBytes) + chunk.length;
|
|
85
|
-
if (nextBytes > GIT_OUTPUT_LIMIT) {
|
|
98
|
+
if (nextBytes > (isStdout ? stdoutLimit : GIT_OUTPUT_LIMIT)) {
|
|
86
99
|
failure = new GitFailure("too-large");
|
|
87
100
|
child.kill();
|
|
88
101
|
return;
|
|
@@ -119,49 +132,16 @@ function missingFile(error: unknown): boolean {
|
|
|
119
132
|
return error !== null && typeof error === "object" && "code" in error && error.code === "ENOENT";
|
|
120
133
|
}
|
|
121
134
|
|
|
122
|
-
type FilterConfiguration = { names: readonly string[]; sources: ReadonlyMap<string, Buffer> };
|
|
123
135
|
type Repository = {
|
|
124
136
|
root: string;
|
|
125
137
|
gitDirectory: string;
|
|
126
138
|
commonDirectory: string;
|
|
127
139
|
objectDirectory: string;
|
|
128
|
-
filterConfiguration: FilterConfiguration;
|
|
129
140
|
blobCache: Map<string, Buffer>;
|
|
130
141
|
blobCacheBytes: number;
|
|
131
142
|
};
|
|
132
143
|
const repositoryCache = new Map<string, Promise<Repository>>();
|
|
133
144
|
|
|
134
|
-
async function loadFilterConfiguration(root: string, gitDirectory: string): Promise<FilterConfiguration> {
|
|
135
|
-
const records = decode(await runGit(root, ["config", "--null", "--show-origin", "--name-only", "--list"])).split("\0");
|
|
136
|
-
const names = new Set<string>();
|
|
137
|
-
const sourcePaths = new Set<string>([path.join(gitDirectory, "HEAD")]);
|
|
138
|
-
for (let index = 0; index + 1 < records.length; index += 2) {
|
|
139
|
-
const origin = records[index];
|
|
140
|
-
const key = records[index + 1];
|
|
141
|
-
if (origin?.startsWith("file:")) sourcePaths.add(path.resolve(root, origin.slice("file:".length)));
|
|
142
|
-
if (key && /^filter\..*\.(clean|process)$/u.test(key)) names.add(key.slice("filter.".length, key.lastIndexOf(".")));
|
|
143
|
-
}
|
|
144
|
-
const sources = new Map<string, Buffer>();
|
|
145
|
-
for (const sourcePath of sourcePaths) sources.set(sourcePath, await readBoundedFile(sourcePath, GIT_OUTPUT_LIMIT));
|
|
146
|
-
return { names: [...names], sources };
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
async function currentFilterNames(repo: Repository): Promise<readonly string[]> {
|
|
150
|
-
for (const [sourcePath, previous] of repo.filterConfiguration.sources) {
|
|
151
|
-
try {
|
|
152
|
-
if (!(await readBoundedFile(sourcePath, GIT_OUTPUT_LIMIT)).equals(previous)) {
|
|
153
|
-
repo.filterConfiguration = await loadFilterConfiguration(repo.root, repo.gitDirectory);
|
|
154
|
-
break;
|
|
155
|
-
}
|
|
156
|
-
} catch (error) {
|
|
157
|
-
if (!missingFile(error)) throw error;
|
|
158
|
-
repo.filterConfiguration = await loadFilterConfiguration(repo.root, repo.gitDirectory);
|
|
159
|
-
break;
|
|
160
|
-
}
|
|
161
|
-
}
|
|
162
|
-
return repo.filterConfiguration.names;
|
|
163
|
-
}
|
|
164
|
-
|
|
165
145
|
async function repository(cwd: string): Promise<Repository> {
|
|
166
146
|
const cached = repositoryCache.get(cwd);
|
|
167
147
|
if (cached) return cached;
|
|
@@ -174,7 +154,6 @@ async function repository(cwd: string): Promise<Repository> {
|
|
|
174
154
|
gitDirectory,
|
|
175
155
|
commonDirectory,
|
|
176
156
|
objectDirectory: path.join(commonDirectory, "objects"),
|
|
177
|
-
filterConfiguration: await loadFilterConfiguration(root, gitDirectory),
|
|
178
157
|
blobCache: new Map(),
|
|
179
158
|
blobCacheBytes: 0,
|
|
180
159
|
};
|
|
@@ -278,13 +257,15 @@ function discardMonitor(monitor: RepositoryMonitor): void {
|
|
|
278
257
|
}
|
|
279
258
|
|
|
280
259
|
async function snapshot(repo: Repository, paths?: readonly string[]): Promise<Snapshot> {
|
|
281
|
-
const
|
|
260
|
+
const config = decode(await runGit(repo.root, PASSIVE_GIT_CONFIG_ARGS));
|
|
261
|
+
const safetyArgs = passiveStatusSafetyArgs(config);
|
|
262
|
+
if (!safetyArgs) throw new GitFailure("error");
|
|
282
263
|
const output = decode(await runGit(repo.root, [
|
|
283
|
-
|
|
284
|
-
...filterNames.flatMap((name) => ["-c", `filter.${name}.clean=`, "-c", `filter.${name}.process=`, "-c", `filter.${name}.required=false`]),
|
|
264
|
+
...safetyArgs,
|
|
285
265
|
"status", "--porcelain=v2", "--branch", "--no-ahead-behind", "-z", "--no-renames", "--untracked-files=all", "--ignore-submodules=all",
|
|
286
266
|
...(paths ? ["--", ...paths] : []),
|
|
287
267
|
]));
|
|
268
|
+
if (!samePassiveFilters(config, decode(await runGit(repo.root, PASSIVE_GIT_CONFIG_ARGS)))) throw new GitFailure("error");
|
|
288
269
|
const records = output.split("\0").filter(Boolean);
|
|
289
270
|
const headRecord = records.find((record) => record.startsWith("# branch.oid "));
|
|
290
271
|
if (!headRecord) throw new Error("missing HEAD state");
|
|
@@ -295,12 +276,13 @@ async function snapshot(repo: Repository, paths?: readonly string[]): Promise<Sn
|
|
|
295
276
|
if (record.startsWith("? ")) {
|
|
296
277
|
const filePath = record.slice(2);
|
|
297
278
|
if (!filePath || filePath.endsWith("/")) continue;
|
|
279
|
+
const stats = await lstat(path.join(repo.root, ...filePath.split("/")));
|
|
298
280
|
files.set(filePath, {
|
|
299
281
|
...files.get(filePath),
|
|
300
282
|
path: filePath,
|
|
301
283
|
indexMode: undefined,
|
|
302
284
|
indexObjectId: undefined,
|
|
303
|
-
mode: "100644",
|
|
285
|
+
mode: stats.isSymbolicLink() ? "120000" : stats.mode & 0o111 ? "100755" : "100644",
|
|
304
286
|
contentObjectId: undefined,
|
|
305
287
|
});
|
|
306
288
|
continue;
|
|
@@ -426,24 +408,60 @@ function createMonitor(repo: Repository, initialSnapshot: Snapshot): RepositoryM
|
|
|
426
408
|
return monitor;
|
|
427
409
|
}
|
|
428
410
|
|
|
429
|
-
|
|
411
|
+
function boundedBlobSize(value: string, limit: number): number {
|
|
412
|
+
const size = Number(value);
|
|
413
|
+
if (!Number.isSafeInteger(size) || size > limit) throw new GitFailure("too-large");
|
|
414
|
+
return size;
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
async function looseBlob(objectDirectory: string, id: string, limit: number): Promise<Buffer | undefined> {
|
|
418
|
+
const source = createReadStream(path.join(objectDirectory, id.slice(0, 2), id.slice(2)));
|
|
419
|
+
const inflater = createInflate();
|
|
420
|
+
source.once("error", (error) => inflater.destroy(error));
|
|
421
|
+
source.pipe(inflater);
|
|
422
|
+
let header = Buffer.alloc(0);
|
|
423
|
+
let expectedSize: number | undefined;
|
|
424
|
+
let body: Buffer | undefined;
|
|
425
|
+
let bodyBytes = 0;
|
|
430
426
|
try {
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
427
|
+
for await (const chunk of inflater) {
|
|
428
|
+
let data = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
429
|
+
if (expectedSize === undefined) {
|
|
430
|
+
const separator = data.indexOf(0);
|
|
431
|
+
if (separator < 0) {
|
|
432
|
+
if (header.length + data.length > GIT_OBJECT_HEADER_LIMIT) throw new Error("invalid blob header");
|
|
433
|
+
header = header.length === 0 ? Buffer.from(data) : Buffer.concat([header, data]);
|
|
434
|
+
continue;
|
|
435
|
+
}
|
|
436
|
+
if (header.length + separator > GIT_OBJECT_HEADER_LIMIT) throw new Error("invalid blob header");
|
|
437
|
+
const headerBytes = header.length === 0 ? data.subarray(0, separator) : Buffer.concat([header, data.subarray(0, separator)]);
|
|
438
|
+
const match = /^blob ([0-9]+)$/u.exec(headerBytes.toString("ascii"));
|
|
439
|
+
if (!match) throw new Error("invalid blob");
|
|
440
|
+
expectedSize = boundedBlobSize(match[1], limit);
|
|
441
|
+
body = Buffer.alloc(expectedSize);
|
|
442
|
+
data = data.subarray(separator + 1);
|
|
443
|
+
}
|
|
444
|
+
if (!body || expectedSize === undefined || data.length > expectedSize - bodyBytes) throw new Error("invalid blob");
|
|
445
|
+
data.copy(body, bodyBytes);
|
|
446
|
+
bodyBytes += data.length;
|
|
447
|
+
}
|
|
448
|
+
if (expectedSize === undefined || !body || bodyBytes !== expectedSize) throw new Error("invalid blob");
|
|
449
|
+
return body;
|
|
435
450
|
} catch (error) {
|
|
436
|
-
if (error
|
|
451
|
+
if (missingFile(error)) return undefined;
|
|
437
452
|
throw error;
|
|
453
|
+
} finally {
|
|
454
|
+
source.destroy();
|
|
455
|
+
inflater.destroy();
|
|
438
456
|
}
|
|
439
457
|
}
|
|
440
458
|
|
|
441
459
|
function cacheBlob(repo: Repository, id: string, content: Buffer): void {
|
|
460
|
+
if (content.length > SNAPSHOT_CONTENT_LIMIT) return;
|
|
442
461
|
if (repo.blobCacheBytes + content.length > SNAPSHOT_CONTENT_LIMIT) {
|
|
443
462
|
repo.blobCache.clear();
|
|
444
463
|
repo.blobCacheBytes = 0;
|
|
445
464
|
}
|
|
446
|
-
if (content.length > SNAPSHOT_CONTENT_LIMIT) return;
|
|
447
465
|
repo.blobCache.set(id, content);
|
|
448
466
|
repo.blobCacheBytes += content.length;
|
|
449
467
|
}
|
|
@@ -451,34 +469,42 @@ function cacheBlob(repo: Repository, id: string, content: Buffer): void {
|
|
|
451
469
|
async function loadHeadBlobs(repo: Repository, ids: readonly string[]): Promise<Map<string, Buffer>> {
|
|
452
470
|
const blobs = new Map<string, Buffer>();
|
|
453
471
|
const missing: string[] = [];
|
|
472
|
+
let blobBytes = 0;
|
|
454
473
|
for (const id of new Set(ids)) {
|
|
455
474
|
const cached = repo.blobCache.get(id);
|
|
456
|
-
if (cached) {
|
|
475
|
+
if (cached !== undefined) {
|
|
476
|
+
if (cached.length > SNAPSHOT_CONTENT_LIMIT - blobBytes) throw new GitFailure("too-large");
|
|
477
|
+
blobBytes += cached.length;
|
|
457
478
|
blobs.set(id, cached);
|
|
458
479
|
continue;
|
|
459
480
|
}
|
|
460
|
-
const content = await looseBlob(repo.objectDirectory, id);
|
|
461
|
-
if (content) {
|
|
481
|
+
const content = await looseBlob(repo.objectDirectory, id, SNAPSHOT_CONTENT_LIMIT - blobBytes);
|
|
482
|
+
if (content !== undefined) {
|
|
483
|
+
blobBytes += content.length;
|
|
462
484
|
blobs.set(id, content);
|
|
463
485
|
cacheBlob(repo, id, content);
|
|
464
486
|
} else missing.push(id);
|
|
465
487
|
}
|
|
466
488
|
if (missing.length > 0) {
|
|
467
|
-
const
|
|
489
|
+
const batchOutputLimit = SNAPSHOT_CONTENT_LIMIT + missing.reduce((total, id) => total + id.length + 32, 0);
|
|
490
|
+
const output = await runGit(repo.root, ["cat-file", "--batch"], Buffer.from(`${missing.join("\n")}\n`), batchOutputLimit);
|
|
468
491
|
let offset = 0;
|
|
469
492
|
for (const id of missing) {
|
|
470
493
|
const headerEnd = output.indexOf(10, offset);
|
|
471
|
-
|
|
494
|
+
if (headerEnd < 0 || headerEnd - offset > GIT_OBJECT_HEADER_LIMIT) throw new Error("invalid batch blob");
|
|
495
|
+
const match = /^([0-9a-f]+) blob ([0-9]+)$/u.exec(output.subarray(offset, headerEnd).toString("ascii"));
|
|
472
496
|
if (!match || match[1] !== id) throw new Error("invalid batch blob");
|
|
473
|
-
const size =
|
|
497
|
+
const size = boundedBlobSize(match[2], SNAPSHOT_CONTENT_LIMIT - blobBytes);
|
|
474
498
|
const start = headerEnd + 1;
|
|
475
499
|
const end = start + size;
|
|
476
500
|
if (output[end] !== 10) throw new Error("invalid batch body");
|
|
477
501
|
const content = Buffer.from(output.subarray(start, end));
|
|
502
|
+
blobBytes += size;
|
|
478
503
|
blobs.set(id, content);
|
|
479
504
|
cacheBlob(repo, id, content);
|
|
480
505
|
offset = end + 1;
|
|
481
506
|
}
|
|
507
|
+
if (offset !== output.length) throw new Error("invalid batch blob");
|
|
482
508
|
}
|
|
483
509
|
return blobs;
|
|
484
510
|
}
|
|
@@ -657,14 +683,36 @@ async function compare(repo: Repository, baseline: Snapshot, settlement: Snapsho
|
|
|
657
683
|
return { state: "available", totalFiles: changes.length, additions, deletions, files: changes.slice(0, MAX_FILES), omittedFiles: Math.max(0, changes.length - MAX_FILES) };
|
|
658
684
|
}
|
|
659
685
|
|
|
686
|
+
function isFocusedTestFile(value: string): boolean {
|
|
687
|
+
if (value.startsWith("-") || value.startsWith("~") || path.posix.isAbsolute(value) || path.win32.isAbsolute(value)
|
|
688
|
+
|| /^[A-Za-z][A-Za-z\d+.-]*:/u.test(value) || /[\s\p{Cc}]/u.test(value)
|
|
689
|
+
|| FOCUSED_TEST_UNSAFE_CHARACTERS.some((character) => value.includes(character))) return false;
|
|
690
|
+
const segments = value.split(/[\\/]/u);
|
|
691
|
+
const filename = segments.at(-1);
|
|
692
|
+
return segments.every((segment) => segment.length > 0 && segment !== "." && segment !== "..")
|
|
693
|
+
&& filename !== undefined
|
|
694
|
+
&& filename.includes(".test.")
|
|
695
|
+
&& /\.(?:js|cjs|mjs|ts|cts|mts)$/u.test(filename);
|
|
696
|
+
}
|
|
697
|
+
|
|
660
698
|
export function recognizedCheck(command: unknown, failed: boolean): CheckAttempt | undefined {
|
|
661
699
|
if (typeof command !== "string") return undefined;
|
|
662
700
|
const normalized = command.replace(/^[\t ]+|[\t ]+$/gu, "");
|
|
701
|
+
const outcome = failed ? "failed" : "passed";
|
|
663
702
|
const label = CHECK_LABELS.find((candidate) => normalized === candidate);
|
|
664
|
-
|
|
703
|
+
if (label) return { label, outcome };
|
|
704
|
+
const focused = /^node --test(?: --experimental-strip-types)? ([^ \t]+)$/u.exec(normalized);
|
|
705
|
+
if (!focused?.[1] || !isFocusedTestFile(focused[1])) return undefined;
|
|
706
|
+
return { label: FOCUSED_CHECK_LABEL, outcome };
|
|
665
707
|
}
|
|
666
708
|
|
|
667
|
-
export async function beginChangeReceipt(cwd: string): Promise<ChangeReceiptCollection> {
|
|
709
|
+
export async function beginChangeReceipt(cwd: string, trusted = true): Promise<ChangeReceiptCollection> {
|
|
710
|
+
if (!trusted) {
|
|
711
|
+
return {
|
|
712
|
+
finish: async () => ({ state: "unavailable", reason: "error" }),
|
|
713
|
+
dispose: async () => undefined,
|
|
714
|
+
};
|
|
715
|
+
}
|
|
668
716
|
try {
|
|
669
717
|
const repo = await repository(cwd);
|
|
670
718
|
let monitor = repositoryMonitors.get(repo.root);
|
package/killeros/codex-fast.ts
CHANGED
|
@@ -10,10 +10,15 @@ function isRequestPayload(value: unknown): value is RequestPayload {
|
|
|
10
10
|
|
|
11
11
|
export function registerCodexFastMode(pi: ExtensionAPI): void {
|
|
12
12
|
pi.registerCommand("codex-fast", {
|
|
13
|
-
description: "Toggle Codex fast mode",
|
|
13
|
+
description: "Toggle or report Codex fast mode",
|
|
14
14
|
handler: async (args, ctx) => {
|
|
15
|
-
|
|
16
|
-
|
|
15
|
+
const trimmed = args.trim();
|
|
16
|
+
if (trimmed === "status") {
|
|
17
|
+
ctx.ui.notify(`Codex fast mode: ${isCodexFastEnabled() ? "enabled" : "disabled"}`, "info");
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
if (trimmed) {
|
|
21
|
+
ctx.ui.notify("Usage: /codex-fast [status]", "error");
|
|
17
22
|
return;
|
|
18
23
|
}
|
|
19
24
|
|
package/killeros/footer.ts
CHANGED
|
@@ -4,6 +4,7 @@ import { type ExtensionAPI, type ExtensionContext, type Theme, type ThemeColor }
|
|
|
4
4
|
import { truncateToWidth, visibleWidth, type TUI } from "@earendil-works/pi-tui";
|
|
5
5
|
import { isCodexFastEnabled, subscribeCodexFast } from "./codex-fast-state.ts";
|
|
6
6
|
import { formatCwd, formatTime, modelDisplayName, padRight } from "./display.ts";
|
|
7
|
+
import { PASSIVE_GIT_CONFIG_ARGS, passiveGitCommand, passiveGitEnv, passiveStatusSafetyArgs, samePassiveFilters } from "./passive-git-status.ts";
|
|
7
8
|
import { goalElapsedMilliseconds } from "./goal-state.ts";
|
|
8
9
|
import type { GoalRuntime, GoalState } from "./runtime.ts";
|
|
9
10
|
import { safeTerminalText } from "./safe-terminal-text.ts";
|
|
@@ -35,42 +36,90 @@ type GitStatusExecutor = (
|
|
|
35
36
|
callback: (error: Error | null, stdout: string) => void,
|
|
36
37
|
) => unknown;
|
|
37
38
|
|
|
38
|
-
/** Resolves changed-file counts with a bounded asynchronous Git status process. */
|
|
39
|
+
/** Resolves changed-file counts with a bounded asynchronous Git status process in a trusted project. */
|
|
39
40
|
export function resolveGitFileChanges(
|
|
40
41
|
cwd: string,
|
|
41
42
|
execute: GitStatusExecutor = execFile,
|
|
43
|
+
trusted = true,
|
|
42
44
|
): Promise<GitFileChanges | undefined> {
|
|
45
|
+
if (!trusted) return Promise.resolve(undefined);
|
|
43
46
|
return new Promise((resolve) => {
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
{
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
47
|
+
let gitCommand: string;
|
|
48
|
+
try {
|
|
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
|
+
}
|
|
59
|
+
const options = {
|
|
60
|
+
encoding: "utf8" as const,
|
|
61
|
+
env: passiveGitEnv(),
|
|
62
|
+
maxBuffer: 4 * 1024 * 1024,
|
|
63
|
+
timeout: GIT_STATUS_TIMEOUT_MS,
|
|
64
|
+
windowsHide: true as const,
|
|
65
|
+
};
|
|
66
|
+
const verifyFiltersUnchanged = (before: string, done: (unchanged: boolean) => void): void => {
|
|
67
|
+
try {
|
|
68
|
+
execute(gitCommand, ["-C", cwd, ...PASSIVE_GIT_CONFIG_ARGS], options, (error, after) => {
|
|
69
|
+
done(!error && samePassiveFilters(before, after));
|
|
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) => {
|
|
55
109
|
if (error) {
|
|
56
110
|
resolve(undefined);
|
|
57
111
|
return;
|
|
58
112
|
}
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
const entry = entries[index];
|
|
64
|
-
if (!entry) continue;
|
|
65
|
-
const status = entry.slice(0, 2);
|
|
66
|
-
if (status.includes("D")) changes.deleted += 1;
|
|
67
|
-
else if (status === "??" || status.includes("A")) changes.added += 1;
|
|
68
|
-
else changes.modified += 1;
|
|
69
|
-
if (status.includes("R") || status.includes("C")) index += 1;
|
|
113
|
+
const safetyArgs = passiveStatusSafetyArgs(config);
|
|
114
|
+
if (!safetyArgs) {
|
|
115
|
+
resolve(undefined);
|
|
116
|
+
return;
|
|
70
117
|
}
|
|
71
|
-
|
|
72
|
-
}
|
|
73
|
-
|
|
118
|
+
runStatus(config, ["-C", cwd, ...safetyArgs, "status", "--porcelain=v1", "-z", "--untracked-files=all"]);
|
|
119
|
+
});
|
|
120
|
+
} catch {
|
|
121
|
+
resolve(undefined);
|
|
122
|
+
}
|
|
74
123
|
});
|
|
75
124
|
}
|
|
76
125
|
|
|
@@ -389,7 +438,7 @@ export function registerFooter(pi: ExtensionAPI, goalRuntime: GoalRuntime): void
|
|
|
389
438
|
if (JSON.stringify(changes) === JSON.stringify(gitFileChanges)) return;
|
|
390
439
|
gitFileChanges = changes;
|
|
391
440
|
tui.requestRender();
|
|
392
|
-
});
|
|
441
|
+
}, (cwd) => resolveGitFileChanges(cwd, execFile, ctx.isProjectTrusted()));
|
|
393
442
|
const unsubscribe = footerData.onBranchChange(() => {
|
|
394
443
|
gitStatus.request();
|
|
395
444
|
tui.requestRender();
|