killeros 2.0.16 → 2.0.18
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 +54 -0
- package/Killeros.ts +1 -1
- package/README.md +4 -4
- package/killeros/auto-compaction.ts +2 -4
- package/killeros/codex-fast-state.ts +14 -3
- package/killeros/commands.ts +21 -6
- package/killeros/display.ts +7 -4
- package/killeros/errors.ts +7 -2
- package/killeros/footer.ts +4 -2
- package/killeros/goals.ts +32 -30
- package/killeros/handoff.ts +4 -14
- package/killeros/hooks.ts +67 -12
- package/killeros/init-target.ts +1 -0
- package/killeros/init.ts +8 -8
- package/killeros/notifications.ts +3 -5
- package/killeros/personal-instructions.ts +11 -12
- package/killeros/shell-ui.ts +13 -0
- package/killeros/variants.ts +3 -2
- package/killeros/worked-for.ts +46 -13
- package/package.json +7 -7
- package/killeros/limits.ts +0 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,60 @@ All notable changes to KillerOS are documented here.
|
|
|
4
4
|
|
|
5
5
|
## [Unreleased]
|
|
6
6
|
|
|
7
|
+
## [2.0.18] - 2026-08-24
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- Added a real Pi SDK lifecycle test for package activation, shutdown-before-reload, fresh registration, and stale-context rejection.
|
|
12
|
+
|
|
13
|
+
### Changed
|
|
14
|
+
|
|
15
|
+
- Raised the locked Pi development packages and minimum supported Pi peer version to 0.84.3.
|
|
16
|
+
- Made Pi lifecycle compatibility coverage install the generated npm tarball offline before activation and reload.
|
|
17
|
+
|
|
18
|
+
### Fixed
|
|
19
|
+
|
|
20
|
+
- Preserved provider-managed OAuth routing during `/handoff`, fixing GitHub Copilot `421 Misdirected Request` failures.
|
|
21
|
+
- Repaired incompatible process-global `/codex-fast` state during extension updates while preserving a valid saved enabled flag.
|
|
22
|
+
- Stopped lifecycle-hook failures from echoing configured shell commands, which could expose inline credentials in Pi diagnostics.
|
|
23
|
+
- Kept lifecycle-hook configuration paths terminal-safe and single-line in warnings.
|
|
24
|
+
- Limited trusted lifecycle-hook configuration to a 64 KiB regular, non-linked file in the project's real `.pi` directory and verified the opened file identity before parsing it.
|
|
25
|
+
- Sanitized dynamic slash-command descriptions and omitted unsafe command names from KillerOS autocomplete.
|
|
26
|
+
- Rejected explicit file goals when their starting filesystem baseline cannot be inspected instead of treating every error as a missing file.
|
|
27
|
+
- Kept custom model and provider labels single-line and terminal-safe in the shell header and footer.
|
|
28
|
+
- Sanitized model-reported `/init` policy conflicts before returning, storing, or notifying with them.
|
|
29
|
+
- Kept session-storage failure details terminal-safe when an explicit goal pause falls back to in-memory state.
|
|
30
|
+
- Stripped terminal commands and unsafe controls from saved goal objectives before clear confirmations.
|
|
31
|
+
- Kept rejected `/variants` arguments terminal-safe and single-line in error notifications.
|
|
32
|
+
- Kept provider and model identifiers terminal-safe and single-line throughout `/variants`.
|
|
33
|
+
- Flushed partial UTF-8 lifecycle-hook output on process close instead of silently dropping final diagnostic bytes.
|
|
34
|
+
- Contained synchronous lifecycle-hook process-start failures inside normal hook failure handling.
|
|
35
|
+
- Kept header and footer paths single-line and terminal-safe for unusual cwd or home-directory names.
|
|
36
|
+
- Sanitized manual-compaction abort diagnostics before saving recovery-eligible goal state.
|
|
37
|
+
- Stripped terminal commands and unsafe controls from saved goal text before showing status panels or notifications.
|
|
38
|
+
- Sanitized provider and storage diagnostics before persisting or announcing an automatically paused goal.
|
|
39
|
+
- Routed handoff generation and session-replacement failures through the shared terminal-safe error reporter.
|
|
40
|
+
- Stripped terminal commands and unsafe control bytes from caught errors before showing KillerOS failure notifications.
|
|
41
|
+
- Stripped terminal commands and control bytes from cwd and session names before emitting the KillerOS terminal title.
|
|
42
|
+
- Removed filesystem source paths from personal-instruction prompt wrappers so unusual project paths cannot alter prompt structure.
|
|
43
|
+
- Stripped terminal commands and unsafe control bytes from lifecycle hook failure notifications and tool block reasons.
|
|
44
|
+
|
|
45
|
+
## [2.0.17] - 2026-08-23
|
|
46
|
+
|
|
47
|
+
### Added
|
|
48
|
+
|
|
49
|
+
- Added per-task token usage to settled TUI receipts for ordinary requests and individual goal turns.
|
|
50
|
+
|
|
51
|
+
### Changed
|
|
52
|
+
|
|
53
|
+
- Limited push CI to `main` and `dev`; feature branches run through pull request CI without duplicate push runs.
|
|
54
|
+
- Removed obsolete Pi tool API casts, shared caught-error formatting, and expanded the non-repeating startup tip and editor suggestion banks.
|
|
55
|
+
- Clarified that the README's pinned Git tag is an example rather than the current package version.
|
|
56
|
+
|
|
57
|
+
### Fixed
|
|
58
|
+
|
|
59
|
+
- Limited retired-feature repository checks to tracked and non-ignored files so ignored private notes cannot fail the suite.
|
|
60
|
+
|
|
7
61
|
## [2.0.16] - 2026-08-23
|
|
8
62
|
|
|
9
63
|
### Fixed
|
package/Killeros.ts
CHANGED
|
@@ -48,10 +48,10 @@ export default function Killeros(pi: ExtensionAPI, options: KillerosOptions = {}
|
|
|
48
48
|
registerCodexFastMode(pi);
|
|
49
49
|
registerInitCommand(pi, initRuntime, goalRuntime);
|
|
50
50
|
registerLifecycleHooks(pi);
|
|
51
|
+
registerWorkedFor(pi);
|
|
51
52
|
const goalCompaction = registerGoalSettlement(pi, goalRuntime, initRuntime);
|
|
52
53
|
registerAutoCompaction(pi, { goal: goalCompaction });
|
|
53
54
|
registerInitSettlement(pi, initRuntime);
|
|
54
55
|
registerRequestActivity(pi);
|
|
55
56
|
registerCompletionNotifications(pi, options.completionNotifications);
|
|
56
|
-
registerWorkedFor(pi);
|
|
57
57
|
}
|
package/README.md
CHANGED
|
@@ -4,7 +4,7 @@ A TypeScript extension for the [Pi coding agent](https://github.com/earendil-wor
|
|
|
4
4
|
|
|
5
5
|
## What you get
|
|
6
6
|
|
|
7
|
-
- A custom TUI: startup card with version, model, provider, 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.
|
|
7
|
+
- A custom TUI: startup card with version, model, provider, working directory, and Git branch; a dark theme with coral accents; a multiline editor with slash-command completion; a footer that tracks model, context, and goal state; settled task receipts with duration and token usage.
|
|
8
8
|
- `/goal`: set an objective and Pi keeps working toward it across turns, compaction, reloads, and branch navigation. Pause, resume, edit, or clear it anytime.
|
|
9
9
|
- `/init`: generates a root `AGENTS.md` from repository evidence, preserving compatible existing rules.
|
|
10
10
|
- `/variants`: pick a reasoning level supported by the active model.
|
|
@@ -19,7 +19,7 @@ A TypeScript extension for the [Pi coding agent](https://github.com/earendil-wor
|
|
|
19
19
|
## Requirements
|
|
20
20
|
|
|
21
21
|
- Node.js 22.19.0+
|
|
22
|
-
- Pi 0.84.
|
|
22
|
+
- Pi 0.84.3+
|
|
23
23
|
- An interactive TUI session for the custom header, editor, footer, `question`, and `/init`
|
|
24
24
|
|
|
25
25
|
## Install
|
|
@@ -34,7 +34,7 @@ Or from GitHub:
|
|
|
34
34
|
pi install git:github.com/KyrosHendrix/pi-KillerOS
|
|
35
35
|
```
|
|
36
36
|
|
|
37
|
-
Pin a
|
|
37
|
+
Pin a release by appending its tag, for example `@v2.0.18`. Add `-l` to install only for the current project. Restart Pi after installing.
|
|
38
38
|
|
|
39
39
|
## Commands
|
|
40
40
|
|
|
@@ -85,7 +85,7 @@ Releases go through CI on `main`; do not push version tags manually.
|
|
|
85
85
|
|
|
86
86
|
## Security
|
|
87
87
|
|
|
88
|
-
Pi extensions run with your user permissions. Review the source before installing globally. Hook commands run only for projects Pi marks as trusted; check `.pi/killeros-hooks.json` before enabling project trust.
|
|
88
|
+
Pi extensions run with your user permissions. Review the source before installing globally. Hook commands run only for projects Pi marks as trusted; check `.pi/killeros-hooks.json` before enabling project trust. KillerOS accepts that configuration only as a regular, non-linked file no larger than 64 KiB in the project's real `.pi` directory.
|
|
89
89
|
|
|
90
90
|
## License
|
|
91
91
|
|
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
type ExtensionAPI,
|
|
7
7
|
type ExtensionContext,
|
|
8
8
|
} from "@earendil-works/pi-coding-agent";
|
|
9
|
+
import { errorMessage } from "./errors.ts";
|
|
9
10
|
import { createKillerosSettingsStore } from "./settings.ts";
|
|
10
11
|
|
|
11
12
|
export const DEFAULT_AUTO_COMPACTION_PERCENT_REMAINING = 15;
|
|
@@ -71,6 +72,7 @@ function reserveTokens(settings: Pick<CompactionSettings, "reserveTokens">): num
|
|
|
71
72
|
: 0;
|
|
72
73
|
}
|
|
73
74
|
|
|
75
|
+
/** Triggers at the stricter of the user's percentage and Pi's token reserve. */
|
|
74
76
|
export function shouldTriggerAutoCompaction(
|
|
75
77
|
usage: Pick<ContextUsage, "tokens" | "contextWindow"> | undefined,
|
|
76
78
|
preference: AutoCompactionPreference,
|
|
@@ -90,10 +92,6 @@ export function shouldTriggerAutoCompaction(
|
|
|
90
92
|
return remainingTokens <= threshold;
|
|
91
93
|
}
|
|
92
94
|
|
|
93
|
-
function errorMessage(error: unknown): string {
|
|
94
|
-
return error instanceof Error ? error.message : String(error);
|
|
95
|
-
}
|
|
96
|
-
|
|
97
95
|
function supportedMode(ctx: ExtensionContext): boolean {
|
|
98
96
|
return ctx.mode === "tui" || ctx.mode === "rpc";
|
|
99
97
|
}
|
|
@@ -7,14 +7,25 @@ interface CodexFastState {
|
|
|
7
7
|
|
|
8
8
|
const GLOBAL_STATE_KEY = "__killerosCodexFastState";
|
|
9
9
|
type GlobalWithCodexFastState = typeof globalThis & {
|
|
10
|
-
[GLOBAL_STATE_KEY]?:
|
|
10
|
+
[GLOBAL_STATE_KEY]?: unknown;
|
|
11
11
|
};
|
|
12
12
|
|
|
13
|
+
// Accepts process-global state only when it is safe to reuse across extension versions.
|
|
14
|
+
function isCodexFastState(value: unknown): value is CodexFastState {
|
|
15
|
+
return typeof value === "object" && value !== null
|
|
16
|
+
&& "enabled" in value && typeof value.enabled === "boolean"
|
|
17
|
+
&& "listeners" in value && value.listeners instanceof Set
|
|
18
|
+
&& [...value.listeners].every((listener) => typeof listener === "function");
|
|
19
|
+
}
|
|
20
|
+
|
|
13
21
|
const globalState = globalThis as GlobalWithCodexFastState;
|
|
14
|
-
const
|
|
15
|
-
|
|
22
|
+
const savedState = globalState[GLOBAL_STATE_KEY];
|
|
23
|
+
const state: CodexFastState = isCodexFastState(savedState) ? savedState : {
|
|
24
|
+
enabled: typeof savedState === "object" && savedState !== null
|
|
25
|
+
&& "enabled" in savedState && savedState.enabled === true,
|
|
16
26
|
listeners: new Set<CodexFastStateListener>(),
|
|
17
27
|
};
|
|
28
|
+
globalState[GLOBAL_STATE_KEY] = state;
|
|
18
29
|
|
|
19
30
|
export function isCodexFastEnabled(): boolean {
|
|
20
31
|
return state.enabled;
|
package/killeros/commands.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { type ExtensionAPI, type ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import type { AutocompleteItem } from "@earendil-works/pi-tui";
|
|
3
|
+
import { safeTerminalText } from "./safe-terminal-text.ts";
|
|
3
4
|
|
|
4
5
|
async function confirmNewSession(ctx: ExtensionCommandContext): Promise<boolean> {
|
|
5
6
|
if (!ctx.hasUI) return true;
|
|
@@ -87,6 +88,11 @@ interface TaggedAutocompleteItem extends AutocompleteItem {
|
|
|
87
88
|
const SLASH_COMMAND_PREFIX_PATTERN = /(?:^|[ \t])\/([^\s/]*)$/u;
|
|
88
89
|
const SLASH_COMMAND_TOKEN_PATTERN = /(?:^|[ \t])\/([^\s/]+)(?=$|[ \t])/gu;
|
|
89
90
|
|
|
91
|
+
function safeCommandName(name: string): string | undefined {
|
|
92
|
+
const safe = safeTerminalText(name).replaceAll("\n", "").trim();
|
|
93
|
+
return safe === name && safe && !/[\s/]/u.test(safe) ? safe : undefined;
|
|
94
|
+
}
|
|
95
|
+
|
|
90
96
|
export function getSlashCommandPrefix(line: string): { prefix: string; slashIndex: number } | undefined {
|
|
91
97
|
const match = SLASH_COMMAND_PREFIX_PATTERN.exec(line);
|
|
92
98
|
if (!match || match.index === undefined) return undefined;
|
|
@@ -107,7 +113,8 @@ export function findSlashCommandTokens(line: string): SlashCommandToken[] {
|
|
|
107
113
|
}
|
|
108
114
|
|
|
109
115
|
function commandNameFromAutocompleteItem(item: AutocompleteItem): string {
|
|
110
|
-
|
|
116
|
+
const name = (item.value || item.label).replace(/^\//u, "").trim().split(/\s+/u)[0] ?? "";
|
|
117
|
+
return safeCommandName(name) ?? "";
|
|
111
118
|
}
|
|
112
119
|
|
|
113
120
|
export function createSlashCommandResolver(
|
|
@@ -124,16 +131,20 @@ export function createSlashCommandResolver(
|
|
|
124
131
|
}));
|
|
125
132
|
|
|
126
133
|
for (const command of pi.getCommands()) {
|
|
134
|
+
const name = safeCommandName(command.name);
|
|
135
|
+
if (!name) continue;
|
|
127
136
|
const category: CommandInfo["category"] = command.source === "skill"
|
|
128
137
|
? "Skill"
|
|
129
138
|
: command.source === "prompt"
|
|
130
139
|
? "Prompt"
|
|
131
140
|
: "Extension";
|
|
132
|
-
commands.set(
|
|
133
|
-
name
|
|
134
|
-
description: command.description
|
|
141
|
+
commands.set(name, {
|
|
142
|
+
name,
|
|
143
|
+
description: command.description === undefined
|
|
144
|
+
? undefined
|
|
145
|
+
: safeTerminalText(command.description).replaceAll("\n", " "),
|
|
135
146
|
category,
|
|
136
|
-
syntaxHint: COMMAND_SYNTAX_HINTS[
|
|
147
|
+
syntaxHint: COMMAND_SYNTAX_HINTS[name],
|
|
137
148
|
});
|
|
138
149
|
}
|
|
139
150
|
|
|
@@ -142,7 +153,11 @@ export function createSlashCommandResolver(
|
|
|
142
153
|
: fallbackCommands;
|
|
143
154
|
for (const [name, description] of baseCommands) {
|
|
144
155
|
if (name && !commands.has(name)) {
|
|
145
|
-
commands.set(name, {
|
|
156
|
+
commands.set(name, {
|
|
157
|
+
name,
|
|
158
|
+
description: description === undefined ? undefined : safeTerminalText(description).replaceAll("\n", " "),
|
|
159
|
+
category: "Built-in",
|
|
160
|
+
});
|
|
146
161
|
}
|
|
147
162
|
}
|
|
148
163
|
return commands;
|
package/killeros/display.ts
CHANGED
|
@@ -1,18 +1,21 @@
|
|
|
1
1
|
import os from "node:os";
|
|
2
2
|
import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
3
|
+
import { safeTerminalText } from "./safe-terminal-text.ts";
|
|
3
4
|
|
|
5
|
+
/** Formats a terminal-safe, single-line project path with home abbreviation. */
|
|
4
6
|
export function formatCwd(cwd: string): string {
|
|
5
|
-
const
|
|
6
|
-
|
|
7
|
+
const safeCwd = safeTerminalText(cwd).replaceAll("\n", "");
|
|
8
|
+
const home = safeTerminalText(process.env.HOME || process.env.USERPROFILE || os.homedir()).replaceAll("\n", "");
|
|
9
|
+
if (!home) return safeCwd;
|
|
7
10
|
const normalizedHome = home.replace(/[\\/]+$/, "");
|
|
8
|
-
const normalizedCwd =
|
|
11
|
+
const normalizedCwd = safeCwd.replace(/[\\/]+$/, "");
|
|
9
12
|
const comparedHome = process.platform === "win32" ? normalizedHome.toLowerCase() : normalizedHome;
|
|
10
13
|
const comparedCwd = process.platform === "win32" ? normalizedCwd.toLowerCase() : normalizedCwd;
|
|
11
14
|
if (comparedCwd === comparedHome) return "~";
|
|
12
15
|
const separator = normalizedCwd.slice(normalizedHome.length, normalizedHome.length + 1);
|
|
13
16
|
return comparedCwd.startsWith(comparedHome) && (separator === "/" || separator === "\\")
|
|
14
17
|
? `~${normalizedCwd.slice(normalizedHome.length)}`
|
|
15
|
-
:
|
|
18
|
+
: safeCwd;
|
|
16
19
|
}
|
|
17
20
|
|
|
18
21
|
export function padRight(text: string, width: number): string {
|
package/killeros/errors.ts
CHANGED
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { safeTerminalText } from "./safe-terminal-text.ts";
|
|
3
|
+
|
|
4
|
+
/** Converts unknown caught values into text suitable for user-facing errors. */
|
|
5
|
+
export function errorMessage(error: unknown): string {
|
|
6
|
+
return safeTerminalText(error instanceof Error ? error.message : String(error));
|
|
7
|
+
}
|
|
2
8
|
|
|
3
9
|
export function reportError(ctx: ExtensionContext, area: string, error: unknown): void {
|
|
4
|
-
|
|
5
|
-
ctx.ui.notify(`${area}: ${message}`, "error");
|
|
10
|
+
ctx.ui.notify(`${area}: ${errorMessage(error)}`, "error");
|
|
6
11
|
}
|
package/killeros/footer.ts
CHANGED
|
@@ -4,6 +4,7 @@ import { isCodexFastEnabled, subscribeCodexFast } from "./codex-fast-state.ts";
|
|
|
4
4
|
import { formatCwd, formatTime, formatTokens, padRight } from "./display.ts";
|
|
5
5
|
import { goalElapsedMilliseconds } from "./goals.ts";
|
|
6
6
|
import type { GoalRuntime, GoalState } from "./runtime.ts";
|
|
7
|
+
import { safeTerminalText } from "./safe-terminal-text.ts";
|
|
7
8
|
import { LEVEL_COLORS, type ThinkingLevel } from "./variants.ts";
|
|
8
9
|
|
|
9
10
|
const FOOTER_REFRESH_INTERVAL_MS = 1_000;
|
|
@@ -76,7 +77,7 @@ const PROVIDER_WORDS: Readonly<Record<string, string>> = {
|
|
|
76
77
|
};
|
|
77
78
|
|
|
78
79
|
function formatProviderName(provider: string): string {
|
|
79
|
-
const normalized = provider.trim();
|
|
80
|
+
const normalized = safeTerminalText(provider).replaceAll("\n", "").trim();
|
|
80
81
|
const known = PROVIDER_LABELS[normalized.toLowerCase()];
|
|
81
82
|
if (known) return known;
|
|
82
83
|
return normalized
|
|
@@ -87,7 +88,8 @@ function formatProviderName(provider: string): string {
|
|
|
87
88
|
}
|
|
88
89
|
|
|
89
90
|
function modelDisplayName(model: NonNullable<ExtensionContext["model"]>): string {
|
|
90
|
-
|
|
91
|
+
const name = safeTerminalText(model.name ?? "").replaceAll("\n", "").trim();
|
|
92
|
+
return name || safeTerminalText(model.id).replaceAll("\n", "").trim() || "Unknown model";
|
|
91
93
|
}
|
|
92
94
|
|
|
93
95
|
export function formatModel(
|
package/killeros/goals.ts
CHANGED
|
@@ -5,7 +5,6 @@ import path from "node:path";
|
|
|
5
5
|
import { type ExtensionAPI, type ExtensionCommandContext, type ExtensionContext, type ThemeColor } from "@earendil-works/pi-coding-agent";
|
|
6
6
|
import { Text } from "@earendil-works/pi-tui";
|
|
7
7
|
import { Type } from "typebox";
|
|
8
|
-
import { MAX_NODE_TIMER_MS } from "./limits.ts";
|
|
9
8
|
import { BoundedText } from "./bounded-text.ts";
|
|
10
9
|
import { formatTime, formatTokens } from "./display.ts";
|
|
11
10
|
import { reportError } from "./errors.ts";
|
|
@@ -119,8 +118,9 @@ function captureGoalFileBaseline(filePath: string): GoalFileBaseline {
|
|
|
119
118
|
let artifact: ReturnType<typeof lstatSync>;
|
|
120
119
|
try {
|
|
121
120
|
artifact = lstatSync(filePath);
|
|
122
|
-
} catch {
|
|
123
|
-
return { exists: false };
|
|
121
|
+
} catch (error) {
|
|
122
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return { exists: false };
|
|
123
|
+
throw error;
|
|
124
124
|
}
|
|
125
125
|
const baseline = { exists: true as const, size: artifact.size, mtimeMs: artifact.mtimeMs };
|
|
126
126
|
if (!artifact.isFile()) return baseline;
|
|
@@ -131,6 +131,7 @@ function captureGoalFileBaseline(filePath: string): GoalFileBaseline {
|
|
|
131
131
|
}
|
|
132
132
|
}
|
|
133
133
|
|
|
134
|
+
/** Captures one explicit absolute output path so goal completion can verify its creation or modification. */
|
|
134
135
|
function inferGoalVerification(objective: string): GoalFileVerification | undefined {
|
|
135
136
|
const destination = /\b(?:create|write|save|generate)\b[^\r\n]{0,160}?\b(?:file|document|markdown|report|spreadsheet|presentation|image)\b\s+(?:to|at|as|destination(?:\s+is)?|output(?:\s+(?:to|at))?)\b\s*(?:`([^`\r\n]+)`|"([^"\r\n]+)"|'([^'\r\n]+)'|([A-Za-z]:\\[^\s,;]+|\/[^\s,;]+))/giu;
|
|
136
137
|
const paths = [...objective.matchAll(destination)]
|
|
@@ -283,12 +284,10 @@ function sumGoalTokens(ctx: ExtensionContext): number {
|
|
|
283
284
|
}
|
|
284
285
|
|
|
285
286
|
function setGoalUpdateToolActive(pi: ExtensionAPI, active: boolean): void {
|
|
286
|
-
const
|
|
287
|
-
if (!api.getActiveTools || !api.setActiveTools) return;
|
|
288
|
-
const activeTools = api.getActiveTools();
|
|
287
|
+
const activeTools = pi.getActiveTools();
|
|
289
288
|
const isActive = activeTools.includes(GOAL_UPDATE_TOOL);
|
|
290
289
|
if (active === isActive) return;
|
|
291
|
-
|
|
290
|
+
pi.setActiveTools(active
|
|
292
291
|
? [...activeTools, GOAL_UPDATE_TOOL]
|
|
293
292
|
: activeTools.filter((name) => name !== GOAL_UPDATE_TOOL));
|
|
294
293
|
}
|
|
@@ -384,7 +383,7 @@ function goalStatusSummary(state: GoalState, ctx: ExtensionContext): string {
|
|
|
384
383
|
state.objective,
|
|
385
384
|
];
|
|
386
385
|
if (state.result) lines.push(state.result);
|
|
387
|
-
return lines.join("\n");
|
|
386
|
+
return safeTerminalText(lines.join("\n"));
|
|
388
387
|
}
|
|
389
388
|
|
|
390
389
|
export function pauseGoalAfterFailure(
|
|
@@ -396,13 +395,14 @@ export function pauseGoalAfterFailure(
|
|
|
396
395
|
notify = true,
|
|
397
396
|
): void {
|
|
398
397
|
if (runtime.state?.status !== "active") return;
|
|
398
|
+
const safeReason = safeTerminalText(reason);
|
|
399
399
|
try {
|
|
400
|
-
transitionGoal(pi, runtime, "error", "paused",
|
|
400
|
+
transitionGoal(pi, runtime, "error", "paused", safeReason);
|
|
401
401
|
} catch {
|
|
402
402
|
runtime.state = runtime.state ? {
|
|
403
403
|
...stopGoalClock(runtime.state, Date.now()),
|
|
404
404
|
status: "paused",
|
|
405
|
-
result:
|
|
405
|
+
result: safeReason,
|
|
406
406
|
resumeAfterManualCompaction: undefined,
|
|
407
407
|
} : undefined;
|
|
408
408
|
syncGoalUpdateTool(pi, runtime);
|
|
@@ -411,7 +411,7 @@ export function pauseGoalAfterFailure(
|
|
|
411
411
|
runtime.automaticCompaction = undefined;
|
|
412
412
|
runtime.requestRender?.();
|
|
413
413
|
}
|
|
414
|
-
if (notify) ctx.ui.notify(`Goal paused: ${
|
|
414
|
+
if (notify) ctx.ui.notify(`Goal paused: ${safeReason}\n${recoveryInstruction}`, "error");
|
|
415
415
|
}
|
|
416
416
|
|
|
417
417
|
function pauseGoalForPossibleManualCompaction(
|
|
@@ -421,15 +421,16 @@ function pauseGoalForPossibleManualCompaction(
|
|
|
421
421
|
reason: string,
|
|
422
422
|
): void {
|
|
423
423
|
if (runtime.state?.status !== "active") return;
|
|
424
|
+
const safeReason = safeTerminalText(reason);
|
|
424
425
|
try {
|
|
425
|
-
transitionGoal(pi, runtime, "error", "paused",
|
|
426
|
+
transitionGoal(pi, runtime, "error", "paused", safeReason, {
|
|
426
427
|
resumeAfterManualCompaction: true,
|
|
427
428
|
});
|
|
428
429
|
} catch {
|
|
429
430
|
runtime.state = runtime.state ? {
|
|
430
431
|
...stopGoalClock(runtime.state, Date.now()),
|
|
431
432
|
status: "paused",
|
|
432
|
-
result:
|
|
433
|
+
result: safeReason,
|
|
433
434
|
resumeAfterManualCompaction: true,
|
|
434
435
|
} : undefined;
|
|
435
436
|
syncGoalUpdateTool(pi, runtime);
|
|
@@ -495,6 +496,7 @@ function beginGoalTurn(
|
|
|
495
496
|
return next;
|
|
496
497
|
}
|
|
497
498
|
|
|
499
|
+
/** Starts one goal turn only after Pi is idle and all competing workflow gates are clear. */
|
|
498
500
|
function scheduleGoalContinuation(
|
|
499
501
|
pi: ExtensionAPI,
|
|
500
502
|
runtime: GoalRuntime,
|
|
@@ -588,7 +590,7 @@ function goalContinuationMessage(state: GoalState, ctx: ExtensionContext): strin
|
|
|
588
590
|
if (ctx.isProjectTrusted()) {
|
|
589
591
|
const personal = resolvePersonalInstructions(ctx.cwd);
|
|
590
592
|
if (personal) {
|
|
591
|
-
sections.push(
|
|
593
|
+
sections.push(personal);
|
|
592
594
|
}
|
|
593
595
|
}
|
|
594
596
|
return sections.join("\n\n");
|
|
@@ -794,7 +796,7 @@ export function registerGoal(
|
|
|
794
796
|
const selected = await ctx.ui.select(goalStatusSummary(runtime.state, ctx), actions.map((action) => action.label));
|
|
795
797
|
const action = actions.find((candidate) => candidate.label === selected);
|
|
796
798
|
if (!action) return;
|
|
797
|
-
if (action.control === "clear" && !await ctx.ui.confirm("Clear goal?", runtime.state.objective)) return;
|
|
799
|
+
if (action.control === "clear" && !await ctx.ui.confirm("Clear goal?", safeTerminalText(runtime.state.objective))) return;
|
|
798
800
|
await handleGoalCommand(action.control, ctx);
|
|
799
801
|
return;
|
|
800
802
|
}
|
|
@@ -879,7 +881,7 @@ export function registerGoal(
|
|
|
879
881
|
transitionGoal(pi, runtime, "pause", "paused");
|
|
880
882
|
saved = true;
|
|
881
883
|
} catch (error) {
|
|
882
|
-
failureReason = `the requested pause could not be saved: ${error instanceof Error ? error.message : String(error)}
|
|
884
|
+
failureReason = safeTerminalText(`the requested pause could not be saved: ${error instanceof Error ? error.message : String(error)}`);
|
|
883
885
|
pauseGoalAfterFailure(
|
|
884
886
|
pi,
|
|
885
887
|
runtime,
|
|
@@ -1039,21 +1041,21 @@ export function registerGoal(
|
|
|
1039
1041
|
return;
|
|
1040
1042
|
}
|
|
1041
1043
|
const now = Date.now();
|
|
1042
|
-
const state: GoalState = {
|
|
1043
|
-
version: GOAL_VERSION,
|
|
1044
|
-
revision: 1,
|
|
1045
|
-
objective,
|
|
1046
|
-
status: "active",
|
|
1047
|
-
createdAt: now,
|
|
1048
|
-
updatedAt: now,
|
|
1049
|
-
activeMilliseconds: 0,
|
|
1050
|
-
activeStartedAt: now,
|
|
1051
|
-
turns: 0,
|
|
1052
|
-
blockedAuditStartTurn: 0,
|
|
1053
|
-
baselineTokens: sumGoalTokens(ctx),
|
|
1054
|
-
verification: inferGoalVerification(objective),
|
|
1055
|
-
};
|
|
1056
1044
|
try {
|
|
1045
|
+
const state: GoalState = {
|
|
1046
|
+
version: GOAL_VERSION,
|
|
1047
|
+
revision: 1,
|
|
1048
|
+
objective,
|
|
1049
|
+
status: "active",
|
|
1050
|
+
createdAt: now,
|
|
1051
|
+
updatedAt: now,
|
|
1052
|
+
activeMilliseconds: 0,
|
|
1053
|
+
activeStartedAt: now,
|
|
1054
|
+
turns: 0,
|
|
1055
|
+
blockedAuditStartTurn: 0,
|
|
1056
|
+
baselineTokens: sumGoalTokens(ctx),
|
|
1057
|
+
verification: inferGoalVerification(objective),
|
|
1058
|
+
};
|
|
1057
1059
|
persistGoalState(pi, runtime, unfinished ? "replace" : "set", state);
|
|
1058
1060
|
if (scheduleGoalContinuation(pi, runtime, initState, ctx)) {
|
|
1059
1061
|
ctx.ui.notify("Goal active. KillerOS will continue until completion, a repeated blocker, or pause.", "info");
|
package/killeros/handoff.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { contentText } from "@earendil-works/pi-ai";
|
|
2
2
|
import { BorderedLoader, convertToLlm, type ExtensionAPI, type ExtensionCommandContext, serializeConversation, sessionEntryToContextMessages } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import { reportError } from "./errors.ts";
|
|
3
4
|
import type { GoalRuntime } from "./runtime.ts";
|
|
4
5
|
import { safeTerminalText } from "./safe-terminal-text.ts";
|
|
5
6
|
|
|
@@ -81,12 +82,6 @@ function hasRequiredHandoffContent(document: string, focus: string): boolean {
|
|
|
81
82
|
});
|
|
82
83
|
}
|
|
83
84
|
|
|
84
|
-
/** Reports a failed handoff through the session context that remains valid. */
|
|
85
|
-
function reportHandoffError(ctx: ExtensionCommandContext, error: unknown): void {
|
|
86
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
87
|
-
ctx.ui.notify(`Handoff failed: ${message}`, "error");
|
|
88
|
-
}
|
|
89
|
-
|
|
90
85
|
/** Generates and validates a handoff summary with optional cancellation. */
|
|
91
86
|
async function generateHandoffSummary(
|
|
92
87
|
ctx: ExtensionCommandContext,
|
|
@@ -96,9 +91,7 @@ async function generateHandoffSummary(
|
|
|
96
91
|
): Promise<string> {
|
|
97
92
|
if (!ctx.model) throw new Error("No current model is available");
|
|
98
93
|
|
|
99
|
-
const auth = await ctx.modelRegistry.getApiKeyAndHeaders(ctx.model);
|
|
100
94
|
signal?.throwIfAborted();
|
|
101
|
-
if (!auth.ok) throw new Error(auth.error);
|
|
102
95
|
|
|
103
96
|
const response = await ctx.modelRegistry.complete(ctx.model, {
|
|
104
97
|
systemPrompt: HANDOFF_SYSTEM_PROMPT,
|
|
@@ -108,9 +101,6 @@ async function generateHandoffSummary(
|
|
|
108
101
|
timestamp: Date.now(),
|
|
109
102
|
}],
|
|
110
103
|
}, {
|
|
111
|
-
apiKey: auth.apiKey,
|
|
112
|
-
headers: auth.headers,
|
|
113
|
-
env: auth.env,
|
|
114
104
|
maxTokens: 2_048,
|
|
115
105
|
signal,
|
|
116
106
|
});
|
|
@@ -173,7 +163,7 @@ export function registerHandoff(pi: ExtensionAPI, goalRuntime: GoalRuntime): voi
|
|
|
173
163
|
throw new Error("The handoff summary did not contain every required section");
|
|
174
164
|
}
|
|
175
165
|
} catch (error) {
|
|
176
|
-
|
|
166
|
+
reportError(ctx, "Handoff failed", error);
|
|
177
167
|
return;
|
|
178
168
|
}
|
|
179
169
|
|
|
@@ -191,7 +181,7 @@ export function registerHandoff(pi: ExtensionAPI, goalRuntime: GoalRuntime): voi
|
|
|
191
181
|
},
|
|
192
182
|
withSession: async (destination) => {
|
|
193
183
|
if (setupFailure) {
|
|
194
|
-
|
|
184
|
+
reportError(destination, "Handoff failed", setupFailure.error);
|
|
195
185
|
return;
|
|
196
186
|
}
|
|
197
187
|
destination.ui.notify("Handoff ready in a new session", "info");
|
|
@@ -199,7 +189,7 @@ export function registerHandoff(pi: ExtensionAPI, goalRuntime: GoalRuntime): voi
|
|
|
199
189
|
});
|
|
200
190
|
} catch (error) {
|
|
201
191
|
try {
|
|
202
|
-
|
|
192
|
+
reportError(ctx, "Handoff failed", error);
|
|
203
193
|
} catch {
|
|
204
194
|
throw error;
|
|
205
195
|
}
|
package/killeros/hooks.ts
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
|
-
import { existsSync,
|
|
2
|
+
import { closeSync, existsSync, fstatSync, lstatSync, openSync, readSync, realpathSync } from "node:fs";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { StringDecoder } from "node:string_decoder";
|
|
5
5
|
import { CONFIG_DIR_NAME, type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
6
|
-
import { reportError } from "./errors.ts";
|
|
6
|
+
import { errorMessage, reportError } from "./errors.ts";
|
|
7
|
+
import { safeTerminalText } from "./safe-terminal-text.ts";
|
|
7
8
|
|
|
8
9
|
type KillerosHookEvent = "tool_call" | "tool_result" | "agent_settled";
|
|
9
10
|
|
|
@@ -27,20 +28,67 @@ interface HookExecutionResult {
|
|
|
27
28
|
}
|
|
28
29
|
|
|
29
30
|
const HOOK_EVENTS: readonly KillerosHookEvent[] = ["tool_call", "tool_result", "agent_settled"];
|
|
31
|
+
const HOOK_CONFIG_LIMIT = 64 * 1024;
|
|
30
32
|
const HOOK_OUTPUT_LIMIT = 16 * 1024;
|
|
31
33
|
const HOOK_PAYLOAD_LIMIT = 8_000;
|
|
32
34
|
const HOOK_TIMEOUT_MAX_MS = 300_000;
|
|
33
35
|
|
|
36
|
+
// Reads executable project configuration through a bounded, project-local file descriptor.
|
|
37
|
+
function readHookConfig(configPath: string, projectRoot: string): string {
|
|
38
|
+
const actualPath = realpathSync(configPath);
|
|
39
|
+
const expectedPath = path.join(realpathSync(projectRoot), CONFIG_DIR_NAME, "killeros-hooks.json");
|
|
40
|
+
const samePath = process.platform === "win32"
|
|
41
|
+
? actualPath.toLowerCase() === expectedPath.toLowerCase()
|
|
42
|
+
: actualPath === expectedPath;
|
|
43
|
+
if (!samePath) {
|
|
44
|
+
throw new Error("Hook config must be stored in the real project .pi directory");
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const linkedFile = lstatSync(configPath);
|
|
48
|
+
if (!linkedFile.isFile() || linkedFile.nlink !== 1) {
|
|
49
|
+
throw new Error("Hook config must be a regular, non-linked file");
|
|
50
|
+
}
|
|
51
|
+
if (linkedFile.size > HOOK_CONFIG_LIMIT) {
|
|
52
|
+
throw new Error(`Hook config exceeds ${HOOK_CONFIG_LIMIT} bytes`);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const descriptor = openSync(configPath, "r");
|
|
56
|
+
try {
|
|
57
|
+
const openedFile = fstatSync(descriptor);
|
|
58
|
+
if (!openedFile.isFile() || openedFile.nlink !== 1) {
|
|
59
|
+
throw new Error("Hook config must be a regular, non-linked file");
|
|
60
|
+
}
|
|
61
|
+
if (openedFile.dev !== linkedFile.dev || openedFile.ino !== linkedFile.ino) {
|
|
62
|
+
throw new Error("Hook config changed while being opened");
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const contents = Buffer.alloc(HOOK_CONFIG_LIMIT + 1);
|
|
66
|
+
let bytesRead = 0;
|
|
67
|
+
while (bytesRead < contents.length) {
|
|
68
|
+
const count = readSync(descriptor, contents, bytesRead, contents.length - bytesRead, null);
|
|
69
|
+
if (count === 0) break;
|
|
70
|
+
bytesRead += count;
|
|
71
|
+
}
|
|
72
|
+
if (bytesRead > HOOK_CONFIG_LIMIT) {
|
|
73
|
+
throw new Error(`Hook config exceeds ${HOOK_CONFIG_LIMIT} bytes`);
|
|
74
|
+
}
|
|
75
|
+
return contents.toString("utf8", 0, bytesRead);
|
|
76
|
+
} finally {
|
|
77
|
+
closeSync(descriptor);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
34
81
|
function loadKillerosHooks(ctx: ExtensionContext): KillerosHookConfig {
|
|
35
82
|
const configPath = path.join(ctx.cwd, CONFIG_DIR_NAME, "killeros-hooks.json");
|
|
36
83
|
if (!existsSync(configPath)) return {};
|
|
84
|
+
const displayPath = safeTerminalText(configPath).replaceAll("\n", "");
|
|
37
85
|
if (!ctx.isProjectTrusted()) {
|
|
38
|
-
ctx.ui.notify(`Ignored untrusted project hooks in ${
|
|
86
|
+
ctx.ui.notify(`Ignored untrusted project hooks in ${displayPath}`, "warning");
|
|
39
87
|
return {};
|
|
40
88
|
}
|
|
41
89
|
|
|
42
90
|
try {
|
|
43
|
-
const parsed = JSON.parse(
|
|
91
|
+
const parsed = JSON.parse(readHookConfig(configPath, ctx.cwd)) as KillerosHookConfig;
|
|
44
92
|
const hooks: KillerosHookConfig["hooks"] = {};
|
|
45
93
|
for (const event of HOOK_EVENTS) {
|
|
46
94
|
const candidates = parsed.hooks?.[event];
|
|
@@ -59,7 +107,7 @@ function loadKillerosHooks(ctx: ExtensionContext): KillerosHookConfig {
|
|
|
59
107
|
&& hook.command.trim().length > 0
|
|
60
108
|
&& (hook.matcher === undefined || typeof hook.matcher === "string");
|
|
61
109
|
if (!valid) {
|
|
62
|
-
ctx.ui.notify(`Ignored invalid ${event} hook ${index + 1} in ${
|
|
110
|
+
ctx.ui.notify(`Ignored invalid ${event} hook ${index + 1} in ${displayPath}`, "warning");
|
|
63
111
|
return false;
|
|
64
112
|
}
|
|
65
113
|
if (hook.matcher && hook.matcher !== "*") {
|
|
@@ -140,8 +188,9 @@ export function executeHook(
|
|
|
140
188
|
if (signal?.aborted) {
|
|
141
189
|
return Promise.resolve({ code: 130, stdout: "", stderr: "", timedOut: false, cancelled: true, exitUnconfirmed: false });
|
|
142
190
|
}
|
|
143
|
-
|
|
144
|
-
|
|
191
|
+
let child;
|
|
192
|
+
try {
|
|
193
|
+
child = spawnProcess(command, {
|
|
145
194
|
cwd,
|
|
146
195
|
env: { ...process.env, ...environment },
|
|
147
196
|
detached: process.platform !== "win32",
|
|
@@ -149,6 +198,10 @@ export function executeHook(
|
|
|
149
198
|
stdio: ["ignore", "pipe", "pipe"],
|
|
150
199
|
windowsHide: true,
|
|
151
200
|
});
|
|
201
|
+
} catch (error) {
|
|
202
|
+
return Promise.resolve({ code: 1, stdout: "", stderr: errorMessage(error), timedOut: false, cancelled: false, exitUnconfirmed: false });
|
|
203
|
+
}
|
|
204
|
+
return new Promise((resolve) => {
|
|
152
205
|
const stdout: HookOutputBuffer = { bytes: 0, decoder: new StringDecoder("utf8"), text: "" };
|
|
153
206
|
const stderr: HookOutputBuffer = { bytes: 0, decoder: new StringDecoder("utf8"), text: "" };
|
|
154
207
|
let completed = false;
|
|
@@ -163,6 +216,8 @@ export function executeHook(
|
|
|
163
216
|
if (forceTimer) clearTimeout(forceTimer);
|
|
164
217
|
if (settleTimer) clearTimeout(settleTimer);
|
|
165
218
|
signal?.removeEventListener("abort", abort);
|
|
219
|
+
stdout.text += stdout.decoder.end();
|
|
220
|
+
stderr.text += stderr.decoder.end();
|
|
166
221
|
resolve({
|
|
167
222
|
code,
|
|
168
223
|
stdout: stdout.text,
|
|
@@ -212,9 +267,9 @@ function hookEnvironment(event: KillerosHookEvent, toolName = "", payload: unkno
|
|
|
212
267
|
};
|
|
213
268
|
}
|
|
214
269
|
|
|
215
|
-
function hookFailureMessage(
|
|
270
|
+
function hookFailureMessage(result: HookExecutionResult): string {
|
|
216
271
|
const detail = result.stderr.trim() || result.stdout.trim() || `exit code ${result.code}`;
|
|
217
|
-
return `Hook failed${result.timedOut ? " (timed out)" : ""}${result.exitUnconfirmed ? " (process exit unconfirmed)" : ""}
|
|
272
|
+
return safeTerminalText(`Hook failed${result.timedOut ? " (timed out)" : ""}${result.exitUnconfirmed ? " (process exit unconfirmed)" : ""}\n${detail}`);
|
|
218
273
|
}
|
|
219
274
|
|
|
220
275
|
export function registerLifecycleHooks(pi: ExtensionAPI): void {
|
|
@@ -234,7 +289,7 @@ export function registerLifecycleHooks(pi: ExtensionAPI): void {
|
|
|
234
289
|
);
|
|
235
290
|
if (result.cancelled) return { block: true, reason: "Hook cancelled because the parent request was aborted" };
|
|
236
291
|
if (result.code !== 0) {
|
|
237
|
-
const reason = hookFailureMessage(
|
|
292
|
+
const reason = hookFailureMessage(result);
|
|
238
293
|
ctx.ui.notify(reason, "error");
|
|
239
294
|
return { block: true, reason };
|
|
240
295
|
}
|
|
@@ -256,7 +311,7 @@ export function registerLifecycleHooks(pi: ExtensionAPI): void {
|
|
|
256
311
|
ctx.signal,
|
|
257
312
|
);
|
|
258
313
|
if (result.cancelled) break;
|
|
259
|
-
if (result.code !== 0) ctx.ui.notify(hookFailureMessage(
|
|
314
|
+
if (result.code !== 0) ctx.ui.notify(hookFailureMessage(result), "error");
|
|
260
315
|
}
|
|
261
316
|
});
|
|
262
317
|
|
|
@@ -271,7 +326,7 @@ export function registerLifecycleHooks(pi: ExtensionAPI): void {
|
|
|
271
326
|
ctx.signal,
|
|
272
327
|
);
|
|
273
328
|
if (result.cancelled) break;
|
|
274
|
-
if (result.code !== 0) ctx.ui.notify(hookFailureMessage(
|
|
329
|
+
if (result.code !== 0) ctx.ui.notify(hookFailureMessage(result), "error");
|
|
275
330
|
}
|
|
276
331
|
});
|
|
277
332
|
}
|
package/killeros/init-target.ts
CHANGED
|
@@ -146,6 +146,7 @@ async function pathExists(filePath: string): Promise<boolean> {
|
|
|
146
146
|
}
|
|
147
147
|
}
|
|
148
148
|
|
|
149
|
+
/** Installs generated guidance atomically and preserves any target changed after baseline capture. */
|
|
149
150
|
export async function installInitAgentsFile(
|
|
150
151
|
targetPath: string,
|
|
151
152
|
content: string,
|
package/killeros/init.ts
CHANGED
|
@@ -17,6 +17,7 @@ import {
|
|
|
17
17
|
validateGeneratedGuidance,
|
|
18
18
|
} from "./init-target.ts";
|
|
19
19
|
import { resetInitRuntime, type GoalRuntime, type InitOutcome, type InitRuntime } from "./runtime.ts";
|
|
20
|
+
import { safeTerminalText } from "./safe-terminal-text.ts";
|
|
20
21
|
|
|
21
22
|
const INIT_WRITE_TOOL = "killeros_init_write";
|
|
22
23
|
const INIT_CONFLICT_TOOL = "killeros_init_conflict";
|
|
@@ -47,16 +48,14 @@ After a successful write, read generated AGENTS.md once through killeros_init_re
|
|
|
47
48
|
`.trim();
|
|
48
49
|
|
|
49
50
|
function setInitTools(pi: ExtensionAPI, initState: InitRuntime, active: boolean): void {
|
|
50
|
-
const runtime = pi as ExtensionAPI & { getActiveTools?: () => string[]; setActiveTools?: (names: string[]) => void };
|
|
51
|
-
if (!runtime.getActiveTools || !runtime.setActiveTools) return;
|
|
52
51
|
if (active) {
|
|
53
|
-
initState.activeTools ??=
|
|
54
|
-
|
|
52
|
+
initState.activeTools ??= pi.getActiveTools().filter((name) => !INIT_SCOPED_TOOLS.includes(name as (typeof INIT_SCOPED_TOOLS)[number]));
|
|
53
|
+
pi.setActiveTools([...INIT_SCOPED_TOOLS]);
|
|
55
54
|
} else if (initState.activeTools) {
|
|
56
|
-
|
|
55
|
+
pi.setActiveTools(initState.activeTools);
|
|
57
56
|
initState.activeTools = undefined;
|
|
58
57
|
} else {
|
|
59
|
-
|
|
58
|
+
pi.setActiveTools(pi.getActiveTools().filter((name) => !INIT_SCOPED_TOOLS.includes(name as (typeof INIT_SCOPED_TOOLS)[number])));
|
|
60
59
|
}
|
|
61
60
|
}
|
|
62
61
|
|
|
@@ -126,8 +125,9 @@ export function registerInitCommand(pi: ExtensionAPI, initState: InitRuntime, go
|
|
|
126
125
|
executionMode: "sequential",
|
|
127
126
|
async execute(_toolCallId, { reason }) {
|
|
128
127
|
requirePending(initState);
|
|
129
|
-
|
|
130
|
-
|
|
128
|
+
const safeReason = safeTerminalText(reason);
|
|
129
|
+
initState.outcome = { kind: "policy-conflict", reason: safeReason };
|
|
130
|
+
return { content: [{ type: "text" as const, text: `Root AGENTS.md was left unchanged: ${safeReason}` }], details: { reason: safeReason } };
|
|
131
131
|
},
|
|
132
132
|
});
|
|
133
133
|
|
|
@@ -4,6 +4,8 @@ import {
|
|
|
4
4
|
type ExtensionAPI,
|
|
5
5
|
type ExtensionContext,
|
|
6
6
|
} from "@earendil-works/pi-coding-agent";
|
|
7
|
+
import { errorMessage } from "./errors.ts";
|
|
8
|
+
import { safeTerminalText } from "./safe-terminal-text.ts";
|
|
7
9
|
import { createKillerosSettingsStore } from "./settings.ts";
|
|
8
10
|
|
|
9
11
|
export interface NotificationPreferenceStore {
|
|
@@ -37,11 +39,7 @@ export function formatNotificationTitle(
|
|
|
37
39
|
): string {
|
|
38
40
|
const directory = basename(cwd) || cwd;
|
|
39
41
|
const base = sessionName ? `π - ${sessionName} - ${directory}` : `π - ${directory}`;
|
|
40
|
-
return enabled ? `${base} ${COMPLETION_BELL_GLYPH}` : base;
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
function errorMessage(error: unknown): string {
|
|
44
|
-
return error instanceof Error ? error.message : String(error);
|
|
42
|
+
return safeTerminalText(enabled ? `${base} ${COMPLETION_BELL_GLYPH}` : base).replaceAll("\n", "");
|
|
45
43
|
}
|
|
46
44
|
|
|
47
45
|
export function registerCompletionNotifications(
|
|
@@ -34,20 +34,21 @@ function readBoundedText(filePath: string, limit = PERSONAL_INSTRUCTIONS_LIMIT):
|
|
|
34
34
|
}
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
-
export function resolvePersonalInstructions(cwd: string):
|
|
37
|
+
export function resolvePersonalInstructions(cwd: string): string | undefined {
|
|
38
38
|
const localPath = path.join(cwd, PERSONAL_INSTRUCTIONS_FILE);
|
|
39
39
|
const local = readBoundedText(localPath);
|
|
40
40
|
if (!local) return undefined;
|
|
41
41
|
|
|
42
42
|
const importMatch = local.trim().match(/^@(.+)$/u);
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
43
|
+
let content = local;
|
|
44
|
+
if (importMatch) {
|
|
45
|
+
const requestedPath = importMatch[1]!.trim();
|
|
46
|
+
const importedPath = requestedPath.startsWith("~/") || requestedPath.startsWith("~\\")
|
|
47
|
+
? path.join(os.homedir(), requestedPath.slice(2))
|
|
48
|
+
: path.resolve(cwd, requestedPath);
|
|
49
|
+
content = readBoundedText(importedPath) ?? local;
|
|
50
|
+
}
|
|
51
|
+
return `<personal_instructions>\n${content}\n</personal_instructions>`;
|
|
51
52
|
}
|
|
52
53
|
|
|
53
54
|
export function registerPersonalInstructions(pi: ExtensionAPI, initState: InitRuntime): void {
|
|
@@ -59,9 +60,7 @@ export function registerPersonalInstructions(pi: ExtensionAPI, initState: InitRu
|
|
|
59
60
|
systemPrompt: [
|
|
60
61
|
event.systemPrompt,
|
|
61
62
|
"",
|
|
62
|
-
|
|
63
|
-
personal.content,
|
|
64
|
-
"</personal_instructions>",
|
|
63
|
+
personal,
|
|
65
64
|
].join("\n"),
|
|
66
65
|
};
|
|
67
66
|
});
|
package/killeros/shell-ui.ts
CHANGED
|
@@ -45,6 +45,12 @@ const STARTUP_TIPS = [
|
|
|
45
45
|
"Run /variants to tune the model's reasoning depth.",
|
|
46
46
|
"Type / to browse every command available in this session.",
|
|
47
47
|
"Run /notification to enable a terminal bell when work settles.",
|
|
48
|
+
"Run /goal <objective> to keep long-running work moving across turns.",
|
|
49
|
+
"Run /init to generate root AGENTS.md from bounded repository evidence.",
|
|
50
|
+
"Run /handoff [focus] to continue work in a fresh linked session.",
|
|
51
|
+
"Run /codex-fast to toggle priority requests for Codex models.",
|
|
52
|
+
"Run /clear to start a fresh session after confirmation.",
|
|
53
|
+
"Ask the agent to show a short choice list when you need to decide.",
|
|
48
54
|
] as const;
|
|
49
55
|
|
|
50
56
|
const EDITOR_SUGGESTIONS = [
|
|
@@ -52,6 +58,12 @@ const EDITOR_SUGGESTIONS = [
|
|
|
52
58
|
'Try "find edge cases in <filepath>"',
|
|
53
59
|
'Try "simplify <filepath> without changing behavior"',
|
|
54
60
|
'Try "write tests for <filepath>"',
|
|
61
|
+
'Try "trace this failure to its first bad state"',
|
|
62
|
+
'Try "review this diff against <spec>"',
|
|
63
|
+
'Try "explain why this test fails"',
|
|
64
|
+
'Try "find the smallest safe fix for <issue>"',
|
|
65
|
+
'Try "map the data flow through <feature>"',
|
|
66
|
+
'Try "draft an implementation plan for <feature>"',
|
|
55
67
|
] as const;
|
|
56
68
|
|
|
57
69
|
export function resolveGitBranch(cwd: string): Promise<string | undefined> {
|
|
@@ -216,6 +228,7 @@ function extractTerminalSequence(text: string, position: number): { code: string
|
|
|
216
228
|
return undefined;
|
|
217
229
|
}
|
|
218
230
|
|
|
231
|
+
/** Styles valid slash-command tokens while preserving embedded terminal control sequences. */
|
|
219
232
|
export function highlightSlashCommands(
|
|
220
233
|
line: string,
|
|
221
234
|
isValidCommand: (name: string) => boolean,
|
package/killeros/variants.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { DynamicBorder, type ExtensionAPI, type ExtensionContext, type ThemeColor } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import { SelectList, truncateToWidth } from "@earendil-works/pi-tui";
|
|
3
|
+
import { safeTerminalText } from "./safe-terminal-text.ts";
|
|
3
4
|
|
|
4
5
|
export type ThinkingLevel = ReturnType<ExtensionAPI["getThinkingLevel"]>;
|
|
5
6
|
|
|
@@ -60,7 +61,7 @@ function supportedLevels(model: ExtensionContext["model"]): ThinkingLevel[] {
|
|
|
60
61
|
}
|
|
61
62
|
|
|
62
63
|
function modelLabel(model: ExtensionContext["model"]): string {
|
|
63
|
-
return model ? `${model.provider}/${model.id}` : "unknown model";
|
|
64
|
+
return model ? safeTerminalText(`${model.provider}/${model.id}`).replaceAll("\n", "") : "unknown model";
|
|
64
65
|
}
|
|
65
66
|
|
|
66
67
|
export function registerVariants(pi: ExtensionAPI): void {
|
|
@@ -80,7 +81,7 @@ export function registerVariants(pi: ExtensionAPI): void {
|
|
|
80
81
|
if (args.trim()) {
|
|
81
82
|
const level = resolveThinkingLevel(args);
|
|
82
83
|
if (!level) {
|
|
83
|
-
ctx.ui.notify(`Unknown reasoning level "${args.trim()}". Use: ${ALL_LEVELS.join(", ")}`, "error");
|
|
84
|
+
ctx.ui.notify(`Unknown reasoning level "${safeTerminalText(args.trim()).replaceAll("\n", "")}". Use: ${ALL_LEVELS.join(", ")}`, "error");
|
|
84
85
|
return;
|
|
85
86
|
}
|
|
86
87
|
setLevel(ctx, level);
|
package/killeros/worked-for.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import type { StopReason } from "@earendil-works/pi-ai";
|
|
3
3
|
import { Text } from "@earendil-works/pi-tui";
|
|
4
|
+
import { formatTokens } from "./display.ts";
|
|
5
|
+
import { errorMessage } from "./errors.ts";
|
|
4
6
|
|
|
5
7
|
const WORKED_FOR_ENTRY_TYPE = "killeros-worked-for";
|
|
6
8
|
|
|
@@ -17,7 +19,14 @@ interface WorkedForEntryDataV2 {
|
|
|
17
19
|
outcome: WorkedForOutcome;
|
|
18
20
|
}
|
|
19
21
|
|
|
20
|
-
|
|
22
|
+
interface WorkedForEntryDataV3 {
|
|
23
|
+
version: 3;
|
|
24
|
+
milliseconds: number;
|
|
25
|
+
outcome: WorkedForOutcome;
|
|
26
|
+
tokens: number;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
type WorkedForEntryData = WorkedForEntryDataV1 | WorkedForEntryDataV2 | WorkedForEntryDataV3;
|
|
21
30
|
|
|
22
31
|
const OUTCOMES = {
|
|
23
32
|
done: { marker: "✓", label: "Done", color: "success" },
|
|
@@ -29,6 +38,23 @@ function isWorkedForOutcome(value: unknown): value is WorkedForOutcome {
|
|
|
29
38
|
return value === "done" || value === "stopped" || value === "failed";
|
|
30
39
|
}
|
|
31
40
|
|
|
41
|
+
function sessionTokenTotal(ctx: ExtensionContext): number | undefined {
|
|
42
|
+
try {
|
|
43
|
+
let total = 0;
|
|
44
|
+
for (const entry of ctx.sessionManager.getEntries()) {
|
|
45
|
+
const tokens = entry.type === "message" && (entry.message.role === "assistant" || entry.message.role === "toolResult")
|
|
46
|
+
? entry.message.usage?.totalTokens
|
|
47
|
+
: (entry.type === "compaction" || entry.type === "branch_summary")
|
|
48
|
+
? entry.usage?.totalTokens
|
|
49
|
+
: undefined;
|
|
50
|
+
if (typeof tokens === "number" && Number.isFinite(tokens) && tokens > 0) total += tokens;
|
|
51
|
+
}
|
|
52
|
+
return total;
|
|
53
|
+
} catch {
|
|
54
|
+
return undefined;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
32
58
|
export function formatWorkedForDuration(milliseconds: number): string {
|
|
33
59
|
const boundedMilliseconds = Number.isFinite(milliseconds) ? Math.max(0, milliseconds) : 0;
|
|
34
60
|
const totalSeconds = Math.max(1, Math.floor(boundedMilliseconds / 1_000));
|
|
@@ -49,10 +75,11 @@ function parseWorkedForEntryData(data: unknown): WorkedForEntryData | undefined
|
|
|
49
75
|
return undefined;
|
|
50
76
|
}
|
|
51
77
|
if (data.version === 1) return { version: 1, milliseconds: data.milliseconds };
|
|
52
|
-
if (
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
78
|
+
if (!("outcome" in data) || !isWorkedForOutcome(data.outcome)) return undefined;
|
|
79
|
+
if (data.version === 2) return { version: 2, milliseconds: data.milliseconds, outcome: data.outcome };
|
|
80
|
+
if (data.version !== 3 || !("tokens" in data)
|
|
81
|
+
|| typeof data.tokens !== "number" || !Number.isFinite(data.tokens) || data.tokens < 0) return undefined;
|
|
82
|
+
return { version: 3, milliseconds: data.milliseconds, outcome: data.outcome, tokens: data.tokens };
|
|
56
83
|
}
|
|
57
84
|
|
|
58
85
|
export function workedForOutcome(stopReason: StopReason | undefined): WorkedForOutcome {
|
|
@@ -61,15 +88,12 @@ export function workedForOutcome(stopReason: StopReason | undefined): WorkedForO
|
|
|
61
88
|
return "failed";
|
|
62
89
|
}
|
|
63
90
|
|
|
64
|
-
function errorMessage(error: unknown): string {
|
|
65
|
-
return error instanceof Error ? error.message : String(error);
|
|
66
|
-
}
|
|
67
|
-
|
|
68
91
|
export function registerWorkedFor(
|
|
69
92
|
pi: ExtensionAPI,
|
|
70
93
|
now: () => number = Date.now,
|
|
71
94
|
): void {
|
|
72
95
|
let startedAt: number | undefined;
|
|
96
|
+
let startedTokens: number | undefined;
|
|
73
97
|
let stopReason: StopReason | undefined;
|
|
74
98
|
|
|
75
99
|
pi.registerEntryRenderer<WorkedForEntryData>(WORKED_FOR_ENTRY_TYPE, (entry, _options, theme) => {
|
|
@@ -83,8 +107,9 @@ export function registerWorkedFor(
|
|
|
83
107
|
);
|
|
84
108
|
}
|
|
85
109
|
const outcome = OUTCOMES[data.outcome];
|
|
110
|
+
const tokens = data.version === 3 ? ` · ↑ ${formatTokens(data.tokens)} tokens` : "";
|
|
86
111
|
return new Text(
|
|
87
|
-
`${theme.fg(outcome.color, `${outcome.marker} ${outcome.label}`)}${theme.fg("dim", ` · ${formatWorkedForDuration(data.milliseconds)}`)}`,
|
|
112
|
+
`${theme.fg(outcome.color, `${outcome.marker} ${outcome.label}`)}${theme.fg("dim", ` · ${formatWorkedForDuration(data.milliseconds)}${tokens}`)}`,
|
|
88
113
|
0,
|
|
89
114
|
0,
|
|
90
115
|
);
|
|
@@ -92,12 +117,14 @@ export function registerWorkedFor(
|
|
|
92
117
|
|
|
93
118
|
pi.on("session_start", () => {
|
|
94
119
|
startedAt = undefined;
|
|
120
|
+
startedTokens = undefined;
|
|
95
121
|
stopReason = undefined;
|
|
96
122
|
});
|
|
97
123
|
|
|
98
124
|
pi.on("agent_start", (_event, ctx) => {
|
|
99
125
|
if (ctx.mode !== "tui" || startedAt !== undefined) return;
|
|
100
126
|
startedAt = now();
|
|
127
|
+
startedTokens = sessionTokenTotal(ctx);
|
|
101
128
|
});
|
|
102
129
|
|
|
103
130
|
pi.on("agent_end", (event, ctx) => {
|
|
@@ -112,16 +139,21 @@ export function registerWorkedFor(
|
|
|
112
139
|
|
|
113
140
|
pi.on("agent_settled", (_event, ctx) => {
|
|
114
141
|
if (ctx.mode !== "tui" || startedAt === undefined) return;
|
|
115
|
-
if (!ctx.isIdle() || ctx.hasPendingMessages()) return;
|
|
116
142
|
const milliseconds = Math.max(0, now() - startedAt);
|
|
143
|
+
const settledTokens = sessionTokenTotal(ctx);
|
|
144
|
+
const tokens = startedTokens === undefined || settledTokens === undefined
|
|
145
|
+
? 0
|
|
146
|
+
: Math.max(0, settledTokens - startedTokens);
|
|
117
147
|
const outcome = workedForOutcome(stopReason);
|
|
118
148
|
startedAt = undefined;
|
|
149
|
+
startedTokens = undefined;
|
|
119
150
|
stopReason = undefined;
|
|
120
151
|
try {
|
|
121
|
-
pi.appendEntry<
|
|
122
|
-
version:
|
|
152
|
+
pi.appendEntry<WorkedForEntryDataV3>(WORKED_FOR_ENTRY_TYPE, {
|
|
153
|
+
version: 3,
|
|
123
154
|
milliseconds,
|
|
124
155
|
outcome,
|
|
156
|
+
tokens,
|
|
125
157
|
});
|
|
126
158
|
} catch (error) {
|
|
127
159
|
ctx.ui.notify(`Worked-for timing could not be saved: ${errorMessage(error)}`, "error");
|
|
@@ -130,6 +162,7 @@ export function registerWorkedFor(
|
|
|
130
162
|
|
|
131
163
|
pi.on("session_shutdown", () => {
|
|
132
164
|
startedAt = undefined;
|
|
165
|
+
startedTokens = undefined;
|
|
133
166
|
stopReason = undefined;
|
|
134
167
|
});
|
|
135
168
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "killeros",
|
|
3
|
-
"version": "2.0.
|
|
3
|
+
"version": "2.0.18",
|
|
4
4
|
"description": "TUI, goals, and workflow automation for the Pi coding agent",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"keywords": [
|
|
@@ -42,15 +42,15 @@
|
|
|
42
42
|
]
|
|
43
43
|
},
|
|
44
44
|
"peerDependencies": {
|
|
45
|
-
"@earendil-works/pi-ai": ">=0.84.
|
|
46
|
-
"@earendil-works/pi-coding-agent": ">=0.84.
|
|
47
|
-
"@earendil-works/pi-tui": ">=0.84.
|
|
45
|
+
"@earendil-works/pi-ai": ">=0.84.3",
|
|
46
|
+
"@earendil-works/pi-coding-agent": ">=0.84.3",
|
|
47
|
+
"@earendil-works/pi-tui": ">=0.84.3",
|
|
48
48
|
"typebox": ">=1.1.38 <2"
|
|
49
49
|
},
|
|
50
50
|
"devDependencies": {
|
|
51
|
-
"@earendil-works/pi-ai": "0.84.
|
|
52
|
-
"@earendil-works/pi-coding-agent": "0.84.
|
|
53
|
-
"@earendil-works/pi-tui": "0.84.
|
|
51
|
+
"@earendil-works/pi-ai": "0.84.3",
|
|
52
|
+
"@earendil-works/pi-coding-agent": "0.84.3",
|
|
53
|
+
"@earendil-works/pi-tui": "0.84.3",
|
|
54
54
|
"@types/node": "24.12.4",
|
|
55
55
|
"typebox": "1.1.38",
|
|
56
56
|
"typescript": "5.9.3"
|
package/killeros/limits.ts
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export const MAX_NODE_TIMER_MS = 2_147_483_647;
|