killeros 2.0.6 → 2.0.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +38 -0
- package/Killeros.ts +22 -5
- package/README.md +21 -16
- package/killeros/activity.ts +97 -0
- package/killeros/decision-gated-workflow.ts +76 -0
- package/killeros/footer.ts +31 -45
- package/killeros/goals.ts +85 -17
- package/killeros/init.ts +23 -2
- package/killeros/question.ts +56 -26
- 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/killeros/workflow-gate.ts +347 -0
- package/package.json +7 -6
- package/killeros/concise.ts +0 -69
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
|
}
|
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ExtensionAPI,
|
|
3
|
+
ExtensionContext,
|
|
4
|
+
ToolCallEvent,
|
|
5
|
+
} from "@earendil-works/pi-coding-agent";
|
|
6
|
+
import type { QuestionDetails, QuestionParamsValue, QuestionRunner } from "./question.ts";
|
|
7
|
+
|
|
8
|
+
export type WorkflowToolAuthorization = true | false | string;
|
|
9
|
+
|
|
10
|
+
export interface WorkflowPolicy {
|
|
11
|
+
id: string;
|
|
12
|
+
allowedTools: readonly string[];
|
|
13
|
+
authorizeTool?: (
|
|
14
|
+
toolName: string,
|
|
15
|
+
input: Readonly<Record<string, unknown>>,
|
|
16
|
+
ctx: ExtensionContext,
|
|
17
|
+
) => WorkflowToolAuthorization;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface WorkflowAdapter {
|
|
21
|
+
id: string;
|
|
22
|
+
activation: string;
|
|
23
|
+
question: QuestionParamsValue;
|
|
24
|
+
policies: readonly WorkflowPolicy[];
|
|
25
|
+
selectPolicy: (details: QuestionDetails) => WorkflowPolicy | undefined;
|
|
26
|
+
onActivated?: (policy: WorkflowPolicy, details: QuestionDetails) => void | Promise<void>;
|
|
27
|
+
onFinish?: () => void | Promise<void>;
|
|
28
|
+
onCancel?: (reason: string) => void | Promise<void>;
|
|
29
|
+
onFailure?: (error: unknown) => void | Promise<void>;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export type WorkflowGateState =
|
|
33
|
+
| { kind: "inactive" }
|
|
34
|
+
| { kind: "pending_decision"; adapterId: string; activation: string }
|
|
35
|
+
| { kind: "active"; adapterId: string; activation: string; policyId: string }
|
|
36
|
+
| { kind: "terminal_cleanup"; adapterId: string; activation: string; reason: WorkflowTerminalReason };
|
|
37
|
+
|
|
38
|
+
export interface WorkflowGateController {
|
|
39
|
+
getState(): WorkflowGateState;
|
|
40
|
+
finish(): Promise<boolean>;
|
|
41
|
+
cancel(reason?: string): Promise<boolean>;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export type WorkflowTerminalReason = "finish" | "cancel" | "fail" | "session-reset";
|
|
45
|
+
|
|
46
|
+
type InternalState =
|
|
47
|
+
| { kind: "inactive" }
|
|
48
|
+
| {
|
|
49
|
+
kind: "pending_decision";
|
|
50
|
+
adapter: WorkflowAdapter;
|
|
51
|
+
abortController: AbortController;
|
|
52
|
+
token: symbol;
|
|
53
|
+
}
|
|
54
|
+
| { kind: "active"; adapter: WorkflowAdapter; policy: WorkflowPolicy; token: symbol }
|
|
55
|
+
| { kind: "terminal_cleanup"; adapter: WorkflowAdapter; reason: WorkflowTerminalReason; token: symbol };
|
|
56
|
+
|
|
57
|
+
function explicitSkillActivation(text: string): string | undefined {
|
|
58
|
+
if (!text.startsWith("/skill:")) return;
|
|
59
|
+
const spaceIndex = text.indexOf(" ");
|
|
60
|
+
const activation = spaceIndex === -1 ? text.slice(7) : text.slice(7, spaceIndex);
|
|
61
|
+
return /^[A-Za-z0-9][A-Za-z0-9._-]*$/u.test(activation) ? activation : undefined;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function isCancelled(details: QuestionDetails): boolean {
|
|
65
|
+
if (details.cancelled) return true;
|
|
66
|
+
return "answer" in details && details.answer === null;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function errorMessage(error: unknown): string {
|
|
70
|
+
return error instanceof Error ? error.message : String(error);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function publicState(state: InternalState): WorkflowGateState {
|
|
74
|
+
if (state.kind === "inactive") return state;
|
|
75
|
+
if (state.kind === "pending_decision") {
|
|
76
|
+
return {
|
|
77
|
+
kind: state.kind,
|
|
78
|
+
adapterId: state.adapter.id,
|
|
79
|
+
activation: state.adapter.activation,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
if (state.kind === "terminal_cleanup") {
|
|
83
|
+
return {
|
|
84
|
+
kind: state.kind,
|
|
85
|
+
adapterId: state.adapter.id,
|
|
86
|
+
activation: state.adapter.activation,
|
|
87
|
+
reason: state.reason,
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
return {
|
|
91
|
+
kind: state.kind,
|
|
92
|
+
adapterId: state.adapter.id,
|
|
93
|
+
activation: state.adapter.activation,
|
|
94
|
+
policyId: state.policy.id,
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function notify(ctx: ExtensionContext, message: string, type: "info" | "warning" | "error" = "error"): void {
|
|
99
|
+
ctx.ui.notify(message, type);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function isKnownPolicy(adapter: WorkflowAdapter, policy: WorkflowPolicy): boolean {
|
|
103
|
+
return adapter.policies.includes(policy);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async function invokeCleanup(callback: (() => void | Promise<void>) | undefined): Promise<void> {
|
|
107
|
+
try {
|
|
108
|
+
await callback?.();
|
|
109
|
+
} catch {
|
|
110
|
+
// Cleanup callbacks are best effort; the gate must still reach a terminal state.
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
async function invokeCancel(
|
|
115
|
+
callback: ((reason: string) => void | Promise<void>) | undefined,
|
|
116
|
+
reason: string,
|
|
117
|
+
): Promise<void> {
|
|
118
|
+
try {
|
|
119
|
+
await callback?.(reason);
|
|
120
|
+
} catch {
|
|
121
|
+
// Cleanup callbacks are best effort; the gate must still reach a terminal state.
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
async function invokeFailure(
|
|
126
|
+
callback: ((error: unknown) => void | Promise<void>) | undefined,
|
|
127
|
+
error: unknown,
|
|
128
|
+
): Promise<void> {
|
|
129
|
+
try {
|
|
130
|
+
await callback?.(error);
|
|
131
|
+
} catch {
|
|
132
|
+
// Cleanup callbacks are best effort; the gate must still reach a terminal state.
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export function registerWorkflowGate(
|
|
137
|
+
pi: ExtensionAPI,
|
|
138
|
+
questionRunner: QuestionRunner,
|
|
139
|
+
adapters: readonly WorkflowAdapter[],
|
|
140
|
+
): WorkflowGateController {
|
|
141
|
+
const adaptersByActivation = new Map<string, WorkflowAdapter>();
|
|
142
|
+
for (const adapter of adapters) {
|
|
143
|
+
if (!adapter.id.trim()) throw new Error("Decision-gated workflow adapters require an id");
|
|
144
|
+
if (!/^[-A-Za-z0-9._]+$/u.test(adapter.activation)) {
|
|
145
|
+
throw new Error(`Invalid decision-gated workflow activation: ${adapter.activation}`);
|
|
146
|
+
}
|
|
147
|
+
if (adaptersByActivation.has(adapter.activation)) {
|
|
148
|
+
throw new Error(`Duplicate decision-gated workflow activation: ${adapter.activation}`);
|
|
149
|
+
}
|
|
150
|
+
if (adapter.policies.length === 0) throw new Error(`Workflow adapter ${adapter.id} has no policies`);
|
|
151
|
+
adaptersByActivation.set(adapter.activation, adapter);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
let state: InternalState = { kind: "inactive" };
|
|
155
|
+
|
|
156
|
+
const transitionToCleanup = (
|
|
157
|
+
current: Exclude<InternalState, { kind: "inactive" } | { kind: "terminal_cleanup" }>,
|
|
158
|
+
reason: WorkflowTerminalReason,
|
|
159
|
+
): symbol => {
|
|
160
|
+
state = {
|
|
161
|
+
kind: "terminal_cleanup",
|
|
162
|
+
adapter: current.adapter,
|
|
163
|
+
reason,
|
|
164
|
+
token: current.token,
|
|
165
|
+
};
|
|
166
|
+
if (current.kind === "pending_decision") current.abortController.abort();
|
|
167
|
+
return current.token;
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
const finishCleanup = (token: symbol): void => {
|
|
171
|
+
if (state.kind === "terminal_cleanup" && state.token === token) state = { kind: "inactive" };
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
const finish = async (): Promise<boolean> => {
|
|
175
|
+
if (state.kind !== "active") return false;
|
|
176
|
+
const active = state;
|
|
177
|
+
const token = transitionToCleanup(active, "finish");
|
|
178
|
+
await invokeCleanup(active.adapter.onFinish);
|
|
179
|
+
finishCleanup(token);
|
|
180
|
+
return true;
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
const cancel = async (reason = "Workflow cancelled"): Promise<boolean> => {
|
|
184
|
+
if (state.kind === "inactive" || state.kind === "terminal_cleanup") return false;
|
|
185
|
+
const current = state;
|
|
186
|
+
const token = transitionToCleanup(current, "cancel");
|
|
187
|
+
await invokeCancel(current.adapter.onCancel, reason);
|
|
188
|
+
finishCleanup(token);
|
|
189
|
+
return true;
|
|
190
|
+
};
|
|
191
|
+
|
|
192
|
+
const fail = async (
|
|
193
|
+
adapter: WorkflowAdapter,
|
|
194
|
+
token: symbol,
|
|
195
|
+
error: unknown,
|
|
196
|
+
ctx: ExtensionContext,
|
|
197
|
+
): Promise<void> => {
|
|
198
|
+
if (state.kind === "inactive" || state.kind === "terminal_cleanup") return;
|
|
199
|
+
if (state.adapter !== adapter || state.token !== token) return;
|
|
200
|
+
const cleanupToken = transitionToCleanup(state, "fail");
|
|
201
|
+
await invokeFailure(adapter.onFailure, error);
|
|
202
|
+
finishCleanup(cleanupToken);
|
|
203
|
+
notify(ctx, `Decision-gated workflow was not activated: ${errorMessage(error)}`);
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
pi.on("input", async (event, ctx) => {
|
|
207
|
+
const activation = explicitSkillActivation(event.text);
|
|
208
|
+
if (!activation) return;
|
|
209
|
+
|
|
210
|
+
if (state.kind === "pending_decision" || state.kind === "terminal_cleanup") {
|
|
211
|
+
notify(ctx, "A decision-gated workflow is waiting for its structured question; skill routing is blocked", "warning");
|
|
212
|
+
return { action: "handled" };
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const adapter = adaptersByActivation.get(activation);
|
|
216
|
+
if (!adapter) return;
|
|
217
|
+
|
|
218
|
+
if (state.kind === "active") {
|
|
219
|
+
notify(ctx, `Cannot start /skill:${activation} while a decision-gated workflow is active`, "warning");
|
|
220
|
+
return { action: "handled" };
|
|
221
|
+
}
|
|
222
|
+
if (ctx.mode !== "tui" || !ctx.hasUI) {
|
|
223
|
+
notify(ctx, `The /skill:${activation} workflow requires interactive TUI mode`);
|
|
224
|
+
return { action: "handled" };
|
|
225
|
+
}
|
|
226
|
+
if (!ctx.isIdle() || ctx.hasPendingMessages()) {
|
|
227
|
+
notify(ctx, `The /skill:${activation} workflow can start only when Pi is idle`, "warning");
|
|
228
|
+
return { action: "handled" };
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const abortController = new AbortController();
|
|
232
|
+
const token = Symbol();
|
|
233
|
+
state = { kind: "pending_decision", adapter, abortController, token };
|
|
234
|
+
let details: QuestionDetails;
|
|
235
|
+
try {
|
|
236
|
+
details = await questionRunner.ask(adapter.question, abortController.signal, ctx);
|
|
237
|
+
} catch (error) {
|
|
238
|
+
if (state.kind !== "pending_decision" || state.adapter !== adapter || state.token !== token) {
|
|
239
|
+
return { action: "handled" };
|
|
240
|
+
}
|
|
241
|
+
await fail(adapter, token, error, ctx);
|
|
242
|
+
return { action: "handled" };
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
if (state.kind !== "pending_decision" || state.adapter !== adapter || state.token !== token) {
|
|
246
|
+
return { action: "handled" };
|
|
247
|
+
}
|
|
248
|
+
if (isCancelled(details)) {
|
|
249
|
+
await cancel("Decision question cancelled");
|
|
250
|
+
notify(ctx, `The /skill:${activation} workflow was cancelled`, "warning");
|
|
251
|
+
return { action: "handled" };
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
let policy: WorkflowPolicy | undefined;
|
|
255
|
+
try {
|
|
256
|
+
policy = adapter.selectPolicy(details);
|
|
257
|
+
} catch (error) {
|
|
258
|
+
await fail(adapter, token, error, ctx);
|
|
259
|
+
return { action: "handled" };
|
|
260
|
+
}
|
|
261
|
+
if (!policy || !isKnownPolicy(adapter, policy)) {
|
|
262
|
+
await fail(adapter, token, new Error("The structured answer did not select a registered policy"), ctx);
|
|
263
|
+
return { action: "handled" };
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
try {
|
|
267
|
+
await adapter.onActivated?.(policy, details);
|
|
268
|
+
} catch (error) {
|
|
269
|
+
if (state.kind !== "pending_decision" || state.adapter !== adapter || state.token !== token) {
|
|
270
|
+
return { action: "handled" };
|
|
271
|
+
}
|
|
272
|
+
await fail(adapter, token, error, ctx);
|
|
273
|
+
return { action: "handled" };
|
|
274
|
+
}
|
|
275
|
+
if (state.kind !== "pending_decision" || state.adapter !== adapter || state.token !== token) {
|
|
276
|
+
return { action: "handled" };
|
|
277
|
+
}
|
|
278
|
+
state = { kind: "active", adapter, policy, token };
|
|
279
|
+
return { action: "continue" };
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
pi.on("tool_call", (event: ToolCallEvent, ctx) => {
|
|
283
|
+
if (state.kind === "inactive") return;
|
|
284
|
+
if (state.kind === "pending_decision" || state.kind === "terminal_cleanup") {
|
|
285
|
+
return {
|
|
286
|
+
block: true,
|
|
287
|
+
reason: "A decision-gated workflow is waiting for its structured question; no model tool calls are allowed yet",
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
const { policy } = state;
|
|
292
|
+
if (!policy.allowedTools.includes(event.toolName)) {
|
|
293
|
+
return {
|
|
294
|
+
block: true,
|
|
295
|
+
reason: `Tool ${event.toolName} is not allowed by decision-gated policy ${policy.id}`,
|
|
296
|
+
};
|
|
297
|
+
}
|
|
298
|
+
const authorization = policy.authorizeTool?.(event.toolName, event.input, ctx) ?? true;
|
|
299
|
+
if (authorization === true) return;
|
|
300
|
+
return {
|
|
301
|
+
block: true,
|
|
302
|
+
reason: typeof authorization === "string"
|
|
303
|
+
? authorization
|
|
304
|
+
: `Tool ${event.toolName} is denied by decision-gated policy ${policy.id}`,
|
|
305
|
+
};
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
const resetForLifecycle = async (reason: string): Promise<void> => {
|
|
309
|
+
if (state.kind === "inactive" || state.kind === "terminal_cleanup") return;
|
|
310
|
+
const current = state;
|
|
311
|
+
const token = transitionToCleanup(current, "session-reset");
|
|
312
|
+
await invokeCancel(current.adapter.onCancel, reason);
|
|
313
|
+
finishCleanup(token);
|
|
314
|
+
};
|
|
315
|
+
|
|
316
|
+
pi.on("session_start", () => resetForLifecycle("Session lifecycle reset"));
|
|
317
|
+
pi.on("session_shutdown", () => resetForLifecycle("Session lifecycle reset"));
|
|
318
|
+
pi.on("session_tree", () => resetForLifecycle("Session tree reset"));
|
|
319
|
+
pi.on("session_before_switch", () => resetForLifecycle("Session switch reset"));
|
|
320
|
+
pi.on("session_before_fork", () => resetForLifecycle("Session fork reset"));
|
|
321
|
+
pi.on("session_before_tree", () => resetForLifecycle("Session tree reset"));
|
|
322
|
+
|
|
323
|
+
pi.on("agent_end", async (event, ctx) => {
|
|
324
|
+
if (state.kind !== "active") return;
|
|
325
|
+
|
|
326
|
+
let failureMessage: string | undefined;
|
|
327
|
+
for (let index = event.messages.length - 1; index >= 0; index -= 1) {
|
|
328
|
+
const message = event.messages[index];
|
|
329
|
+
if (message?.role === "assistant" && message.errorMessage) {
|
|
330
|
+
failureMessage = message.errorMessage;
|
|
331
|
+
break;
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
if (failureMessage === undefined) return;
|
|
335
|
+
|
|
336
|
+
const active = state;
|
|
337
|
+
await fail(active.adapter, active.token, new Error(failureMessage), ctx);
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
return {
|
|
341
|
+
getState: () => publicState(state),
|
|
342
|
+
finish,
|
|
343
|
+
cancel,
|
|
344
|
+
};
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
export { explicitSkillActivation };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "killeros",
|
|
3
|
-
"version": "2.0.
|
|
3
|
+
"version": "2.0.8",
|
|
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.2",
|
|
52
|
+
"@earendil-works/pi-coding-agent": "0.84.2",
|
|
53
|
+
"@earendil-works/pi-tui": "0.84.2",
|
|
53
54
|
"@types/node": "24.12.4",
|
|
54
55
|
"typebox": "1.1.38",
|
|
55
56
|
"typescript": "5.9.3"
|