killeros 2.0.6 → 2.0.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +23 -0
- package/Killeros.ts +2 -0
- package/README.md +9 -8
- package/killeros/activity.ts +97 -0
- package/killeros/footer.ts +31 -45
- package/killeros/goals.ts +85 -17
- package/killeros/init.ts +23 -2
- package/killeros/question.ts +33 -21
- package/killeros/runtime.ts +9 -0
- package/killeros/shell-ui.ts +14 -68
- package/killeros/variants.ts +74 -32
- package/killeros/worked-for.ts +69 -14
- package/package.json +7 -6
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,29 @@ All notable changes to KillerOS are documented here.
|
|
|
4
4
|
|
|
5
5
|
## [Unreleased]
|
|
6
6
|
|
|
7
|
+
## [2.0.7] - 2026-08-13
|
|
8
|
+
|
|
9
|
+
### Changed
|
|
10
|
+
|
|
11
|
+
- Raised the supported and locked Pi baseline from 0.82.1 to the current matched 0.84.1 AI, coding-agent, and TUI packages.
|
|
12
|
+
- Added a compact two-deck footer beneath the prompt and removed the work-trail widget.
|
|
13
|
+
- Replaced the framed prompt and shuffled activity verbs with a focus-aware single-arrow editor and event-derived working copy.
|
|
14
|
+
- Made settled timing entries report `Done`, `Stopped`, or `Failed` while keeping existing `Worked for` history readable.
|
|
15
|
+
|
|
16
|
+
### Fixed
|
|
17
|
+
|
|
18
|
+
- Separated response output from the prompt editor while keeping footer telemetry close beneath a quiet muted divider.
|
|
19
|
+
- Kept `/variants` within the active terminal height, preserved the focused reasoning level across resizes, and initially focused the current level.
|
|
20
|
+
- Rendered `/variants` controls from the same Pi keybinding manager that handles input, including installations with separate package module instances.
|
|
21
|
+
- Rendered active, paused, and blocked `/goal` status text consistently in the footer's far-right slot.
|
|
22
|
+
- Kept the private goal-update tool inactive outside active `/goal` runs and rendered its real execution errors instead of malformed blocker-audit fields.
|
|
23
|
+
- Verified exact persisted paths for clearly declared file-deliverable goals before completion, while preserving model-reported completion for general objectives.
|
|
24
|
+
- Accepted explicit `1`/`1` bounds for single-select questions and applied the same bounds validation before rendering and execution.
|
|
25
|
+
- Prevented reload and branch navigation from preserving or reconstructing stale manual-compaction recovery eligibility; only Pi's live event in the current session can resume the interrupted goal safely.
|
|
26
|
+
- Restricted goal deliverable verification to explicit destination phrases so source and reference paths cannot be reported as completed output.
|
|
27
|
+
- Kept saved goals fully inactive in print and JSON modes, including the private update tool and shutdown checkpoints.
|
|
28
|
+
- Rejected concurrent `/init` starts during preflight, cancelled pending preflight work on shutdown, and settled active `/init` command handlers when their session closes.
|
|
29
|
+
|
|
7
30
|
## [2.0.6] - 2026-08-11
|
|
8
31
|
|
|
9
32
|
### Added
|
package/Killeros.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { registerRequestActivity } from "./killeros/activity.ts";
|
|
2
3
|
import { registerAliases, registerSlashAutocomplete } from "./killeros/commands.ts";
|
|
3
4
|
import { registerConcisePrompt } from "./killeros/concise.ts";
|
|
4
5
|
import { registerFooter } from "./killeros/footer.ts";
|
|
@@ -43,6 +44,7 @@ export default function Killeros(pi: ExtensionAPI, options: KillerosOptions = {}
|
|
|
43
44
|
registerLifecycleHooks(pi);
|
|
44
45
|
registerGoalSettlement(pi, goalRuntime, initRuntime);
|
|
45
46
|
registerInitSettlement(pi, initRuntime);
|
|
47
|
+
registerRequestActivity(pi);
|
|
46
48
|
registerCompletionNotifications(pi, options.completionNotifications);
|
|
47
49
|
registerWorkedFor(pi);
|
|
48
50
|
}
|
package/README.md
CHANGED
|
@@ -5,7 +5,7 @@ A production-hardened Pi extension that combines a custom TUI, repository initia
|
|
|
5
5
|
## Requirements
|
|
6
6
|
|
|
7
7
|
- Node.js `22.19.0` or later
|
|
8
|
-
- Pi `0.
|
|
8
|
+
- Pi `0.84.1` or later
|
|
9
9
|
- Interactive TUI mode for the custom header, editor, footer, `question` tool, and `/init`
|
|
10
10
|
|
|
11
11
|
The extension is strict TypeScript. Pi provides the runtime modules.
|
|
@@ -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.7
|
|
35
35
|
```
|
|
36
36
|
|
|
37
37
|
Add `-l` to either command for a project-only install. Restart Pi after installing.
|
|
@@ -40,16 +40,17 @@ Add `-l` to either command for a project-only install. Restart Pi after installi
|
|
|
40
40
|
|
|
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
|
-
- Animated orange 12-frame activity glyph loop at 120 ms per frame
|
|
44
|
-
-
|
|
45
|
-
-
|
|
43
|
+
- Animated orange 12-frame activity glyph loop at 120 ms per frame with contextual copy derived from request, tool, result, and response events, plus a quiet hidden-thinking label
|
|
44
|
+
- Frameless multiline editor with one focus-aware `❯`, a shuffled session-stable empty-state suggestion, overflow-only scroll indicators, Shift+Enter support, and slash-command autocomplete; KillerOS preserves an editor factory configured by another extension
|
|
45
|
+
- One compact TUI transcript line reporting truthful `Done`, `Stopped`, or `Failed` settlement with elapsed time while preserving older `✻ Worked for …` entries
|
|
46
|
+
- Compact two-deck footer with session state above workspace state; model, context, and active goals stay prioritized as reasoning, time, cost, branch, and path reduce by available width
|
|
46
47
|
- Pi-owned context compaction with active goals continuing from Pi's settled boundary after manual, threshold, and overflow compaction
|
|
47
48
|
- Optional completion sounds after successful or failed settled requests, excluding manual aborts
|
|
48
49
|
- `/variants` selector and direct reasoning-level arguments
|
|
49
50
|
- 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
51
|
- 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
52
|
- `question` tool with single-select and opt-in bounded multi-select, height-bounded option windows, configured Pi keybindings, live option/input progress, proposal previews, custom answers, history, cancellation, and compact expandable transcript rendering
|
|
52
|
-
- Mid-prompt slash completion with current Pi `0.
|
|
53
|
+
- Mid-prompt slash completion with current Pi `0.84.1` commands, extensions, prompts, and skills; paths, URLs, and invalid commands remain plain text
|
|
53
54
|
- Goal-aware `/clear` that confirms, aborts active work, waits for settlement, and starts a new session, plus `/exit` for graceful shutdown
|
|
54
55
|
- Concise system-prompt guidance and supported native concise defaults that preserve explicit provider settings
|
|
55
56
|
|
|
@@ -70,7 +71,7 @@ Add `-l` to either command for a project-only install. Restart Pi after installi
|
|
|
70
71
|
/exit Quit Pi gracefully
|
|
71
72
|
```
|
|
72
73
|
|
|
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
|
|
74
|
+
`/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 report completion. For an objective that clearly asks to create, write, save, or generate a named file-like deliverable at one quoted absolute path, KillerOS saves that exact path with the goal and verifies that a regular file exists there before accepting completion. Other objectives retain model-reported 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
|
|
|
75
76
|
`/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.
|
|
76
77
|
|
|
@@ -80,7 +81,7 @@ The generated file uses the four behavioral sections adapted from `writing-great
|
|
|
80
81
|
|
|
81
82
|
### Interactive questions
|
|
82
83
|
|
|
83
|
-
Single-select remains the default. An agent opts into multi-select with `mode: "multiple"` and may set `minSelections` and `maxSelections`; the custom answer counts as one selection.
|
|
84
|
+
Single-select remains the default. Explicit `minSelections: 1` and `maxSelections: 1` are equivalent to omitting both bounds; other single-select bounds are rejected. An agent opts into multi-select with `mode: "multiple"` and may set `minSelections` and `maxSelections`; the custom answer counts as one selection.
|
|
84
85
|
|
|
85
86
|
In multi-select, use Space or a visible number to toggle an option, `/` to filter, and Enter to submit. The filter accepts spaces; Enter applies it and Escape returns to the choices. Checked options remain selected when the filter changes. Select **Type a custom answer** with Enter to add or edit one custom item alongside checked options.
|
|
86
87
|
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import type { ExtensionAPI, ExtensionContext, Theme } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { truncateToWidth } from "@earendil-works/pi-tui";
|
|
3
|
+
|
|
4
|
+
export type ActivityMessage =
|
|
5
|
+
| { kind: "prompt" }
|
|
6
|
+
| { kind: "tool"; toolName: string }
|
|
7
|
+
| { kind: "tool-result"; failed: boolean }
|
|
8
|
+
| { kind: "responding" };
|
|
9
|
+
|
|
10
|
+
function safeToolName(toolName: string): string {
|
|
11
|
+
const normalized = toolName.replace(/[\u0000-\u001F\u007F]+/gu, " ").replace(/\s+/gu, " ").trim();
|
|
12
|
+
return truncateToWidth(normalized || "tool", 32, "…");
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function formatActivityMessage(message: ActivityMessage, theme: Theme): string {
|
|
16
|
+
let verb: string;
|
|
17
|
+
let detail: string;
|
|
18
|
+
|
|
19
|
+
switch (message.kind) {
|
|
20
|
+
case "prompt":
|
|
21
|
+
verb = "Mapping…";
|
|
22
|
+
detail = "understanding request";
|
|
23
|
+
break;
|
|
24
|
+
case "tool-result":
|
|
25
|
+
verb = message.failed ? "Recovering…" : "Reviewing…";
|
|
26
|
+
detail = message.failed ? "tool failed" : "reading the result";
|
|
27
|
+
break;
|
|
28
|
+
case "responding":
|
|
29
|
+
verb = "Responding…";
|
|
30
|
+
detail = "assembling the answer";
|
|
31
|
+
break;
|
|
32
|
+
case "tool":
|
|
33
|
+
switch (message.toolName.trim().toLocaleLowerCase()) {
|
|
34
|
+
case "read":
|
|
35
|
+
case "grep":
|
|
36
|
+
case "find":
|
|
37
|
+
case "ls":
|
|
38
|
+
verb = "Inspecting…";
|
|
39
|
+
detail = "reading relevant code";
|
|
40
|
+
break;
|
|
41
|
+
case "edit":
|
|
42
|
+
case "write":
|
|
43
|
+
verb = "Changing…";
|
|
44
|
+
detail = "editing";
|
|
45
|
+
break;
|
|
46
|
+
case "bash":
|
|
47
|
+
verb = "Running…";
|
|
48
|
+
detail = "command";
|
|
49
|
+
break;
|
|
50
|
+
default:
|
|
51
|
+
verb = "Working…";
|
|
52
|
+
detail = `using ${safeToolName(message.toolName)}`;
|
|
53
|
+
}
|
|
54
|
+
break;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return `${theme.fg("accent", verb)} ${theme.fg("dim", `(${theme.bold("esc")} to interrupt · ${detail})`)}`;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function registerRequestActivity(pi: ExtensionAPI): void {
|
|
61
|
+
let active = false;
|
|
62
|
+
|
|
63
|
+
const clear = (ctx?: ExtensionContext): void => {
|
|
64
|
+
if (ctx?.mode === "tui") {
|
|
65
|
+
ctx.ui.setWorkingMessage();
|
|
66
|
+
}
|
|
67
|
+
active = false;
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
pi.on("agent_start", (_event, ctx) => {
|
|
71
|
+
if (ctx.mode !== "tui") return;
|
|
72
|
+
active = true;
|
|
73
|
+
ctx.ui.setWorkingMessage(formatActivityMessage({ kind: "prompt" }, ctx.ui.theme));
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
pi.on("tool_execution_start", (event, ctx) => {
|
|
77
|
+
if (ctx.mode !== "tui" || !active) return;
|
|
78
|
+
ctx.ui.setWorkingMessage(formatActivityMessage({ kind: "tool", toolName: event.toolName }, ctx.ui.theme));
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
pi.on("tool_execution_end", (event, ctx) => {
|
|
82
|
+
if (ctx.mode !== "tui" || !active) return;
|
|
83
|
+
ctx.ui.setWorkingMessage(formatActivityMessage({ kind: "tool-result", failed: event.isError }, ctx.ui.theme));
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
pi.on("message_update", (event, ctx) => {
|
|
87
|
+
if (ctx.mode !== "tui" || !active || event.assistantMessageEvent.type !== "text_start") return;
|
|
88
|
+
ctx.ui.setWorkingMessage(formatActivityMessage({ kind: "responding" }, ctx.ui.theme));
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
pi.on("agent_settled", (_event, ctx) => {
|
|
92
|
+
if (ctx.mode !== "tui" || !active || !ctx.isIdle?.() || ctx.hasPendingMessages?.()) return;
|
|
93
|
+
clear(ctx);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
pi.on("session_shutdown", (_event, ctx) => clear(ctx));
|
|
97
|
+
}
|
package/killeros/footer.ts
CHANGED
|
@@ -125,6 +125,10 @@ function renderFooterRow(left: string, right: string, width: number): string {
|
|
|
125
125
|
return ` ${clippedLeft}${gap}${clippedRight} `;
|
|
126
126
|
}
|
|
127
127
|
|
|
128
|
+
function renderFooter(rows: string[], width: number, theme: Theme): string[] {
|
|
129
|
+
return [theme.fg("borderMuted", "─".repeat(width)), ...rows];
|
|
130
|
+
}
|
|
131
|
+
|
|
128
132
|
function formatGoalElapsed(milliseconds: number): string {
|
|
129
133
|
const totalSeconds = Number.isFinite(milliseconds) ? Math.max(0, Math.floor(milliseconds / 1_000)) : 0;
|
|
130
134
|
if (totalSeconds < 60) return `${totalSeconds}s`;
|
|
@@ -138,15 +142,13 @@ function formatGoalElapsed(milliseconds: number): string {
|
|
|
138
142
|
return `${hours}h ${minutes.toString().padStart(2, "0")}m ${seconds.toString().padStart(2, "0")}s`;
|
|
139
143
|
}
|
|
140
144
|
|
|
141
|
-
function formatActiveGoalFooter(state: GoalState | undefined, theme: Theme): string {
|
|
142
|
-
if (state?.status !== "active") return "";
|
|
143
|
-
return theme.fg("warning", `/goal is active (${formatGoalElapsed(goalElapsedMilliseconds(state))})`);
|
|
144
|
-
}
|
|
145
|
-
|
|
146
145
|
function formatGoalFooter(state: GoalState | undefined, theme: Theme): string {
|
|
147
146
|
if (!state) return "";
|
|
148
|
-
if (state.status === "
|
|
149
|
-
|
|
147
|
+
if (state.status === "active") {
|
|
148
|
+
return theme.fg("warning", `/goal is active (${formatGoalElapsed(goalElapsedMilliseconds(state))})`);
|
|
149
|
+
}
|
|
150
|
+
if (state.status === "paused") return theme.fg("warning", "/goal is paused");
|
|
151
|
+
if (state.status === "blocked") return theme.fg("error", "/goal is blocked");
|
|
150
152
|
return "";
|
|
151
153
|
}
|
|
152
154
|
|
|
@@ -211,52 +213,36 @@ export function registerFooter(pi: ExtensionAPI, goalRuntime: GoalRuntime): void
|
|
|
211
213
|
const signature = formatModel(model, theme);
|
|
212
214
|
const fullDirectory = theme.fg("dim", cwd);
|
|
213
215
|
const focusedDirectory = theme.fg("dim", compactDirectory(cwd));
|
|
214
|
-
const activeGoal = formatActiveGoalFooter(goalRuntime.state, theme);
|
|
215
216
|
const goal = formatGoalFooter(goalRuntime.state, theme);
|
|
216
|
-
const
|
|
217
|
+
const primary = joinFooterParts([
|
|
217
218
|
signature,
|
|
218
219
|
level,
|
|
219
220
|
context,
|
|
220
|
-
|
|
221
|
-
|
|
221
|
+
], theme);
|
|
222
|
+
const primaryFocused = joinFooterParts([signature, context], theme);
|
|
223
|
+
const session = joinFooterParts([
|
|
222
224
|
theme.fg("dim", formatTime(Date.now() - sessionStart)),
|
|
223
225
|
theme.fg("dim", formatCost(getSessionCost(ctx))),
|
|
224
226
|
], theme);
|
|
225
|
-
const focused = joinFooterParts([signature, context, goal], theme);
|
|
226
|
-
|
|
227
|
-
if (activeGoal) {
|
|
228
|
-
if (footerRowFits(rich, activeGoal, width)) {
|
|
229
|
-
return [renderFooterRow(rich, activeGoal, width)];
|
|
230
|
-
}
|
|
231
|
-
if (footerRowFits(focused, activeGoal, width)) {
|
|
232
|
-
return [renderFooterRow(focused, activeGoal, width)];
|
|
233
|
-
}
|
|
234
|
-
if (footerRowFits(context, activeGoal, width)) {
|
|
235
|
-
return [renderFooterRow(context, activeGoal, width)];
|
|
236
|
-
}
|
|
237
|
-
return [renderFooterRow("", activeGoal, width)];
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
if (footerRowFits(rich, fullDirectory, width)) {
|
|
241
|
-
return [renderFooterRow(rich, fullDirectory, width)];
|
|
242
|
-
}
|
|
243
|
-
if (footerRowFits(rich, focusedDirectory, width)) {
|
|
244
|
-
return [renderFooterRow(rich, focusedDirectory, width)];
|
|
245
|
-
}
|
|
246
|
-
if (footerRowFits(focused, focusedDirectory, width)) {
|
|
247
|
-
return [renderFooterRow(focused, focusedDirectory, width)];
|
|
248
|
-
}
|
|
249
|
-
if (footerRowFits(focused, "", width)) {
|
|
250
|
-
return [renderFooterRow(focused, "", width)];
|
|
251
|
-
}
|
|
252
|
-
if (goal) {
|
|
253
|
-
const essentialGoal = joinFooterParts([context, goal], theme);
|
|
254
|
-
if (footerRowFits(essentialGoal, "", width)) return [renderFooterRow(essentialGoal, "", width)];
|
|
255
|
-
return [renderFooterRow(goal, context, width)];
|
|
256
|
-
}
|
|
257
|
-
|
|
258
227
|
const essentialModel = formatModel(model, theme, false);
|
|
259
|
-
|
|
228
|
+
const primaryRow = footerRowFits(primary, session, width)
|
|
229
|
+
? renderFooterRow(primary, session, width)
|
|
230
|
+
: footerRowFits(primary, "", width)
|
|
231
|
+
? renderFooterRow(primary, "", width)
|
|
232
|
+
: footerRowFits(primaryFocused, "", width)
|
|
233
|
+
? renderFooterRow(primaryFocused, "", width)
|
|
234
|
+
: renderFooterRow(essentialModel, context, width);
|
|
235
|
+
const branchLabel = branch ? theme.fg("dim", branch) : "";
|
|
236
|
+
const workspaceRight = goal || fullDirectory;
|
|
237
|
+
const secondaryRow = footerRowFits(branchLabel, workspaceRight, width)
|
|
238
|
+
? renderFooterRow(branchLabel, workspaceRight, width)
|
|
239
|
+
: goal
|
|
240
|
+
? renderFooterRow("", goal, width)
|
|
241
|
+
: footerRowFits(branchLabel, focusedDirectory, width)
|
|
242
|
+
? renderFooterRow(branchLabel, focusedDirectory, width)
|
|
243
|
+
: renderFooterRow(branchLabel, "", width);
|
|
244
|
+
|
|
245
|
+
return renderFooter([primaryRow, secondaryRow], width, theme);
|
|
260
246
|
},
|
|
261
247
|
};
|
|
262
248
|
});
|
package/killeros/goals.ts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { StringEnum } from "@earendil-works/pi-ai";
|
|
2
|
+
import { lstatSync } from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
2
4
|
import { type ExtensionAPI, type ExtensionCommandContext, type ExtensionContext, type ThemeColor } from "@earendil-works/pi-coding-agent";
|
|
3
5
|
import { Text } from "@earendil-works/pi-tui";
|
|
4
6
|
import { Type } from "typebox";
|
|
@@ -7,10 +9,11 @@ import { BoundedText } from "./bounded-text.ts";
|
|
|
7
9
|
import { formatTime, formatTokens } from "./display.ts";
|
|
8
10
|
import { reportError } from "./errors.ts";
|
|
9
11
|
import { resolvePersonalInstructions } from "./personal-instructions.ts";
|
|
10
|
-
import type { GoalBlockerAudit, GoalRuntime, GoalState, GoalStatus, InitRuntime } from "./runtime.ts";
|
|
12
|
+
import type { GoalBlockerAudit, GoalFileVerification, GoalRuntime, GoalState, GoalStatus, InitRuntime } from "./runtime.ts";
|
|
11
13
|
|
|
12
14
|
const GOAL_ENTRY_TYPE = "killeros-goal";
|
|
13
15
|
const GOAL_CONTINUATION_TYPE = "killeros-goal-continuation";
|
|
16
|
+
const GOAL_UPDATE_TOOL = "killeros_goal_update";
|
|
14
17
|
const GOAL_OBJECTIVE_LIMIT = 4_000;
|
|
15
18
|
const GOAL_VERSION = 1;
|
|
16
19
|
|
|
@@ -29,7 +32,6 @@ interface GoalTransitionOptions {
|
|
|
29
32
|
|
|
30
33
|
interface RestoredGoalState {
|
|
31
34
|
state?: GoalState;
|
|
32
|
-
recoveryProven: boolean;
|
|
33
35
|
}
|
|
34
36
|
|
|
35
37
|
const GoalUpdateParams = Type.Object({
|
|
@@ -52,6 +54,7 @@ const GoalUpdateParams = Type.Object({
|
|
|
52
54
|
interface GoalUpdateDetails {
|
|
53
55
|
status: "complete" | "blocked" | "blocker-audit";
|
|
54
56
|
evidence: string;
|
|
57
|
+
verification?: "file" | "model-reported";
|
|
55
58
|
blockerKey?: string;
|
|
56
59
|
streak?: number;
|
|
57
60
|
}
|
|
@@ -64,6 +67,41 @@ function finiteNonNegative(value: unknown): value is number {
|
|
|
64
67
|
return typeof value === "number" && Number.isFinite(value) && value >= 0;
|
|
65
68
|
}
|
|
66
69
|
|
|
70
|
+
function isGoalFileVerification(value: unknown): value is GoalFileVerification {
|
|
71
|
+
if (!value || typeof value !== "object") return false;
|
|
72
|
+
const candidate = value as Partial<GoalFileVerification>;
|
|
73
|
+
return candidate.kind === "file"
|
|
74
|
+
&& typeof candidate.path === "string"
|
|
75
|
+
&& candidate.path === candidate.path.trim()
|
|
76
|
+
&& isAbsoluteFilePath(candidate.path);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function isAbsoluteFilePath(value: string): boolean {
|
|
80
|
+
if (!value || /^(?:https?|file):\/\//iu.test(value) || /[\\\/]$/u.test(value)) return false;
|
|
81
|
+
return path.isAbsolute(value) || path.win32.isAbsolute(value);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function inferGoalVerification(objective: string): GoalFileVerification | undefined {
|
|
85
|
+
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;
|
|
86
|
+
const paths = [...objective.matchAll(destination)]
|
|
87
|
+
.map((match) => (match[1] ?? match[2] ?? match[3] ?? match[4] ?? "").trim())
|
|
88
|
+
.filter(isAbsoluteFilePath);
|
|
89
|
+
const unique = [...new Set(paths)];
|
|
90
|
+
return unique.length === 1 ? { kind: "file", path: unique[0]! } : undefined;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function verifyGoalDeliverable(verification: GoalFileVerification): void {
|
|
94
|
+
let artifact: ReturnType<typeof lstatSync>;
|
|
95
|
+
try {
|
|
96
|
+
artifact = lstatSync(verification.path);
|
|
97
|
+
} catch {
|
|
98
|
+
throw new Error(`Goal deliverable is not a regular file at the required path: ${verification.path}`);
|
|
99
|
+
}
|
|
100
|
+
if (!artifact.isFile()) {
|
|
101
|
+
throw new Error(`Goal deliverable is not a regular file at the required path: ${verification.path}`);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
67
105
|
function isGoalBlockerAudit(value: unknown, turns: number, status: GoalStatus): value is GoalBlockerAudit {
|
|
68
106
|
if (!value || typeof value !== "object") return false;
|
|
69
107
|
const candidate = value as Partial<GoalBlockerAudit>;
|
|
@@ -92,6 +130,7 @@ function parseGoalState(value: unknown): GoalState | undefined {
|
|
|
92
130
|
|| !finiteNonNegative(candidate.baselineTokens)
|
|
93
131
|
|| candidate.activeStartedAt !== undefined && !finiteNonNegative(candidate.activeStartedAt)
|
|
94
132
|
|| candidate.result !== undefined && typeof candidate.result !== "string"
|
|
133
|
+
|| candidate.verification !== undefined && !isGoalFileVerification(candidate.verification)
|
|
95
134
|
|| candidate.blockerAudit !== undefined && !isGoalBlockerAudit(candidate.blockerAudit, candidate.turns!, candidate.status)
|
|
96
135
|
|| candidate.resumeAfterManualCompaction !== undefined && candidate.resumeAfterManualCompaction !== true
|
|
97
136
|
|| candidate.resumeAfterManualCompaction === true && candidate.status !== "paused") {
|
|
@@ -112,6 +151,7 @@ function parseGoalState(value: unknown): GoalState | undefined {
|
|
|
112
151
|
result: candidate.result,
|
|
113
152
|
resumeAfterManualCompaction: candidate.resumeAfterManualCompaction,
|
|
114
153
|
blockerAudit: candidate.blockerAudit,
|
|
154
|
+
verification: candidate.verification,
|
|
115
155
|
};
|
|
116
156
|
}
|
|
117
157
|
|
|
@@ -130,19 +170,17 @@ function restoreGoalState(ctx: ExtensionContext): RestoredGoalState {
|
|
|
130
170
|
if (entry?.type !== "custom" || entry.customType !== GOAL_ENTRY_TYPE) continue;
|
|
131
171
|
const data = entry.data as Partial<GoalEntryData> | undefined;
|
|
132
172
|
if (!data || data.version !== GOAL_VERSION || data.state === null) {
|
|
133
|
-
return { state: undefined
|
|
173
|
+
return { state: undefined };
|
|
134
174
|
}
|
|
135
175
|
const restored = parseGoalState(data.state);
|
|
136
|
-
if (!restored) return { state: undefined
|
|
176
|
+
if (!restored) return { state: undefined };
|
|
137
177
|
const state = restored.status === "active"
|
|
138
178
|
? { ...restored, activeStartedAt: Date.now() }
|
|
139
179
|
: { ...restored, activeStartedAt: undefined };
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
&& entries.slice(index + 1).some((candidate) => candidate.type === "compaction");
|
|
143
|
-
return { state, recoveryProven };
|
|
180
|
+
if (state.status === "paused") state.resumeAfterManualCompaction = undefined;
|
|
181
|
+
return { state };
|
|
144
182
|
}
|
|
145
|
-
return { state: undefined
|
|
183
|
+
return { state: undefined };
|
|
146
184
|
}
|
|
147
185
|
|
|
148
186
|
export function goalElapsedMilliseconds(state: GoalState, now = Date.now()): number {
|
|
@@ -173,6 +211,21 @@ function sumGoalTokens(ctx: ExtensionContext): number {
|
|
|
173
211
|
return total;
|
|
174
212
|
}
|
|
175
213
|
|
|
214
|
+
function setGoalUpdateToolActive(pi: ExtensionAPI, active: boolean): void {
|
|
215
|
+
const api = pi as ExtensionAPI & { getActiveTools?: () => string[]; setActiveTools?: (names: string[]) => void };
|
|
216
|
+
if (!api.getActiveTools || !api.setActiveTools) return;
|
|
217
|
+
const activeTools = api.getActiveTools();
|
|
218
|
+
const isActive = activeTools.includes(GOAL_UPDATE_TOOL);
|
|
219
|
+
if (active === isActive) return;
|
|
220
|
+
api.setActiveTools(active
|
|
221
|
+
? [...activeTools, GOAL_UPDATE_TOOL]
|
|
222
|
+
: activeTools.filter((name) => name !== GOAL_UPDATE_TOOL));
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function syncGoalUpdateTool(pi: ExtensionAPI, runtime: GoalRuntime): void {
|
|
226
|
+
setGoalUpdateToolActive(pi, runtime.state?.status === "active");
|
|
227
|
+
}
|
|
228
|
+
|
|
176
229
|
function persistGoalState(
|
|
177
230
|
pi: ExtensionAPI,
|
|
178
231
|
runtime: GoalRuntime,
|
|
@@ -182,6 +235,7 @@ function persistGoalState(
|
|
|
182
235
|
const data: GoalEntryData = { version: GOAL_VERSION, event, state: state ?? null };
|
|
183
236
|
pi.appendEntry(GOAL_ENTRY_TYPE, data);
|
|
184
237
|
runtime.state = state;
|
|
238
|
+
syncGoalUpdateTool(pi, runtime);
|
|
185
239
|
runtime.persistenceRetryNeeded = false;
|
|
186
240
|
runtime.requestRender?.();
|
|
187
241
|
}
|
|
@@ -276,6 +330,7 @@ export function pauseGoalAfterFailure(
|
|
|
276
330
|
result: reason,
|
|
277
331
|
resumeAfterManualCompaction: undefined,
|
|
278
332
|
} : undefined;
|
|
333
|
+
syncGoalUpdateTool(pi, runtime);
|
|
279
334
|
runtime.persistenceRetryNeeded = true;
|
|
280
335
|
runtime.continuationScheduled = false;
|
|
281
336
|
runtime.requestRender?.();
|
|
@@ -301,6 +356,7 @@ function pauseGoalForPossibleManualCompaction(
|
|
|
301
356
|
result: reason,
|
|
302
357
|
resumeAfterManualCompaction: true,
|
|
303
358
|
} : undefined;
|
|
359
|
+
syncGoalUpdateTool(pi, runtime);
|
|
304
360
|
runtime.persistenceRetryNeeded = true;
|
|
305
361
|
runtime.continuationScheduled = false;
|
|
306
362
|
runtime.requestRender?.();
|
|
@@ -465,7 +521,7 @@ export function registerGoal(
|
|
|
465
521
|
});
|
|
466
522
|
|
|
467
523
|
pi.registerTool<typeof GoalUpdateParams, GoalUpdateDetails>({
|
|
468
|
-
name:
|
|
524
|
+
name: GOAL_UPDATE_TOOL,
|
|
469
525
|
label: "Goal update",
|
|
470
526
|
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.",
|
|
471
527
|
parameters: GoalUpdateParams,
|
|
@@ -478,10 +534,14 @@ export function registerGoal(
|
|
|
478
534
|
const evidence = params.evidence.trim();
|
|
479
535
|
if (!evidence) throw new Error("Goal evidence must not be empty");
|
|
480
536
|
if (params.status === "complete") {
|
|
537
|
+
if (state.verification) verifyGoalDeliverable(state.verification);
|
|
538
|
+
const verification = state.verification ? "file" : "model-reported";
|
|
481
539
|
transitionGoal(pi, runtime, "complete", "complete", evidence, { resetBlockedAudit: true });
|
|
482
540
|
return {
|
|
483
|
-
content: [{ type: "text", text:
|
|
484
|
-
|
|
541
|
+
content: [{ type: "text", text: state.verification
|
|
542
|
+
? `Goal verified complete at ${state.verification.path}: ${evidence}`
|
|
543
|
+
: `Goal marked complete (model-reported): ${evidence}` }],
|
|
544
|
+
details: { status: "complete", evidence, verification },
|
|
485
545
|
};
|
|
486
546
|
}
|
|
487
547
|
if (!runtime.goalTurnInFlight) throw new Error("A blocker audit can only be recorded during an active KillerOS goal turn");
|
|
@@ -516,7 +576,12 @@ export function registerGoal(
|
|
|
516
576
|
renderCall(args, theme) {
|
|
517
577
|
return new Text(`${theme.fg("toolTitle", theme.bold("goal "))}${theme.fg("muted", args.status)}`, 0, 0);
|
|
518
578
|
},
|
|
519
|
-
renderResult(result, options, theme) {
|
|
579
|
+
renderResult(result, options, theme, context) {
|
|
580
|
+
if (context?.isError) {
|
|
581
|
+
const first = result.content[0];
|
|
582
|
+
const message = first?.type === "text" ? first.text : "Goal update failed";
|
|
583
|
+
return new BoundedText(theme.fg("error", message), options.expanded ? undefined : 3);
|
|
584
|
+
}
|
|
520
585
|
const details = result.details;
|
|
521
586
|
if (!details) return new BoundedText(theme.fg("dim", "Goal updated"));
|
|
522
587
|
const label = details.status === "complete" ? "✓ Complete" : details.status === "blocked" ? "! Blocked" : `! Blocker audit ${details.streak}/3`;
|
|
@@ -527,7 +592,8 @@ export function registerGoal(
|
|
|
527
592
|
|
|
528
593
|
const restoreGoal = (ctx: ExtensionContext): void => {
|
|
529
594
|
const restored = restoreGoalState(ctx);
|
|
530
|
-
runtime.state = restored.state;
|
|
595
|
+
runtime.state = isGoalModeSupported(ctx) ? restored.state : undefined;
|
|
596
|
+
syncGoalUpdateTool(pi, runtime);
|
|
531
597
|
runtime.continuationScheduled = false;
|
|
532
598
|
runtime.continuationHeld = false;
|
|
533
599
|
runtime.goalTurnInFlight = false;
|
|
@@ -536,9 +602,7 @@ export function registerGoal(
|
|
|
536
602
|
runtime.lastStopReason = undefined;
|
|
537
603
|
runtime.lastError = undefined;
|
|
538
604
|
runtime.requestRender?.();
|
|
539
|
-
if (
|
|
540
|
-
recoverGoalAfterManualCompaction(pi, runtime, initState, ctx);
|
|
541
|
-
} else if (runtime.state?.status === "active") {
|
|
605
|
+
if (runtime.state?.status === "active") {
|
|
542
606
|
setImmediate(() => scheduleGoalContinuation(pi, runtime, initState, ctx));
|
|
543
607
|
}
|
|
544
608
|
};
|
|
@@ -561,6 +625,7 @@ export function registerGoal(
|
|
|
561
625
|
}
|
|
562
626
|
}
|
|
563
627
|
runtime.state = undefined;
|
|
628
|
+
syncGoalUpdateTool(pi, runtime);
|
|
564
629
|
runtime.continuationScheduled = false;
|
|
565
630
|
runtime.continuationHeld = false;
|
|
566
631
|
runtime.goalTurnInFlight = false;
|
|
@@ -680,6 +745,7 @@ export function registerGoal(
|
|
|
680
745
|
ctx.ui.notify("Goal pause saved. Goal remains paused. Automatic compaction recovery is off.", "info");
|
|
681
746
|
} catch (error) {
|
|
682
747
|
runtime.state = checkpoint;
|
|
748
|
+
syncGoalUpdateTool(pi, runtime);
|
|
683
749
|
runtime.persistenceRetryNeeded = true;
|
|
684
750
|
runtime.continuationScheduled = false;
|
|
685
751
|
runtime.requestRender?.();
|
|
@@ -798,6 +864,7 @@ export function registerGoal(
|
|
|
798
864
|
activeStartedAt: now,
|
|
799
865
|
blockedAuditStartTurn: current.turns,
|
|
800
866
|
blockerAudit: undefined,
|
|
867
|
+
verification: inferGoalVerification(objective),
|
|
801
868
|
result: undefined,
|
|
802
869
|
resumeAfterManualCompaction: undefined,
|
|
803
870
|
};
|
|
@@ -869,6 +936,7 @@ export function registerGoal(
|
|
|
869
936
|
turns: 0,
|
|
870
937
|
blockedAuditStartTurn: 0,
|
|
871
938
|
baselineTokens: sumGoalTokens(ctx),
|
|
939
|
+
verification: inferGoalVerification(objective),
|
|
872
940
|
};
|
|
873
941
|
try {
|
|
874
942
|
persistGoalState(pi, runtime, unfinished ? "replace" : "set", state);
|
package/killeros/init.ts
CHANGED
|
@@ -133,8 +133,10 @@ export function registerInitCommand(pi: ExtensionAPI, initState: InitRuntime, go
|
|
|
133
133
|
|
|
134
134
|
pi.on("session_start", () => setInitTools(pi, initState, false));
|
|
135
135
|
pi.on("session_shutdown", () => {
|
|
136
|
+
const settle = initState.settle;
|
|
136
137
|
setInitTools(pi, initState, false);
|
|
137
138
|
resetInitRuntime(initState);
|
|
139
|
+
settle?.({ kind: "cancelled" });
|
|
138
140
|
});
|
|
139
141
|
pi.on("before_agent_start", () => {
|
|
140
142
|
if (initState.active) setInitTools(pi, initState, true);
|
|
@@ -160,7 +162,7 @@ export function registerInitCommand(pi: ExtensionAPI, initState: InitRuntime, go
|
|
|
160
162
|
ctx.ui.notify("/init requires interactive TUI mode", "error");
|
|
161
163
|
return;
|
|
162
164
|
}
|
|
163
|
-
if (initState.active) {
|
|
165
|
+
if (initState.active || initState.starting) {
|
|
164
166
|
ctx.ui.notify("/init is already running", "warning");
|
|
165
167
|
return;
|
|
166
168
|
}
|
|
@@ -172,28 +174,45 @@ export function registerInitCommand(pi: ExtensionAPI, initState: InitRuntime, go
|
|
|
172
174
|
ctx.ui.notify("Trust this project before running /init", "error");
|
|
173
175
|
return;
|
|
174
176
|
}
|
|
175
|
-
|
|
177
|
+
const starting = Symbol();
|
|
178
|
+
initState.starting = starting;
|
|
179
|
+
try {
|
|
180
|
+
await ctx.waitForIdle();
|
|
181
|
+
} catch (error) {
|
|
182
|
+
if (initState.starting !== starting) return;
|
|
183
|
+
initState.starting = undefined;
|
|
184
|
+
reportError(ctx, "/init could not wait for active work", error);
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
if (initState.starting !== starting) return;
|
|
176
188
|
|
|
177
189
|
let projectRoot: string;
|
|
178
190
|
try {
|
|
179
191
|
projectRoot = await fs.realpath(ctx.cwd);
|
|
180
192
|
} catch (error) {
|
|
193
|
+
if (initState.starting !== starting) return;
|
|
194
|
+
initState.starting = undefined;
|
|
181
195
|
reportError(ctx, "/init could not resolve the project root", error);
|
|
182
196
|
return;
|
|
183
197
|
}
|
|
198
|
+
if (initState.starting !== starting) return;
|
|
184
199
|
const targetPath = path.join(projectRoot, "AGENTS.md");
|
|
185
200
|
try {
|
|
186
201
|
const [{ index: evidence }, baseline] = await Promise.all([
|
|
187
202
|
buildInitEvidence(projectRoot),
|
|
188
203
|
captureInitTargetBaseline(targetPath),
|
|
189
204
|
]);
|
|
205
|
+
if (initState.starting !== starting) return;
|
|
190
206
|
initState.active = true;
|
|
191
207
|
initState.projectRoot = projectRoot;
|
|
192
208
|
initState.targetPath = targetPath;
|
|
193
209
|
initState.evidence = evidence;
|
|
194
210
|
initState.baseline = baseline;
|
|
195
211
|
initState.outcome = { kind: "pending" };
|
|
212
|
+
initState.starting = undefined;
|
|
196
213
|
} catch (error) {
|
|
214
|
+
if (initState.starting !== starting) return;
|
|
215
|
+
initState.starting = undefined;
|
|
197
216
|
reportError(ctx, "/init could not capture safe repository evidence", error);
|
|
198
217
|
return;
|
|
199
218
|
}
|
|
@@ -234,6 +253,8 @@ export function registerInitCommand(pi: ExtensionAPI, initState: InitRuntime, go
|
|
|
234
253
|
case "policy-conflict":
|
|
235
254
|
ctx.ui.notify(`/init left AGENTS.md unchanged: ${outcome.reason}`, "warning");
|
|
236
255
|
break;
|
|
256
|
+
case "cancelled":
|
|
257
|
+
break;
|
|
237
258
|
default:
|
|
238
259
|
reportError(ctx, "/init did not generate AGENTS.md", "the model completed without a write or policy-conflict outcome");
|
|
239
260
|
}
|
package/killeros/question.ts
CHANGED
|
@@ -9,7 +9,7 @@ import {
|
|
|
9
9
|
wrapTextWithAnsi,
|
|
10
10
|
type EditorTheme,
|
|
11
11
|
} from "@earendil-works/pi-tui";
|
|
12
|
-
import { Type } from "typebox";
|
|
12
|
+
import { Type, type Static } from "typebox";
|
|
13
13
|
import { BoundedText } from "./bounded-text.ts";
|
|
14
14
|
|
|
15
15
|
const OptionSchema = Type.Object({
|
|
@@ -31,16 +31,42 @@ const QuestionParams = Type.Object({
|
|
|
31
31
|
minSelections: Type.Optional(Type.Integer({
|
|
32
32
|
minimum: 1,
|
|
33
33
|
maximum: 10,
|
|
34
|
-
description: "Minimum answers required in multiple mode; defaults to 1",
|
|
34
|
+
description: "Minimum answers required in multiple mode; defaults to 1. Single mode accepts only an explicit 1/1 bounds pair",
|
|
35
35
|
})),
|
|
36
36
|
maxSelections: Type.Optional(Type.Integer({
|
|
37
37
|
minimum: 1,
|
|
38
38
|
maximum: 10,
|
|
39
|
-
description: "Maximum answers allowed in multiple mode; defaults to all options plus one custom answer",
|
|
39
|
+
description: "Maximum answers allowed in multiple mode; defaults to all options plus one custom answer. Single mode accepts only an explicit 1/1 bounds pair",
|
|
40
40
|
})),
|
|
41
41
|
});
|
|
42
42
|
|
|
43
|
-
type
|
|
43
|
+
type QuestionParamsValue = Static<typeof QuestionParams>;
|
|
44
|
+
|
|
45
|
+
type NormalizedQuestionSelection =
|
|
46
|
+
| { mode: "single"; minSelections: 1; maxSelections: 1 }
|
|
47
|
+
| { mode: "multiple"; minSelections: number; maxSelections: number };
|
|
48
|
+
|
|
49
|
+
function normalizeQuestionSelection(params: QuestionParamsValue): NormalizedQuestionSelection {
|
|
50
|
+
const mode = params.mode ?? "single";
|
|
51
|
+
if (mode === "single") {
|
|
52
|
+
const hasSelectionBounds = params.minSelections !== undefined || params.maxSelections !== undefined;
|
|
53
|
+
if (hasSelectionBounds && (params.minSelections !== 1 || params.maxSelections !== 1)) {
|
|
54
|
+
throw new Error("Single-select question bounds must be omitted or both be 1");
|
|
55
|
+
}
|
|
56
|
+
return { mode, minSelections: 1, maxSelections: 1 };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const maximumAvailable = params.options.length + 1;
|
|
60
|
+
const minSelections = params.minSelections ?? 1;
|
|
61
|
+
const maxSelections = params.maxSelections ?? maximumAvailable;
|
|
62
|
+
if (minSelections > maxSelections) {
|
|
63
|
+
throw new Error("Question minimum selections cannot exceed maximum selections");
|
|
64
|
+
}
|
|
65
|
+
if (maxSelections > maximumAvailable) {
|
|
66
|
+
throw new Error(`Question allows at most ${maximumAvailable} selections including one custom answer`);
|
|
67
|
+
}
|
|
68
|
+
return { mode, minSelections, maxSelections };
|
|
69
|
+
}
|
|
44
70
|
|
|
45
71
|
interface DisplayOption {
|
|
46
72
|
label: string;
|
|
@@ -229,20 +255,7 @@ export function registerQuestionTool(pi: ExtensionAPI): void {
|
|
|
229
255
|
executionMode: "sequential",
|
|
230
256
|
|
|
231
257
|
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
|
|
232
|
-
const mode
|
|
233
|
-
const hasSelectionBounds = params.minSelections !== undefined || params.maxSelections !== undefined;
|
|
234
|
-
if (mode === "single" && hasSelectionBounds) {
|
|
235
|
-
throw new Error("Question selection bounds require mode \"multiple\"");
|
|
236
|
-
}
|
|
237
|
-
const maximumAvailable = params.options.length + 1;
|
|
238
|
-
const minSelections = params.minSelections ?? 1;
|
|
239
|
-
const maxSelections = params.maxSelections ?? maximumAvailable;
|
|
240
|
-
if (minSelections > maxSelections) {
|
|
241
|
-
throw new Error("Question minimum selections cannot exceed maximum selections");
|
|
242
|
-
}
|
|
243
|
-
if (maxSelections > maximumAvailable) {
|
|
244
|
-
throw new Error(`Question allows at most ${maximumAvailable} selections including one custom answer`);
|
|
245
|
-
}
|
|
258
|
+
const { mode, minSelections, maxSelections } = normalizeQuestionSelection(params);
|
|
246
259
|
if (ctx.mode !== "tui") throw new Error("The question tool requires interactive TUI mode");
|
|
247
260
|
if (signal?.aborted) throw new Error("Question cancelled before it opened");
|
|
248
261
|
|
|
@@ -703,9 +716,8 @@ export function registerQuestionTool(pi: ExtensionAPI): void {
|
|
|
703
716
|
},
|
|
704
717
|
|
|
705
718
|
renderCall(args, theme, context) {
|
|
706
|
-
const
|
|
707
|
-
const
|
|
708
|
-
const maximum = args.maxSelections ?? args.options.length + 1;
|
|
719
|
+
const { mode, minSelections: minimum, maxSelections: maximum } = normalizeQuestionSelection(args);
|
|
720
|
+
const multiple = mode === "multiple";
|
|
709
721
|
if (!context.expanded) {
|
|
710
722
|
const title = multiple ? "question (multi-select) " : "question ";
|
|
711
723
|
const detail = multiple ? `${args.options.length} options · choose ${minimum}–${maximum}` : `${args.options.length} option${args.options.length === 1 ? "" : "s"}`;
|
package/killeros/runtime.ts
CHANGED
|
@@ -5,10 +5,12 @@ export type InitOutcome =
|
|
|
5
5
|
| { kind: "pending" }
|
|
6
6
|
| { kind: "written" }
|
|
7
7
|
| { kind: "policy-conflict"; reason: string }
|
|
8
|
+
| { kind: "cancelled" }
|
|
8
9
|
| { kind: "no-outcome" };
|
|
9
10
|
|
|
10
11
|
export interface InitRuntime {
|
|
11
12
|
active: boolean;
|
|
13
|
+
starting?: symbol;
|
|
12
14
|
targetPath?: string;
|
|
13
15
|
projectRoot?: string;
|
|
14
16
|
activeTools?: string[];
|
|
@@ -26,6 +28,11 @@ export interface GoalBlockerAudit {
|
|
|
26
28
|
lastTurn: number;
|
|
27
29
|
}
|
|
28
30
|
|
|
31
|
+
export interface GoalFileVerification {
|
|
32
|
+
kind: "file";
|
|
33
|
+
path: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
29
36
|
export interface GoalState {
|
|
30
37
|
version: 1;
|
|
31
38
|
revision: number;
|
|
@@ -41,6 +48,7 @@ export interface GoalState {
|
|
|
41
48
|
result?: string;
|
|
42
49
|
resumeAfterManualCompaction?: true;
|
|
43
50
|
blockerAudit?: GoalBlockerAudit;
|
|
51
|
+
verification?: GoalFileVerification;
|
|
44
52
|
}
|
|
45
53
|
|
|
46
54
|
export interface GoalRuntime {
|
|
@@ -71,6 +79,7 @@ export function createGoalRuntime(): GoalRuntime {
|
|
|
71
79
|
|
|
72
80
|
export function resetInitRuntime(state: InitRuntime): void {
|
|
73
81
|
state.active = false;
|
|
82
|
+
state.starting = undefined;
|
|
74
83
|
state.targetPath = undefined;
|
|
75
84
|
state.projectRoot = undefined;
|
|
76
85
|
state.activeTools = undefined;
|
package/killeros/shell-ui.ts
CHANGED
|
@@ -2,7 +2,6 @@ import { execFile } from "node:child_process";
|
|
|
2
2
|
import { readFileSync } from "node:fs";
|
|
3
3
|
import {
|
|
4
4
|
CustomEditor,
|
|
5
|
-
DynamicBorder,
|
|
6
5
|
VERSION,
|
|
7
6
|
type ExtensionAPI,
|
|
8
7
|
type ExtensionContext,
|
|
@@ -10,9 +9,7 @@ import {
|
|
|
10
9
|
type Theme,
|
|
11
10
|
} from "@earendil-works/pi-coding-agent";
|
|
12
11
|
import {
|
|
13
|
-
Container,
|
|
14
12
|
CURSOR_MARKER,
|
|
15
|
-
Text,
|
|
16
13
|
truncateToWidth,
|
|
17
14
|
visibleWidth,
|
|
18
15
|
wrapTextWithAnsi,
|
|
@@ -182,7 +179,7 @@ function isBorderLine(line: string): boolean {
|
|
|
182
179
|
|
|
183
180
|
function isScrolledTopBorder(line: string): boolean {
|
|
184
181
|
const unstyled = stripAnsi(line);
|
|
185
|
-
return unstyled.includes("↑")
|
|
182
|
+
return unstyled.includes("↑");
|
|
186
183
|
}
|
|
187
184
|
|
|
188
185
|
class PiCodeEditor extends CustomEditor {
|
|
@@ -219,10 +216,9 @@ class PiCodeEditor extends CustomEditor {
|
|
|
219
216
|
|
|
220
217
|
override render(width: number): string[] {
|
|
221
218
|
if (width <= 0) return [];
|
|
222
|
-
|
|
223
|
-
const innerWidth = width - 2;
|
|
219
|
+
const innerWidth = Math.max(1, width - 2);
|
|
224
220
|
const lines = super.render(innerWidth);
|
|
225
|
-
if (lines.length < 2) return lines.map((line) => truncateToWidth(line, width, ""));
|
|
221
|
+
if (lines.length < 2) return ["", ...lines.map((line) => truncateToWidth(line, width, ""))];
|
|
226
222
|
let bottomBorderIndex = lines.length - 1;
|
|
227
223
|
for (let index = lines.length - 1; index >= 1; index -= 1) {
|
|
228
224
|
if (isBorderLine(lines[index] ?? "")) {
|
|
@@ -231,44 +227,40 @@ class PiCodeEditor extends CustomEditor {
|
|
|
231
227
|
}
|
|
232
228
|
}
|
|
233
229
|
|
|
234
|
-
const
|
|
235
|
-
const
|
|
230
|
+
const dim = (text: string): string => this.runtimeTheme.fg("dim", text);
|
|
231
|
+
const rendered: string[] = [];
|
|
236
232
|
const top = stripAnsi(lines[0] ?? "");
|
|
237
233
|
const isScrolledHeader = isScrolledTopBorder(lines[0] ?? "");
|
|
238
234
|
if (isScrolledHeader) {
|
|
239
235
|
const count = top.match(/↑\s*(\d+)/)?.[1] ?? "";
|
|
240
|
-
|
|
241
|
-
framed.push(truncateToWidth(indicator, width, ""));
|
|
242
|
-
} else {
|
|
243
|
-
framed.push(gray("─".repeat(width)));
|
|
236
|
+
rendered.push(truncateToWidth(dim(` ↑ ${count} more`), width, ""));
|
|
244
237
|
}
|
|
245
238
|
|
|
246
239
|
for (let index = 1; index < bottomBorderIndex; index += 1) {
|
|
247
240
|
const isPromptLine = index === 1 && !isScrolledHeader;
|
|
248
|
-
const prefix = isPromptLine
|
|
241
|
+
const prefix = isPromptLine
|
|
242
|
+
? this.runtimeTheme.fg(this.focused ? "accent" : "dim", "❯\u00A0")
|
|
243
|
+
: " ";
|
|
249
244
|
let content = lines[index] ?? "";
|
|
250
245
|
if (isPromptLine && this.getText() === "") {
|
|
251
246
|
const first = this.suggestion.slice(0, 1);
|
|
252
247
|
const rest = this.suggestion.slice(1);
|
|
253
248
|
const cursorMarker = this.focused ? CURSOR_MARKER : "";
|
|
254
|
-
content = `${cursorMarker}\x1B[7m${
|
|
249
|
+
content = `${cursorMarker}\x1B[7m${dim(first)}\x1B[27m${dim(rest)}`;
|
|
255
250
|
}
|
|
256
|
-
|
|
251
|
+
rendered.push(`${prefix}${padRight(content, innerWidth)}`);
|
|
257
252
|
}
|
|
258
253
|
|
|
259
254
|
const bottom = stripAnsi(lines[bottomBorderIndex] ?? "");
|
|
260
255
|
if (bottom.includes("↓")) {
|
|
261
256
|
const count = bottom.match(/↓\s*(\d+)/)?.[1] ?? "";
|
|
262
|
-
|
|
263
|
-
framed.push(truncateToWidth(indicator, width, ""));
|
|
264
|
-
} else {
|
|
265
|
-
framed.push(gray("─".repeat(width)));
|
|
257
|
+
rendered.push(truncateToWidth(dim(` ↓ ${count} more`), width, ""));
|
|
266
258
|
}
|
|
267
259
|
|
|
268
260
|
for (let index = bottomBorderIndex + 1; index < lines.length; index += 1) {
|
|
269
|
-
|
|
261
|
+
rendered.push(` ${padRight(lines[index] ?? "", innerWidth)}`);
|
|
270
262
|
}
|
|
271
|
-
return
|
|
263
|
+
return ["", ...rendered.map((line) => truncateToWidth(line, width, ""))];
|
|
272
264
|
}
|
|
273
265
|
}
|
|
274
266
|
|
|
@@ -277,39 +269,11 @@ const ACTIVITY_FRAMES = [
|
|
|
277
269
|
"✽", "✻", "✶", "✱", "✢", "·",
|
|
278
270
|
] as const;
|
|
279
271
|
const ACTIVITY_FRAME_INTERVAL_MS = 120;
|
|
280
|
-
const ACTIVITY_WORDS = ["Brewing", "Pondering", "Tinkering", "Wrangling", "Noodling", "Cooking"] as const;
|
|
281
|
-
|
|
282
|
-
function formatActivityMessage(word: string, theme: Theme): string {
|
|
283
|
-
return `${theme.fg("accent", `${word}…`)} ${theme.fg("dim", `(${theme.bold("esc")} to interrupt · thinking)`)}`;
|
|
284
|
-
}
|
|
285
272
|
|
|
286
273
|
let killerosEditorFactory: ReturnType<ExtensionContext["ui"]["getEditorComponent"]>;
|
|
287
274
|
|
|
288
275
|
export function registerShellUi(pi: ExtensionAPI): void {
|
|
289
276
|
let activeHeader: PiStartupHeader | undefined;
|
|
290
|
-
let activityDeck: string[] = [];
|
|
291
|
-
let lastActivityWord: string | undefined;
|
|
292
|
-
let activityTimer: ReturnType<typeof setInterval> | undefined;
|
|
293
|
-
const refillActivityDeck = (): void => {
|
|
294
|
-
activityDeck = [...ACTIVITY_WORDS];
|
|
295
|
-
for (let index = activityDeck.length - 1; index > 0; index -= 1) {
|
|
296
|
-
const swapIndex = Math.floor(Math.random() * (index + 1));
|
|
297
|
-
[activityDeck[index], activityDeck[swapIndex]] = [activityDeck[swapIndex]!, activityDeck[index]!];
|
|
298
|
-
}
|
|
299
|
-
if (activityDeck.length > 1 && activityDeck.at(-1) === lastActivityWord) {
|
|
300
|
-
[activityDeck[0], activityDeck[activityDeck.length - 1]] = [activityDeck.at(-1)!, activityDeck[0]!];
|
|
301
|
-
}
|
|
302
|
-
};
|
|
303
|
-
const nextActivityWord = (): string => {
|
|
304
|
-
if (activityDeck.length === 0) refillActivityDeck();
|
|
305
|
-
const word = activityDeck.pop() ?? ACTIVITY_WORDS[0];
|
|
306
|
-
lastActivityWord = word;
|
|
307
|
-
return word;
|
|
308
|
-
};
|
|
309
|
-
const clearActivityTimer = (): void => {
|
|
310
|
-
if (activityTimer) clearInterval(activityTimer);
|
|
311
|
-
activityTimer = undefined;
|
|
312
|
-
};
|
|
313
277
|
|
|
314
278
|
pi.on("session_start", (_event, ctx) => {
|
|
315
279
|
if (ctx.mode !== "tui") return;
|
|
@@ -321,7 +285,6 @@ export function registerShellUi(pi: ExtensionAPI): void {
|
|
|
321
285
|
activeHeader = new PiStartupHeader(pi, ctx, startupTip, tui);
|
|
322
286
|
return activeHeader;
|
|
323
287
|
});
|
|
324
|
-
clearActivityTimer();
|
|
325
288
|
ctx.ui.setWorkingIndicator({
|
|
326
289
|
frames: ACTIVITY_FRAMES.map((frame) => ctx.ui.theme.fg("accent", frame)),
|
|
327
290
|
intervalMs: ACTIVITY_FRAME_INTERVAL_MS,
|
|
@@ -339,25 +302,8 @@ export function registerShellUi(pi: ExtensionAPI): void {
|
|
|
339
302
|
}
|
|
340
303
|
});
|
|
341
304
|
|
|
342
|
-
pi.on("agent_start", (_event, ctx) => {
|
|
343
|
-
if (ctx.mode !== "tui") return;
|
|
344
|
-
clearActivityTimer();
|
|
345
|
-
const updateWorkingWord = (): void => ctx.ui.setWorkingMessage(formatActivityMessage(nextActivityWord(), ctx.ui.theme));
|
|
346
|
-
updateWorkingWord();
|
|
347
|
-
activityTimer = setInterval(updateWorkingWord, 2_500);
|
|
348
|
-
activityTimer.unref?.();
|
|
349
|
-
});
|
|
350
|
-
|
|
351
|
-
pi.on("agent_end", (_event, ctx) => {
|
|
352
|
-
clearActivityTimer();
|
|
353
|
-
if (ctx.mode === "tui") ctx.ui.setWorkingMessage();
|
|
354
|
-
});
|
|
355
|
-
|
|
356
305
|
pi.on("session_shutdown", () => {
|
|
357
|
-
clearActivityTimer();
|
|
358
306
|
activeHeader?.dispose();
|
|
359
307
|
activeHeader = undefined;
|
|
360
|
-
activityDeck = [];
|
|
361
|
-
lastActivityWord = undefined;
|
|
362
308
|
});
|
|
363
309
|
}
|
package/killeros/variants.ts
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
import { DynamicBorder,
|
|
2
|
-
import {
|
|
1
|
+
import { DynamicBorder, type ExtensionAPI, type ExtensionContext, type ThemeColor } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { SelectList, truncateToWidth } from "@earendil-works/pi-tui";
|
|
3
3
|
|
|
4
|
-
export type ThinkingLevel = "
|
|
4
|
+
export type ThinkingLevel = ReturnType<ExtensionAPI["getThinkingLevel"]>;
|
|
5
5
|
|
|
6
|
-
const ALL_LEVELS
|
|
6
|
+
const ALL_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh", "max"] as const satisfies readonly ThinkingLevel[];
|
|
7
7
|
const LEVEL_LABELS: Readonly<Record<ThinkingLevel, string>> = {
|
|
8
8
|
off: "Off",
|
|
9
9
|
minimal: "Minimal",
|
|
@@ -42,7 +42,7 @@ const LEVEL_ALIASES: Readonly<Record<string, ThinkingLevel>> = {
|
|
|
42
42
|
};
|
|
43
43
|
|
|
44
44
|
function isThinkingLevel(value: string): value is ThinkingLevel {
|
|
45
|
-
return (
|
|
45
|
+
return ALL_LEVELS.some((level) => level === value);
|
|
46
46
|
}
|
|
47
47
|
|
|
48
48
|
function resolveThinkingLevel(input: string): ThinkingLevel | undefined {
|
|
@@ -96,40 +96,82 @@ export function registerVariants(pi: ExtensionAPI): void {
|
|
|
96
96
|
ctx.ui.notify(`${modelLabel(ctx.model)} does not support extended reasoning`, "info");
|
|
97
97
|
return;
|
|
98
98
|
}
|
|
99
|
-
const current = pi.getThinkingLevel()
|
|
99
|
+
const current = pi.getThinkingLevel();
|
|
100
100
|
const items = supported.map((level) => ({
|
|
101
101
|
value: level,
|
|
102
102
|
label: level === current ? `${LEVEL_LABELS[level]} ← current` : LEVEL_LABELS[level],
|
|
103
103
|
description: LEVEL_DESCRIPTIONS[level],
|
|
104
104
|
}));
|
|
105
|
-
const selected = await ctx.ui.custom<ThinkingLevel | null>((tui, theme,
|
|
106
|
-
const
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
105
|
+
const selected = await ctx.ui.custom<ThinkingLevel | null>((tui, theme, keybindings, done) => {
|
|
106
|
+
const listTheme = {
|
|
107
|
+
selectedPrefix: (text: string) => theme.fg("accent", text),
|
|
108
|
+
selectedText: (text: string) => theme.fg("accent", text),
|
|
109
|
+
description: (text: string) => theme.fg("muted", text),
|
|
110
|
+
scrollInfo: (text: string) => theme.fg("dim", text),
|
|
111
|
+
noMatch: (text: string) => theme.fg("warning", text),
|
|
112
|
+
};
|
|
113
|
+
let selectList: SelectList | undefined;
|
|
114
|
+
let visibleOptionRows = 0;
|
|
115
|
+
|
|
116
|
+
const chromeFor = (rowBudget: number): "full" | "compact" | "none" => (
|
|
117
|
+
rowBudget >= 8 ? "full" : rowBudget >= 4 ? "compact" : "none"
|
|
118
|
+
);
|
|
119
|
+
const visibleRowsFor = (rowBudget: number): number => {
|
|
120
|
+
const chrome = chromeFor(rowBudget);
|
|
121
|
+
const chromeRows = chrome === "full" ? 5 : chrome === "compact" ? 2 : 0;
|
|
122
|
+
const availableListRows = Math.max(1, rowBudget - chromeRows);
|
|
123
|
+
return availableListRows >= items.length
|
|
124
|
+
? items.length
|
|
125
|
+
: Math.max(1, availableListRows - 1);
|
|
126
|
+
};
|
|
127
|
+
const ensureSelectList = (nextVisibleOptionRows: number): SelectList => {
|
|
128
|
+
if (selectList && visibleOptionRows === nextVisibleOptionRows) return selectList;
|
|
129
|
+
const selectedValue = selectList?.getSelectedItem()?.value ?? current;
|
|
130
|
+
const nextSelectList = new SelectList(items, nextVisibleOptionRows, listTheme);
|
|
131
|
+
const selectedIndex = items.findIndex((item) => item.value === selectedValue);
|
|
132
|
+
nextSelectList.setSelectedIndex(Math.max(0, selectedIndex));
|
|
133
|
+
nextSelectList.onSelect = (item) => done(isThinkingLevel(item.value) ? item.value : null);
|
|
134
|
+
nextSelectList.onCancel = () => done(null);
|
|
135
|
+
selectList = nextSelectList;
|
|
136
|
+
visibleOptionRows = nextVisibleOptionRows;
|
|
137
|
+
return nextSelectList;
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
const border = new DynamicBorder((text: string) => theme.fg("accent", text));
|
|
141
|
+
const title = ` ${theme.fg("accent", theme.bold("Thinking variants"))}`;
|
|
142
|
+
const model = ` ${theme.fg("dim", `Model: ${modelLabel(ctx.model)}`)}`;
|
|
143
|
+
const keyHint = (keybinding: Parameters<typeof keybindings.getKeys>[0], description: string): string => {
|
|
144
|
+
const keyText = keybindings.getKeys(keybinding)
|
|
145
|
+
.join("/")
|
|
146
|
+
.split("/")
|
|
147
|
+
.map((key) => key.split("+").map((part) => process.platform === "darwin" && part.toLocaleLowerCase() === "alt" ? "option" : part).join("+"))
|
|
148
|
+
.join("/");
|
|
149
|
+
return theme.fg("dim", keyText) + theme.fg("muted", ` ${description}`);
|
|
150
|
+
};
|
|
151
|
+
const controls = ` ${theme.fg("dim", `${keyHint("tui.select.up", "up")} • ${keyHint("tui.select.down", "down")} • ${keyHint("tui.select.confirm", "select")} • ${keyHint("tui.select.cancel", "cancel")}`)}`;
|
|
152
|
+
|
|
153
|
+
const render = (width: number): string[] => {
|
|
154
|
+
const rowBudget = Math.max(0, tui.terminal.rows);
|
|
155
|
+
if (width <= 0 || rowBudget === 0) return [];
|
|
156
|
+
const chrome = chromeFor(rowBudget);
|
|
157
|
+
const list = ensureSelectList(visibleRowsFor(rowBudget));
|
|
158
|
+
const lines: string[] = [];
|
|
159
|
+
|
|
160
|
+
if (chrome === "full") lines.push(...border.render(width));
|
|
161
|
+
if (chrome !== "none") lines.push(title);
|
|
162
|
+
if (chrome === "full") lines.push(model);
|
|
163
|
+
lines.push(...list.render(width));
|
|
164
|
+
if (chrome !== "none") lines.push(controls);
|
|
165
|
+
if (chrome === "full") lines.push(...border.render(width));
|
|
166
|
+
|
|
167
|
+
return lines.slice(0, rowBudget).map((line) => truncateToWidth(line, width, ""));
|
|
168
|
+
};
|
|
169
|
+
|
|
128
170
|
return {
|
|
129
|
-
render
|
|
130
|
-
invalidate: () =>
|
|
171
|
+
render,
|
|
172
|
+
invalidate: () => selectList?.invalidate(),
|
|
131
173
|
handleInput: (data) => {
|
|
132
|
-
|
|
174
|
+
ensureSelectList(visibleRowsFor(Math.max(1, tui.terminal.rows))).handleInput(data);
|
|
133
175
|
tui.requestRender();
|
|
134
176
|
},
|
|
135
177
|
};
|
package/killeros/worked-for.ts
CHANGED
|
@@ -1,14 +1,34 @@
|
|
|
1
1
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import type { StopReason } from "@earendil-works/pi-ai";
|
|
2
3
|
import { Text } from "@earendil-works/pi-tui";
|
|
3
4
|
|
|
4
5
|
const WORKED_FOR_ENTRY_TYPE = "killeros-worked-for";
|
|
5
|
-
const WORKED_FOR_ENTRY_VERSION = 1;
|
|
6
6
|
|
|
7
|
-
interface
|
|
8
|
-
version:
|
|
7
|
+
interface WorkedForEntryDataV1 {
|
|
8
|
+
version: 1;
|
|
9
9
|
milliseconds: number;
|
|
10
10
|
}
|
|
11
11
|
|
|
12
|
+
export type WorkedForOutcome = "done" | "stopped" | "failed";
|
|
13
|
+
|
|
14
|
+
interface WorkedForEntryDataV2 {
|
|
15
|
+
version: 2;
|
|
16
|
+
milliseconds: number;
|
|
17
|
+
outcome: WorkedForOutcome;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
type WorkedForEntryData = WorkedForEntryDataV1 | WorkedForEntryDataV2;
|
|
21
|
+
|
|
22
|
+
const OUTCOMES = {
|
|
23
|
+
done: { marker: "✓", label: "Done", color: "success" },
|
|
24
|
+
stopped: { marker: "■", label: "Stopped", color: "warning" },
|
|
25
|
+
failed: { marker: "×", label: "Failed", color: "error" },
|
|
26
|
+
} as const satisfies Record<WorkedForOutcome, { marker: string; label: string; color: string }>;
|
|
27
|
+
|
|
28
|
+
function isWorkedForOutcome(value: unknown): value is WorkedForOutcome {
|
|
29
|
+
return value === "done" || value === "stopped" || value === "failed";
|
|
30
|
+
}
|
|
31
|
+
|
|
12
32
|
export function formatWorkedForDuration(milliseconds: number): string {
|
|
13
33
|
const boundedMilliseconds = Number.isFinite(milliseconds) ? Math.max(0, milliseconds) : 0;
|
|
14
34
|
const totalSeconds = Math.max(1, Math.floor(boundedMilliseconds / 1_000));
|
|
@@ -22,13 +42,23 @@ export function formatWorkedForDuration(milliseconds: number): string {
|
|
|
22
42
|
return `${Math.floor(totalMinutes / 60)}h ${(totalMinutes % 60).toString().padStart(2, "0")}m`;
|
|
23
43
|
}
|
|
24
44
|
|
|
25
|
-
function
|
|
26
|
-
if (!data || typeof data !== "object") return
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
45
|
+
function parseWorkedForEntryData(data: unknown): WorkedForEntryData | undefined {
|
|
46
|
+
if (!data || typeof data !== "object" || Array.isArray(data)) return undefined;
|
|
47
|
+
if (!("version" in data) || !("milliseconds" in data)) return undefined;
|
|
48
|
+
if (typeof data.milliseconds !== "number" || !Number.isFinite(data.milliseconds) || data.milliseconds < 0) {
|
|
49
|
+
return undefined;
|
|
50
|
+
}
|
|
51
|
+
if (data.version === 1) return { version: 1, milliseconds: data.milliseconds };
|
|
52
|
+
if (data.version !== 2 || !("outcome" in data) || !isWorkedForOutcome(data.outcome)) {
|
|
53
|
+
return undefined;
|
|
54
|
+
}
|
|
55
|
+
return { version: 2, milliseconds: data.milliseconds, outcome: data.outcome };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function workedForOutcome(stopReason: StopReason | undefined): WorkedForOutcome {
|
|
59
|
+
if (stopReason === "stop") return "done";
|
|
60
|
+
if (stopReason === "aborted") return "stopped";
|
|
61
|
+
return "failed";
|
|
32
62
|
}
|
|
33
63
|
|
|
34
64
|
function errorMessage(error: unknown): string {
|
|
@@ -40,11 +70,21 @@ export function registerWorkedFor(
|
|
|
40
70
|
now: () => number = Date.now,
|
|
41
71
|
): void {
|
|
42
72
|
let startedAt: number | undefined;
|
|
73
|
+
let stopReason: StopReason | undefined;
|
|
43
74
|
|
|
44
75
|
pi.registerEntryRenderer<WorkedForEntryData>(WORKED_FOR_ENTRY_TYPE, (entry, _options, theme) => {
|
|
45
|
-
|
|
76
|
+
const data = parseWorkedForEntryData(entry.data);
|
|
77
|
+
if (!data) return undefined;
|
|
78
|
+
if (data.version === 1) {
|
|
79
|
+
return new Text(
|
|
80
|
+
theme.fg("dim", `✻ Worked for ${formatWorkedForDuration(data.milliseconds)}`),
|
|
81
|
+
0,
|
|
82
|
+
0,
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
const outcome = OUTCOMES[data.outcome];
|
|
46
86
|
return new Text(
|
|
47
|
-
theme.fg("dim",
|
|
87
|
+
`${theme.fg(outcome.color, `${outcome.marker} ${outcome.label}`)}${theme.fg("dim", ` · ${formatWorkedForDuration(data.milliseconds)}`)}`,
|
|
48
88
|
0,
|
|
49
89
|
0,
|
|
50
90
|
);
|
|
@@ -52,6 +92,7 @@ export function registerWorkedFor(
|
|
|
52
92
|
|
|
53
93
|
pi.on("session_start", () => {
|
|
54
94
|
startedAt = undefined;
|
|
95
|
+
stopReason = undefined;
|
|
55
96
|
});
|
|
56
97
|
|
|
57
98
|
pi.on("agent_start", (_event, ctx) => {
|
|
@@ -59,15 +100,28 @@ export function registerWorkedFor(
|
|
|
59
100
|
startedAt = now();
|
|
60
101
|
});
|
|
61
102
|
|
|
103
|
+
pi.on("agent_end", (event, ctx) => {
|
|
104
|
+
if (ctx.mode !== "tui" || startedAt === undefined) return;
|
|
105
|
+
for (let index = event.messages.length - 1; index >= 0; index -= 1) {
|
|
106
|
+
const message = event.messages[index];
|
|
107
|
+
if (message?.role !== "assistant") continue;
|
|
108
|
+
stopReason = message.stopReason;
|
|
109
|
+
break;
|
|
110
|
+
}
|
|
111
|
+
});
|
|
112
|
+
|
|
62
113
|
pi.on("agent_settled", (_event, ctx) => {
|
|
63
114
|
if (ctx.mode !== "tui" || startedAt === undefined) return;
|
|
64
115
|
if (!ctx.isIdle() || ctx.hasPendingMessages()) return;
|
|
65
116
|
const milliseconds = Math.max(0, now() - startedAt);
|
|
117
|
+
const outcome = workedForOutcome(stopReason);
|
|
66
118
|
startedAt = undefined;
|
|
119
|
+
stopReason = undefined;
|
|
67
120
|
try {
|
|
68
|
-
pi.appendEntry<
|
|
69
|
-
version:
|
|
121
|
+
pi.appendEntry<WorkedForEntryDataV2>(WORKED_FOR_ENTRY_TYPE, {
|
|
122
|
+
version: 2,
|
|
70
123
|
milliseconds,
|
|
124
|
+
outcome,
|
|
71
125
|
});
|
|
72
126
|
} catch (error) {
|
|
73
127
|
ctx.ui.notify(`Worked-for timing could not be saved: ${errorMessage(error)}`, "error");
|
|
@@ -76,5 +130,6 @@ export function registerWorkedFor(
|
|
|
76
130
|
|
|
77
131
|
pi.on("session_shutdown", () => {
|
|
78
132
|
startedAt = undefined;
|
|
133
|
+
stopReason = undefined;
|
|
79
134
|
});
|
|
80
135
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "killeros",
|
|
3
|
-
"version": "2.0.
|
|
3
|
+
"version": "2.0.7",
|
|
4
4
|
"description": "TUI, goals, and workflow automation for the Pi coding agent",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"keywords": [
|
|
@@ -42,14 +42,15 @@
|
|
|
42
42
|
]
|
|
43
43
|
},
|
|
44
44
|
"peerDependencies": {
|
|
45
|
-
"@earendil-works/pi-ai": ">=0.
|
|
46
|
-
"@earendil-works/pi-coding-agent": ">=0.
|
|
47
|
-
"@earendil-works/pi-tui": ">=0.
|
|
45
|
+
"@earendil-works/pi-ai": ">=0.84.1",
|
|
46
|
+
"@earendil-works/pi-coding-agent": ">=0.84.1",
|
|
47
|
+
"@earendil-works/pi-tui": ">=0.84.1",
|
|
48
48
|
"typebox": ">=1.1.38 <2"
|
|
49
49
|
},
|
|
50
50
|
"devDependencies": {
|
|
51
|
-
"@earendil-works/pi-
|
|
52
|
-
"@earendil-works/pi-
|
|
51
|
+
"@earendil-works/pi-ai": "0.84.1",
|
|
52
|
+
"@earendil-works/pi-coding-agent": "0.84.1",
|
|
53
|
+
"@earendil-works/pi-tui": "0.84.1",
|
|
53
54
|
"@types/node": "24.12.4",
|
|
54
55
|
"typebox": "1.1.38",
|
|
55
56
|
"typescript": "5.9.3"
|