killeros 2.1.27 → 2.1.29
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 +32 -0
- package/README.md +8 -4
- package/killeros/activity.ts +16 -7
- package/killeros/auto-compaction.ts +58 -7
- package/killeros/change-receipt.ts +37 -130
- package/killeros/commands.ts +1 -0
- package/killeros/footer.ts +48 -77
- package/killeros/goal-interface.ts +195 -48
- package/killeros/goal-runtime.ts +191 -45
- package/killeros/goal-settlement.ts +129 -30
- package/killeros/goal-state.ts +205 -32
- package/killeros/passive-git-status.ts +212 -72
- package/killeros/runtime.ts +54 -0
- package/killeros/worked-for.ts +1 -1
- package/package.json +1 -1
- package/themes/killeros.json +3 -2
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,38 @@ All notable changes to KillerOS are documented here.
|
|
|
4
4
|
|
|
5
5
|
## [Unreleased]
|
|
6
6
|
|
|
7
|
+
## [2.1.29] - 2026-09-19
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- Added `/auto-compact [status|on|off|<percent>]` to inspect and update the global automatic compaction preference without restarting Pi.
|
|
12
|
+
|
|
13
|
+
### Fixed
|
|
14
|
+
|
|
15
|
+
- Kept normalized CRLF checkouts clean in passive Git scans while respecting text attributes and binary files without running filters.
|
|
16
|
+
- Detected same-size tracked-file rewrites even when tools restore the indexed modification time.
|
|
17
|
+
- Resolved change-receipt repository ownership at the start of every response so nested or replaced repositories cannot reuse a stale monitor.
|
|
18
|
+
- Kept the 100% automatic-compaction threshold active for later user requests and goal turns without compacting the generated ordinary continuation.
|
|
19
|
+
- Prevented automatic footer and change-receipt scans from starting clean or process filters, including filters added and removed while a scan is running.
|
|
20
|
+
|
|
21
|
+
## [2.1.28] - 2026-09-15
|
|
22
|
+
|
|
23
|
+
### Changed
|
|
24
|
+
|
|
25
|
+
- Simplified goal presentation without changing goal behavior: transcript history shows one iconless event row per lifecycle change (`Goal started`, `Goal replaced`, `Goal paused`, `Goal resumed`, `Goal cleared`), progress and blocker outcomes render once through the goal tool result, the footer shows compact `/goal active · 1/20 · 10s` status, the `/goal` panel leads with status then objective then `Pause goal` / `Resume goal` / `Clear goal` actions, and duplicate TUI success notifications are suppressed while RPC confirmations remain. Active-goal labels use muted teal `#6FAEB2` through the packaged theme's extension-label role.
|
|
26
|
+
- Removed the legacy `✻` marker from version-1 `Worked for …` transcript rows; stored entries are unchanged and remain readable.
|
|
27
|
+
|
|
28
|
+
### Added
|
|
29
|
+
|
|
30
|
+
- Appended the selected model ID as a gray `· <model-id>` suffix to every TUI activity working message, retained for the full request cycle and falling back to `unknown model` when no usable ID exists.
|
|
31
|
+
|
|
32
|
+
### Fixed
|
|
33
|
+
|
|
34
|
+
- Kept resumed blocked goals and goals paused by truncated provider errors readable after reload.
|
|
35
|
+
- Made goal continuation explicit: each settled turn now needs one accepted `continue`, `complete`, or blocker decision. Missing decisions, repeated continuation reports, unavailable goal tools, and runtime failures pause without scheduling another turn; compaction recovery resumes the same logical turn.
|
|
36
|
+
- Preserved accepted goal decisions across automatic compaction: a `continue` or blocker audit recorded before compaction now authorizes exactly one next turn instead of being discarded and re-decided. Duplicate compaction and settlement callbacks cannot start a second turn.
|
|
37
|
+
- Scoped goal pause reasons to the current paused state: resume and manual pause no longer surface an earlier automatic reason, terminal states keep no pause reason, and contradictory persisted states fail closed instead of starting automatic work.
|
|
38
|
+
|
|
7
39
|
## [2.1.27] - 2026-09-12
|
|
8
40
|
|
|
9
41
|
### Added
|
package/README.md
CHANGED
|
@@ -5,10 +5,10 @@ A TypeScript extension for the [Pi coding agent](https://github.com/earendil-wor
|
|
|
5
5
|
## What you get
|
|
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
|
-
- `/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.
|
|
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.29`. 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.27`. 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:
|
|
@@ -85,7 +89,7 @@ A direct quoted file target binds silent file proof:
|
|
|
85
89
|
/goal Fix `killeros/footer.ts`, verified by npm test
|
|
86
90
|
```
|
|
87
91
|
|
|
88
|
-
KillerOS captures the file baseline at goal start and only completes when the file is created or changed. New goals pause after 20 turns without warning. An explicit `/goal resume` on an exhausted goal grants another 20 turns; compaction recovery never grants turns. Restored goals keep their persisted limit.
|
|
92
|
+
KillerOS captures the file baseline at goal start and only completes when the file is created or changed. A normal response never continues a goal by itself: the agent must record `continue`, `complete`, or a blocker decision through `killeros_goal_update`. Repeated continuation reports and unavailable goal tools pause the goal. New goals pause after 20 turns without warning. An explicit `/goal resume` on an exhausted goal grants another 20 turns; compaction recovery never grants turns. Restored goals keep their persisted limit.
|
|
89
93
|
|
|
90
94
|
Completion sounds are off by default; change with `/notification` in TUI mode. The tab-title indicator requires a Nerd Font.
|
|
91
95
|
|
package/killeros/activity.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { ExtensionAPI, ExtensionContext, Theme } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import { truncateToWidth } from "@earendil-works/pi-tui";
|
|
3
|
+
import { modelDisplayName } from "./display.ts";
|
|
3
4
|
|
|
4
5
|
export type ActivityMessage =
|
|
5
6
|
| { kind: "prompt" }
|
|
@@ -12,7 +13,7 @@ function safeToolName(toolName: string): string {
|
|
|
12
13
|
return truncateToWidth(normalized || "tool", 32, "…");
|
|
13
14
|
}
|
|
14
15
|
|
|
15
|
-
export function formatActivityMessage(message: ActivityMessage, theme: Theme): string {
|
|
16
|
+
export function formatActivityMessage(message: ActivityMessage, theme: Theme, modelId?: string): string {
|
|
16
17
|
let verb: string;
|
|
17
18
|
let detail: string;
|
|
18
19
|
|
|
@@ -58,38 +59,46 @@ export function formatActivityMessage(message: ActivityMessage, theme: Theme): s
|
|
|
58
59
|
}
|
|
59
60
|
}
|
|
60
61
|
|
|
61
|
-
|
|
62
|
+
const suffix = modelDisplayName({ id: modelId }) || "unknown model";
|
|
63
|
+
return `${theme.fg("accent", verb)} ${theme.fg("dim", `(${theme.bold("esc")} to interrupt · ${detail})`)}${theme.fg("dim", ` · ${suffix}`)}`;
|
|
62
64
|
}
|
|
63
65
|
|
|
64
66
|
export function registerRequestActivity(pi: ExtensionAPI): void {
|
|
65
67
|
let active = false;
|
|
68
|
+
let activeModel: string | undefined;
|
|
69
|
+
|
|
70
|
+
const render = (message: ActivityMessage, ctx: ExtensionContext): void => {
|
|
71
|
+
ctx.ui.setWorkingMessage(formatActivityMessage(message, ctx.ui.theme, activeModel));
|
|
72
|
+
};
|
|
66
73
|
|
|
67
74
|
const clear = (ctx?: ExtensionContext): void => {
|
|
75
|
+
active = false;
|
|
76
|
+
activeModel = undefined;
|
|
68
77
|
if (ctx?.mode === "tui") {
|
|
69
78
|
ctx.ui.setWorkingMessage();
|
|
70
79
|
}
|
|
71
|
-
active = false;
|
|
72
80
|
};
|
|
73
81
|
|
|
74
82
|
pi.on("agent_start", (_event, ctx) => {
|
|
75
83
|
if (ctx.mode !== "tui") return;
|
|
84
|
+
if (!active) activeModel = ctx.model?.id;
|
|
76
85
|
active = true;
|
|
77
|
-
|
|
86
|
+
render({ kind: "prompt" }, ctx);
|
|
78
87
|
});
|
|
79
88
|
|
|
80
89
|
pi.on("tool_execution_start", (event, ctx) => {
|
|
81
90
|
if (ctx.mode !== "tui" || !active) return;
|
|
82
|
-
|
|
91
|
+
render({ kind: "tool", toolName: event.toolName }, ctx);
|
|
83
92
|
});
|
|
84
93
|
|
|
85
94
|
pi.on("tool_execution_end", (event, ctx) => {
|
|
86
95
|
if (ctx.mode !== "tui" || !active) return;
|
|
87
|
-
|
|
96
|
+
render({ kind: "tool-result", failed: event.isError }, ctx);
|
|
88
97
|
});
|
|
89
98
|
|
|
90
99
|
pi.on("message_update", (event, ctx) => {
|
|
91
100
|
if (ctx.mode !== "tui" || !active || event.assistantMessageEvent.type !== "text_start") return;
|
|
92
|
-
|
|
101
|
+
render({ kind: "responding" }, ctx);
|
|
93
102
|
});
|
|
94
103
|
|
|
95
104
|
pi.on("agent_settled", (_event, ctx) => {
|
|
@@ -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> {
|
|
@@ -360,7 +261,8 @@ function observes(filePath: string, observedPaths: readonly string[]): boolean {
|
|
|
360
261
|
async function refreshSnapshot(monitor: RepositoryMonitor, observedPaths: readonly string[], current = monitor.snapshot): Promise<Snapshot> {
|
|
361
262
|
if (monitor.watchFailed || observedPaths.length > 200) return snapshot(monitor.repo);
|
|
362
263
|
if (observedPaths.length === 0) return current;
|
|
363
|
-
const scoped = await snapshotKnownPaths(monitor.repo, current, observedPaths)
|
|
264
|
+
const scoped = await snapshotKnownPaths(monitor.repo, current, observedPaths);
|
|
265
|
+
if (!scoped) return snapshot(monitor.repo);
|
|
364
266
|
const files = new Map(current.files);
|
|
365
267
|
for (const filePath of files.keys()) {
|
|
366
268
|
if (observes(filePath, observedPaths)) files.delete(filePath);
|
|
@@ -714,11 +616,17 @@ export async function beginChangeReceipt(cwd: string, trusted = true): Promise<C
|
|
|
714
616
|
};
|
|
715
617
|
}
|
|
716
618
|
try {
|
|
717
|
-
|
|
619
|
+
let repo = await repository(cwd);
|
|
718
620
|
let monitor = repositoryMonitors.get(repo.root);
|
|
621
|
+
if (monitor && !sameRepository(monitor.repo, repo)) {
|
|
622
|
+
discardMonitor(monitor);
|
|
623
|
+
monitor = undefined;
|
|
624
|
+
}
|
|
719
625
|
if (!monitor) {
|
|
720
626
|
monitor = createMonitor(repo, await snapshot(repo));
|
|
721
627
|
repositoryMonitors.set(repo.root, monitor);
|
|
628
|
+
} else {
|
|
629
|
+
repo = monitor.repo;
|
|
722
630
|
}
|
|
723
631
|
if (monitor.inUse) throw new Error("change collection already active");
|
|
724
632
|
const idleChanges = [...monitor.changedPaths];
|
|
@@ -771,5 +679,4 @@ export async function beginChangeReceipt(cwd: string, trusted = true): Promise<C
|
|
|
771
679
|
export function disposeChangeReceipts(): void {
|
|
772
680
|
for (const monitor of repositoryMonitors.values()) discardMonitor(monitor);
|
|
773
681
|
repositoryMonitors.clear();
|
|
774
|
-
repositoryCache.clear();
|
|
775
682
|
}
|
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]",
|