killeros 2.0.17 → 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 +38 -0
- package/README.md +3 -3
- package/killeros/codex-fast-state.ts +14 -3
- package/killeros/commands.ts +21 -6
- package/killeros/display.ts +7 -4
- package/killeros/errors.ts +2 -1
- package/killeros/footer.ts +4 -2
- package/killeros/goals.ts +28 -25
- package/killeros/handoff.ts +4 -14
- package/killeros/hooks.ts +67 -12
- package/killeros/init.ts +4 -2
- package/killeros/notifications.ts +2 -1
- package/killeros/personal-instructions.ts +11 -12
- package/killeros/variants.ts +3 -2
- package/package.json +7 -7
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,44 @@ 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
|
+
|
|
7
45
|
## [2.0.17] - 2026-08-23
|
|
8
46
|
|
|
9
47
|
### Added
|
package/README.md
CHANGED
|
@@ -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 release by appending its tag, for example `@v2.0.
|
|
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
|
|
|
@@ -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,8 +1,9 @@
|
|
|
1
1
|
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { safeTerminalText } from "./safe-terminal-text.ts";
|
|
2
3
|
|
|
3
4
|
/** Converts unknown caught values into text suitable for user-facing errors. */
|
|
4
5
|
export function errorMessage(error: unknown): string {
|
|
5
|
-
return error instanceof Error ? error.message : String(error);
|
|
6
|
+
return safeTerminalText(error instanceof Error ? error.message : String(error));
|
|
6
7
|
}
|
|
7
8
|
|
|
8
9
|
export function reportError(ctx: ExtensionContext, area: string, error: unknown): void {
|
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
|
@@ -118,8 +118,9 @@ function captureGoalFileBaseline(filePath: string): GoalFileBaseline {
|
|
|
118
118
|
let artifact: ReturnType<typeof lstatSync>;
|
|
119
119
|
try {
|
|
120
120
|
artifact = lstatSync(filePath);
|
|
121
|
-
} catch {
|
|
122
|
-
return { exists: false };
|
|
121
|
+
} catch (error) {
|
|
122
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return { exists: false };
|
|
123
|
+
throw error;
|
|
123
124
|
}
|
|
124
125
|
const baseline = { exists: true as const, size: artifact.size, mtimeMs: artifact.mtimeMs };
|
|
125
126
|
if (!artifact.isFile()) return baseline;
|
|
@@ -382,7 +383,7 @@ function goalStatusSummary(state: GoalState, ctx: ExtensionContext): string {
|
|
|
382
383
|
state.objective,
|
|
383
384
|
];
|
|
384
385
|
if (state.result) lines.push(state.result);
|
|
385
|
-
return lines.join("\n");
|
|
386
|
+
return safeTerminalText(lines.join("\n"));
|
|
386
387
|
}
|
|
387
388
|
|
|
388
389
|
export function pauseGoalAfterFailure(
|
|
@@ -394,13 +395,14 @@ export function pauseGoalAfterFailure(
|
|
|
394
395
|
notify = true,
|
|
395
396
|
): void {
|
|
396
397
|
if (runtime.state?.status !== "active") return;
|
|
398
|
+
const safeReason = safeTerminalText(reason);
|
|
397
399
|
try {
|
|
398
|
-
transitionGoal(pi, runtime, "error", "paused",
|
|
400
|
+
transitionGoal(pi, runtime, "error", "paused", safeReason);
|
|
399
401
|
} catch {
|
|
400
402
|
runtime.state = runtime.state ? {
|
|
401
403
|
...stopGoalClock(runtime.state, Date.now()),
|
|
402
404
|
status: "paused",
|
|
403
|
-
result:
|
|
405
|
+
result: safeReason,
|
|
404
406
|
resumeAfterManualCompaction: undefined,
|
|
405
407
|
} : undefined;
|
|
406
408
|
syncGoalUpdateTool(pi, runtime);
|
|
@@ -409,7 +411,7 @@ export function pauseGoalAfterFailure(
|
|
|
409
411
|
runtime.automaticCompaction = undefined;
|
|
410
412
|
runtime.requestRender?.();
|
|
411
413
|
}
|
|
412
|
-
if (notify) ctx.ui.notify(`Goal paused: ${
|
|
414
|
+
if (notify) ctx.ui.notify(`Goal paused: ${safeReason}\n${recoveryInstruction}`, "error");
|
|
413
415
|
}
|
|
414
416
|
|
|
415
417
|
function pauseGoalForPossibleManualCompaction(
|
|
@@ -419,15 +421,16 @@ function pauseGoalForPossibleManualCompaction(
|
|
|
419
421
|
reason: string,
|
|
420
422
|
): void {
|
|
421
423
|
if (runtime.state?.status !== "active") return;
|
|
424
|
+
const safeReason = safeTerminalText(reason);
|
|
422
425
|
try {
|
|
423
|
-
transitionGoal(pi, runtime, "error", "paused",
|
|
426
|
+
transitionGoal(pi, runtime, "error", "paused", safeReason, {
|
|
424
427
|
resumeAfterManualCompaction: true,
|
|
425
428
|
});
|
|
426
429
|
} catch {
|
|
427
430
|
runtime.state = runtime.state ? {
|
|
428
431
|
...stopGoalClock(runtime.state, Date.now()),
|
|
429
432
|
status: "paused",
|
|
430
|
-
result:
|
|
433
|
+
result: safeReason,
|
|
431
434
|
resumeAfterManualCompaction: true,
|
|
432
435
|
} : undefined;
|
|
433
436
|
syncGoalUpdateTool(pi, runtime);
|
|
@@ -587,7 +590,7 @@ function goalContinuationMessage(state: GoalState, ctx: ExtensionContext): strin
|
|
|
587
590
|
if (ctx.isProjectTrusted()) {
|
|
588
591
|
const personal = resolvePersonalInstructions(ctx.cwd);
|
|
589
592
|
if (personal) {
|
|
590
|
-
sections.push(
|
|
593
|
+
sections.push(personal);
|
|
591
594
|
}
|
|
592
595
|
}
|
|
593
596
|
return sections.join("\n\n");
|
|
@@ -793,7 +796,7 @@ export function registerGoal(
|
|
|
793
796
|
const selected = await ctx.ui.select(goalStatusSummary(runtime.state, ctx), actions.map((action) => action.label));
|
|
794
797
|
const action = actions.find((candidate) => candidate.label === selected);
|
|
795
798
|
if (!action) return;
|
|
796
|
-
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;
|
|
797
800
|
await handleGoalCommand(action.control, ctx);
|
|
798
801
|
return;
|
|
799
802
|
}
|
|
@@ -878,7 +881,7 @@ export function registerGoal(
|
|
|
878
881
|
transitionGoal(pi, runtime, "pause", "paused");
|
|
879
882
|
saved = true;
|
|
880
883
|
} catch (error) {
|
|
881
|
-
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)}`);
|
|
882
885
|
pauseGoalAfterFailure(
|
|
883
886
|
pi,
|
|
884
887
|
runtime,
|
|
@@ -1038,21 +1041,21 @@ export function registerGoal(
|
|
|
1038
1041
|
return;
|
|
1039
1042
|
}
|
|
1040
1043
|
const now = Date.now();
|
|
1041
|
-
const state: GoalState = {
|
|
1042
|
-
version: GOAL_VERSION,
|
|
1043
|
-
revision: 1,
|
|
1044
|
-
objective,
|
|
1045
|
-
status: "active",
|
|
1046
|
-
createdAt: now,
|
|
1047
|
-
updatedAt: now,
|
|
1048
|
-
activeMilliseconds: 0,
|
|
1049
|
-
activeStartedAt: now,
|
|
1050
|
-
turns: 0,
|
|
1051
|
-
blockedAuditStartTurn: 0,
|
|
1052
|
-
baselineTokens: sumGoalTokens(ctx),
|
|
1053
|
-
verification: inferGoalVerification(objective),
|
|
1054
|
-
};
|
|
1055
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
|
+
};
|
|
1056
1059
|
persistGoalState(pi, runtime, unfinished ? "replace" : "set", state);
|
|
1057
1060
|
if (scheduleGoalContinuation(pi, runtime, initState, ctx)) {
|
|
1058
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.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";
|
|
@@ -124,8 +125,9 @@ export function registerInitCommand(pi: ExtensionAPI, initState: InitRuntime, go
|
|
|
124
125
|
executionMode: "sequential",
|
|
125
126
|
async execute(_toolCallId, { reason }) {
|
|
126
127
|
requirePending(initState);
|
|
127
|
-
|
|
128
|
-
|
|
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 } };
|
|
129
131
|
},
|
|
130
132
|
});
|
|
131
133
|
|
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
type ExtensionContext,
|
|
6
6
|
} from "@earendil-works/pi-coding-agent";
|
|
7
7
|
import { errorMessage } from "./errors.ts";
|
|
8
|
+
import { safeTerminalText } from "./safe-terminal-text.ts";
|
|
8
9
|
import { createKillerosSettingsStore } from "./settings.ts";
|
|
9
10
|
|
|
10
11
|
export interface NotificationPreferenceStore {
|
|
@@ -38,7 +39,7 @@ export function formatNotificationTitle(
|
|
|
38
39
|
): string {
|
|
39
40
|
const directory = basename(cwd) || cwd;
|
|
40
41
|
const base = sessionName ? `π - ${sessionName} - ${directory}` : `π - ${directory}`;
|
|
41
|
-
return enabled ? `${base} ${COMPLETION_BELL_GLYPH}` : base;
|
|
42
|
+
return safeTerminalText(enabled ? `${base} ${COMPLETION_BELL_GLYPH}` : base).replaceAll("\n", "");
|
|
42
43
|
}
|
|
43
44
|
|
|
44
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/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/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"
|