killeros 1.1.0 → 1.3.0
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 +23 -6
- package/Killeros.ts +1544 -126
- package/README.md +25 -14
- package/package.json +1 -1
- package/themes/killeros.json +11 -11
package/Killeros.ts
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { execFileSync, spawn } from "node:child_process";
|
|
2
|
+
import { promises as fs, closeSync, existsSync, openSync, readFileSync, readSync } from "node:fs";
|
|
2
3
|
import os from "node:os";
|
|
3
|
-
import
|
|
4
|
+
import path from "node:path";
|
|
4
5
|
import {
|
|
6
|
+
CONFIG_DIR_NAME,
|
|
5
7
|
CustomEditor,
|
|
6
8
|
DynamicBorder,
|
|
7
9
|
VERSION,
|
|
@@ -17,6 +19,7 @@ import {
|
|
|
17
19
|
decodeKittyPrintable,
|
|
18
20
|
Editor,
|
|
19
21
|
Key,
|
|
22
|
+
Markdown,
|
|
20
23
|
matchesKey,
|
|
21
24
|
SelectList,
|
|
22
25
|
Text,
|
|
@@ -29,25 +32,53 @@ import {
|
|
|
29
32
|
} from "@earendil-works/pi-tui";
|
|
30
33
|
import { Type } from "typebox";
|
|
31
34
|
|
|
32
|
-
const
|
|
35
|
+
const COMMAND_BLUE_RGB = "120;169;255";
|
|
33
36
|
const FOOTER_REFRESH_INTERVAL_MS = 1_000;
|
|
34
|
-
const COMPACT_HEADER_MAX_WIDTH =
|
|
37
|
+
const COMPACT_HEADER_MAX_WIDTH = 52;
|
|
35
38
|
|
|
36
|
-
const
|
|
39
|
+
const commandBlue = (text: string): string => `\x1B[38;2;${COMMAND_BLUE_RGB}m${text}\x1B[39m`;
|
|
37
40
|
|
|
38
|
-
function
|
|
41
|
+
function readPackageVersion(path: string | URL): string | undefined {
|
|
39
42
|
try {
|
|
40
|
-
const value = JSON.parse(readFileSync(path, "utf8")) as {
|
|
41
|
-
return
|
|
42
|
-
name: typeof value.name === "string" ? value.name : undefined,
|
|
43
|
-
version: typeof value.version === "string" ? value.version : undefined,
|
|
44
|
-
};
|
|
43
|
+
const value = JSON.parse(readFileSync(path, "utf8")) as { version?: unknown };
|
|
44
|
+
return typeof value.version === "string" ? value.version : undefined;
|
|
45
45
|
} catch {
|
|
46
|
-
return
|
|
46
|
+
return undefined;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const KILLEROS_VERSION = readPackageVersion(new URL("./package.json", import.meta.url));
|
|
51
|
+
|
|
52
|
+
const STARTUP_TIPS = [
|
|
53
|
+
"Press Shift+Enter to insert a line break without sending.",
|
|
54
|
+
"Run /variants to tune the model's reasoning depth.",
|
|
55
|
+
"Type / to browse every command available in this session.",
|
|
56
|
+
] as const;
|
|
57
|
+
|
|
58
|
+
function resolveGitBranch(cwd: string): string | undefined {
|
|
59
|
+
try {
|
|
60
|
+
const branch = execFileSync("git", ["-C", cwd, "rev-parse", "--abbrev-ref", "HEAD"], {
|
|
61
|
+
encoding: "utf8",
|
|
62
|
+
maxBuffer: 64 * 1024,
|
|
63
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
64
|
+
timeout: 500,
|
|
65
|
+
windowsHide: true,
|
|
66
|
+
}).trim();
|
|
67
|
+
if (!branch) return undefined;
|
|
68
|
+
return branch === "HEAD" ? "detached" : branch;
|
|
69
|
+
} catch {
|
|
70
|
+
return undefined;
|
|
47
71
|
}
|
|
48
72
|
}
|
|
49
73
|
|
|
50
|
-
|
|
74
|
+
function shuffledTips(): string[] {
|
|
75
|
+
const tips = [...STARTUP_TIPS];
|
|
76
|
+
for (let index = tips.length - 1; index > 0; index -= 1) {
|
|
77
|
+
const swapIndex = Math.floor(Math.random() * (index + 1));
|
|
78
|
+
[tips[index], tips[swapIndex]] = [tips[swapIndex]!, tips[index]!];
|
|
79
|
+
}
|
|
80
|
+
return tips;
|
|
81
|
+
}
|
|
51
82
|
|
|
52
83
|
function formatCwd(cwd: string): string {
|
|
53
84
|
const home = process.env.HOME || process.env.USERPROFILE || os.homedir();
|
|
@@ -67,62 +98,6 @@ function padRight(text: string, width: number): string {
|
|
|
67
98
|
return clipped + " ".repeat(Math.max(0, width - visibleWidth(clipped)));
|
|
68
99
|
}
|
|
69
100
|
|
|
70
|
-
interface CapabilitySourceInfo {
|
|
71
|
-
path?: string;
|
|
72
|
-
source?: string;
|
|
73
|
-
baseDir?: string;
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
interface CompactCapability {
|
|
77
|
-
label: string;
|
|
78
|
-
version?: string;
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
function capabilityLabel(packageName: string): string {
|
|
82
|
-
const name = packageName.replace(/^npm:/, "").split("/").at(-1) ?? packageName;
|
|
83
|
-
if (name === "pi-mcp-adapter") return "MCP adapter";
|
|
84
|
-
if (name === "pi-web-access") return "Web access";
|
|
85
|
-
return name.replace(/^pi-/, "").replace(/[-_]+/g, " ").replace(/^\w/, (letter) => letter.toUpperCase());
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
function collectCompactCapabilities(pi: Pick<ExtensionAPI, "getCommands" | "getAllTools">): CompactCapability[] {
|
|
89
|
-
const sources: CapabilitySourceInfo[] = [];
|
|
90
|
-
try {
|
|
91
|
-
for (const command of pi.getCommands()) {
|
|
92
|
-
if (command.source === "extension" && command.sourceInfo.source !== "inline") sources.push(command.sourceInfo);
|
|
93
|
-
}
|
|
94
|
-
} catch {}
|
|
95
|
-
try {
|
|
96
|
-
for (const tool of pi.getAllTools()) {
|
|
97
|
-
if (tool.sourceInfo.source !== "builtin" && tool.sourceInfo.source !== "sdk") sources.push(tool.sourceInfo);
|
|
98
|
-
}
|
|
99
|
-
} catch {}
|
|
100
|
-
|
|
101
|
-
const capabilities = new Map<string, CompactCapability>();
|
|
102
|
-
for (const source of sources) {
|
|
103
|
-
const directories = [source.baseDir, source.path ? dirname(source.path) : undefined]
|
|
104
|
-
.filter((directory): directory is string => Boolean(directory));
|
|
105
|
-
let metadata: { name?: string; version?: string } = {};
|
|
106
|
-
for (const directory of new Set(directories)) {
|
|
107
|
-
metadata = readPackageMetadata(join(directory, "package.json"));
|
|
108
|
-
if (metadata.name) break;
|
|
109
|
-
}
|
|
110
|
-
const packageName = metadata.name ?? (source.source?.startsWith("npm:") ? source.source.slice(4) : undefined);
|
|
111
|
-
if (!packageName || packageName === "killeros") continue;
|
|
112
|
-
capabilities.set(packageName, { label: capabilityLabel(packageName), version: metadata.version });
|
|
113
|
-
}
|
|
114
|
-
return [...capabilities.values()].sort((left, right) => left.label.localeCompare(right.label));
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
function alignEdges(left: string, right: string, width: number): string {
|
|
118
|
-
if (width <= 0) return "";
|
|
119
|
-
const clippedRight = truncateToWidth(right, width, "");
|
|
120
|
-
const leftWidth = Math.max(0, width - visibleWidth(clippedRight) - 1);
|
|
121
|
-
const clippedLeft = truncateToWidth(left, leftWidth, "…");
|
|
122
|
-
const gap = " ".repeat(Math.max(1, width - visibleWidth(clippedLeft) - visibleWidth(clippedRight)));
|
|
123
|
-
return truncateToWidth(`${clippedLeft}${gap}${clippedRight}`, width, "");
|
|
124
|
-
}
|
|
125
|
-
|
|
126
101
|
function compactBoxLine(content: string, width: number, theme: Theme): string {
|
|
127
102
|
if (width < 4) return truncateToWidth(content, width, "");
|
|
128
103
|
return `${theme.fg("dim", "│")} ${padRight(content, width - 4)} ${theme.fg("dim", "│")}`;
|
|
@@ -131,50 +106,55 @@ function compactBoxLine(content: string, width: number, theme: Theme): string {
|
|
|
131
106
|
class PiStartupHeader {
|
|
132
107
|
private readonly pi: ExtensionAPI;
|
|
133
108
|
private readonly ctx: ExtensionContext;
|
|
134
|
-
private readonly
|
|
109
|
+
private readonly branch: string | undefined;
|
|
110
|
+
private readonly tip: string;
|
|
135
111
|
|
|
136
|
-
constructor(pi: ExtensionAPI, ctx: ExtensionContext) {
|
|
112
|
+
constructor(pi: ExtensionAPI, ctx: ExtensionContext, tip: string) {
|
|
137
113
|
this.pi = pi;
|
|
138
114
|
this.ctx = ctx;
|
|
139
|
-
this.
|
|
115
|
+
this.branch = resolveGitBranch(ctx.cwd);
|
|
116
|
+
this.tip = tip;
|
|
140
117
|
}
|
|
141
118
|
|
|
142
|
-
private
|
|
143
|
-
const
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
return theme.fg(percent < 20 ? "error" : percent <= 50 ? "warning" : "success", `${percent}% context`);
|
|
119
|
+
private tipLines(width: number, theme: Theme): string[] {
|
|
120
|
+
const indent = " ";
|
|
121
|
+
const text = `${theme.fg("text", theme.bold("Tip:"))}${theme.fg("dim", ` ${this.tip}`)}`;
|
|
122
|
+
return wrapTextWithAnsi(text, width - indent.length)
|
|
123
|
+
.map((line) => padRight(`${indent}${line}`, width));
|
|
148
124
|
}
|
|
149
125
|
|
|
150
126
|
render(width: number): string[] {
|
|
151
127
|
if (width <= 0) return [];
|
|
152
128
|
const theme = this.ctx.ui.theme;
|
|
153
|
-
if (width < 28) return [truncateToWidth(
|
|
129
|
+
if (width < 28) return [truncateToWidth(theme.fg("text", theme.bold("KillerOS")), width, "")];
|
|
154
130
|
|
|
155
131
|
const panelWidth = Math.min(width, COMPACT_HEADER_MAX_WIDTH);
|
|
156
132
|
const innerWidth = panelWidth - 4;
|
|
157
|
-
const version = KILLEROS_VERSION ? theme.fg("dim", ` ${KILLEROS_VERSION}`) : "";
|
|
158
|
-
const identity = `${
|
|
159
|
-
const
|
|
160
|
-
const
|
|
133
|
+
const version = KILLEROS_VERSION ? theme.fg("dim", ` (v${KILLEROS_VERSION})`) : "";
|
|
134
|
+
const identity = `${theme.fg("dim", "›")} ${theme.fg("text", theme.bold("KillerOS"))}${version}`;
|
|
135
|
+
const thinkingLevel = this.pi.getThinkingLevel() as ThinkingLevel;
|
|
136
|
+
const reasoning = this.ctx.model?.reasoning === false
|
|
137
|
+
? theme.fg("thinkingOff", "no reasoning")
|
|
138
|
+
: theme.fg(LEVEL_COLORS[thinkingLevel], thinkingLevel);
|
|
139
|
+
const agent = `${formatModel(this.ctx.model, theme)}${theme.fg("dim", " · ")}${reasoning}`;
|
|
161
140
|
const directory = formatCwd(this.ctx.cwd);
|
|
141
|
+
const repository = this.branch
|
|
142
|
+
? `${directory} ${theme.fg("dim", `· ${this.branch}`)}`
|
|
143
|
+
: directory;
|
|
144
|
+
const modelCommand = commandBlue("/model");
|
|
145
|
+
const agentWidth = Math.max(0, innerWidth - visibleWidth(modelCommand) - 1);
|
|
146
|
+
const agentCommand = `${truncateToWidth(agent, agentWidth, "…")} ${modelCommand}`;
|
|
162
147
|
const border = (left: string, right: string): string => theme.fg("dim", `${left}${"─".repeat(panelWidth - 2)}${right}`);
|
|
163
148
|
const lines = [
|
|
164
149
|
border("╭", "╮"),
|
|
165
|
-
compactBoxLine(
|
|
150
|
+
compactBoxLine(identity, panelWidth, theme),
|
|
166
151
|
compactBoxLine("", panelWidth, theme),
|
|
167
|
-
compactBoxLine(
|
|
168
|
-
compactBoxLine(
|
|
152
|
+
compactBoxLine(agentCommand, panelWidth, theme),
|
|
153
|
+
compactBoxLine(repository, panelWidth, theme),
|
|
154
|
+
border("╰", "╯"),
|
|
155
|
+
" ".repeat(panelWidth),
|
|
156
|
+
...this.tipLines(panelWidth, theme),
|
|
169
157
|
];
|
|
170
|
-
if (this.capabilities.length > 0) {
|
|
171
|
-
lines.push(compactBoxLine(theme.fg("dim", "─".repeat(innerWidth)), panelWidth, theme));
|
|
172
|
-
const capabilityText = this.capabilities
|
|
173
|
-
.map((capability) => `${capability.label}${capability.version ? ` ${capability.version}` : ""}`)
|
|
174
|
-
.join(" · ");
|
|
175
|
-
lines.push(compactBoxLine(theme.fg("dim", capabilityText), panelWidth, theme));
|
|
176
|
-
}
|
|
177
|
-
lines.push(border("╰", "╯"));
|
|
178
158
|
return lines;
|
|
179
159
|
}
|
|
180
160
|
|
|
@@ -273,14 +253,20 @@ const ACTIVITY_WORDS = ["Brewing", "Pondering", "Tinkering", "Wrangling", "Noodl
|
|
|
273
253
|
function registerShellUi(pi: ExtensionAPI): void {
|
|
274
254
|
let activeHeader: PiStartupHeader | undefined;
|
|
275
255
|
let activityWordIndex = 0;
|
|
256
|
+
let tipDeck: string[] = [];
|
|
257
|
+
const nextStartupTip = (): string => {
|
|
258
|
+
if (tipDeck.length === 0) tipDeck = shuffledTips();
|
|
259
|
+
return tipDeck.pop() ?? STARTUP_TIPS[0];
|
|
260
|
+
};
|
|
276
261
|
|
|
277
262
|
pi.on("session_start", (_event, ctx) => {
|
|
278
263
|
if (ctx.mode !== "tui") return;
|
|
279
264
|
try {
|
|
280
265
|
ctx.ui.setTheme("killeros");
|
|
266
|
+
const startupTip = nextStartupTip();
|
|
281
267
|
ctx.ui.setHeader(() => {
|
|
282
268
|
activeHeader?.dispose();
|
|
283
|
-
activeHeader = new PiStartupHeader(pi, ctx);
|
|
269
|
+
activeHeader = new PiStartupHeader(pi, ctx, startupTip);
|
|
284
270
|
return activeHeader;
|
|
285
271
|
});
|
|
286
272
|
ctx.ui.setWorkingIndicator({
|
|
@@ -338,9 +324,771 @@ function registerConcisePrompt(pi: ExtensionAPI): void {
|
|
|
338
324
|
}));
|
|
339
325
|
}
|
|
340
326
|
|
|
327
|
+
const INIT_READ_ONLY_TOOLS = new Set(["read", "ls", "find", "grep"]);
|
|
328
|
+
|
|
329
|
+
interface InitWorkflowState {
|
|
330
|
+
active: boolean;
|
|
331
|
+
targetPath?: string;
|
|
332
|
+
writeAttempted: boolean;
|
|
333
|
+
writeSucceeded: boolean;
|
|
334
|
+
writeToolCallId?: string;
|
|
335
|
+
settle?: (writeSucceeded: boolean) => void;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
function resetInitState(state: InitWorkflowState): void {
|
|
339
|
+
state.active = false;
|
|
340
|
+
state.targetPath = undefined;
|
|
341
|
+
state.writeAttempted = false;
|
|
342
|
+
state.writeSucceeded = false;
|
|
343
|
+
state.writeToolCallId = undefined;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
const GOAL_ENTRY_TYPE = "killeros-goal";
|
|
347
|
+
const GOAL_CONTINUATION_TYPE = "killeros-goal-continuation";
|
|
348
|
+
const GOAL_OBJECTIVE_LIMIT = 4_000;
|
|
349
|
+
const GOAL_VERSION = 1;
|
|
350
|
+
|
|
351
|
+
type GoalStatus = "active" | "paused" | "blocked" | "complete";
|
|
352
|
+
type GoalEntryEvent = "set" | "replace" | "edit" | "turn" | "pause" | "resume" | "blocked" | "complete" | "error" | "clear" | "checkpoint";
|
|
353
|
+
|
|
354
|
+
interface GoalState {
|
|
355
|
+
version: 1;
|
|
356
|
+
revision: number;
|
|
357
|
+
objective: string;
|
|
358
|
+
status: GoalStatus;
|
|
359
|
+
createdAt: number;
|
|
360
|
+
updatedAt: number;
|
|
361
|
+
activeMilliseconds: number;
|
|
362
|
+
activeStartedAt?: number;
|
|
363
|
+
turns: number;
|
|
364
|
+
blockedAuditStartTurn: number;
|
|
365
|
+
baselineTokens: number;
|
|
366
|
+
result?: string;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
interface GoalEntryData {
|
|
370
|
+
version: 1;
|
|
371
|
+
event: GoalEntryEvent;
|
|
372
|
+
state: GoalState | null;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
interface GoalRuntime {
|
|
376
|
+
state?: GoalState;
|
|
377
|
+
continuationScheduled: boolean;
|
|
378
|
+
continuationHeld: boolean;
|
|
379
|
+
goalTurnInFlight: boolean;
|
|
380
|
+
agentEndObserved: boolean;
|
|
381
|
+
persistenceRetryNeeded: boolean;
|
|
382
|
+
lastStopReason?: string;
|
|
383
|
+
lastError?: string;
|
|
384
|
+
requestRender?: () => void;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
const GoalUpdateParams = Type.Object({
|
|
388
|
+
status: Type.Union([Type.Literal("complete"), Type.Literal("blocked")], {
|
|
389
|
+
description: "Mark the active goal complete or blocked",
|
|
390
|
+
}),
|
|
391
|
+
evidence: Type.String({
|
|
392
|
+
minLength: 1,
|
|
393
|
+
maxLength: 2_000,
|
|
394
|
+
description: "Concise evidence that the objective is complete, or the repeated blocker and attempted workarounds",
|
|
395
|
+
}),
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
interface GoalUpdateDetails {
|
|
399
|
+
status: "complete" | "blocked";
|
|
400
|
+
evidence: string;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
function isGoalStatus(value: unknown): value is GoalStatus {
|
|
404
|
+
return value === "active" || value === "paused" || value === "blocked" || value === "complete";
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
function finiteNonNegative(value: unknown): value is number {
|
|
408
|
+
return typeof value === "number" && Number.isFinite(value) && value >= 0;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
function parseGoalState(value: unknown): GoalState | undefined {
|
|
412
|
+
if (!value || typeof value !== "object") return undefined;
|
|
413
|
+
const candidate = value as Partial<GoalState>;
|
|
414
|
+
if (candidate.version !== GOAL_VERSION
|
|
415
|
+
|| !Number.isInteger(candidate.revision) || (candidate.revision ?? 0) < 1
|
|
416
|
+
|| typeof candidate.objective !== "string" || !candidate.objective.trim()
|
|
417
|
+
|| [...candidate.objective].length > GOAL_OBJECTIVE_LIMIT
|
|
418
|
+
|| !isGoalStatus(candidate.status)
|
|
419
|
+
|| !finiteNonNegative(candidate.createdAt)
|
|
420
|
+
|| !finiteNonNegative(candidate.updatedAt)
|
|
421
|
+
|| !finiteNonNegative(candidate.activeMilliseconds)
|
|
422
|
+
|| !Number.isInteger(candidate.turns) || (candidate.turns ?? -1) < 0
|
|
423
|
+
|| candidate.blockedAuditStartTurn !== undefined
|
|
424
|
+
&& (!Number.isInteger(candidate.blockedAuditStartTurn) || candidate.blockedAuditStartTurn < 0 || candidate.blockedAuditStartTurn > candidate.turns!)
|
|
425
|
+
|| !finiteNonNegative(candidate.baselineTokens)
|
|
426
|
+
|| candidate.activeStartedAt !== undefined && !finiteNonNegative(candidate.activeStartedAt)
|
|
427
|
+
|| candidate.result !== undefined && typeof candidate.result !== "string") {
|
|
428
|
+
return undefined;
|
|
429
|
+
}
|
|
430
|
+
return {
|
|
431
|
+
version: GOAL_VERSION,
|
|
432
|
+
revision: candidate.revision!,
|
|
433
|
+
objective: candidate.objective.trim(),
|
|
434
|
+
status: candidate.status,
|
|
435
|
+
createdAt: candidate.createdAt,
|
|
436
|
+
updatedAt: candidate.updatedAt,
|
|
437
|
+
activeMilliseconds: candidate.activeMilliseconds,
|
|
438
|
+
activeStartedAt: candidate.activeStartedAt,
|
|
439
|
+
turns: candidate.turns!,
|
|
440
|
+
blockedAuditStartTurn: candidate.blockedAuditStartTurn ?? 0,
|
|
441
|
+
baselineTokens: candidate.baselineTokens,
|
|
442
|
+
result: candidate.result,
|
|
443
|
+
};
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
function goalBranchEntries(ctx: ExtensionContext): ReturnType<ExtensionContext["sessionManager"]["getEntries"]> {
|
|
447
|
+
try {
|
|
448
|
+
return ctx.sessionManager.getBranch();
|
|
449
|
+
} catch {
|
|
450
|
+
return [];
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
function restoreGoalState(ctx: ExtensionContext): GoalState | undefined {
|
|
455
|
+
const entries = goalBranchEntries(ctx);
|
|
456
|
+
for (let index = entries.length - 1; index >= 0; index -= 1) {
|
|
457
|
+
const entry = entries[index];
|
|
458
|
+
if (entry?.type !== "custom" || entry.customType !== GOAL_ENTRY_TYPE) continue;
|
|
459
|
+
const data = entry.data as Partial<GoalEntryData> | undefined;
|
|
460
|
+
if (!data || data.version !== GOAL_VERSION) return undefined;
|
|
461
|
+
if (data.state === null) return undefined;
|
|
462
|
+
const restored = parseGoalState(data.state);
|
|
463
|
+
if (!restored) return undefined;
|
|
464
|
+
return restored.status === "active"
|
|
465
|
+
? { ...restored, activeStartedAt: Date.now() }
|
|
466
|
+
: { ...restored, activeStartedAt: undefined };
|
|
467
|
+
}
|
|
468
|
+
return undefined;
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
function goalElapsedMilliseconds(state: GoalState, now = Date.now()): number {
|
|
472
|
+
const activeInterval = state.status === "active" && state.activeStartedAt !== undefined
|
|
473
|
+
? Math.max(0, now - state.activeStartedAt)
|
|
474
|
+
: 0;
|
|
475
|
+
return state.activeMilliseconds + activeInterval;
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
function stopGoalClock(state: GoalState, now: number): GoalState {
|
|
479
|
+
if (state.status !== "active" || state.activeStartedAt === undefined) return state;
|
|
480
|
+
return {
|
|
481
|
+
...state,
|
|
482
|
+
activeMilliseconds: state.activeMilliseconds + Math.max(0, now - state.activeStartedAt),
|
|
483
|
+
activeStartedAt: undefined,
|
|
484
|
+
};
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
function sumGoalTokens(ctx: ExtensionContext): number {
|
|
488
|
+
let total = 0;
|
|
489
|
+
for (const entry of goalBranchEntries(ctx)) {
|
|
490
|
+
if (entry.type === "message" && (entry.message.role === "assistant" || entry.message.role === "toolResult")) {
|
|
491
|
+
total += entry.message.usage?.totalTokens ?? 0;
|
|
492
|
+
} else if ((entry.type === "compaction" || entry.type === "branch_summary") && entry.usage) {
|
|
493
|
+
total += entry.usage.totalTokens;
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
return total;
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
function persistGoalState(
|
|
500
|
+
pi: ExtensionAPI,
|
|
501
|
+
runtime: GoalRuntime,
|
|
502
|
+
event: GoalEntryEvent,
|
|
503
|
+
state: GoalState | undefined,
|
|
504
|
+
): void {
|
|
505
|
+
const data: GoalEntryData = { version: GOAL_VERSION, event, state: state ?? null };
|
|
506
|
+
pi.appendEntry(GOAL_ENTRY_TYPE, data);
|
|
507
|
+
runtime.state = state;
|
|
508
|
+
runtime.persistenceRetryNeeded = false;
|
|
509
|
+
runtime.requestRender?.();
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
function transitionGoal(
|
|
513
|
+
pi: ExtensionAPI,
|
|
514
|
+
runtime: GoalRuntime,
|
|
515
|
+
event: GoalEntryEvent,
|
|
516
|
+
status: GoalStatus,
|
|
517
|
+
result?: string,
|
|
518
|
+
resetBlockedAudit = false,
|
|
519
|
+
): GoalState {
|
|
520
|
+
const current = runtime.state;
|
|
521
|
+
if (!current) throw new Error("No goal is set");
|
|
522
|
+
const now = Date.now();
|
|
523
|
+
const stopped = stopGoalClock(current, now);
|
|
524
|
+
const next: GoalState = {
|
|
525
|
+
...stopped,
|
|
526
|
+
revision: stopped.revision + 1,
|
|
527
|
+
status,
|
|
528
|
+
updatedAt: now,
|
|
529
|
+
activeStartedAt: status === "active" ? now : undefined,
|
|
530
|
+
blockedAuditStartTurn: resetBlockedAudit ? stopped.turns : stopped.blockedAuditStartTurn,
|
|
531
|
+
result,
|
|
532
|
+
};
|
|
533
|
+
persistGoalState(pi, runtime, event, next);
|
|
534
|
+
if (status !== "active") runtime.continuationScheduled = false;
|
|
535
|
+
return next;
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
function goalStatusLabel(status: GoalStatus): string {
|
|
539
|
+
return `${status.charAt(0).toLocaleUpperCase()}${status.slice(1)}`;
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
function goalStatusSummary(state: GoalState, ctx: ExtensionContext): string {
|
|
543
|
+
const usedTokens = Math.max(0, sumGoalTokens(ctx) - state.baselineTokens);
|
|
544
|
+
const lines = [
|
|
545
|
+
`Goal ${goalStatusLabel(state.status).toLocaleLowerCase()} · ${state.turns} turn${state.turns === 1 ? "" : "s"} · ${formatTime(goalElapsedMilliseconds(state))} · ${formatTokens(usedTokens)} tokens`,
|
|
546
|
+
state.objective,
|
|
547
|
+
];
|
|
548
|
+
if (state.result) lines.push(state.result);
|
|
549
|
+
return lines.join("\n");
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
function pauseGoalAfterFailure(
|
|
553
|
+
pi: ExtensionAPI,
|
|
554
|
+
runtime: GoalRuntime,
|
|
555
|
+
ctx: ExtensionContext,
|
|
556
|
+
reason: string,
|
|
557
|
+
recoveryInstruction = "Run /goal resume after resolving the problem.",
|
|
558
|
+
): void {
|
|
559
|
+
if (runtime.state?.status !== "active") return;
|
|
560
|
+
try {
|
|
561
|
+
transitionGoal(pi, runtime, "error", "paused", reason);
|
|
562
|
+
} catch {
|
|
563
|
+
runtime.state = runtime.state ? { ...stopGoalClock(runtime.state, Date.now()), status: "paused", result: reason } : undefined;
|
|
564
|
+
runtime.persistenceRetryNeeded = true;
|
|
565
|
+
runtime.continuationScheduled = false;
|
|
566
|
+
runtime.requestRender?.();
|
|
567
|
+
}
|
|
568
|
+
ctx.ui.notify(`Goal paused: ${reason}\n${recoveryInstruction}`, "error");
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
function scheduleGoalContinuation(
|
|
572
|
+
pi: ExtensionAPI,
|
|
573
|
+
runtime: GoalRuntime,
|
|
574
|
+
initState: InitWorkflowState,
|
|
575
|
+
ctx: ExtensionContext,
|
|
576
|
+
): void {
|
|
577
|
+
if (!isGoalModeSupported(ctx)
|
|
578
|
+
|| !isSavedSession(ctx)
|
|
579
|
+
|| runtime.state?.status !== "active"
|
|
580
|
+
|| runtime.continuationScheduled
|
|
581
|
+
|| runtime.continuationHeld
|
|
582
|
+
|| initState.active
|
|
583
|
+
|| ctx.hasPendingMessages()) return;
|
|
584
|
+
const current = runtime.state;
|
|
585
|
+
const now = Date.now();
|
|
586
|
+
const next: GoalState = {
|
|
587
|
+
...current,
|
|
588
|
+
revision: current.revision + 1,
|
|
589
|
+
turns: current.turns + 1,
|
|
590
|
+
updatedAt: now,
|
|
591
|
+
activeStartedAt: current.activeStartedAt ?? now,
|
|
592
|
+
};
|
|
593
|
+
try {
|
|
594
|
+
persistGoalState(pi, runtime, "turn", next);
|
|
595
|
+
} catch (error) {
|
|
596
|
+
pauseGoalAfterFailure(pi, runtime, ctx, `continuation state could not be saved: ${error instanceof Error ? error.message : String(error)}`);
|
|
597
|
+
return;
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
runtime.continuationScheduled = true;
|
|
601
|
+
runtime.goalTurnInFlight = true;
|
|
602
|
+
runtime.agentEndObserved = false;
|
|
603
|
+
runtime.lastStopReason = undefined;
|
|
604
|
+
runtime.lastError = undefined;
|
|
605
|
+
try {
|
|
606
|
+
pi.sendMessage({
|
|
607
|
+
customType: GOAL_CONTINUATION_TYPE,
|
|
608
|
+
content: goalContinuationMessage(next, ctx),
|
|
609
|
+
display: false,
|
|
610
|
+
}, { triggerTurn: true, deliverAs: "followUp" });
|
|
611
|
+
} catch (error) {
|
|
612
|
+
runtime.continuationScheduled = false;
|
|
613
|
+
runtime.goalTurnInFlight = false;
|
|
614
|
+
pauseGoalAfterFailure(pi, runtime, ctx, `continuation could not start: ${error instanceof Error ? error.message : String(error)}`);
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
function goalInstructions(state: GoalState, heading: string): string {
|
|
619
|
+
return [
|
|
620
|
+
`# ${heading}`,
|
|
621
|
+
`Status: active · Turn: ${state.turns}`,
|
|
622
|
+
"Objective:",
|
|
623
|
+
state.objective,
|
|
624
|
+
"",
|
|
625
|
+
"Continue making concrete progress toward this unchanged objective. Re-check repository state and prior results instead of repeating work.",
|
|
626
|
+
"Do not stop merely because one response is complete: KillerOS will start another goal turn while the goal remains active.",
|
|
627
|
+
"Before declaring completion, audit every part of the objective and verify the relevant results. Then call killeros_goal_update with status complete and concise evidence.",
|
|
628
|
+
"Call killeros_goal_update with status blocked only when the same external impasse has prevented progress for three consecutive goal turns; name the blocker and attempted workarounds.",
|
|
629
|
+
"Never use the goal tool to pause, resume, edit, replace, or clear the objective. Those transitions belong to the user.",
|
|
630
|
+
].join("\n");
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
function goalSystemPrompt(state: GoalState): string {
|
|
634
|
+
return goalInstructions(state, "Active KillerOS goal");
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
function goalContinuationMessage(state: GoalState, ctx: ExtensionContext): string {
|
|
638
|
+
const sections = [goalInstructions(state, "KillerOS long-running goal turn")];
|
|
639
|
+
if (ctx.isProjectTrusted()) {
|
|
640
|
+
const personal = resolvePersonalInstructions(ctx.cwd);
|
|
641
|
+
if (personal) {
|
|
642
|
+
sections.push(`<personal_instructions source=${JSON.stringify(personal.source)}>\n${personal.content}\n</personal_instructions>`);
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
sections.push(CONCISE_SYSTEM_PROMPT);
|
|
646
|
+
return sections.join("\n\n");
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
function isGoalModeSupported(ctx: ExtensionContext): boolean {
|
|
650
|
+
return ctx.mode === "tui" || ctx.mode === "rpc";
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
function isSavedSession(ctx: ExtensionContext): boolean {
|
|
654
|
+
try {
|
|
655
|
+
return Boolean(ctx.sessionManager.getSessionFile());
|
|
656
|
+
} catch {
|
|
657
|
+
return false;
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
function validateGoalObjective(input: string): string | undefined {
|
|
662
|
+
const objective = input.trim();
|
|
663
|
+
if (!objective) return undefined;
|
|
664
|
+
return [...objective].length <= GOAL_OBJECTIVE_LIMIT ? objective : undefined;
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
function registerGoal(
|
|
668
|
+
pi: ExtensionAPI,
|
|
669
|
+
runtime: GoalRuntime,
|
|
670
|
+
initState: InitWorkflowState,
|
|
671
|
+
): void {
|
|
672
|
+
pi.registerEntryRenderer<GoalEntryData>(GOAL_ENTRY_TYPE, (entry, _options, theme) => {
|
|
673
|
+
const data = entry.data;
|
|
674
|
+
if (!data || data.version !== GOAL_VERSION || data.event === "turn" || data.event === "checkpoint") return undefined;
|
|
675
|
+
if (data.event === "clear" || data.state === null) return new Text(theme.fg("dim", "Goal cleared"), 0, 0);
|
|
676
|
+
const state = parseGoalState(data.state);
|
|
677
|
+
if (!state) return undefined;
|
|
678
|
+
const icon = state.status === "active" ? "✻" : state.status === "paused" ? "Ⅱ" : state.status === "blocked" ? "!" : "✓";
|
|
679
|
+
const color: ThemeColor = state.status === "active" ? "accent" : state.status === "paused" ? "warning" : state.status === "blocked" ? "error" : "success";
|
|
680
|
+
return new Text(`${theme.fg(color, `${icon} Goal ${state.status}`)}${theme.fg("dim", ` · ${state.objective}`)}`, 0, 0);
|
|
681
|
+
});
|
|
682
|
+
|
|
683
|
+
pi.registerTool<typeof GoalUpdateParams, GoalUpdateDetails>({
|
|
684
|
+
name: "killeros_goal_update",
|
|
685
|
+
label: "Goal update",
|
|
686
|
+
description: "Mark the active KillerOS long-running goal complete after verification, or blocked after the same impasse persists for three consecutive goal turns.",
|
|
687
|
+
parameters: GoalUpdateParams,
|
|
688
|
+
executionMode: "sequential",
|
|
689
|
+
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
690
|
+
if (!isGoalModeSupported(ctx)) throw new Error("KillerOS goals require TUI or RPC mode");
|
|
691
|
+
if (!isSavedSession(ctx)) throw new Error("KillerOS goals require a saved session");
|
|
692
|
+
const state = runtime.state;
|
|
693
|
+
if (!state || state.status !== "active") throw new Error("There is no active KillerOS goal to update");
|
|
694
|
+
const evidence = params.evidence.trim();
|
|
695
|
+
if (!evidence) throw new Error("Goal evidence must not be empty");
|
|
696
|
+
if (params.status === "blocked" && state.turns - state.blockedAuditStartTurn < 3) {
|
|
697
|
+
throw new Error("A goal cannot be marked blocked before three goal turns in the current audit; keep working and audit the same blocker again");
|
|
698
|
+
}
|
|
699
|
+
transitionGoal(pi, runtime, params.status, params.status, evidence);
|
|
700
|
+
return {
|
|
701
|
+
content: [{ type: "text", text: `Goal marked ${params.status}: ${evidence}` }],
|
|
702
|
+
details: { status: params.status, evidence },
|
|
703
|
+
};
|
|
704
|
+
},
|
|
705
|
+
renderCall(args, theme) {
|
|
706
|
+
return new Text(`${theme.fg("toolTitle", theme.bold("goal "))}${theme.fg("muted", args.status)}`, 0, 0);
|
|
707
|
+
},
|
|
708
|
+
renderResult(result, _options, theme) {
|
|
709
|
+
const details = result.details;
|
|
710
|
+
return new Text(details
|
|
711
|
+
? `${theme.fg(details.status === "complete" ? "success" : "warning", details.status === "complete" ? "✓ Complete" : "! Blocked")}${theme.fg("dim", ` · ${details.evidence}`)}`
|
|
712
|
+
: theme.fg("dim", "Goal updated"), 0, 0);
|
|
713
|
+
},
|
|
714
|
+
});
|
|
715
|
+
|
|
716
|
+
pi.on("session_start", (_event, ctx) => {
|
|
717
|
+
runtime.state = restoreGoalState(ctx);
|
|
718
|
+
runtime.continuationScheduled = false;
|
|
719
|
+
runtime.continuationHeld = false;
|
|
720
|
+
runtime.goalTurnInFlight = false;
|
|
721
|
+
runtime.agentEndObserved = false;
|
|
722
|
+
runtime.persistenceRetryNeeded = false;
|
|
723
|
+
runtime.lastStopReason = undefined;
|
|
724
|
+
runtime.lastError = undefined;
|
|
725
|
+
runtime.requestRender?.();
|
|
726
|
+
if (runtime.state?.status === "active") {
|
|
727
|
+
setImmediate(() => scheduleGoalContinuation(pi, runtime, initState, ctx));
|
|
728
|
+
}
|
|
729
|
+
});
|
|
730
|
+
|
|
731
|
+
pi.on("session_tree", (_event, ctx) => {
|
|
732
|
+
runtime.state = restoreGoalState(ctx);
|
|
733
|
+
runtime.continuationScheduled = false;
|
|
734
|
+
runtime.continuationHeld = false;
|
|
735
|
+
runtime.goalTurnInFlight = false;
|
|
736
|
+
runtime.agentEndObserved = false;
|
|
737
|
+
runtime.persistenceRetryNeeded = false;
|
|
738
|
+
runtime.lastStopReason = undefined;
|
|
739
|
+
runtime.lastError = undefined;
|
|
740
|
+
runtime.requestRender?.();
|
|
741
|
+
if (runtime.state?.status === "active") {
|
|
742
|
+
setImmediate(() => scheduleGoalContinuation(pi, runtime, initState, ctx));
|
|
743
|
+
}
|
|
744
|
+
});
|
|
745
|
+
|
|
746
|
+
pi.on("session_shutdown", (_event, ctx) => {
|
|
747
|
+
if (runtime.state?.status === "active") {
|
|
748
|
+
const now = Date.now();
|
|
749
|
+
const checkpoint: GoalState = {
|
|
750
|
+
...stopGoalClock(runtime.state, now),
|
|
751
|
+
revision: runtime.state.revision + 1,
|
|
752
|
+
updatedAt: now,
|
|
753
|
+
};
|
|
754
|
+
try {
|
|
755
|
+
persistGoalState(pi, runtime, "checkpoint", checkpoint);
|
|
756
|
+
} catch (error) {
|
|
757
|
+
reportError(ctx, "Goal state could not be checkpointed", error);
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
runtime.state = undefined;
|
|
761
|
+
runtime.continuationScheduled = false;
|
|
762
|
+
runtime.continuationHeld = false;
|
|
763
|
+
runtime.goalTurnInFlight = false;
|
|
764
|
+
runtime.agentEndObserved = false;
|
|
765
|
+
runtime.persistenceRetryNeeded = false;
|
|
766
|
+
runtime.lastStopReason = undefined;
|
|
767
|
+
runtime.lastError = undefined;
|
|
768
|
+
});
|
|
769
|
+
|
|
770
|
+
pi.on("before_agent_start", (event, ctx) => {
|
|
771
|
+
runtime.continuationScheduled = false;
|
|
772
|
+
const current = runtime.state;
|
|
773
|
+
if (!isGoalModeSupported(ctx) || !isSavedSession(ctx) || !current || current.status !== "active" || initState.active) return;
|
|
774
|
+
const now = Date.now();
|
|
775
|
+
const next: GoalState = {
|
|
776
|
+
...current,
|
|
777
|
+
revision: current.revision + 1,
|
|
778
|
+
turns: current.turns + 1,
|
|
779
|
+
updatedAt: now,
|
|
780
|
+
activeStartedAt: current.activeStartedAt ?? now,
|
|
781
|
+
};
|
|
782
|
+
try {
|
|
783
|
+
persistGoalState(pi, runtime, "turn", next);
|
|
784
|
+
} catch (error) {
|
|
785
|
+
pauseGoalAfterFailure(pi, runtime, ctx, `turn state could not be saved: ${error instanceof Error ? error.message : String(error)}`);
|
|
786
|
+
return;
|
|
787
|
+
}
|
|
788
|
+
runtime.goalTurnInFlight = true;
|
|
789
|
+
runtime.agentEndObserved = false;
|
|
790
|
+
runtime.lastStopReason = undefined;
|
|
791
|
+
runtime.lastError = undefined;
|
|
792
|
+
return { systemPrompt: `${event.systemPrompt}\n\n${goalSystemPrompt(next)}` };
|
|
793
|
+
});
|
|
794
|
+
|
|
795
|
+
pi.on("agent_end", (event) => {
|
|
796
|
+
if (!runtime.goalTurnInFlight) return;
|
|
797
|
+
const finalAssistant = [...event.messages].reverse().find((message) => message.role === "assistant");
|
|
798
|
+
runtime.agentEndObserved = finalAssistant !== undefined;
|
|
799
|
+
runtime.lastStopReason = finalAssistant?.stopReason;
|
|
800
|
+
runtime.lastError = finalAssistant?.errorMessage;
|
|
801
|
+
});
|
|
802
|
+
|
|
803
|
+
pi.registerCommand("goal", {
|
|
804
|
+
description: "Set or view the goal for a long-running task",
|
|
805
|
+
getArgumentCompletions: (prefix) => {
|
|
806
|
+
const normalized = prefix.trimStart().toLocaleLowerCase();
|
|
807
|
+
if (normalized.includes(" ")) return null;
|
|
808
|
+
const actions = [
|
|
809
|
+
{ value: "clear", description: "Remove the current goal" },
|
|
810
|
+
{ value: "edit", description: "Edit and reactivate the current goal" },
|
|
811
|
+
{ value: "pause", description: "Stop automatic continuation" },
|
|
812
|
+
{ value: "resume", description: "Resume automatic continuation" },
|
|
813
|
+
];
|
|
814
|
+
return actions
|
|
815
|
+
.filter((action) => action.value.startsWith(normalized))
|
|
816
|
+
.map((action) => ({ ...action, label: action.value }));
|
|
817
|
+
},
|
|
818
|
+
handler: async (args, ctx) => {
|
|
819
|
+
if (ctx.mode === "print" || ctx.mode === "json") {
|
|
820
|
+
ctx.ui.notify("/goal requires TUI or RPC mode", "error");
|
|
821
|
+
return;
|
|
822
|
+
}
|
|
823
|
+
if (!isSavedSession(ctx)) {
|
|
824
|
+
ctx.ui.notify("/goal requires a saved session", "error");
|
|
825
|
+
return;
|
|
826
|
+
}
|
|
827
|
+
const input = args.trim();
|
|
828
|
+
const control = input.toLocaleLowerCase();
|
|
829
|
+
const isControl = control === "clear" || control === "edit" || control === "pause" || control === "resume";
|
|
830
|
+
|
|
831
|
+
if (!input) {
|
|
832
|
+
if (!runtime.state) {
|
|
833
|
+
ctx.ui.notify("No goal is set. Use /goal <objective> to start a long-running task.", "info");
|
|
834
|
+
return;
|
|
835
|
+
}
|
|
836
|
+
ctx.ui.notify(goalStatusSummary(runtime.state, ctx), "info");
|
|
837
|
+
return;
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
if (control === "clear") {
|
|
841
|
+
if (!runtime.state) {
|
|
842
|
+
ctx.ui.notify("No goal is set", "info");
|
|
843
|
+
return;
|
|
844
|
+
}
|
|
845
|
+
try {
|
|
846
|
+
persistGoalState(pi, runtime, "clear", undefined);
|
|
847
|
+
runtime.continuationScheduled = false;
|
|
848
|
+
ctx.ui.notify("Goal cleared", "info");
|
|
849
|
+
} catch (error) {
|
|
850
|
+
if (runtime.state?.status === "active") {
|
|
851
|
+
pauseGoalAfterFailure(
|
|
852
|
+
pi,
|
|
853
|
+
runtime,
|
|
854
|
+
ctx,
|
|
855
|
+
`the requested clear could not be saved: ${error instanceof Error ? error.message : String(error)}`,
|
|
856
|
+
"Automatic continuation is stopped. Retry /goal clear to remove the goal.",
|
|
857
|
+
);
|
|
858
|
+
} else {
|
|
859
|
+
reportError(ctx, "Goal could not be cleared", error);
|
|
860
|
+
}
|
|
861
|
+
}
|
|
862
|
+
return;
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
if (control === "pause") {
|
|
866
|
+
if (!runtime.state) {
|
|
867
|
+
ctx.ui.notify("No goal is set", "info");
|
|
868
|
+
return;
|
|
869
|
+
}
|
|
870
|
+
if (runtime.state.status === "paused") {
|
|
871
|
+
if (!runtime.persistenceRetryNeeded) {
|
|
872
|
+
ctx.ui.notify("Goal is already paused", "info");
|
|
873
|
+
return;
|
|
874
|
+
}
|
|
875
|
+
const now = Date.now();
|
|
876
|
+
const checkpoint: GoalState = {
|
|
877
|
+
...runtime.state,
|
|
878
|
+
revision: runtime.state.revision + 1,
|
|
879
|
+
updatedAt: now,
|
|
880
|
+
};
|
|
881
|
+
try {
|
|
882
|
+
persistGoalState(pi, runtime, "pause", checkpoint);
|
|
883
|
+
ctx.ui.notify("Goal pause saved", "info");
|
|
884
|
+
} catch (error) {
|
|
885
|
+
reportError(ctx, "Goal pause still could not be saved", error);
|
|
886
|
+
}
|
|
887
|
+
return;
|
|
888
|
+
}
|
|
889
|
+
if (runtime.state.status !== "active") {
|
|
890
|
+
ctx.ui.notify(`Goal is ${runtime.state.status}; only an active goal can be paused`, "warning");
|
|
891
|
+
return;
|
|
892
|
+
}
|
|
893
|
+
try {
|
|
894
|
+
transitionGoal(pi, runtime, "pause", "paused");
|
|
895
|
+
ctx.ui.notify("Goal paused. Run /goal resume to continue.", "info");
|
|
896
|
+
} catch (error) {
|
|
897
|
+
pauseGoalAfterFailure(
|
|
898
|
+
pi,
|
|
899
|
+
runtime,
|
|
900
|
+
ctx,
|
|
901
|
+
`the requested pause could not be saved: ${error instanceof Error ? error.message : String(error)}`,
|
|
902
|
+
"Automatic continuation is stopped. If session storage is still unavailable, retry /goal pause after it recovers.",
|
|
903
|
+
);
|
|
904
|
+
}
|
|
905
|
+
return;
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
if (control === "resume") {
|
|
909
|
+
if (initState.active) {
|
|
910
|
+
ctx.ui.notify("Wait for /init to finish before resuming a goal", "error");
|
|
911
|
+
return;
|
|
912
|
+
}
|
|
913
|
+
if (!runtime.state) {
|
|
914
|
+
ctx.ui.notify("No goal is set", "info");
|
|
915
|
+
return;
|
|
916
|
+
}
|
|
917
|
+
if (runtime.state.status === "active") {
|
|
918
|
+
ctx.ui.notify("Goal is already active", "info");
|
|
919
|
+
return;
|
|
920
|
+
}
|
|
921
|
+
if (runtime.state.status === "complete") {
|
|
922
|
+
ctx.ui.notify("The goal is complete. Set a new objective or use /goal edit.", "info");
|
|
923
|
+
return;
|
|
924
|
+
}
|
|
925
|
+
try {
|
|
926
|
+
transitionGoal(pi, runtime, "resume", "active", undefined, true);
|
|
927
|
+
runtime.continuationScheduled = false;
|
|
928
|
+
scheduleGoalContinuation(pi, runtime, initState, ctx);
|
|
929
|
+
ctx.ui.notify("Goal resumed", "info");
|
|
930
|
+
} catch (error) {
|
|
931
|
+
reportError(ctx, "Goal could not be resumed", error);
|
|
932
|
+
}
|
|
933
|
+
return;
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
if (control === "edit") {
|
|
937
|
+
if (initState.active) {
|
|
938
|
+
ctx.ui.notify("Wait for /init to finish before editing a goal", "error");
|
|
939
|
+
return;
|
|
940
|
+
}
|
|
941
|
+
if (!runtime.state) {
|
|
942
|
+
ctx.ui.notify("No goal is set", "info");
|
|
943
|
+
return;
|
|
944
|
+
}
|
|
945
|
+
if (ctx.mode !== "tui") {
|
|
946
|
+
ctx.ui.notify("/goal edit requires interactive TUI mode", "error");
|
|
947
|
+
return;
|
|
948
|
+
}
|
|
949
|
+
runtime.continuationHeld = true;
|
|
950
|
+
let waitError: unknown;
|
|
951
|
+
try {
|
|
952
|
+
await ctx.waitForIdle();
|
|
953
|
+
} catch (error) {
|
|
954
|
+
waitError = error;
|
|
955
|
+
} finally {
|
|
956
|
+
runtime.continuationHeld = false;
|
|
957
|
+
}
|
|
958
|
+
if (waitError) {
|
|
959
|
+
reportError(ctx, "Goal could not wait for the active turn", waitError);
|
|
960
|
+
scheduleGoalContinuation(pi, runtime, initState, ctx);
|
|
961
|
+
return;
|
|
962
|
+
}
|
|
963
|
+
const edited = await ctx.ui.editor("Edit long-running goal", runtime.state.objective);
|
|
964
|
+
if (edited === undefined) {
|
|
965
|
+
scheduleGoalContinuation(pi, runtime, initState, ctx);
|
|
966
|
+
return;
|
|
967
|
+
}
|
|
968
|
+
const objective = validateGoalObjective(edited);
|
|
969
|
+
if (!objective) {
|
|
970
|
+
ctx.ui.notify(edited.trim() ? "A goal objective may not exceed 4,000 characters" : "A goal objective may not be empty", "error");
|
|
971
|
+
scheduleGoalContinuation(pi, runtime, initState, ctx);
|
|
972
|
+
return;
|
|
973
|
+
}
|
|
974
|
+
const now = Date.now();
|
|
975
|
+
const current = stopGoalClock(runtime.state, now);
|
|
976
|
+
const next: GoalState = {
|
|
977
|
+
...current,
|
|
978
|
+
revision: current.revision + 1,
|
|
979
|
+
objective,
|
|
980
|
+
status: "active",
|
|
981
|
+
updatedAt: now,
|
|
982
|
+
activeStartedAt: now,
|
|
983
|
+
blockedAuditStartTurn: current.turns,
|
|
984
|
+
result: undefined,
|
|
985
|
+
};
|
|
986
|
+
try {
|
|
987
|
+
persistGoalState(pi, runtime, "edit", next);
|
|
988
|
+
runtime.continuationScheduled = false;
|
|
989
|
+
scheduleGoalContinuation(pi, runtime, initState, ctx);
|
|
990
|
+
ctx.ui.notify("Goal updated and active", "info");
|
|
991
|
+
} catch (error) {
|
|
992
|
+
reportError(ctx, "Goal could not be edited", error);
|
|
993
|
+
scheduleGoalContinuation(pi, runtime, initState, ctx);
|
|
994
|
+
}
|
|
995
|
+
return;
|
|
996
|
+
}
|
|
997
|
+
|
|
998
|
+
if (isControl) return;
|
|
999
|
+
if (initState.active) {
|
|
1000
|
+
ctx.ui.notify("Wait for /init to finish before starting a goal", "error");
|
|
1001
|
+
return;
|
|
1002
|
+
}
|
|
1003
|
+
const objective = validateGoalObjective(input);
|
|
1004
|
+
if (!objective) {
|
|
1005
|
+
ctx.ui.notify(input ? "A goal objective may not exceed 4,000 characters" : "A goal objective may not be empty", "error");
|
|
1006
|
+
return;
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
const unfinished = runtime.state && runtime.state.status !== "complete";
|
|
1010
|
+
if (unfinished) {
|
|
1011
|
+
if (!ctx.hasUI) {
|
|
1012
|
+
ctx.ui.notify("Clear the current goal before replacing it outside TUI mode", "error");
|
|
1013
|
+
return;
|
|
1014
|
+
}
|
|
1015
|
+
const replace = await ctx.ui.confirm("Replace active goal", "Replace the current unfinished goal and discard its continuation state?");
|
|
1016
|
+
if (!replace) return;
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
runtime.continuationHeld = true;
|
|
1020
|
+
let waitError: unknown;
|
|
1021
|
+
try {
|
|
1022
|
+
await ctx.waitForIdle();
|
|
1023
|
+
} catch (error) {
|
|
1024
|
+
waitError = error;
|
|
1025
|
+
} finally {
|
|
1026
|
+
runtime.continuationHeld = false;
|
|
1027
|
+
}
|
|
1028
|
+
if (waitError) {
|
|
1029
|
+
reportError(ctx, "Goal could not wait for the active turn", waitError);
|
|
1030
|
+
scheduleGoalContinuation(pi, runtime, initState, ctx);
|
|
1031
|
+
return;
|
|
1032
|
+
}
|
|
1033
|
+
const now = Date.now();
|
|
1034
|
+
const state: GoalState = {
|
|
1035
|
+
version: GOAL_VERSION,
|
|
1036
|
+
revision: 1,
|
|
1037
|
+
objective,
|
|
1038
|
+
status: "active",
|
|
1039
|
+
createdAt: now,
|
|
1040
|
+
updatedAt: now,
|
|
1041
|
+
activeMilliseconds: 0,
|
|
1042
|
+
activeStartedAt: now,
|
|
1043
|
+
turns: 0,
|
|
1044
|
+
blockedAuditStartTurn: 0,
|
|
1045
|
+
baselineTokens: sumGoalTokens(ctx),
|
|
1046
|
+
};
|
|
1047
|
+
try {
|
|
1048
|
+
persistGoalState(pi, runtime, unfinished ? "replace" : "set", state);
|
|
1049
|
+
scheduleGoalContinuation(pi, runtime, initState, ctx);
|
|
1050
|
+
ctx.ui.notify("Goal active. KillerOS will continue until completion, a repeated blocker, or pause.", "info");
|
|
1051
|
+
} catch (error) {
|
|
1052
|
+
reportError(ctx, "Goal could not be started", error);
|
|
1053
|
+
scheduleGoalContinuation(pi, runtime, initState, ctx);
|
|
1054
|
+
}
|
|
1055
|
+
},
|
|
1056
|
+
});
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1059
|
+
function registerGoalSettlement(
|
|
1060
|
+
pi: ExtensionAPI,
|
|
1061
|
+
runtime: GoalRuntime,
|
|
1062
|
+
initState: InitWorkflowState,
|
|
1063
|
+
): void {
|
|
1064
|
+
pi.on("agent_settled", (_event, ctx) => {
|
|
1065
|
+
const wasGoalTurn = runtime.goalTurnInFlight;
|
|
1066
|
+
const agentEndObserved = runtime.agentEndObserved;
|
|
1067
|
+
runtime.goalTurnInFlight = false;
|
|
1068
|
+
runtime.agentEndObserved = false;
|
|
1069
|
+
runtime.continuationScheduled = false;
|
|
1070
|
+
if (!wasGoalTurn || runtime.state?.status !== "active" || initState.active) return;
|
|
1071
|
+
if (!agentEndObserved) {
|
|
1072
|
+
pauseGoalAfterFailure(pi, runtime, ctx, "the goal turn ended without an agent result");
|
|
1073
|
+
return;
|
|
1074
|
+
}
|
|
1075
|
+
if (runtime.lastStopReason === "error" || runtime.lastStopReason === "aborted") {
|
|
1076
|
+
const reason = runtime.lastError || (runtime.lastStopReason === "aborted" ? "the agent turn was aborted" : "the agent turn failed");
|
|
1077
|
+
runtime.lastStopReason = undefined;
|
|
1078
|
+
runtime.lastError = undefined;
|
|
1079
|
+
pauseGoalAfterFailure(pi, runtime, ctx, reason);
|
|
1080
|
+
return;
|
|
1081
|
+
}
|
|
1082
|
+
runtime.lastStopReason = undefined;
|
|
1083
|
+
runtime.lastError = undefined;
|
|
1084
|
+
scheduleGoalContinuation(pi, runtime, initState, ctx);
|
|
1085
|
+
});
|
|
1086
|
+
}
|
|
1087
|
+
|
|
341
1088
|
const OptionSchema = Type.Object({
|
|
342
1089
|
label: Type.String({ minLength: 1, maxLength: 200, description: "Display label for the option" }),
|
|
343
1090
|
description: Type.Optional(Type.String({ maxLength: 500, description: "Optional detail shown for the selected option" })),
|
|
1091
|
+
preview: Type.Optional(Type.String({ maxLength: 8_000, description: "Optional markdown proposal preview shown for the selected option" })),
|
|
344
1092
|
});
|
|
345
1093
|
|
|
346
1094
|
const QuestionParams = Type.Object({
|
|
@@ -355,6 +1103,7 @@ const QuestionParams = Type.Object({
|
|
|
355
1103
|
interface DisplayOption {
|
|
356
1104
|
label: string;
|
|
357
1105
|
description?: string;
|
|
1106
|
+
preview?: string;
|
|
358
1107
|
originalIndex: number;
|
|
359
1108
|
isOther: boolean;
|
|
360
1109
|
}
|
|
@@ -434,6 +1183,7 @@ function registerQuestionTool(pi: ExtensionAPI): void {
|
|
|
434
1183
|
...params.options.map((option, index) => ({
|
|
435
1184
|
label: option.label,
|
|
436
1185
|
description: option.description,
|
|
1186
|
+
preview: option.preview,
|
|
437
1187
|
originalIndex: index + 1,
|
|
438
1188
|
isOther: false,
|
|
439
1189
|
})),
|
|
@@ -613,6 +1363,48 @@ function registerQuestionTool(pi: ExtensionAPI): void {
|
|
|
613
1363
|
}
|
|
614
1364
|
});
|
|
615
1365
|
|
|
1366
|
+
const selectedPreview = visibleOptions[optionIndex]?.preview;
|
|
1367
|
+
if (!editMode && selectedPreview) {
|
|
1368
|
+
const footerRows = 3;
|
|
1369
|
+
const previewChromeRows = 2;
|
|
1370
|
+
const availableRows = tui.terminal.rows - lines.length - footerRows;
|
|
1371
|
+
if (availableRows > previewChromeRows) {
|
|
1372
|
+
lines.push("");
|
|
1373
|
+
addWrappedWithPrefix(" ", theme.fg("accent", theme.bold("Proposal preview")));
|
|
1374
|
+
const markdownLines = new Markdown(
|
|
1375
|
+
selectedPreview,
|
|
1376
|
+
1,
|
|
1377
|
+
0,
|
|
1378
|
+
{
|
|
1379
|
+
heading: (text) => theme.fg("accent", theme.bold(text)),
|
|
1380
|
+
link: (text) => theme.fg("accent", text),
|
|
1381
|
+
linkUrl: (text) => theme.fg("dim", text),
|
|
1382
|
+
code: (text) => theme.fg("mdCode", text),
|
|
1383
|
+
codeBlock: (text) => theme.fg("mdCodeBlock", text),
|
|
1384
|
+
codeBlockBorder: (text) => theme.fg("mdCodeBlockBorder", text),
|
|
1385
|
+
quote: (text) => theme.fg("mdQuote", text),
|
|
1386
|
+
quoteBorder: (text) => theme.fg("mdQuoteBorder", text),
|
|
1387
|
+
hr: (text) => theme.fg("mdHr", text),
|
|
1388
|
+
listBullet: (text) => theme.fg("mdListBullet", text),
|
|
1389
|
+
bold: (text) => theme.bold(text),
|
|
1390
|
+
italic: (text) => theme.italic(text),
|
|
1391
|
+
strikethrough: (text) => theme.strikethrough(text),
|
|
1392
|
+
underline: (text) => theme.underline(text),
|
|
1393
|
+
},
|
|
1394
|
+
{ color: (text) => theme.fg("muted", text) },
|
|
1395
|
+
).render(renderWidth);
|
|
1396
|
+
const maxPreviewRows = Math.min(12, availableRows - previewChromeRows);
|
|
1397
|
+
if (markdownLines.length <= maxPreviewRows) {
|
|
1398
|
+
lines.push(...markdownLines);
|
|
1399
|
+
} else {
|
|
1400
|
+
const visiblePreviewRows = Math.max(0, maxPreviewRows - 1);
|
|
1401
|
+
lines.push(...markdownLines.slice(0, visiblePreviewRows));
|
|
1402
|
+
const hiddenRows = markdownLines.length - visiblePreviewRows;
|
|
1403
|
+
lines.push(theme.fg("dim", ` … ${hiddenRows} more line${hiddenRows === 1 ? "" : "s"}`));
|
|
1404
|
+
}
|
|
1405
|
+
}
|
|
1406
|
+
}
|
|
1407
|
+
|
|
616
1408
|
if (editMode) {
|
|
617
1409
|
lines.push("");
|
|
618
1410
|
addWrappedWithPrefix(" ", theme.fg("muted", "Your answer:"));
|
|
@@ -706,6 +1498,515 @@ function registerQuestionTool(pi: ExtensionAPI): void {
|
|
|
706
1498
|
});
|
|
707
1499
|
}
|
|
708
1500
|
|
|
1501
|
+
const PERSONAL_INSTRUCTIONS_FILE = "AGENTS.local.md";
|
|
1502
|
+
const PERSONAL_INSTRUCTIONS_LIMIT = 32 * 1024;
|
|
1503
|
+
|
|
1504
|
+
function readBoundedText(filePath: string, limit = PERSONAL_INSTRUCTIONS_LIMIT): string | undefined {
|
|
1505
|
+
let descriptor: number | undefined;
|
|
1506
|
+
try {
|
|
1507
|
+
descriptor = openSync(filePath, "r");
|
|
1508
|
+
const buffer = Buffer.alloc(limit + 1);
|
|
1509
|
+
const bytesRead = readSync(descriptor, buffer, 0, buffer.length, 0);
|
|
1510
|
+
const content = buffer.toString("utf8", 0, Math.min(bytesRead, limit));
|
|
1511
|
+
if (!content.trim()) return undefined;
|
|
1512
|
+
return bytesRead > limit
|
|
1513
|
+
? `${content}\n\n[Personal instructions truncated by KillerOS]`
|
|
1514
|
+
: content;
|
|
1515
|
+
} catch {
|
|
1516
|
+
return undefined;
|
|
1517
|
+
} finally {
|
|
1518
|
+
if (descriptor !== undefined) {
|
|
1519
|
+
try {
|
|
1520
|
+
closeSync(descriptor);
|
|
1521
|
+
} catch {
|
|
1522
|
+
// Ignore cleanup failures after a bounded best-effort read.
|
|
1523
|
+
}
|
|
1524
|
+
}
|
|
1525
|
+
}
|
|
1526
|
+
}
|
|
1527
|
+
|
|
1528
|
+
function resolvePersonalInstructions(cwd: string): { content: string; source: string } | undefined {
|
|
1529
|
+
const localPath = path.join(cwd, PERSONAL_INSTRUCTIONS_FILE);
|
|
1530
|
+
const local = readBoundedText(localPath);
|
|
1531
|
+
if (!local) return undefined;
|
|
1532
|
+
|
|
1533
|
+
const importMatch = local.trim().match(/^@(.+)$/u);
|
|
1534
|
+
if (!importMatch) return { content: local, source: localPath };
|
|
1535
|
+
|
|
1536
|
+
const requestedPath = importMatch[1]!.trim();
|
|
1537
|
+
const importedPath = requestedPath.startsWith("~/") || requestedPath.startsWith("~\\")
|
|
1538
|
+
? path.join(os.homedir(), requestedPath.slice(2))
|
|
1539
|
+
: path.resolve(cwd, requestedPath);
|
|
1540
|
+
const imported = readBoundedText(importedPath);
|
|
1541
|
+
return imported ? { content: imported, source: importedPath } : { content: local, source: localPath };
|
|
1542
|
+
}
|
|
1543
|
+
|
|
1544
|
+
function registerPersonalInstructions(pi: ExtensionAPI, initState: InitWorkflowState): void {
|
|
1545
|
+
pi.on("before_agent_start", (event, ctx) => {
|
|
1546
|
+
if (initState.active || !ctx.isProjectTrusted()) return;
|
|
1547
|
+
const personal = resolvePersonalInstructions(ctx.cwd);
|
|
1548
|
+
if (!personal) return;
|
|
1549
|
+
return {
|
|
1550
|
+
systemPrompt: [
|
|
1551
|
+
event.systemPrompt,
|
|
1552
|
+
"",
|
|
1553
|
+
`<personal_instructions source="${personal.source}">`,
|
|
1554
|
+
personal.content,
|
|
1555
|
+
"</personal_instructions>",
|
|
1556
|
+
].join("\n"),
|
|
1557
|
+
};
|
|
1558
|
+
});
|
|
1559
|
+
}
|
|
1560
|
+
|
|
1561
|
+
type KillerosHookEvent = "tool_call" | "tool_result" | "agent_settled";
|
|
1562
|
+
|
|
1563
|
+
interface KillerosHook {
|
|
1564
|
+
matcher?: string;
|
|
1565
|
+
command: string;
|
|
1566
|
+
timeoutMs?: number;
|
|
1567
|
+
}
|
|
1568
|
+
|
|
1569
|
+
interface KillerosHookConfig {
|
|
1570
|
+
hooks?: Partial<Record<KillerosHookEvent, KillerosHook[]>>;
|
|
1571
|
+
}
|
|
1572
|
+
|
|
1573
|
+
interface HookExecutionResult {
|
|
1574
|
+
code: number;
|
|
1575
|
+
stdout: string;
|
|
1576
|
+
stderr: string;
|
|
1577
|
+
timedOut: boolean;
|
|
1578
|
+
}
|
|
1579
|
+
|
|
1580
|
+
const HOOK_EVENTS: readonly KillerosHookEvent[] = ["tool_call", "tool_result", "agent_settled"];
|
|
1581
|
+
const HOOK_OUTPUT_LIMIT = 16 * 1024;
|
|
1582
|
+
|
|
1583
|
+
function loadKillerosHooks(ctx: ExtensionContext): KillerosHookConfig {
|
|
1584
|
+
const configPath = path.join(ctx.cwd, CONFIG_DIR_NAME, "killeros-hooks.json");
|
|
1585
|
+
if (!existsSync(configPath)) return {};
|
|
1586
|
+
if (!ctx.isProjectTrusted()) {
|
|
1587
|
+
ctx.ui.notify(`Ignored untrusted project hooks in ${configPath}`, "warning");
|
|
1588
|
+
return {};
|
|
1589
|
+
}
|
|
1590
|
+
|
|
1591
|
+
try {
|
|
1592
|
+
const parsed = JSON.parse(readFileSync(configPath, "utf8")) as KillerosHookConfig;
|
|
1593
|
+
const hooks: KillerosHookConfig["hooks"] = {};
|
|
1594
|
+
for (const event of HOOK_EVENTS) {
|
|
1595
|
+
const candidates = parsed.hooks?.[event];
|
|
1596
|
+
if (!Array.isArray(candidates)) continue;
|
|
1597
|
+
hooks[event] = candidates.filter((hook, index) => {
|
|
1598
|
+
const valid = hook
|
|
1599
|
+
&& typeof hook.command === "string"
|
|
1600
|
+
&& hook.command.trim().length > 0
|
|
1601
|
+
&& (hook.matcher === undefined || typeof hook.matcher === "string")
|
|
1602
|
+
&& (hook.timeoutMs === undefined || Number.isFinite(hook.timeoutMs));
|
|
1603
|
+
if (!valid) {
|
|
1604
|
+
ctx.ui.notify(`Ignored invalid ${event} hook ${index + 1} in ${configPath}`, "warning");
|
|
1605
|
+
return false;
|
|
1606
|
+
}
|
|
1607
|
+
if (hook.matcher && hook.matcher !== "*") {
|
|
1608
|
+
try {
|
|
1609
|
+
new RegExp(hook.matcher, "u");
|
|
1610
|
+
} catch {
|
|
1611
|
+
ctx.ui.notify(`Ignored ${event} hook ${index + 1}: invalid matcher ${JSON.stringify(hook.matcher)}`, "warning");
|
|
1612
|
+
return false;
|
|
1613
|
+
}
|
|
1614
|
+
}
|
|
1615
|
+
return true;
|
|
1616
|
+
});
|
|
1617
|
+
}
|
|
1618
|
+
return { hooks };
|
|
1619
|
+
} catch (error) {
|
|
1620
|
+
reportError(ctx, `Invalid ${CONFIG_DIR_NAME}/killeros-hooks.json`, error);
|
|
1621
|
+
return {};
|
|
1622
|
+
}
|
|
1623
|
+
}
|
|
1624
|
+
|
|
1625
|
+
function matchesHook(hook: KillerosHook, value: string): boolean {
|
|
1626
|
+
if (!hook.matcher || hook.matcher === "*") return true;
|
|
1627
|
+
try {
|
|
1628
|
+
return new RegExp(hook.matcher, "u").test(value);
|
|
1629
|
+
} catch {
|
|
1630
|
+
return false;
|
|
1631
|
+
}
|
|
1632
|
+
}
|
|
1633
|
+
|
|
1634
|
+
function appendBounded(current: string, chunk: Buffer | string): string {
|
|
1635
|
+
if (current.length >= HOOK_OUTPUT_LIMIT) return current;
|
|
1636
|
+
return (current + chunk.toString()).slice(0, HOOK_OUTPUT_LIMIT);
|
|
1637
|
+
}
|
|
1638
|
+
|
|
1639
|
+
function executeHook(command: string, cwd: string, environment: Record<string, string>, timeoutMs = 30_000): Promise<HookExecutionResult> {
|
|
1640
|
+
return new Promise((resolve) => {
|
|
1641
|
+
const child = spawn(command, {
|
|
1642
|
+
cwd,
|
|
1643
|
+
env: { ...process.env, ...environment },
|
|
1644
|
+
shell: true,
|
|
1645
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
1646
|
+
windowsHide: true,
|
|
1647
|
+
});
|
|
1648
|
+
let stdout = "";
|
|
1649
|
+
let stderr = "";
|
|
1650
|
+
let completed = false;
|
|
1651
|
+
let timedOut = false;
|
|
1652
|
+
let timer: NodeJS.Timeout | undefined;
|
|
1653
|
+
const finish = (code: number): void => {
|
|
1654
|
+
if (completed) return;
|
|
1655
|
+
completed = true;
|
|
1656
|
+
if (timer) clearTimeout(timer);
|
|
1657
|
+
resolve({ code, stdout, stderr, timedOut });
|
|
1658
|
+
};
|
|
1659
|
+
child.stdout.on("data", (chunk) => { stdout = appendBounded(stdout, chunk); });
|
|
1660
|
+
child.stderr.on("data", (chunk) => { stderr = appendBounded(stderr, chunk); });
|
|
1661
|
+
child.on("error", (error) => {
|
|
1662
|
+
stderr = appendBounded(stderr, error.message);
|
|
1663
|
+
finish(1);
|
|
1664
|
+
});
|
|
1665
|
+
child.on("close", (code) => finish(code ?? 1));
|
|
1666
|
+
timer = setTimeout(() => {
|
|
1667
|
+
timedOut = true;
|
|
1668
|
+
child.kill("SIGTERM");
|
|
1669
|
+
setTimeout(() => child.kill("SIGKILL"), 1_000).unref?.();
|
|
1670
|
+
finish(124);
|
|
1671
|
+
}, Math.max(1_000, Math.min(timeoutMs, 300_000)));
|
|
1672
|
+
timer.unref?.();
|
|
1673
|
+
});
|
|
1674
|
+
}
|
|
1675
|
+
|
|
1676
|
+
function hookEnvironment(event: KillerosHookEvent, toolName = "", payload: unknown = {}): Record<string, string> {
|
|
1677
|
+
return {
|
|
1678
|
+
KILLEROS_EVENT: event,
|
|
1679
|
+
KILLEROS_TOOL: toolName,
|
|
1680
|
+
KILLEROS_PAYLOAD: JSON.stringify(payload).slice(0, 8_000),
|
|
1681
|
+
};
|
|
1682
|
+
}
|
|
1683
|
+
|
|
1684
|
+
function hookFailureMessage(hook: KillerosHook, result: HookExecutionResult): string {
|
|
1685
|
+
const detail = result.stderr.trim() || result.stdout.trim() || `exit code ${result.code}`;
|
|
1686
|
+
return `Hook failed${result.timedOut ? " (timed out)" : ""}: ${hook.command}\n${detail}`;
|
|
1687
|
+
}
|
|
1688
|
+
|
|
1689
|
+
function registerLifecycleHooks(pi: ExtensionAPI): void {
|
|
1690
|
+
let config: KillerosHookConfig = {};
|
|
1691
|
+
pi.on("session_start", (_event, ctx) => { config = loadKillerosHooks(ctx); });
|
|
1692
|
+
|
|
1693
|
+
pi.on("tool_call", async (event, ctx) => {
|
|
1694
|
+
for (const hook of config.hooks?.tool_call ?? []) {
|
|
1695
|
+
if (!matchesHook(hook, event.toolName)) continue;
|
|
1696
|
+
const result = await executeHook(
|
|
1697
|
+
hook.command,
|
|
1698
|
+
ctx.cwd,
|
|
1699
|
+
hookEnvironment("tool_call", event.toolName, event.input),
|
|
1700
|
+
hook.timeoutMs,
|
|
1701
|
+
);
|
|
1702
|
+
if (result.code !== 0) {
|
|
1703
|
+
const reason = hookFailureMessage(hook, result);
|
|
1704
|
+
ctx.ui.notify(reason, "error");
|
|
1705
|
+
return { block: true, reason };
|
|
1706
|
+
}
|
|
1707
|
+
}
|
|
1708
|
+
});
|
|
1709
|
+
|
|
1710
|
+
pi.on("tool_result", async (event, ctx) => {
|
|
1711
|
+
for (const hook of config.hooks?.tool_result ?? []) {
|
|
1712
|
+
if (!matchesHook(hook, event.toolName)) continue;
|
|
1713
|
+
const result = await executeHook(
|
|
1714
|
+
hook.command,
|
|
1715
|
+
ctx.cwd,
|
|
1716
|
+
hookEnvironment("tool_result", event.toolName, {
|
|
1717
|
+
input: event.input,
|
|
1718
|
+
isError: event.isError,
|
|
1719
|
+
}),
|
|
1720
|
+
hook.timeoutMs,
|
|
1721
|
+
);
|
|
1722
|
+
if (result.code !== 0) ctx.ui.notify(hookFailureMessage(hook, result), "error");
|
|
1723
|
+
}
|
|
1724
|
+
});
|
|
1725
|
+
|
|
1726
|
+
pi.on("agent_settled", async (_event, ctx) => {
|
|
1727
|
+
for (const hook of config.hooks?.agent_settled ?? []) {
|
|
1728
|
+
const result = await executeHook(
|
|
1729
|
+
hook.command,
|
|
1730
|
+
ctx.cwd,
|
|
1731
|
+
hookEnvironment("agent_settled"),
|
|
1732
|
+
hook.timeoutMs,
|
|
1733
|
+
);
|
|
1734
|
+
if (result.code !== 0) ctx.ui.notify(hookFailureMessage(hook, result), "error");
|
|
1735
|
+
}
|
|
1736
|
+
});
|
|
1737
|
+
}
|
|
1738
|
+
|
|
1739
|
+
const INIT_SURVEY_OUTPUT_LIMIT = 40 * 1024;
|
|
1740
|
+
const INIT_SURVEY_FILE_LIMIT = 8 * 1024;
|
|
1741
|
+
const INIT_SURVEY_PATH_LIMIT = 400;
|
|
1742
|
+
const INIT_SURVEY_DIRECTORY_LIMIT = 120;
|
|
1743
|
+
const INIT_SURVEY_DEPTH_LIMIT = 4;
|
|
1744
|
+
const INIT_SURVEY_EXCLUDED_DIRS = new Set([
|
|
1745
|
+
".agents", ".claude", ".git", ".next", ".pi", ".pytest_cache", ".turbo", ".venv", "__pycache__", "archive", "build", "coverage", "data", "dist", "logs", "node_modules", "target", "test-results", "vendor",
|
|
1746
|
+
]);
|
|
1747
|
+
const INIT_SURVEY_EXCLUDED_FILES = new Set([
|
|
1748
|
+
".cursorrules", "AGENTS.md", "AGENTS.local.md", "CLAUDE.md", "CLAUDE.local.md", "GEMINI.md", "MEMORY.md", "SKILL.md", "copilot-instructions.md",
|
|
1749
|
+
]);
|
|
1750
|
+
const INIT_SURVEY_ROOT_FILES = [
|
|
1751
|
+
"README.md",
|
|
1752
|
+
"README.rst",
|
|
1753
|
+
"README.txt",
|
|
1754
|
+
"package.json",
|
|
1755
|
+
"pyproject.toml",
|
|
1756
|
+
"requirements.txt",
|
|
1757
|
+
"Cargo.toml",
|
|
1758
|
+
"go.mod",
|
|
1759
|
+
"Makefile",
|
|
1760
|
+
"Dockerfile",
|
|
1761
|
+
"compose.yaml",
|
|
1762
|
+
"compose.yml",
|
|
1763
|
+
"config.yaml",
|
|
1764
|
+
"config.yml",
|
|
1765
|
+
"tsconfig.json",
|
|
1766
|
+
"vite.config.ts",
|
|
1767
|
+
"vite.config.js",
|
|
1768
|
+
"eslint.config.js",
|
|
1769
|
+
"eslint.config.mjs",
|
|
1770
|
+
] as const;
|
|
1771
|
+
const INIT_SURVEY_NESTED_FILES = new Set([
|
|
1772
|
+
"package.json", "pyproject.toml", "requirements.txt", "Cargo.toml", "go.mod",
|
|
1773
|
+
]);
|
|
1774
|
+
|
|
1775
|
+
async function collectInitProjectFiles(cwd: string): Promise<string[]> {
|
|
1776
|
+
const files: string[] = [];
|
|
1777
|
+
const queue: Array<{ relativePath: string; depth: number }> = [{ relativePath: "", depth: 0 }];
|
|
1778
|
+
let directoriesRead = 0;
|
|
1779
|
+
while (queue.length && files.length < INIT_SURVEY_PATH_LIMIT && directoriesRead < INIT_SURVEY_DIRECTORY_LIMIT) {
|
|
1780
|
+
const current = queue.shift()!;
|
|
1781
|
+
directoriesRead += 1;
|
|
1782
|
+
let entries;
|
|
1783
|
+
try {
|
|
1784
|
+
entries = await fs.readdir(path.join(cwd, current.relativePath), { withFileTypes: true });
|
|
1785
|
+
} catch (error) {
|
|
1786
|
+
if (!current.relativePath) throw error;
|
|
1787
|
+
continue;
|
|
1788
|
+
}
|
|
1789
|
+
entries.sort((left, right) => left.name === right.name ? 0 : left.name < right.name ? -1 : 1);
|
|
1790
|
+
for (const entry of entries) {
|
|
1791
|
+
if (files.length >= INIT_SURVEY_PATH_LIMIT) break;
|
|
1792
|
+
const relativePath = path.join(current.relativePath, entry.name);
|
|
1793
|
+
if (entry.isDirectory()) {
|
|
1794
|
+
if (current.depth < INIT_SURVEY_DEPTH_LIMIT && !INIT_SURVEY_EXCLUDED_DIRS.has(entry.name)) {
|
|
1795
|
+
queue.push({ relativePath, depth: current.depth + 1 });
|
|
1796
|
+
}
|
|
1797
|
+
} else if (entry.isFile() && !INIT_SURVEY_EXCLUDED_FILES.has(entry.name)) {
|
|
1798
|
+
files.push(relativePath.replaceAll("\\", "/"));
|
|
1799
|
+
}
|
|
1800
|
+
}
|
|
1801
|
+
}
|
|
1802
|
+
return files;
|
|
1803
|
+
}
|
|
1804
|
+
|
|
1805
|
+
async function readFilePrefix(filePath: string, limit: number): Promise<string> {
|
|
1806
|
+
const handle = await fs.open(filePath, "r");
|
|
1807
|
+
try {
|
|
1808
|
+
const buffer = Buffer.alloc(limit);
|
|
1809
|
+
const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0);
|
|
1810
|
+
return buffer.toString("utf8", 0, bytesRead);
|
|
1811
|
+
} finally {
|
|
1812
|
+
await handle.close();
|
|
1813
|
+
}
|
|
1814
|
+
}
|
|
1815
|
+
|
|
1816
|
+
async function runInitSurvey(
|
|
1817
|
+
cwd: string,
|
|
1818
|
+
): Promise<{ output: string; error?: string }> {
|
|
1819
|
+
let projectFiles: string[];
|
|
1820
|
+
try {
|
|
1821
|
+
projectFiles = await collectInitProjectFiles(cwd);
|
|
1822
|
+
} catch (error) {
|
|
1823
|
+
return { output: "", error: error instanceof Error ? error.message : String(error) };
|
|
1824
|
+
}
|
|
1825
|
+
|
|
1826
|
+
const candidates = new Set<string>(INIT_SURVEY_ROOT_FILES);
|
|
1827
|
+
for (const relativePath of projectFiles) {
|
|
1828
|
+
const fileName = path.posix.basename(relativePath);
|
|
1829
|
+
if (INIT_SURVEY_NESTED_FILES.has(fileName) || /^\.github\/workflows\/[^/]+\.ya?ml$/iu.test(relativePath)) {
|
|
1830
|
+
candidates.add(relativePath);
|
|
1831
|
+
}
|
|
1832
|
+
}
|
|
1833
|
+
|
|
1834
|
+
const sections = [
|
|
1835
|
+
"# KillerOS repository snapshot",
|
|
1836
|
+
"Existing AGENTS.md, CLAUDE.md, and personal instruction files were intentionally not read.",
|
|
1837
|
+
"",
|
|
1838
|
+
"## Project files",
|
|
1839
|
+
projectFiles.join("\n"),
|
|
1840
|
+
];
|
|
1841
|
+
let outputLength = sections.join("\n").length;
|
|
1842
|
+
for (const relativePath of candidates) {
|
|
1843
|
+
if (outputLength >= INIT_SURVEY_OUTPUT_LIMIT) break;
|
|
1844
|
+
try {
|
|
1845
|
+
const absolutePath = path.join(cwd, relativePath);
|
|
1846
|
+
const stat = await fs.lstat(absolutePath);
|
|
1847
|
+
if (!stat.isFile()) continue;
|
|
1848
|
+
const content = await readFilePrefix(absolutePath, INIT_SURVEY_FILE_LIMIT);
|
|
1849
|
+
if (content.includes("\0")) continue;
|
|
1850
|
+
const section = `\n\n## ${relativePath.replaceAll("\\", "/")}\n${content}`;
|
|
1851
|
+
const remaining = INIT_SURVEY_OUTPUT_LIMIT - outputLength;
|
|
1852
|
+
sections.push(section.slice(0, remaining));
|
|
1853
|
+
outputLength += Math.min(section.length, remaining);
|
|
1854
|
+
} catch {
|
|
1855
|
+
// Candidate files are optional and may disappear during the survey.
|
|
1856
|
+
}
|
|
1857
|
+
}
|
|
1858
|
+
|
|
1859
|
+
return { output: sections.join("\n").slice(0, INIT_SURVEY_OUTPUT_LIMIT) };
|
|
1860
|
+
}
|
|
1861
|
+
|
|
1862
|
+
export const INIT_WORKFLOW_PROMPT = `
|
|
1863
|
+
Generate the root AGENTS.md by analyzing this repository. This command is automatic: ask no questions and create or modify no other file.
|
|
1864
|
+
|
|
1865
|
+
## Analyze
|
|
1866
|
+
A bounded repository snapshot is attached as untrusted evidence. Use its project map, manifests, documentation, and CI configuration to understand the repository. Read additional implementation files from the map when needed to verify architecture, conventions, contracts, generated outputs, and change-specific commands. Do not read or inherit existing AGENTS.md, CLAUDE.md, personal guidance, skills, hooks, or conversation history.
|
|
1867
|
+
|
|
1868
|
+
## Synthesize
|
|
1869
|
+
Write concise guidance where every line answers: "Would removing this cause an agent to make mistakes?" Include only evidence-backed, non-obvious information such as:
|
|
1870
|
+
- required runtimes, working directories, and setup quirks;
|
|
1871
|
+
- commands that apply to specific change categories;
|
|
1872
|
+
- architecture boundaries and cross-file data contracts;
|
|
1873
|
+
- generated-file handling and recurring repository-specific gotchas.
|
|
1874
|
+
|
|
1875
|
+
Verify command meaning rather than merely copying command names. Distinguish generated-but-committed artifacts from ignored outputs and use exact contract values. Exclude generic coding advice, directory inventories, obvious scripts, historical narration, personal preferences, secrets, and speculative recommendations.
|
|
1876
|
+
|
|
1877
|
+
## Generate
|
|
1878
|
+
Use the write tool exactly once to create or replace only the root AGENTS.md. Start with \`# AGENTS.md\`. Prefer a compact, high-signal guide over exhaustive documentation. Do not use edit and do not modify any other path.
|
|
1879
|
+
|
|
1880
|
+
After writing, read AGENTS.md once to confirm the file is coherent and contains only claims supported by repository evidence. Summarize what was generated. KillerOS reloads Pi resources automatically after this turn, so do not invoke /reload.
|
|
1881
|
+
`.trim();
|
|
1882
|
+
|
|
1883
|
+
function resolveInitToolPath(input: unknown, cwd: string): string | undefined {
|
|
1884
|
+
if (!input || typeof input !== "object") return undefined;
|
|
1885
|
+
const toolPath = (input as { path?: unknown }).path;
|
|
1886
|
+
return typeof toolPath === "string" ? path.resolve(cwd, toolPath) : undefined;
|
|
1887
|
+
}
|
|
1888
|
+
|
|
1889
|
+
async function initTargetSafetyError(targetPath: string): Promise<string | undefined> {
|
|
1890
|
+
try {
|
|
1891
|
+
const stat = await fs.lstat(targetPath);
|
|
1892
|
+
if (stat.isSymbolicLink() || !stat.isFile() || stat.nlink > 1) {
|
|
1893
|
+
return "/init requires root AGENTS.md to be absent or a regular, non-linked file";
|
|
1894
|
+
}
|
|
1895
|
+
} catch (error) {
|
|
1896
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
|
|
1897
|
+
return `/init could not inspect root AGENTS.md: ${error instanceof Error ? error.message : String(error)}`;
|
|
1898
|
+
}
|
|
1899
|
+
}
|
|
1900
|
+
return undefined;
|
|
1901
|
+
}
|
|
1902
|
+
|
|
1903
|
+
function registerInitCommand(pi: ExtensionAPI, initState: InitWorkflowState, goalRuntime: GoalRuntime): void {
|
|
1904
|
+
pi.on("tool_call", async (event) => {
|
|
1905
|
+
const targetPath = initState.targetPath;
|
|
1906
|
+
if (!initState.active || !targetPath || INIT_READ_ONLY_TOOLS.has(event.toolName)) return;
|
|
1907
|
+
const toolPath = resolveInitToolPath(event.input, path.dirname(targetPath));
|
|
1908
|
+
if (event.toolName === "write" && toolPath === targetPath && !initState.writeAttempted) {
|
|
1909
|
+
const safetyError = await initTargetSafetyError(targetPath);
|
|
1910
|
+
if (safetyError) return { block: true, reason: safetyError };
|
|
1911
|
+
initState.writeAttempted = true;
|
|
1912
|
+
initState.writeToolCallId = event.toolCallId;
|
|
1913
|
+
return;
|
|
1914
|
+
}
|
|
1915
|
+
return {
|
|
1916
|
+
block: true,
|
|
1917
|
+
reason: "/init may write the root AGENTS.md exactly once and may not modify any other file",
|
|
1918
|
+
};
|
|
1919
|
+
});
|
|
1920
|
+
|
|
1921
|
+
pi.on("tool_result", (event) => {
|
|
1922
|
+
if (!initState.active || event.toolName !== "write" || event.toolCallId !== initState.writeToolCallId) return;
|
|
1923
|
+
if (event.isError) {
|
|
1924
|
+
initState.writeAttempted = false;
|
|
1925
|
+
initState.writeToolCallId = undefined;
|
|
1926
|
+
return;
|
|
1927
|
+
}
|
|
1928
|
+
initState.writeSucceeded = true;
|
|
1929
|
+
});
|
|
1930
|
+
|
|
1931
|
+
pi.registerCommand("init", {
|
|
1932
|
+
description: "Generate root AGENTS.md from repository evidence",
|
|
1933
|
+
handler: async (args, ctx) => {
|
|
1934
|
+
if (args.trim()) {
|
|
1935
|
+
ctx.ui.notify("/init does not accept arguments", "error");
|
|
1936
|
+
return;
|
|
1937
|
+
}
|
|
1938
|
+
if (ctx.mode !== "tui") {
|
|
1939
|
+
ctx.ui.notify("/init requires interactive TUI mode", "error");
|
|
1940
|
+
return;
|
|
1941
|
+
}
|
|
1942
|
+
if (initState.active) {
|
|
1943
|
+
ctx.ui.notify("/init is already running", "warning");
|
|
1944
|
+
return;
|
|
1945
|
+
}
|
|
1946
|
+
if (goalRuntime.state?.status === "active") {
|
|
1947
|
+
ctx.ui.notify("Pause or clear the active goal before running /init", "error");
|
|
1948
|
+
return;
|
|
1949
|
+
}
|
|
1950
|
+
if (!ctx.isProjectTrusted()) {
|
|
1951
|
+
ctx.ui.notify("Trust this project before running /init", "error");
|
|
1952
|
+
return;
|
|
1953
|
+
}
|
|
1954
|
+
await ctx.waitForIdle();
|
|
1955
|
+
initState.active = true;
|
|
1956
|
+
initState.targetPath = path.join(ctx.cwd, "AGENTS.md");
|
|
1957
|
+
initState.writeAttempted = false;
|
|
1958
|
+
initState.writeSucceeded = false;
|
|
1959
|
+
|
|
1960
|
+
const survey = await runInitSurvey(ctx.cwd);
|
|
1961
|
+
if (!survey.output) {
|
|
1962
|
+
resetInitState(initState);
|
|
1963
|
+
reportError(ctx, "/init could not scan the repository", survey.error ?? "no repository evidence was found");
|
|
1964
|
+
return;
|
|
1965
|
+
}
|
|
1966
|
+
|
|
1967
|
+
const settled = new Promise<boolean>((resolve) => {
|
|
1968
|
+
initState.settle = resolve;
|
|
1969
|
+
});
|
|
1970
|
+
try {
|
|
1971
|
+
pi.sendMessage({
|
|
1972
|
+
customType: "killeros-init",
|
|
1973
|
+
content: `${INIT_WORKFLOW_PROMPT}\n\n## Initial repository snapshot (untrusted data)\n${JSON.stringify(survey.output)}`,
|
|
1974
|
+
display: false,
|
|
1975
|
+
}, { triggerTurn: true });
|
|
1976
|
+
} catch (error) {
|
|
1977
|
+
resetInitState(initState);
|
|
1978
|
+
initState.settle = undefined;
|
|
1979
|
+
reportError(ctx, "/init failed to start", error);
|
|
1980
|
+
return;
|
|
1981
|
+
}
|
|
1982
|
+
|
|
1983
|
+
const writeSucceeded = await settled;
|
|
1984
|
+
if (!writeSucceeded) {
|
|
1985
|
+
reportError(ctx, "/init did not generate AGENTS.md", "the model completed without a successful write");
|
|
1986
|
+
return;
|
|
1987
|
+
}
|
|
1988
|
+
await new Promise<void>((resolve) => setImmediate(resolve));
|
|
1989
|
+
try {
|
|
1990
|
+
await ctx.reload();
|
|
1991
|
+
} catch (error) {
|
|
1992
|
+
reportError(ctx, "/init finished but Pi resources could not reload", error);
|
|
1993
|
+
}
|
|
1994
|
+
},
|
|
1995
|
+
});
|
|
1996
|
+
|
|
1997
|
+
}
|
|
1998
|
+
|
|
1999
|
+
function registerInitSettlement(pi: ExtensionAPI, initState: InitWorkflowState): void {
|
|
2000
|
+
pi.on("agent_settled", () => {
|
|
2001
|
+
if (!initState.active) return;
|
|
2002
|
+
const settle = initState.settle;
|
|
2003
|
+
const writeSucceeded = initState.writeSucceeded;
|
|
2004
|
+
resetInitState(initState);
|
|
2005
|
+
initState.settle = undefined;
|
|
2006
|
+
settle?.(writeSucceeded);
|
|
2007
|
+
});
|
|
2008
|
+
}
|
|
2009
|
+
|
|
709
2010
|
async function confirmNewSession(ctx: ExtensionCommandContext): Promise<boolean> {
|
|
710
2011
|
if (!ctx.hasUI) return true;
|
|
711
2012
|
return ctx.ui.confirm("Start new session", "Start a new session and leave the current history?");
|
|
@@ -757,6 +2058,7 @@ const BUILTIN_COMMANDS: ReadonlyArray<{ name: string; description: string }> = [
|
|
|
757
2058
|
];
|
|
758
2059
|
|
|
759
2060
|
const COMMAND_SYNTAX_HINTS: Readonly<Record<string, string>> = {
|
|
2061
|
+
goal: "/goal [objective|clear|edit|pause|resume]",
|
|
760
2062
|
variants: "/variants [level]",
|
|
761
2063
|
model: "/model [provider/model]",
|
|
762
2064
|
"scoped-models": "/scoped-models",
|
|
@@ -1006,10 +2308,6 @@ export function formatCost(usd: number): string {
|
|
|
1006
2308
|
return `$${usd.toFixed(2)}`;
|
|
1007
2309
|
}
|
|
1008
2310
|
|
|
1009
|
-
export function resolveShortcutHint(): string {
|
|
1010
|
-
return process.env.PI_SHORTCUT_HINT?.trim() || "/variants";
|
|
1011
|
-
}
|
|
1012
|
-
|
|
1013
2311
|
function formatTime(milliseconds: number): string {
|
|
1014
2312
|
const totalSeconds = Math.max(0, Math.floor(milliseconds / 1_000));
|
|
1015
2313
|
if (totalSeconds < 60) return `${totalSeconds}s`;
|
|
@@ -1021,20 +2319,22 @@ function formatTime(milliseconds: number): string {
|
|
|
1021
2319
|
function formatTokens(value: number): string {
|
|
1022
2320
|
const amount = Math.max(0, value);
|
|
1023
2321
|
if (amount < 1_000) return `${Math.round(amount)}`;
|
|
1024
|
-
if (amount >= 1_000_000)
|
|
1025
|
-
|
|
2322
|
+
if (amount >= 1_000_000) {
|
|
2323
|
+
const precision = amount >= 10_000_000 ? 0 : 1;
|
|
2324
|
+
return `${Number((amount / 1_000_000).toFixed(precision))}M`;
|
|
2325
|
+
}
|
|
2326
|
+
const precision = amount >= 100_000 ? 0 : 1;
|
|
2327
|
+
return `${Number((amount / 1_000).toFixed(precision))}k`;
|
|
1026
2328
|
}
|
|
1027
2329
|
|
|
1028
2330
|
export function formatContextProgress(tokensUsed: number | null, contextWindow: number, theme: Theme): string {
|
|
1029
|
-
if (tokensUsed === null) return theme.fg("dim", "
|
|
2331
|
+
if (tokensUsed === null) return theme.fg("dim", "—% left (—)");
|
|
1030
2332
|
const windowSize = contextWindow > 0 ? contextWindow : 128_000;
|
|
1031
2333
|
const remaining = Math.max(0, Math.min(windowSize, windowSize - Math.max(0, tokensUsed)));
|
|
1032
2334
|
const percentLeft = Math.max(0, Math.min(100, Math.round((remaining / windowSize) * 100)));
|
|
1033
|
-
const filled = Math.round((percentLeft / 100) * 10);
|
|
1034
|
-
const bar = "█".repeat(filled) + "░".repeat(10 - filled);
|
|
1035
2335
|
const color: ThemeColor = percentLeft < 20 ? "error" : percentLeft <= 50 ? "warning" : "success";
|
|
1036
|
-
const
|
|
1037
|
-
return theme.fg(color,
|
|
2336
|
+
const action = percentLeft < 15 ? " · /compact" : "";
|
|
2337
|
+
return theme.fg(color, `${percentLeft}% left (${formatTokens(remaining)})${action}`);
|
|
1038
2338
|
}
|
|
1039
2339
|
|
|
1040
2340
|
function sumSessionCost(ctx: ExtensionContext): number {
|
|
@@ -1050,15 +2350,95 @@ function sumSessionCost(ctx: ExtensionContext): number {
|
|
|
1050
2350
|
return total;
|
|
1051
2351
|
}
|
|
1052
2352
|
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
2353
|
+
const PROVIDER_LABELS: Readonly<Record<string, string>> = {
|
|
2354
|
+
"amazon-bedrock": "Amazon Bedrock",
|
|
2355
|
+
"azure-openai-responses": "Azure OpenAI",
|
|
2356
|
+
"github-copilot": "GitHub Copilot",
|
|
2357
|
+
"google-vertex": "Google Vertex",
|
|
2358
|
+
"openai-codex": "OpenAI",
|
|
2359
|
+
anthropic: "Anthropic",
|
|
2360
|
+
deepseek: "DeepSeek",
|
|
2361
|
+
google: "Google",
|
|
2362
|
+
ollama: "Ollama",
|
|
2363
|
+
openai: "OpenAI",
|
|
2364
|
+
openrouter: "OpenRouter",
|
|
2365
|
+
};
|
|
2366
|
+
|
|
2367
|
+
const PROVIDER_WORDS: Readonly<Record<string, string>> = {
|
|
2368
|
+
ai: "AI",
|
|
2369
|
+
api: "API",
|
|
2370
|
+
deepseek: "DeepSeek",
|
|
2371
|
+
github: "GitHub",
|
|
2372
|
+
llm: "LLM",
|
|
2373
|
+
openai: "OpenAI",
|
|
2374
|
+
openrouter: "OpenRouter",
|
|
2375
|
+
};
|
|
2376
|
+
|
|
2377
|
+
function formatProviderName(provider: string): string {
|
|
2378
|
+
const normalized = provider.trim();
|
|
2379
|
+
const known = PROVIDER_LABELS[normalized.toLocaleLowerCase()];
|
|
2380
|
+
if (known) return known;
|
|
2381
|
+
return normalized
|
|
2382
|
+
.split(/[-_]+/u)
|
|
2383
|
+
.filter(Boolean)
|
|
2384
|
+
.map((word) => PROVIDER_WORDS[word.toLocaleLowerCase()] ?? `${word.charAt(0).toLocaleUpperCase()}${word.slice(1)}`)
|
|
2385
|
+
.join(" ") || "Unknown provider";
|
|
1056
2386
|
}
|
|
1057
2387
|
|
|
1058
|
-
function
|
|
2388
|
+
function modelDisplayName(model: NonNullable<ExtensionContext["model"]>): string {
|
|
2389
|
+
return model.name?.trim() || model.id;
|
|
2390
|
+
}
|
|
2391
|
+
|
|
2392
|
+
function formatModel(model: ExtensionContext["model"], theme: Theme, includeProvider = true): string {
|
|
2393
|
+
if (!model) return theme.fg("dim", "No model");
|
|
2394
|
+
const name = theme.fg("text", theme.bold(modelDisplayName(model)));
|
|
2395
|
+
return includeProvider ? `${name} ${theme.fg("dim", formatProviderName(model.provider))}` : name;
|
|
2396
|
+
}
|
|
2397
|
+
|
|
2398
|
+
function compactDirectory(cwd: string): string {
|
|
2399
|
+
if (cwd === "~" || cwd === "/" || /^[A-Za-z]:[\\/]?$/u.test(cwd)) return cwd;
|
|
2400
|
+
const normalized = cwd.replace(/\\/gu, "/").replace(/\/$/u, "");
|
|
2401
|
+
const finalSegment = normalized.split("/").at(-1);
|
|
2402
|
+
return finalSegment ? `…/${finalSegment}` : cwd;
|
|
2403
|
+
}
|
|
2404
|
+
|
|
2405
|
+
function joinFooterParts(parts: string[], theme: Theme): string {
|
|
2406
|
+
return parts.filter(Boolean).join(theme.fg("dim", " · "));
|
|
2407
|
+
}
|
|
2408
|
+
|
|
2409
|
+
function footerRowFits(left: string, right: string, width: number): boolean {
|
|
2410
|
+
const contentWidth = visibleWidth(left) + (right ? visibleWidth(right) + 1 : 0);
|
|
2411
|
+
return contentWidth + 2 <= width;
|
|
2412
|
+
}
|
|
2413
|
+
|
|
2414
|
+
function renderFooterRow(left: string, right: string, width: number): string {
|
|
2415
|
+
if (width <= 0) return "";
|
|
2416
|
+
if (width < 3) return " ".repeat(width);
|
|
2417
|
+
|
|
2418
|
+
const innerWidth = width - 2;
|
|
2419
|
+
if (!right) return ` ${padRight(left, innerWidth)} `;
|
|
2420
|
+
|
|
2421
|
+
const clippedRight = truncateToWidth(right, innerWidth, "");
|
|
2422
|
+
const rightWidth = visibleWidth(clippedRight);
|
|
2423
|
+
const leftBudget = Math.max(0, innerWidth - rightWidth - 1);
|
|
2424
|
+
const clippedLeft = truncateToWidth(left, leftBudget, "…");
|
|
2425
|
+
const gap = " ".repeat(Math.max(0, innerWidth - visibleWidth(clippedLeft) - rightWidth));
|
|
2426
|
+
return ` ${clippedLeft}${gap}${clippedRight} `;
|
|
2427
|
+
}
|
|
2428
|
+
|
|
2429
|
+
function formatGoalFooter(state: GoalState | undefined, theme: Theme): string {
|
|
2430
|
+
if (!state) return "";
|
|
2431
|
+
if (state.status === "active") return theme.fg("accent", `✻ goal · ${formatTime(goalElapsedMilliseconds(state))}`);
|
|
2432
|
+
if (state.status === "paused") return theme.fg("warning", "Ⅱ goal paused");
|
|
2433
|
+
if (state.status === "blocked") return theme.fg("error", "! goal blocked");
|
|
2434
|
+
return theme.fg("success", "✓ goal complete");
|
|
2435
|
+
}
|
|
2436
|
+
|
|
2437
|
+
function registerFooter(pi: ExtensionAPI, goalRuntime: GoalRuntime): void {
|
|
1059
2438
|
let currentModel: ExtensionContext["model"];
|
|
1060
2439
|
let thinkingLevel: ThinkingLevel = "off";
|
|
1061
2440
|
let activeTui: TUI | undefined;
|
|
2441
|
+
goalRuntime.requestRender = () => activeTui?.requestRender();
|
|
1062
2442
|
|
|
1063
2443
|
pi.on("session_start", (_event, ctx) => {
|
|
1064
2444
|
if (ctx.mode !== "tui") return;
|
|
@@ -1084,27 +2464,46 @@ function registerFooter(pi: ExtensionAPI): void {
|
|
|
1084
2464
|
const model = currentModel ?? ctx.model;
|
|
1085
2465
|
const level = model?.reasoning === false
|
|
1086
2466
|
? theme.fg("thinkingOff", "no reasoning")
|
|
1087
|
-
: theme.fg(LEVEL_COLORS[thinkingLevel],
|
|
2467
|
+
: theme.fg(LEVEL_COLORS[thinkingLevel], thinkingLevel);
|
|
1088
2468
|
const usage = ctx.getContextUsage();
|
|
1089
|
-
const
|
|
2469
|
+
const contextWindow = usage?.contextWindow ?? model?.contextWindow ?? 128_000;
|
|
2470
|
+
const context = formatContextProgress(usage?.tokens ?? null, contextWindow, theme);
|
|
1090
2471
|
const branch = footerData.getGitBranch();
|
|
1091
|
-
const
|
|
1092
|
-
|
|
2472
|
+
const signature = formatModel(model, theme);
|
|
2473
|
+
const fullDirectory = theme.fg("dim", cwd);
|
|
2474
|
+
const focusedDirectory = theme.fg("dim", compactDirectory(cwd));
|
|
2475
|
+
const goal = formatGoalFooter(goalRuntime.state, theme);
|
|
2476
|
+
const rich = joinFooterParts([
|
|
2477
|
+
signature,
|
|
1093
2478
|
level,
|
|
1094
2479
|
context,
|
|
2480
|
+
goal,
|
|
1095
2481
|
branch ? theme.fg("dim", branch) : "",
|
|
1096
2482
|
theme.fg("dim", formatTime(Date.now() - sessionStart)),
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
if (
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
2483
|
+
theme.fg("dim", formatCost(sumSessionCost(ctx))),
|
|
2484
|
+
], theme);
|
|
2485
|
+
const focused = joinFooterParts([signature, context, goal], theme);
|
|
2486
|
+
|
|
2487
|
+
if (footerRowFits(rich, fullDirectory, width)) {
|
|
2488
|
+
return [renderFooterRow(rich, fullDirectory, width)];
|
|
2489
|
+
}
|
|
2490
|
+
if (footerRowFits(rich, focusedDirectory, width)) {
|
|
2491
|
+
return [renderFooterRow(rich, focusedDirectory, width)];
|
|
2492
|
+
}
|
|
2493
|
+
if (footerRowFits(focused, focusedDirectory, width)) {
|
|
2494
|
+
return [renderFooterRow(focused, focusedDirectory, width)];
|
|
2495
|
+
}
|
|
2496
|
+
if (footerRowFits(focused, "", width)) {
|
|
2497
|
+
return [renderFooterRow(focused, "", width)];
|
|
2498
|
+
}
|
|
2499
|
+
if (goal) {
|
|
2500
|
+
const essentialGoal = joinFooterParts([context, goal], theme);
|
|
2501
|
+
if (footerRowFits(essentialGoal, "", width)) return [renderFooterRow(essentialGoal, "", width)];
|
|
2502
|
+
return [renderFooterRow(goal, context, width)];
|
|
2503
|
+
}
|
|
2504
|
+
|
|
2505
|
+
const essentialModel = formatModel(model, theme, false);
|
|
2506
|
+
return [renderFooterRow(essentialModel, context, width)];
|
|
1108
2507
|
},
|
|
1109
2508
|
};
|
|
1110
2509
|
});
|
|
@@ -1120,15 +2519,34 @@ function registerFooter(pi: ExtensionAPI): void {
|
|
|
1120
2519
|
});
|
|
1121
2520
|
pi.on("session_shutdown", () => {
|
|
1122
2521
|
activeTui = undefined;
|
|
2522
|
+
goalRuntime.requestRender = undefined;
|
|
1123
2523
|
});
|
|
1124
2524
|
}
|
|
1125
2525
|
|
|
1126
2526
|
export default function Killeros(pi: ExtensionAPI): void {
|
|
2527
|
+
const initState: InitWorkflowState = {
|
|
2528
|
+
active: false,
|
|
2529
|
+
writeAttempted: false,
|
|
2530
|
+
writeSucceeded: false,
|
|
2531
|
+
};
|
|
2532
|
+
const goalRuntime: GoalRuntime = {
|
|
2533
|
+
continuationScheduled: false,
|
|
2534
|
+
continuationHeld: false,
|
|
2535
|
+
goalTurnInFlight: false,
|
|
2536
|
+
agentEndObserved: false,
|
|
2537
|
+
persistenceRetryNeeded: false,
|
|
2538
|
+
};
|
|
1127
2539
|
registerShellUi(pi);
|
|
1128
2540
|
registerConcisePrompt(pi);
|
|
2541
|
+
registerGoal(pi, goalRuntime, initState);
|
|
2542
|
+
registerPersonalInstructions(pi, initState);
|
|
1129
2543
|
registerQuestionTool(pi);
|
|
1130
2544
|
registerAliases(pi);
|
|
1131
2545
|
registerSlashAutocomplete(pi);
|
|
1132
|
-
registerFooter(pi);
|
|
2546
|
+
registerFooter(pi, goalRuntime);
|
|
1133
2547
|
registerVariants(pi);
|
|
2548
|
+
registerInitCommand(pi, initState, goalRuntime);
|
|
2549
|
+
registerLifecycleHooks(pi);
|
|
2550
|
+
registerGoalSettlement(pi, goalRuntime, initState);
|
|
2551
|
+
registerInitSettlement(pi, initState);
|
|
1134
2552
|
}
|