killeros 2.0.4 → 2.0.5
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 +20 -0
- package/README.md +9 -7
- package/killeros/commands.ts +1 -2
- package/killeros/goals.ts +146 -26
- package/killeros/hooks.ts +25 -10
- package/killeros/init-evidence.ts +49 -13
- package/killeros/notifications.ts +1 -1
- package/killeros/question.ts +24 -9
- package/killeros/runtime.ts +7 -0
- package/killeros/shell-ui.ts +41 -13
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,26 @@ All notable changes to KillerOS are documented here.
|
|
|
4
4
|
|
|
5
5
|
## [Unreleased]
|
|
6
6
|
|
|
7
|
+
## [2.0.5] - 2026-08-11
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- Added shuffled session-stable suggestions to the empty three-line prompt editor.
|
|
12
|
+
|
|
13
|
+
### Fixed
|
|
14
|
+
|
|
15
|
+
- Preserved complete UTF-8 characters in truncated `/init` evidence and chunked lifecycle-hook output, and kept filesystem-root paths visible in notification titles.
|
|
16
|
+
- Kept the startup-tip shuffle deck across session rebinds so every tip appears before the bank repeats.
|
|
17
|
+
- Rejected matchers on `agent_settled` lifecycle hooks instead of silently bypassing their tool-name guard.
|
|
18
|
+
- Made the goal update status schema compatible with Google models while preserving strict completion and blocking values.
|
|
19
|
+
- Kept multiline custom-answer drafts within tiny terminal row limits by rendering a clipped single-line preview.
|
|
20
|
+
- Preserved existing spaces and tabs after the cursor when applying slash-command autocomplete.
|
|
21
|
+
- Made `/init` abort safely when Git ignore inspection fails or returns untrusted output, preventing ignored files from entering model evidence.
|
|
22
|
+
- Wrapped long interactive questions to the current terminal width while keeping the question UI within the terminal height.
|
|
23
|
+
- Made `/goal pause` and `/goal clear` save terminal state before immediately stopping active goal work.
|
|
24
|
+
- Kept failed goal edits and replacements visible and fail-closed without restarting a stale objective.
|
|
25
|
+
- Required one durable blocker key on three consecutive goal turns before a goal can be marked blocked.
|
|
26
|
+
|
|
7
27
|
## [2.0.4] - 2026-08-10
|
|
8
28
|
|
|
9
29
|
### Changed
|
package/README.md
CHANGED
|
@@ -31,7 +31,7 @@ pi install git:github.com/KyrosHendrix/pi-KillerOS
|
|
|
31
31
|
Pin an install to a release:
|
|
32
32
|
|
|
33
33
|
```bash
|
|
34
|
-
pi install git:github.com/KyrosHendrix/pi-KillerOS@v2.0.
|
|
34
|
+
pi install git:github.com/KyrosHendrix/pi-KillerOS@v2.0.5
|
|
35
35
|
```
|
|
36
36
|
|
|
37
37
|
Add `-l` to either command for a project-only install. Restart Pi after installing.
|
|
@@ -41,12 +41,12 @@ Add `-l` to either command for a project-only install. Restart Pi after installi
|
|
|
41
41
|
- 52-column Compact startup card with inline version, polished model/provider identity, adjacent `/model`, directory, conditional Git branch, and a shuffled session-stable tip
|
|
42
42
|
- Cohesive dark theme with coral accents and neutral tool-call containers across pending, success, and error states
|
|
43
43
|
- Animated orange 12-frame activity glyph loop at 120 ms per frame, with orange shuffled Claude-adjacent verbs changing every 2.5 seconds, a gray `(esc to interrupt · thinking)` status with bold `esc`, and a quiet hidden-thinking label
|
|
44
|
-
-
|
|
44
|
+
- Full-width framed multiline editor with a shuffled session-stable empty-state suggestion, Shift+Enter support, and slash-command autocomplete; KillerOS preserves an editor factory configured by another extension
|
|
45
45
|
- Responsive footer with polished model/provider identity, plain-language context, and active goal state remaining; reasoning, Git branch, elapsed time, cost, and path cut down by available width
|
|
46
46
|
- Pi-owned context compaction with active goals continuing from Pi's settled boundary after manual, threshold, and overflow compaction
|
|
47
47
|
- Optional completion sounds after successful or failed settled requests, excluding manual aborts
|
|
48
48
|
- `/variants` selector and direct reasoning-level arguments
|
|
49
|
-
- Codex-style `/goal` with an interactive status/action panel, durable objectives, pause
|
|
49
|
+
- Codex-style `/goal` with an interactive status/action panel, durable objectives, immediate pause and clear cancellation, automatic continuation, explicit completion, and durable blocker audits
|
|
50
50
|
- Automatic `/init` guideline synthesis with a frozen safe evidence map, protected existing policy, and the four packaged behavioral sections adapted from `writing-great-guidelines`
|
|
51
51
|
- `question` tool with height-bounded option windows, configured Pi keybindings, live option/input progress, proposal previews, custom answers, history, cancellation, and compact expandable transcript rendering
|
|
52
52
|
- Mid-prompt slash completion with current Pi `0.82.1` commands, extensions, prompts, and skills; paths, URLs, and invalid commands remain plain text
|
|
@@ -60,9 +60,9 @@ Add `-l` to either command for a project-only install. Restart Pi after installi
|
|
|
60
60
|
/goal Open current goal status and valid actions
|
|
61
61
|
/goal <objective> Set an objective and start working
|
|
62
62
|
/goal edit Edit and reactivate the current goal
|
|
63
|
-
/goal pause Stop automatic continuation
|
|
63
|
+
/goal pause Stop the current goal turn and automatic continuation
|
|
64
64
|
/goal resume Resume automatic continuation
|
|
65
|
-
/goal clear
|
|
65
|
+
/goal clear Stop current goal work and remove the goal
|
|
66
66
|
/variants Open the reasoning-level selector
|
|
67
67
|
/variants high Set a reasoning level directly
|
|
68
68
|
/notification Configure the completion sound
|
|
@@ -70,7 +70,9 @@ Add `-l` to either command for a project-only install. Restart Pi after installi
|
|
|
70
70
|
/exit Quit Pi gracefully
|
|
71
71
|
```
|
|
72
72
|
|
|
73
|
-
`/goal` requires a saved session in TUI or RPC mode. Goal state is stored in versioned session entries on the active branch and restored after reload, resume, fork, or tree navigation. Active goals inject their unchanged objective every turn and continue one settled turn at a time. The model must use KillerOS’s private goal tool to mark verified completion
|
|
73
|
+
`/goal` requires a saved session in TUI or RPC mode. Goal state is stored in versioned session entries on the active branch and restored after reload, resume, fork, or tree navigation. Active goals inject their unchanged objective every turn and continue one settled turn at a time. The model must use KillerOS’s private goal tool to mark verified completion. Blocking requires one stable lowercase blocker key recorded on three consecutive goal turns; a changed key, skipped turn, resume, or edit resets the streak. Final prose alone does not end the loop.
|
|
74
|
+
|
|
75
|
+
`/goal pause` and `/goal clear` save paused or cleared state before aborting current goal work, so settlement cannot restart it. Aborted turns, provider failures, and continuation failures otherwise pause safely. Failed edit and replacement writes dispatch no edited objective; an active prior objective pauses fail-closed, while inactive durable state remains unchanged. Replacing unfinished work requires confirmation, and `/goal edit` requires TUI mode.
|
|
74
76
|
|
|
75
77
|
`/init` freezes a safe project-file map and exposes only dedicated read and list operations while it generates root `AGENTS.md`. Git-ignored files, known secret paths, private-key formats, other guidance, dependencies, links, non-regular files, and files outside that map are unavailable. Existing root `AGENTS.md` is separate protected policy: compatible rules are preserved, a real policy conflict leaves it unchanged with a reason, and any concurrent target change aborts installation without replacing the newer file.
|
|
76
78
|
|
|
@@ -92,7 +94,7 @@ Manual `/compact` aborts the current goal turn before summarization, so KillerOS
|
|
|
92
94
|
|
|
93
95
|
For trusted projects, KillerOS loads `AGENTS.local.md` after Pi's shared repository context. A one-line `@path` or `@~/path` file imports personal guidance from another location.
|
|
94
96
|
|
|
95
|
-
Lifecycle hooks are loaded from `.pi/killeros-hooks.json` at session start. Supported event keys are `tool_call`, `tool_result`, and `agent_settled
|
|
97
|
+
Lifecycle hooks are loaded from `.pi/killeros-hooks.json` at session start. Supported event keys are `tool_call`, `tool_result`, and `agent_settled`. Optional matchers are JavaScript regular expressions over Pi tool names, so they are valid only for `tool_call` and `tool_result`; KillerOS rejects an `agent_settled` hook that defines a matcher. Hook commands run from the repository root with `KILLEROS_EVENT`, `KILLEROS_TOOL`, and `KILLEROS_PAYLOAD` environment variables. Failed `tool_call` hooks block the tool, while later-event failures notify the user. Aborting the parent request stops the hook process tree with bounded graceful and forced cleanup without reporting cancellation as a hook failure.
|
|
96
98
|
|
|
97
99
|
## Behavior by mode
|
|
98
100
|
|
package/killeros/commands.ts
CHANGED
|
@@ -150,12 +150,11 @@ export function registerSlashAutocomplete(pi: ExtensionAPI): void {
|
|
|
150
150
|
usage.set(tagged.killerosCommand, (usage.get(tagged.killerosCommand) ?? 0) + 1);
|
|
151
151
|
const line = lines[cursorLine] ?? "";
|
|
152
152
|
const beforeCursor = line.slice(0, cursorCol);
|
|
153
|
-
|
|
153
|
+
const afterCursor = line.slice(cursorCol);
|
|
154
154
|
const match = beforeCursor.match(/(?:^|[ \t])\/([^\s/]*)$/);
|
|
155
155
|
if (!match || match.index === undefined) return current.applyCompletion(lines, cursorLine, cursorCol, item, prefix);
|
|
156
156
|
const slashIndex = match.index + (match[0].startsWith("/") ? 0 : 1);
|
|
157
157
|
const newBefore = beforeCursor.slice(0, slashIndex) + item.value;
|
|
158
|
-
if (item.value.endsWith(" ") && afterCursor.startsWith(" ")) afterCursor = afterCursor.trimStart();
|
|
159
158
|
const nextLines = [...lines];
|
|
160
159
|
nextLines[cursorLine] = newBefore + afterCursor;
|
|
161
160
|
return { lines: nextLines, cursorLine, cursorCol: newBefore.length };
|
package/killeros/goals.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { StringEnum } from "@earendil-works/pi-ai";
|
|
1
2
|
import { type ExtensionAPI, type ExtensionCommandContext, type ExtensionContext, type ThemeColor } from "@earendil-works/pi-coding-agent";
|
|
2
3
|
import { Text } from "@earendil-works/pi-tui";
|
|
3
4
|
import { Type } from "typebox";
|
|
@@ -6,14 +7,14 @@ import { BoundedText } from "./bounded-text.ts";
|
|
|
6
7
|
import { formatTime, formatTokens } from "./display.ts";
|
|
7
8
|
import { reportError } from "./errors.ts";
|
|
8
9
|
import { resolvePersonalInstructions } from "./personal-instructions.ts";
|
|
9
|
-
import type { GoalRuntime, GoalState, GoalStatus, InitRuntime } from "./runtime.ts";
|
|
10
|
+
import type { GoalBlockerAudit, GoalRuntime, GoalState, GoalStatus, InitRuntime } from "./runtime.ts";
|
|
10
11
|
|
|
11
12
|
const GOAL_ENTRY_TYPE = "killeros-goal";
|
|
12
13
|
const GOAL_CONTINUATION_TYPE = "killeros-goal-continuation";
|
|
13
14
|
const GOAL_OBJECTIVE_LIMIT = 4_000;
|
|
14
15
|
const GOAL_VERSION = 1;
|
|
15
16
|
|
|
16
|
-
type GoalEntryEvent = "set" | "replace" | "edit" | "turn" | "pause" | "resume" | "blocked" | "complete" | "error" | "clear" | "checkpoint";
|
|
17
|
+
type GoalEntryEvent = "set" | "replace" | "edit" | "turn" | "pause" | "resume" | "blocked" | "complete" | "error" | "clear" | "checkpoint" | "blocker-audit";
|
|
17
18
|
interface GoalEntryData {
|
|
18
19
|
version: 1;
|
|
19
20
|
event: GoalEntryEvent;
|
|
@@ -23,6 +24,7 @@ interface GoalEntryData {
|
|
|
23
24
|
interface GoalTransitionOptions {
|
|
24
25
|
resetBlockedAudit?: boolean;
|
|
25
26
|
resumeAfterManualCompaction?: true;
|
|
27
|
+
blockerAudit?: GoalBlockerAudit;
|
|
26
28
|
}
|
|
27
29
|
|
|
28
30
|
interface RestoredGoalState {
|
|
@@ -31,7 +33,7 @@ interface RestoredGoalState {
|
|
|
31
33
|
}
|
|
32
34
|
|
|
33
35
|
const GoalUpdateParams = Type.Object({
|
|
34
|
-
status:
|
|
36
|
+
status: StringEnum(["complete", "blocked"] as const, {
|
|
35
37
|
description: "Mark the active goal complete or blocked",
|
|
36
38
|
}),
|
|
37
39
|
evidence: Type.String({
|
|
@@ -39,11 +41,19 @@ const GoalUpdateParams = Type.Object({
|
|
|
39
41
|
maxLength: 2_000,
|
|
40
42
|
description: "Concise evidence that the objective is complete, or the repeated blocker and attempted workarounds",
|
|
41
43
|
}),
|
|
44
|
+
blockerKey: Type.Optional(Type.String({
|
|
45
|
+
minLength: 1,
|
|
46
|
+
maxLength: 120,
|
|
47
|
+
pattern: "^[a-z0-9][a-z0-9._-]{0,119}$",
|
|
48
|
+
description: "Stable lowercase key identifying the repeated blocker",
|
|
49
|
+
})),
|
|
42
50
|
});
|
|
43
51
|
|
|
44
52
|
interface GoalUpdateDetails {
|
|
45
|
-
status: "complete" | "blocked";
|
|
53
|
+
status: "complete" | "blocked" | "blocker-audit";
|
|
46
54
|
evidence: string;
|
|
55
|
+
blockerKey?: string;
|
|
56
|
+
streak?: number;
|
|
47
57
|
}
|
|
48
58
|
|
|
49
59
|
function isGoalStatus(value: unknown): value is GoalStatus {
|
|
@@ -54,6 +64,17 @@ function finiteNonNegative(value: unknown): value is number {
|
|
|
54
64
|
return typeof value === "number" && Number.isFinite(value) && value >= 0;
|
|
55
65
|
}
|
|
56
66
|
|
|
67
|
+
function isGoalBlockerAudit(value: unknown, turns: number, status: GoalStatus): value is GoalBlockerAudit {
|
|
68
|
+
if (!value || typeof value !== "object") return false;
|
|
69
|
+
const candidate = value as Partial<GoalBlockerAudit>;
|
|
70
|
+
if (typeof candidate.key !== "string"
|
|
71
|
+
|| !/^[a-z0-9][a-z0-9._-]{0,119}$/u.test(candidate.key)
|
|
72
|
+
|| !Number.isInteger(candidate.streak) || (candidate.streak ?? 0) < 1 || (candidate.streak ?? 0) > 3
|
|
73
|
+
|| !Number.isInteger(candidate.lastTurn) || (candidate.lastTurn ?? 0) < 1 || (candidate.lastTurn ?? 0) > turns) return false;
|
|
74
|
+
if (status === "complete") return false;
|
|
75
|
+
return status === "blocked" ? candidate.streak === 3 : candidate.streak! < 3;
|
|
76
|
+
}
|
|
77
|
+
|
|
57
78
|
function parseGoalState(value: unknown): GoalState | undefined {
|
|
58
79
|
if (!value || typeof value !== "object") return undefined;
|
|
59
80
|
const candidate = value as Partial<GoalState>;
|
|
@@ -71,6 +92,7 @@ function parseGoalState(value: unknown): GoalState | undefined {
|
|
|
71
92
|
|| !finiteNonNegative(candidate.baselineTokens)
|
|
72
93
|
|| candidate.activeStartedAt !== undefined && !finiteNonNegative(candidate.activeStartedAt)
|
|
73
94
|
|| candidate.result !== undefined && typeof candidate.result !== "string"
|
|
95
|
+
|| candidate.blockerAudit !== undefined && !isGoalBlockerAudit(candidate.blockerAudit, candidate.turns!, candidate.status)
|
|
74
96
|
|| candidate.resumeAfterManualCompaction !== undefined && candidate.resumeAfterManualCompaction !== true
|
|
75
97
|
|| candidate.resumeAfterManualCompaction === true && candidate.status !== "paused") {
|
|
76
98
|
return undefined;
|
|
@@ -89,6 +111,7 @@ function parseGoalState(value: unknown): GoalState | undefined {
|
|
|
89
111
|
baselineTokens: candidate.baselineTokens,
|
|
90
112
|
result: candidate.result,
|
|
91
113
|
resumeAfterManualCompaction: candidate.resumeAfterManualCompaction,
|
|
114
|
+
blockerAudit: candidate.blockerAudit,
|
|
92
115
|
};
|
|
93
116
|
}
|
|
94
117
|
|
|
@@ -182,6 +205,7 @@ function transitionGoal(
|
|
|
182
205
|
updatedAt: now,
|
|
183
206
|
activeStartedAt: status === "active" ? now : undefined,
|
|
184
207
|
blockedAuditStartTurn: options.resetBlockedAudit ? stopped.turns : stopped.blockedAuditStartTurn,
|
|
208
|
+
blockerAudit: options.resetBlockedAudit ? undefined : options.blockerAudit ?? stopped.blockerAudit,
|
|
185
209
|
result,
|
|
186
210
|
resumeAfterManualCompaction: options.resumeAfterManualCompaction,
|
|
187
211
|
};
|
|
@@ -190,6 +214,24 @@ function transitionGoal(
|
|
|
190
214
|
return next;
|
|
191
215
|
}
|
|
192
216
|
|
|
217
|
+
function clearGoalExecutionFlags(runtime: GoalRuntime): void {
|
|
218
|
+
runtime.continuationScheduled = false;
|
|
219
|
+
runtime.goalTurnInFlight = false;
|
|
220
|
+
runtime.agentEndObserved = false;
|
|
221
|
+
runtime.lastStopReason = undefined;
|
|
222
|
+
runtime.lastError = undefined;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
async function stopGoalRun(runtime: GoalRuntime, ctx: ExtensionCommandContext, shouldStop: boolean): Promise<void> {
|
|
226
|
+
if (!shouldStop) return;
|
|
227
|
+
try {
|
|
228
|
+
ctx.abort();
|
|
229
|
+
await ctx.waitForIdle();
|
|
230
|
+
} finally {
|
|
231
|
+
clearGoalExecutionFlags(runtime);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
193
235
|
function goalStatusLabel(status: GoalStatus): string {
|
|
194
236
|
return `${status.charAt(0).toLocaleUpperCase()}${status.slice(1)}`;
|
|
195
237
|
}
|
|
@@ -222,6 +264,7 @@ export function pauseGoalAfterFailure(
|
|
|
222
264
|
ctx: ExtensionContext,
|
|
223
265
|
reason: string,
|
|
224
266
|
recoveryInstruction = "Run /goal resume after resolving the problem.",
|
|
267
|
+
notify = true,
|
|
225
268
|
): void {
|
|
226
269
|
if (runtime.state?.status !== "active") return;
|
|
227
270
|
try {
|
|
@@ -237,7 +280,7 @@ export function pauseGoalAfterFailure(
|
|
|
237
280
|
runtime.continuationScheduled = false;
|
|
238
281
|
runtime.requestRender?.();
|
|
239
282
|
}
|
|
240
|
-
ctx.ui.notify(`Goal paused: ${reason}\n${recoveryInstruction}`, "error");
|
|
283
|
+
if (notify) ctx.ui.notify(`Goal paused: ${reason}\n${recoveryInstruction}`, "error");
|
|
241
284
|
}
|
|
242
285
|
|
|
243
286
|
function pauseGoalForPossibleManualCompaction(
|
|
@@ -303,6 +346,7 @@ function scheduleGoalContinuation(
|
|
|
303
346
|
|| runtime.continuationHeld
|
|
304
347
|
|| runtime.goalTurnInFlight
|
|
305
348
|
|| initState.active
|
|
349
|
+
|| !ctx.isIdle()
|
|
306
350
|
|| ctx.hasPendingMessages()) return false;
|
|
307
351
|
const current = runtime.state;
|
|
308
352
|
runtime.continuationScheduled = true;
|
|
@@ -337,7 +381,7 @@ function goalInstructions(state: GoalState, heading: string): string {
|
|
|
337
381
|
"Continue making concrete progress toward this unchanged objective. Re-check repository state and prior results instead of repeating work.",
|
|
338
382
|
"Do not stop merely because one response is complete: KillerOS will start another goal turn while the goal remains active.",
|
|
339
383
|
"Before declaring completion, audit every part of the objective and verify the relevant results. Then call killeros_goal_update with status complete and concise evidence.",
|
|
340
|
-
"Call killeros_goal_update with status blocked
|
|
384
|
+
"Call killeros_goal_update with status blocked and the same lowercase blockerKey on each turn where one external impasse persists; attempts one and two record the audit, and attempt three marks the goal blocked.",
|
|
341
385
|
"Never use the goal tool to pause, resume, edit, replace, or clear the objective. Those transitions belong to the user.",
|
|
342
386
|
].join("\n");
|
|
343
387
|
}
|
|
@@ -398,7 +442,7 @@ export function registerGoal(
|
|
|
398
442
|
pi.registerTool<typeof GoalUpdateParams, GoalUpdateDetails>({
|
|
399
443
|
name: "killeros_goal_update",
|
|
400
444
|
label: "Goal update",
|
|
401
|
-
description: "Mark the active KillerOS long-running goal complete after verification, or
|
|
445
|
+
description: "Mark the active KillerOS long-running goal complete after verification, or record the same blocker key on three consecutive goal turns before blocking it.",
|
|
402
446
|
parameters: GoalUpdateParams,
|
|
403
447
|
executionMode: "sequential",
|
|
404
448
|
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
@@ -408,13 +452,40 @@ export function registerGoal(
|
|
|
408
452
|
if (!state || state.status !== "active") throw new Error("There is no active KillerOS goal to update");
|
|
409
453
|
const evidence = params.evidence.trim();
|
|
410
454
|
if (!evidence) throw new Error("Goal evidence must not be empty");
|
|
411
|
-
if (params.status === "
|
|
412
|
-
|
|
455
|
+
if (params.status === "complete") {
|
|
456
|
+
transitionGoal(pi, runtime, "complete", "complete", evidence, { resetBlockedAudit: true });
|
|
457
|
+
return {
|
|
458
|
+
content: [{ type: "text", text: `Goal marked complete: ${evidence}` }],
|
|
459
|
+
details: { status: "complete", evidence },
|
|
460
|
+
};
|
|
413
461
|
}
|
|
414
|
-
|
|
462
|
+
if (!runtime.goalTurnInFlight) throw new Error("A blocker audit can only be recorded during an active KillerOS goal turn");
|
|
463
|
+
const blockerKey = params.blockerKey;
|
|
464
|
+
if (!blockerKey || !/^[a-z0-9][a-z0-9._-]{0,119}$/u.test(blockerKey)) {
|
|
465
|
+
throw new Error("A blocked goal update requires a stable lowercase blockerKey");
|
|
466
|
+
}
|
|
467
|
+
const previous = state.blockerAudit;
|
|
468
|
+
const sameTurn = previous?.key === blockerKey && previous.lastTurn === state.turns;
|
|
469
|
+
const consecutive = previous?.key === blockerKey && previous.lastTurn === state.turns - 1;
|
|
470
|
+
const streak = sameTurn ? previous.streak : consecutive ? previous.streak + 1 : 1;
|
|
471
|
+
const blockerAudit = { key: blockerKey, streak, lastTurn: state.turns };
|
|
472
|
+
if (streak < 3) {
|
|
473
|
+
const next: GoalState = {
|
|
474
|
+
...state,
|
|
475
|
+
revision: state.revision + 1,
|
|
476
|
+
updatedAt: Date.now(),
|
|
477
|
+
blockerAudit,
|
|
478
|
+
};
|
|
479
|
+
persistGoalState(pi, runtime, "blocker-audit", next);
|
|
480
|
+
return {
|
|
481
|
+
content: [{ type: "text", text: `Blocker audit ${streak}/3 recorded; the goal remains active: ${evidence}` }],
|
|
482
|
+
details: { status: "blocker-audit", evidence, blockerKey, streak },
|
|
483
|
+
};
|
|
484
|
+
}
|
|
485
|
+
transitionGoal(pi, runtime, "blocked", "blocked", evidence, { blockerAudit });
|
|
415
486
|
return {
|
|
416
|
-
content: [{ type: "text", text: `Goal marked
|
|
417
|
-
details: { status:
|
|
487
|
+
content: [{ type: "text", text: `Goal marked blocked: ${evidence}` }],
|
|
488
|
+
details: { status: "blocked", evidence, blockerKey, streak },
|
|
418
489
|
};
|
|
419
490
|
},
|
|
420
491
|
renderCall(args, theme) {
|
|
@@ -423,7 +494,8 @@ export function registerGoal(
|
|
|
423
494
|
renderResult(result, options, theme) {
|
|
424
495
|
const details = result.details;
|
|
425
496
|
if (!details) return new BoundedText(theme.fg("dim", "Goal updated"));
|
|
426
|
-
const
|
|
497
|
+
const label = details.status === "complete" ? "✓ Complete" : details.status === "blocked" ? "! Blocked" : `! Blocker audit ${details.streak}/3`;
|
|
498
|
+
const text = `${theme.fg(details.status === "complete" ? "success" : "warning", label)}${theme.fg("dim", ` · ${details.evidence}`)}`;
|
|
427
499
|
return new BoundedText(text, options.expanded ? undefined : 3);
|
|
428
500
|
},
|
|
429
501
|
});
|
|
@@ -544,10 +616,11 @@ export function registerGoal(
|
|
|
544
616
|
ctx.ui.notify("No goal is set", "info");
|
|
545
617
|
return;
|
|
546
618
|
}
|
|
619
|
+
const shouldStopGoalRun = runtime.goalTurnInFlight || runtime.continuationScheduled;
|
|
620
|
+
let saved = false;
|
|
547
621
|
try {
|
|
548
622
|
persistGoalState(pi, runtime, "clear", undefined);
|
|
549
|
-
|
|
550
|
-
ctx.ui.notify("Goal cleared", "info");
|
|
623
|
+
saved = true;
|
|
551
624
|
} catch (error) {
|
|
552
625
|
if (runtime.state?.status === "active") {
|
|
553
626
|
pauseGoalAfterFailure(
|
|
@@ -556,11 +629,24 @@ export function registerGoal(
|
|
|
556
629
|
ctx,
|
|
557
630
|
`the requested clear could not be saved: ${error instanceof Error ? error.message : String(error)}`,
|
|
558
631
|
"Automatic continuation is stopped. Retry /goal clear to remove the goal.",
|
|
632
|
+
false,
|
|
559
633
|
);
|
|
560
634
|
} else {
|
|
561
635
|
reportError(ctx, "Goal could not be cleared", error);
|
|
636
|
+
return;
|
|
562
637
|
}
|
|
563
638
|
}
|
|
639
|
+
try {
|
|
640
|
+
await stopGoalRun(runtime, ctx, shouldStopGoalRun);
|
|
641
|
+
} catch (error) {
|
|
642
|
+
reportError(ctx, saved ? "Goal cleared, but the active goal turn could not be confirmed stopped" : "Goal paused, but the active goal turn could not be confirmed stopped", error);
|
|
643
|
+
return;
|
|
644
|
+
}
|
|
645
|
+
if (saved) {
|
|
646
|
+
ctx.ui.notify("Goal cleared", "info");
|
|
647
|
+
} else {
|
|
648
|
+
ctx.ui.notify("Goal paused: the requested clear could not be saved\nAutomatic continuation is stopped. Retry /goal clear to remove the goal.", "error");
|
|
649
|
+
}
|
|
564
650
|
return;
|
|
565
651
|
}
|
|
566
652
|
|
|
@@ -597,18 +683,34 @@ export function registerGoal(
|
|
|
597
683
|
ctx.ui.notify(`Goal is ${runtime.state.status}; only an active goal can be paused`, "warning");
|
|
598
684
|
return;
|
|
599
685
|
}
|
|
686
|
+
const shouldStopGoalRun = runtime.goalTurnInFlight || runtime.continuationScheduled;
|
|
687
|
+
let saved = false;
|
|
688
|
+
let failureReason: string | undefined;
|
|
600
689
|
try {
|
|
601
690
|
transitionGoal(pi, runtime, "pause", "paused");
|
|
602
|
-
|
|
691
|
+
saved = true;
|
|
603
692
|
} catch (error) {
|
|
693
|
+
failureReason = `the requested pause could not be saved: ${error instanceof Error ? error.message : String(error)}`;
|
|
604
694
|
pauseGoalAfterFailure(
|
|
605
695
|
pi,
|
|
606
696
|
runtime,
|
|
607
697
|
ctx,
|
|
608
|
-
|
|
698
|
+
failureReason,
|
|
609
699
|
"Automatic continuation is stopped. If session storage is still unavailable, retry /goal pause after it recovers.",
|
|
700
|
+
false,
|
|
610
701
|
);
|
|
611
702
|
}
|
|
703
|
+
try {
|
|
704
|
+
await stopGoalRun(runtime, ctx, shouldStopGoalRun);
|
|
705
|
+
} catch (error) {
|
|
706
|
+
reportError(ctx, "Goal paused, but the active goal turn could not be confirmed stopped", error);
|
|
707
|
+
return;
|
|
708
|
+
}
|
|
709
|
+
if (saved) {
|
|
710
|
+
ctx.ui.notify("Goal paused. Run /goal resume to continue.", "info");
|
|
711
|
+
} else {
|
|
712
|
+
ctx.ui.notify(`Goal paused: ${failureReason}\nAutomatic continuation is stopped. If session storage is still unavailable, retry /goal pause after it recovers.`, "error");
|
|
713
|
+
}
|
|
612
714
|
return;
|
|
613
715
|
}
|
|
614
716
|
|
|
@@ -687,6 +789,7 @@ export function registerGoal(
|
|
|
687
789
|
updatedAt: now,
|
|
688
790
|
activeStartedAt: now,
|
|
689
791
|
blockedAuditStartTurn: current.turns,
|
|
792
|
+
blockerAudit: undefined,
|
|
690
793
|
result: undefined,
|
|
691
794
|
resumeAfterManualCompaction: undefined,
|
|
692
795
|
};
|
|
@@ -695,13 +798,17 @@ export function registerGoal(
|
|
|
695
798
|
runtime.continuationScheduled = false;
|
|
696
799
|
if (scheduleGoalContinuation(pi, runtime, initState, ctx)) ctx.ui.notify("Goal updated and active", "info");
|
|
697
800
|
} catch (error) {
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
801
|
+
if (runtime.state?.status === "active") {
|
|
802
|
+
pauseGoalAfterFailure(
|
|
803
|
+
pi,
|
|
804
|
+
runtime,
|
|
805
|
+
ctx,
|
|
806
|
+
`Goal could not be edited: ${error instanceof Error ? error.message : String(error)}`,
|
|
807
|
+
"Automatic continuation is stopped. Retry /goal edit after session storage recovers.",
|
|
808
|
+
);
|
|
809
|
+
} else {
|
|
810
|
+
reportError(ctx, "Goal could not be edited", error);
|
|
811
|
+
}
|
|
705
812
|
}
|
|
706
813
|
return;
|
|
707
814
|
}
|
|
@@ -761,8 +868,19 @@ export function registerGoal(
|
|
|
761
868
|
ctx.ui.notify("Goal active. KillerOS will continue until completion, a repeated blocker, or pause.", "info");
|
|
762
869
|
}
|
|
763
870
|
} catch (error) {
|
|
764
|
-
|
|
765
|
-
|
|
871
|
+
if (!unfinished) {
|
|
872
|
+
reportError(ctx, "Goal could not be started", error);
|
|
873
|
+
} else if (runtime.state?.status === "active") {
|
|
874
|
+
pauseGoalAfterFailure(
|
|
875
|
+
pi,
|
|
876
|
+
runtime,
|
|
877
|
+
ctx,
|
|
878
|
+
`Goal could not be replaced: ${error instanceof Error ? error.message : String(error)}`,
|
|
879
|
+
"Automatic continuation is stopped. Retry replacement after session storage recovers.",
|
|
880
|
+
);
|
|
881
|
+
} else {
|
|
882
|
+
reportError(ctx, "Goal could not be replaced", error);
|
|
883
|
+
}
|
|
766
884
|
}
|
|
767
885
|
};
|
|
768
886
|
|
|
@@ -800,6 +918,8 @@ export function registerGoalSettlement(
|
|
|
800
918
|
if (!wasGoalTurn || runtime.state?.status !== "active" || initState.active) {
|
|
801
919
|
if (continuationWasScheduled && runtime.state?.status === "active" && !initState.active) {
|
|
802
920
|
pauseGoalAfterFailure(pi, runtime, ctx, "the goal continuation ended before an agent turn started");
|
|
921
|
+
} else if (runtime.state?.status === "active" && !initState.active) {
|
|
922
|
+
scheduleGoalContinuation(pi, runtime, initState, ctx);
|
|
803
923
|
}
|
|
804
924
|
return;
|
|
805
925
|
}
|
package/killeros/hooks.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
2
|
import { existsSync, readFileSync } from "node:fs";
|
|
3
3
|
import path from "node:path";
|
|
4
|
+
import { StringDecoder } from "node:string_decoder";
|
|
4
5
|
import { CONFIG_DIR_NAME, type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
5
6
|
import { reportError } from "./errors.ts";
|
|
6
7
|
import { MAX_NODE_TIMER_MS } from "./limits.ts";
|
|
@@ -44,6 +45,10 @@ function loadKillerosHooks(ctx: ExtensionContext): KillerosHookConfig {
|
|
|
44
45
|
const candidates = parsed.hooks?.[event];
|
|
45
46
|
if (!Array.isArray(candidates)) continue;
|
|
46
47
|
hooks[event] = candidates.filter((hook, index) => {
|
|
48
|
+
if (event === "agent_settled" && hook?.matcher !== undefined) {
|
|
49
|
+
ctx.ui.notify(`Ignored ${event} hook ${index + 1}: matchers are only valid for tool events`, "warning");
|
|
50
|
+
return false;
|
|
51
|
+
}
|
|
47
52
|
const valid = hook
|
|
48
53
|
&& typeof hook.command === "string"
|
|
49
54
|
&& hook.command.trim().length > 0
|
|
@@ -80,9 +85,19 @@ function matchesHook(hook: KillerosHook, value: string): boolean {
|
|
|
80
85
|
}
|
|
81
86
|
}
|
|
82
87
|
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
88
|
+
interface HookOutputBuffer {
|
|
89
|
+
bytes: number;
|
|
90
|
+
decoder: StringDecoder;
|
|
91
|
+
text: string;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function appendBounded(output: HookOutputBuffer, chunk: Buffer | string): void {
|
|
95
|
+
const remaining = HOOK_OUTPUT_LIMIT - output.bytes;
|
|
96
|
+
if (remaining <= 0) return;
|
|
97
|
+
const bytes = typeof chunk === "string" ? Buffer.from(chunk, "utf8") : chunk;
|
|
98
|
+
const captured = bytes.subarray(0, remaining);
|
|
99
|
+
output.bytes += captured.length;
|
|
100
|
+
output.text += output.decoder.write(captured);
|
|
86
101
|
}
|
|
87
102
|
|
|
88
103
|
function terminateHookProcess(child: ReturnType<typeof spawn>, force: boolean): void {
|
|
@@ -130,8 +145,8 @@ export function executeHook(
|
|
|
130
145
|
stdio: ["ignore", "pipe", "pipe"],
|
|
131
146
|
windowsHide: true,
|
|
132
147
|
});
|
|
133
|
-
|
|
134
|
-
|
|
148
|
+
const stdout: HookOutputBuffer = { bytes: 0, decoder: new StringDecoder("utf8"), text: "" };
|
|
149
|
+
const stderr: HookOutputBuffer = { bytes: 0, decoder: new StringDecoder("utf8"), text: "" };
|
|
135
150
|
let completed = false;
|
|
136
151
|
let termination: "timeout" | "cancelled" | undefined;
|
|
137
152
|
let timer: NodeJS.Timeout | undefined;
|
|
@@ -146,8 +161,8 @@ export function executeHook(
|
|
|
146
161
|
signal?.removeEventListener("abort", abort);
|
|
147
162
|
resolve({
|
|
148
163
|
code,
|
|
149
|
-
stdout,
|
|
150
|
-
stderr,
|
|
164
|
+
stdout: stdout.text,
|
|
165
|
+
stderr: stderr.text,
|
|
151
166
|
timedOut: termination === "timeout",
|
|
152
167
|
cancelled: termination === "cancelled",
|
|
153
168
|
exitUnconfirmed,
|
|
@@ -167,10 +182,10 @@ export function executeHook(
|
|
|
167
182
|
const abort = (): void => beginTermination("cancelled");
|
|
168
183
|
signal?.addEventListener("abort", abort, { once: true });
|
|
169
184
|
if (signal?.aborted) beginTermination("cancelled");
|
|
170
|
-
child.stdout.on("data", (chunk) =>
|
|
171
|
-
child.stderr.on("data", (chunk) =>
|
|
185
|
+
child.stdout.on("data", (chunk) => appendBounded(stdout, chunk));
|
|
186
|
+
child.stderr.on("data", (chunk) => appendBounded(stderr, chunk));
|
|
172
187
|
child.on("error", (error) => {
|
|
173
|
-
|
|
188
|
+
appendBounded(stderr, error.message);
|
|
174
189
|
finish(termination ? terminationCode() : 1);
|
|
175
190
|
});
|
|
176
191
|
child.once("close", (code) => finish(termination ? terminationCode() : code ?? 1));
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
2
|
import { promises as fs } from "node:fs";
|
|
3
3
|
import path from "node:path";
|
|
4
|
+
import { StringDecoder } from "node:string_decoder";
|
|
4
5
|
|
|
5
6
|
export const INIT_READ_TOOL = "killeros_init_read";
|
|
6
7
|
export const INIT_LIST_TOOL = "killeros_init_list";
|
|
@@ -91,7 +92,7 @@ async function collectCandidates(projectRoot: string): Promise<string[]> {
|
|
|
91
92
|
|
|
92
93
|
async function gitIgnoredPaths(projectRoot: string, candidates: readonly string[]): Promise<ReadonlySet<string>> {
|
|
93
94
|
if (!candidates.length) return new Set();
|
|
94
|
-
return new Promise((resolve) => {
|
|
95
|
+
return new Promise((resolve, reject) => {
|
|
95
96
|
let settled = false;
|
|
96
97
|
let stdout = Buffer.alloc(0);
|
|
97
98
|
const child = spawn("git", ["-C", projectRoot, "check-ignore", "--stdin", "-z"], {
|
|
@@ -99,7 +100,13 @@ async function gitIgnoredPaths(projectRoot: string, candidates: readonly string[
|
|
|
99
100
|
stdio: ["pipe", "pipe", "ignore"],
|
|
100
101
|
windowsHide: true,
|
|
101
102
|
});
|
|
102
|
-
const
|
|
103
|
+
const fail = (): void => {
|
|
104
|
+
if (settled) return;
|
|
105
|
+
settled = true;
|
|
106
|
+
clearTimeout(timer);
|
|
107
|
+
reject(new Error("Git ignore inspection failed; /init did not build repository evidence"));
|
|
108
|
+
};
|
|
109
|
+
const succeed = (value: ReadonlySet<string>): void => {
|
|
103
110
|
if (settled) return;
|
|
104
111
|
settled = true;
|
|
105
112
|
clearTimeout(timer);
|
|
@@ -107,30 +114,59 @@ async function gitIgnoredPaths(projectRoot: string, candidates: readonly string[
|
|
|
107
114
|
};
|
|
108
115
|
const timer = setTimeout(() => {
|
|
109
116
|
child.kill("SIGKILL");
|
|
110
|
-
|
|
117
|
+
fail();
|
|
111
118
|
}, 2_000);
|
|
112
119
|
child.stdout.on("data", (chunk: Buffer) => {
|
|
113
|
-
if (stdout.length
|
|
120
|
+
if (stdout.length + chunk.length > 256 * 1024) {
|
|
121
|
+
child.kill("SIGKILL");
|
|
122
|
+
fail();
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
stdout = Buffer.concat([stdout, chunk]);
|
|
126
|
+
});
|
|
127
|
+
child.once("error", fail);
|
|
128
|
+
child.stdin.once("error", () => {
|
|
129
|
+
child.kill("SIGKILL");
|
|
130
|
+
fail();
|
|
114
131
|
});
|
|
115
|
-
child.once("error", () => finish(new Set()));
|
|
116
132
|
child.once("close", (code) => {
|
|
117
|
-
if (code === 1)
|
|
118
|
-
|
|
133
|
+
if (code === 1) {
|
|
134
|
+
if (stdout.length) fail();
|
|
135
|
+
else succeed(new Set());
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
if (code !== 0 || !stdout.length || stdout.at(-1) !== 0) {
|
|
139
|
+
fail();
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
let values: string[];
|
|
143
|
+
try {
|
|
144
|
+
values = new TextDecoder("utf-8", { fatal: true }).decode(stdout.subarray(0, -1)).split("\0");
|
|
145
|
+
} catch {
|
|
146
|
+
fail();
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
119
149
|
const candidateSet = new Set(candidates.map(evidenceKey));
|
|
120
|
-
const
|
|
121
|
-
if (values.some((value) => !value || !candidateSet.has(evidenceKey(value)))
|
|
122
|
-
|
|
150
|
+
const ignored = new Set(values.map(evidenceKey));
|
|
151
|
+
if (values.some((value) => !value || !candidateSet.has(evidenceKey(value))) || ignored.size !== values.length) {
|
|
152
|
+
fail();
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
succeed(ignored);
|
|
123
156
|
});
|
|
124
|
-
child.stdin.on("error", () => {});
|
|
125
157
|
child.stdin.end(`${candidates.join("\0")}\0`);
|
|
126
158
|
});
|
|
127
159
|
}
|
|
128
160
|
|
|
161
|
+
function decodeCompleteUtf8(bytes: Buffer): string {
|
|
162
|
+
return new StringDecoder("utf8").write(bytes);
|
|
163
|
+
}
|
|
164
|
+
|
|
129
165
|
function appendWithinLimit(current: string, section: string, limit: number): string {
|
|
130
166
|
const remaining = limit - Buffer.byteLength(current, "utf8");
|
|
131
167
|
if (remaining <= 0) return current;
|
|
132
168
|
const bytes = Buffer.from(section, "utf8");
|
|
133
|
-
return current + bytes.subarray(0, remaining)
|
|
169
|
+
return current + decodeCompleteUtf8(bytes.subarray(0, remaining));
|
|
134
170
|
}
|
|
135
171
|
|
|
136
172
|
async function validateAndRead(projectRoot: string, absolutePath: string, limit: number): Promise<{ content: string; truncated: boolean }> {
|
|
@@ -157,7 +193,7 @@ async function validateAndRead(projectRoot: string, absolutePath: string, limit:
|
|
|
157
193
|
const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0);
|
|
158
194
|
const data = buffer.subarray(0, Math.min(bytesRead, limit));
|
|
159
195
|
if (data.includes(0)) throw new Error("/init rejects binary files");
|
|
160
|
-
return { content: data
|
|
196
|
+
return { content: decodeCompleteUtf8(data), truncated: bytesRead > limit };
|
|
161
197
|
} finally {
|
|
162
198
|
await handle.close();
|
|
163
199
|
}
|
|
@@ -65,7 +65,7 @@ export function formatNotificationTitle(
|
|
|
65
65
|
sessionName: string | undefined,
|
|
66
66
|
enabled: boolean,
|
|
67
67
|
): string {
|
|
68
|
-
const directory = basename(cwd);
|
|
68
|
+
const directory = basename(cwd) || cwd;
|
|
69
69
|
const base = sessionName ? `π - ${sessionName} - ${directory}` : `π - ${directory}`;
|
|
70
70
|
return enabled ? `${base} ${COMPLETION_BELL_GLYPH}` : base;
|
|
71
71
|
}
|
package/killeros/question.ts
CHANGED
|
@@ -89,12 +89,24 @@ function oneLine(value: string): string {
|
|
|
89
89
|
return value.replace(/\s+/gu, " ").trim();
|
|
90
90
|
}
|
|
91
91
|
|
|
92
|
+
function boundedRenderLine(value: string, width: number, suffix: string): string {
|
|
93
|
+
return truncateToWidth(value.replace(/\r\n|\r|\n/gu, " "), width, suffix);
|
|
94
|
+
}
|
|
95
|
+
|
|
92
96
|
function visibleOptionRange(total: number, selected: number, capacity: number): { start: number; end: number } {
|
|
93
97
|
const size = Math.max(1, Math.min(total, capacity));
|
|
94
98
|
const start = Math.max(0, Math.min(selected - Math.floor(size / 2), total - size));
|
|
95
99
|
return { start, end: Math.min(total, start + size) };
|
|
96
100
|
}
|
|
97
101
|
|
|
102
|
+
function boundedQuestionLines(question: string, width: number, rowLimit: number): string[] {
|
|
103
|
+
const wrapped = wrapTextWithAnsi(question.replace(/\s+/gu, " ").trim(), width);
|
|
104
|
+
if (wrapped.length <= rowLimit) return wrapped;
|
|
105
|
+
const visible = wrapped.slice(0, rowLimit);
|
|
106
|
+
visible[rowLimit - 1] = truncateToWidth(visible[rowLimit - 1]!, width, "…");
|
|
107
|
+
return visible;
|
|
108
|
+
}
|
|
109
|
+
|
|
98
110
|
export function registerQuestionTool(pi: ExtensionAPI): void {
|
|
99
111
|
const customInputHistory: string[] = [];
|
|
100
112
|
let customInputHistoryBytes = 0;
|
|
@@ -349,27 +361,29 @@ export function registerQuestionTool(pi: ExtensionAPI): void {
|
|
|
349
361
|
if (optionIndex >= visibleOptions.length) optionIndex = Math.max(0, visibleOptions.length - 1);
|
|
350
362
|
const selected = visibleOptions[optionIndex];
|
|
351
363
|
const position = `Option ${Math.min(optionIndex + 1, visibleOptions.length)}/${visibleOptions.length}`;
|
|
352
|
-
const
|
|
364
|
+
const expandedAnswer = editor.getExpandedText();
|
|
365
|
+
const answerCount = inputCharacterCount(expandedAnswer).toLocaleString();
|
|
353
366
|
const filterCount = inputCharacterCount(filterQuery).toLocaleString();
|
|
367
|
+
const compactDraft = expandedAnswer.replace(/\r\n|\r|\n/gu, " ↵ ") || "Type an answer";
|
|
354
368
|
|
|
355
369
|
if (rowBudget <= 2) {
|
|
356
370
|
const compact = editMode
|
|
357
|
-
? [`Answer ${answerCount}/${CUSTOM_INPUT_MAX_CHARACTERS.toLocaleString()}`,
|
|
371
|
+
? [`Answer ${answerCount}/${CUSTOM_INPUT_MAX_CHARACTERS.toLocaleString()}`, compactDraft]
|
|
358
372
|
: [`${selected ? `> ${selected.label}` : "No matching options"} · ${position}`];
|
|
359
373
|
cachedWidth = renderWidth;
|
|
360
374
|
cachedRows = rowBudget;
|
|
361
|
-
cachedLines = compact.slice(0, rowBudget).map((line) =>
|
|
375
|
+
cachedLines = compact.slice(0, rowBudget).map((line) => boundedRenderLine(line, renderWidth, "…"));
|
|
362
376
|
return cachedLines;
|
|
363
377
|
}
|
|
364
378
|
|
|
365
379
|
if (rowBudget <= 5) {
|
|
366
380
|
const compact = [
|
|
367
|
-
|
|
381
|
+
...boundedQuestionLines(params.question, renderWidth, Math.max(1, rowBudget - 3)),
|
|
368
382
|
editMode
|
|
369
383
|
? `Answer ${answerCount}/${CUSTOM_INPUT_MAX_CHARACTERS.toLocaleString()}`
|
|
370
384
|
: `${selected ? `> ${selected.label}` : "No matching options"} · ${position}`,
|
|
371
385
|
editMode
|
|
372
|
-
?
|
|
386
|
+
? compactDraft
|
|
373
387
|
: filterQuery
|
|
374
388
|
? `Filter ${filterCount}/${FILTER_QUERY_MAX_CHARACTERS.toLocaleString()}`
|
|
375
389
|
: position,
|
|
@@ -379,11 +393,12 @@ export function registerQuestionTool(pi: ExtensionAPI): void {
|
|
|
379
393
|
];
|
|
380
394
|
cachedWidth = renderWidth;
|
|
381
395
|
cachedRows = rowBudget;
|
|
382
|
-
cachedLines = compact.slice(0, rowBudget).map((line) =>
|
|
396
|
+
cachedLines = compact.slice(0, rowBudget).map((line) => boundedRenderLine(line, renderWidth, "…"));
|
|
383
397
|
return cachedLines;
|
|
384
398
|
}
|
|
385
399
|
|
|
386
|
-
const
|
|
400
|
+
const questionLines = boundedQuestionLines(params.question, renderWidth, Math.max(1, rowBudget - 5));
|
|
401
|
+
const contentRows = rowBudget - questionLines.length - 4;
|
|
387
402
|
const optionCapacity = Math.max(1, Math.min(5, Math.ceil(contentRows / 2)));
|
|
388
403
|
const detailCapacity = Math.max(0, contentRows - optionCapacity);
|
|
389
404
|
const { start, end } = visibleOptionRange(visibleOptions.length, optionIndex, optionCapacity);
|
|
@@ -401,7 +416,7 @@ export function registerQuestionTool(pi: ExtensionAPI): void {
|
|
|
401
416
|
: `${keyHint("tui.select.up", "up")} • ${keyHint("tui.select.down", "down")} • ${keyHint("tui.select.confirm", "select")} • ${keyHint("tui.select.cancel", filterQuery ? "clear filter" : "cancel")}`;
|
|
402
417
|
const lines: string[] = [
|
|
403
418
|
theme.fg("accent", "─".repeat(renderWidth)),
|
|
404
|
-
|
|
419
|
+
...questionLines.map((line) => theme.fg("text", line)),
|
|
405
420
|
theme.fg("muted", truncateToWidth(` ${progress}`, renderWidth, "…")),
|
|
406
421
|
];
|
|
407
422
|
|
|
@@ -459,7 +474,7 @@ export function registerQuestionTool(pi: ExtensionAPI): void {
|
|
|
459
474
|
lines.push(theme.fg("accent", "─".repeat(renderWidth)));
|
|
460
475
|
cachedWidth = renderWidth;
|
|
461
476
|
cachedRows = rowBudget;
|
|
462
|
-
cachedLines = lines.slice(0, rowBudget).map((line) =>
|
|
477
|
+
cachedLines = lines.slice(0, rowBudget).map((line) => boundedRenderLine(line, renderWidth, ""));
|
|
463
478
|
return cachedLines;
|
|
464
479
|
};
|
|
465
480
|
|
package/killeros/runtime.ts
CHANGED
|
@@ -20,6 +20,12 @@ export interface InitRuntime {
|
|
|
20
20
|
|
|
21
21
|
export type GoalStatus = "active" | "paused" | "blocked" | "complete";
|
|
22
22
|
|
|
23
|
+
export interface GoalBlockerAudit {
|
|
24
|
+
key: string;
|
|
25
|
+
streak: number;
|
|
26
|
+
lastTurn: number;
|
|
27
|
+
}
|
|
28
|
+
|
|
23
29
|
export interface GoalState {
|
|
24
30
|
version: 1;
|
|
25
31
|
revision: number;
|
|
@@ -34,6 +40,7 @@ export interface GoalState {
|
|
|
34
40
|
baselineTokens: number;
|
|
35
41
|
result?: string;
|
|
36
42
|
resumeAfterManualCompaction?: true;
|
|
43
|
+
blockerAudit?: GoalBlockerAudit;
|
|
37
44
|
}
|
|
38
45
|
|
|
39
46
|
export interface GoalRuntime {
|
package/killeros/shell-ui.ts
CHANGED
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
} from "@earendil-works/pi-coding-agent";
|
|
12
12
|
import {
|
|
13
13
|
Container,
|
|
14
|
+
CURSOR_MARKER,
|
|
14
15
|
Text,
|
|
15
16
|
truncateToWidth,
|
|
16
17
|
visibleWidth,
|
|
@@ -43,6 +44,13 @@ const STARTUP_TIPS = [
|
|
|
43
44
|
"Run /notification to enable a terminal bell when work settles.",
|
|
44
45
|
] as const;
|
|
45
46
|
|
|
47
|
+
const EDITOR_SUGGESTIONS = [
|
|
48
|
+
'Try "how does <filepath> work?"',
|
|
49
|
+
'Try "find edge cases in <filepath>"',
|
|
50
|
+
'Try "simplify <filepath> without changing behavior"',
|
|
51
|
+
'Try "write tests for <filepath>"',
|
|
52
|
+
] as const;
|
|
53
|
+
|
|
46
54
|
export function resolveGitBranch(cwd: string): Promise<string | undefined> {
|
|
47
55
|
return new Promise((resolve) => {
|
|
48
56
|
execFile(
|
|
@@ -66,13 +74,26 @@ export function resolveGitBranch(cwd: string): Promise<string | undefined> {
|
|
|
66
74
|
});
|
|
67
75
|
}
|
|
68
76
|
|
|
69
|
-
function
|
|
70
|
-
const
|
|
71
|
-
for (let index =
|
|
77
|
+
function shuffledDeck(values: readonly string[]): string[] {
|
|
78
|
+
const deck = [...values];
|
|
79
|
+
for (let index = deck.length - 1; index > 0; index -= 1) {
|
|
72
80
|
const swapIndex = Math.floor(Math.random() * (index + 1));
|
|
73
|
-
[
|
|
81
|
+
[deck[index], deck[swapIndex]] = [deck[swapIndex]!, deck[index]!];
|
|
74
82
|
}
|
|
75
|
-
return
|
|
83
|
+
return deck;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
let tipDeck: string[] = [];
|
|
87
|
+
let editorSuggestionDeck: string[] = [];
|
|
88
|
+
|
|
89
|
+
function nextStartupTip(): string {
|
|
90
|
+
if (tipDeck.length === 0) tipDeck = shuffledDeck(STARTUP_TIPS);
|
|
91
|
+
return tipDeck.pop() ?? STARTUP_TIPS[0];
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function nextEditorSuggestion(): string {
|
|
95
|
+
if (editorSuggestionDeck.length === 0) editorSuggestionDeck = shuffledDeck(EDITOR_SUGGESTIONS);
|
|
96
|
+
return editorSuggestionDeck.pop() ?? EDITOR_SUGGESTIONS[0];
|
|
76
97
|
}
|
|
77
98
|
|
|
78
99
|
function compactBoxLine(content: string, width: number, theme: Theme): string {
|
|
@@ -167,16 +188,19 @@ function isScrolledTopBorder(line: string): boolean {
|
|
|
167
188
|
class PiCodeEditor extends CustomEditor {
|
|
168
189
|
private readonly appKeybindings: KeybindingsManager;
|
|
169
190
|
private readonly runtimeTheme: Theme;
|
|
191
|
+
private readonly suggestion: string;
|
|
170
192
|
|
|
171
193
|
constructor(
|
|
172
194
|
tui: TUI,
|
|
173
195
|
theme: EditorTheme,
|
|
174
196
|
appKeybindings: KeybindingsManager,
|
|
175
197
|
runtimeTheme: Theme,
|
|
198
|
+
suggestion: string,
|
|
176
199
|
) {
|
|
177
200
|
super(tui, theme, appKeybindings);
|
|
178
201
|
this.appKeybindings = appKeybindings;
|
|
179
202
|
this.runtimeTheme = runtimeTheme;
|
|
203
|
+
this.suggestion = suggestion;
|
|
180
204
|
}
|
|
181
205
|
|
|
182
206
|
override handleInput(data: string): void {
|
|
@@ -220,8 +244,16 @@ class PiCodeEditor extends CustomEditor {
|
|
|
220
244
|
}
|
|
221
245
|
|
|
222
246
|
for (let index = 1; index < bottomBorderIndex; index += 1) {
|
|
223
|
-
const
|
|
224
|
-
|
|
247
|
+
const isPromptLine = index === 1 && !isScrolledHeader;
|
|
248
|
+
const prefix = isPromptLine ? gray("❯\u00A0") : " ";
|
|
249
|
+
let content = lines[index] ?? "";
|
|
250
|
+
if (isPromptLine && this.getText() === "") {
|
|
251
|
+
const first = this.suggestion.slice(0, 1);
|
|
252
|
+
const rest = this.suggestion.slice(1);
|
|
253
|
+
const cursorMarker = this.focused ? CURSOR_MARKER : "";
|
|
254
|
+
content = `${cursorMarker}\x1B[7m${gray(first)}\x1B[27m${gray(rest)}`;
|
|
255
|
+
}
|
|
256
|
+
framed.push(`${prefix}${padRight(content, innerWidth)}`);
|
|
225
257
|
}
|
|
226
258
|
|
|
227
259
|
const bottom = stripAnsi(lines[bottomBorderIndex] ?? "");
|
|
@@ -258,11 +290,6 @@ export function registerShellUi(pi: ExtensionAPI): void {
|
|
|
258
290
|
let activityDeck: string[] = [];
|
|
259
291
|
let lastActivityWord: string | undefined;
|
|
260
292
|
let activityTimer: ReturnType<typeof setInterval> | undefined;
|
|
261
|
-
let tipDeck: string[] = [];
|
|
262
|
-
const nextStartupTip = (): string => {
|
|
263
|
-
if (tipDeck.length === 0) tipDeck = shuffledTips();
|
|
264
|
-
return tipDeck.pop() ?? STARTUP_TIPS[0];
|
|
265
|
-
};
|
|
266
293
|
const refillActivityDeck = (): void => {
|
|
267
294
|
activityDeck = [...ACTIVITY_WORDS];
|
|
268
295
|
for (let index = activityDeck.length - 1; index > 0; index -= 1) {
|
|
@@ -302,8 +329,9 @@ export function registerShellUi(pi: ExtensionAPI): void {
|
|
|
302
329
|
ctx.ui.setHiddenThinkingLabel("└ Thinking…");
|
|
303
330
|
const existingEditorFactory = ctx.ui.getEditorComponent?.();
|
|
304
331
|
if (!existingEditorFactory || existingEditorFactory === killerosEditorFactory) {
|
|
332
|
+
const editorSuggestion = nextEditorSuggestion();
|
|
305
333
|
killerosEditorFactory = (tui, editorTheme, keybindings) =>
|
|
306
|
-
new PiCodeEditor(tui, editorTheme, keybindings, ctx.ui.theme);
|
|
334
|
+
new PiCodeEditor(tui, editorTheme, keybindings, ctx.ui.theme, editorSuggestion);
|
|
307
335
|
ctx.ui.setEditorComponent(killerosEditorFactory);
|
|
308
336
|
}
|
|
309
337
|
} catch (error) {
|