killeros 2.0.7 → 2.0.9
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 +21 -0
- package/Killeros.ts +28 -8
- package/README.md +145 -141
- package/killeros/commands.ts +108 -34
- package/killeros/decision-gated-workflow.ts +76 -0
- package/killeros/question.ts +24 -6
- package/killeros/shell-ui.ts +109 -5
- package/killeros/workflow-gate.ts +347 -0
- package/package.json +4 -4
- package/killeros/concise.ts +0 -69
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import type { QuestionDetails } from "./question.ts";
|
|
4
|
+
import type { WorkflowAdapter, WorkflowPolicy, WorkflowToolAuthorization } from "./workflow-gate.ts";
|
|
5
|
+
|
|
6
|
+
const READ_ONLY_TOOLS = ["read", "grep", "find", "ls", "question"] as const;
|
|
7
|
+
|
|
8
|
+
const DOCUMENTATION_PATHS: readonly RegExp[] = [
|
|
9
|
+
/^(?:docs\/)?(?:glossary|context-map)(?:\.md|\/|$)/u,
|
|
10
|
+
/^docs\/adr\/[^/]+\.md$/u,
|
|
11
|
+
];
|
|
12
|
+
|
|
13
|
+
function relativePath(input: Readonly<Record<string, unknown>>, ctx: ExtensionContext): string | undefined {
|
|
14
|
+
if (typeof input.path !== "string" || input.path.trim().length === 0) return;
|
|
15
|
+
const absolute = path.resolve(ctx.cwd, input.path);
|
|
16
|
+
const relative = path.relative(ctx.cwd, absolute);
|
|
17
|
+
if (!relative || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) return;
|
|
18
|
+
return relative.replaceAll(path.sep, "/").toLocaleLowerCase();
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function authorizeDocumentationTool(
|
|
22
|
+
toolName: string,
|
|
23
|
+
input: Readonly<Record<string, unknown>>,
|
|
24
|
+
ctx: ExtensionContext,
|
|
25
|
+
): WorkflowToolAuthorization {
|
|
26
|
+
if (toolName !== "edit" && toolName !== "write") return true;
|
|
27
|
+
const target = relativePath(input, ctx);
|
|
28
|
+
if (target && DOCUMENTATION_PATHS.some((pattern) => pattern.test(target))) return true;
|
|
29
|
+
return "With docs policy permits writes only to the agreed glossary, context-map, and ADR paths";
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const NORMAL_POLICY: WorkflowPolicy = {
|
|
33
|
+
id: "normal",
|
|
34
|
+
allowedTools: READ_ONLY_TOOLS,
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const WITH_DOCS_POLICY: WorkflowPolicy = {
|
|
38
|
+
id: "with-docs",
|
|
39
|
+
allowedTools: [...READ_ONLY_TOOLS, "edit", "write"],
|
|
40
|
+
authorizeTool: authorizeDocumentationTool,
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const POLICIES = [NORMAL_POLICY, WITH_DOCS_POLICY] as const;
|
|
44
|
+
|
|
45
|
+
function selectedAnswer(details: QuestionDetails): string | undefined {
|
|
46
|
+
if (!("answer" in details) || details.answer === null) return;
|
|
47
|
+
return details.answer;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function createDecisionGatedWorkflowAdapter(): WorkflowAdapter {
|
|
51
|
+
return {
|
|
52
|
+
id: "decision-gated-workflow",
|
|
53
|
+
activation: "decision-gated-workflow",
|
|
54
|
+
question: {
|
|
55
|
+
question: "Choose the policy for this workflow before the model starts",
|
|
56
|
+
options: [
|
|
57
|
+
{
|
|
58
|
+
label: "Normal",
|
|
59
|
+
description: "Interview and read-only work; implementation files stay protected.",
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
label: "With docs",
|
|
63
|
+
description: "Allow only agreed glossary, context-map, or ADR documentation writes.",
|
|
64
|
+
},
|
|
65
|
+
],
|
|
66
|
+
},
|
|
67
|
+
policies: POLICIES,
|
|
68
|
+
selectPolicy(details) {
|
|
69
|
+
switch (selectedAnswer(details)) {
|
|
70
|
+
case "Normal": return NORMAL_POLICY;
|
|
71
|
+
case "With docs": return WITH_DOCS_POLICY;
|
|
72
|
+
default: return undefined;
|
|
73
|
+
}
|
|
74
|
+
},
|
|
75
|
+
};
|
|
76
|
+
}
|
package/killeros/question.ts
CHANGED
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
import { StringEnum } from "@earendil-works/pi-ai";
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
type ExtensionAPI,
|
|
4
|
+
type ExtensionContext,
|
|
5
|
+
type ThemeColor,
|
|
6
|
+
type ToolDefinition,
|
|
7
|
+
} from "@earendil-works/pi-coding-agent";
|
|
3
8
|
import {
|
|
4
9
|
decodeKittyPrintable,
|
|
5
10
|
Editor,
|
|
@@ -40,7 +45,7 @@ const QuestionParams = Type.Object({
|
|
|
40
45
|
})),
|
|
41
46
|
});
|
|
42
47
|
|
|
43
|
-
type QuestionParamsValue = Static<typeof QuestionParams>;
|
|
48
|
+
export type QuestionParamsValue = Static<typeof QuestionParams>;
|
|
44
49
|
|
|
45
50
|
type NormalizedQuestionSelection =
|
|
46
51
|
| { mode: "single"; minSelections: 1; maxSelections: 1 }
|
|
@@ -95,7 +100,11 @@ interface MultipleQuestionDetails {
|
|
|
95
100
|
cancelled?: boolean;
|
|
96
101
|
}
|
|
97
102
|
|
|
98
|
-
type QuestionDetails = SingleQuestionDetails | MultipleQuestionDetails;
|
|
103
|
+
export type QuestionDetails = SingleQuestionDetails | MultipleQuestionDetails;
|
|
104
|
+
|
|
105
|
+
export interface QuestionRunner {
|
|
106
|
+
ask(params: QuestionParamsValue, signal: AbortSignal | undefined, ctx: ExtensionContext): Promise<QuestionDetails>;
|
|
107
|
+
}
|
|
99
108
|
|
|
100
109
|
type QuestionSelection =
|
|
101
110
|
| { kind: "selected"; answer: string; originalIndex: number }
|
|
@@ -210,7 +219,7 @@ class MultipleResultText {
|
|
|
210
219
|
invalidate(): void {}
|
|
211
220
|
}
|
|
212
221
|
|
|
213
|
-
export function registerQuestionTool(pi: ExtensionAPI):
|
|
222
|
+
export function registerQuestionTool(pi: ExtensionAPI): QuestionRunner {
|
|
214
223
|
const customInputHistory: string[] = [];
|
|
215
224
|
let customInputHistoryBytes = 0;
|
|
216
225
|
const clearCustomInputHistory = (): void => {
|
|
@@ -242,7 +251,7 @@ export function registerQuestionTool(pi: ExtensionAPI): void {
|
|
|
242
251
|
pi.on("session_tree", clearCustomInputHistory);
|
|
243
252
|
pi.on("session_shutdown", clearCustomInputHistory);
|
|
244
253
|
|
|
245
|
-
|
|
254
|
+
const questionTool: ToolDefinition<typeof QuestionParams, QuestionDetails> = {
|
|
246
255
|
name: "question",
|
|
247
256
|
label: "Question",
|
|
248
257
|
description: `Ask one interactive multiple-choice question. Provide 1-9 concise options. Single-select is the default; opt into bounded multi-select with mode "multiple". The user can filter options or type a custom answer. Filter queries are limited to ${FILTER_QUERY_MAX_CHARACTERS.toLocaleString()} characters and ${FILTER_QUERY_MAX_BYTES.toLocaleString()} bytes.`,
|
|
@@ -752,5 +761,14 @@ export function registerQuestionTool(pi: ExtensionAPI): void {
|
|
|
752
761
|
}
|
|
753
762
|
return new BoundedText(`${theme.fg("success", "✓ ")}${theme.fg("accent", answer)}`);
|
|
754
763
|
},
|
|
755
|
-
}
|
|
764
|
+
};
|
|
765
|
+
pi.registerTool(questionTool);
|
|
766
|
+
|
|
767
|
+
return {
|
|
768
|
+
async ask(params, signal, ctx): Promise<QuestionDetails> {
|
|
769
|
+
const result = await questionTool.execute("killeros-workflow-gate", params, signal, undefined, ctx);
|
|
770
|
+
if (!result.details) throw new Error("Question did not return a structured result");
|
|
771
|
+
return result.details;
|
|
772
|
+
},
|
|
773
|
+
};
|
|
756
774
|
}
|
package/killeros/shell-ui.ts
CHANGED
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
} from "@earendil-works/pi-coding-agent";
|
|
11
11
|
import {
|
|
12
12
|
CURSOR_MARKER,
|
|
13
|
+
stripTerminalSequences,
|
|
13
14
|
truncateToWidth,
|
|
14
15
|
visibleWidth,
|
|
15
16
|
wrapTextWithAnsi,
|
|
@@ -17,6 +18,11 @@ import {
|
|
|
17
18
|
type TUI,
|
|
18
19
|
} from "@earendil-works/pi-tui";
|
|
19
20
|
import { formatCwd, padRight } from "./display.ts";
|
|
21
|
+
import {
|
|
22
|
+
createSlashCommandResolver,
|
|
23
|
+
findSlashCommandTokens,
|
|
24
|
+
type SlashCommandResolver,
|
|
25
|
+
} from "./commands.ts";
|
|
20
26
|
import { reportError } from "./errors.ts";
|
|
21
27
|
import { formatModel } from "./footer.ts";
|
|
22
28
|
import { LEVEL_COLORS, type ThinkingLevel } from "./variants.ts";
|
|
@@ -166,10 +172,8 @@ class PiStartupHeader {
|
|
|
166
172
|
}
|
|
167
173
|
}
|
|
168
174
|
|
|
169
|
-
const ANSI_REGEX = /\x1b(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])/g;
|
|
170
|
-
|
|
171
175
|
function stripAnsi(text: string): string {
|
|
172
|
-
return text
|
|
176
|
+
return stripTerminalSequences(text).trim();
|
|
173
177
|
}
|
|
174
178
|
|
|
175
179
|
function isBorderLine(line: string): boolean {
|
|
@@ -182,10 +186,98 @@ function isScrolledTopBorder(line: string): boolean {
|
|
|
182
186
|
return unstyled.includes("↑");
|
|
183
187
|
}
|
|
184
188
|
|
|
189
|
+
interface RenderChunk {
|
|
190
|
+
text: string;
|
|
191
|
+
plainStart: number;
|
|
192
|
+
isAnsi: boolean;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function extractTerminalSequence(text: string, position: number): { code: string; length: number } | undefined {
|
|
196
|
+
if (text[position] !== "\x1B") return undefined;
|
|
197
|
+
const next = text[position + 1];
|
|
198
|
+
if (next === "[") {
|
|
199
|
+
let end = position + 2;
|
|
200
|
+
while (end < text.length && !/[\x40-\x7E]/u.test(text[end] ?? "")) end += 1;
|
|
201
|
+
if (end < text.length) return { code: text.slice(position, end + 1), length: end + 1 - position };
|
|
202
|
+
return undefined;
|
|
203
|
+
}
|
|
204
|
+
if (next === "]" || next === "_") {
|
|
205
|
+
let end = position + 2;
|
|
206
|
+
while (end < text.length) {
|
|
207
|
+
if (text[end] === "\x07") return { code: text.slice(position, end + 1), length: end + 1 - position };
|
|
208
|
+
if (text[end] === "\x1B" && text[end + 1] === "\\") {
|
|
209
|
+
return { code: text.slice(position, end + 2), length: end + 2 - position };
|
|
210
|
+
}
|
|
211
|
+
end += 1;
|
|
212
|
+
}
|
|
213
|
+
return undefined;
|
|
214
|
+
}
|
|
215
|
+
if (next !== undefined) return { code: text.slice(position, position + 2), length: 2 };
|
|
216
|
+
return undefined;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
export function highlightSlashCommands(
|
|
220
|
+
line: string,
|
|
221
|
+
isValidCommand: (name: string) => boolean,
|
|
222
|
+
styleCommand: (token: string) => string,
|
|
223
|
+
): string {
|
|
224
|
+
const chunks: RenderChunk[] = [];
|
|
225
|
+
let plain = "";
|
|
226
|
+
let index = 0;
|
|
227
|
+
while (index < line.length) {
|
|
228
|
+
const ansi = extractTerminalSequence(line, index);
|
|
229
|
+
if (ansi) {
|
|
230
|
+
chunks.push({ text: ansi.code, plainStart: plain.length, isAnsi: true });
|
|
231
|
+
index += ansi.length;
|
|
232
|
+
continue;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const start = index;
|
|
236
|
+
const plainStart = plain.length;
|
|
237
|
+
while (index < line.length && !extractTerminalSequence(line, index)) {
|
|
238
|
+
plain += line[index] ?? "";
|
|
239
|
+
index += 1;
|
|
240
|
+
}
|
|
241
|
+
chunks.push({
|
|
242
|
+
text: line.slice(start, index),
|
|
243
|
+
plainStart,
|
|
244
|
+
isAnsi: false,
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
const tokens = findSlashCommandTokens(plain).filter((token) => isValidCommand(token.name));
|
|
249
|
+
if (tokens.length === 0) return line;
|
|
250
|
+
|
|
251
|
+
return chunks.map((chunk) => {
|
|
252
|
+
if (chunk.isAnsi) return chunk.text;
|
|
253
|
+
let output = "";
|
|
254
|
+
let offset = 0;
|
|
255
|
+
while (offset < chunk.text.length) {
|
|
256
|
+
const plainIndex = chunk.plainStart + offset;
|
|
257
|
+
const token = tokens.find(({ start, end }) => plainIndex >= start && plainIndex < end);
|
|
258
|
+
if (!token) {
|
|
259
|
+
const nextTokenStart = tokens.find(({ start }) => start > plainIndex)?.start;
|
|
260
|
+
const end = nextTokenStart === undefined
|
|
261
|
+
? chunk.text.length
|
|
262
|
+
: Math.min(chunk.text.length, nextTokenStart - chunk.plainStart);
|
|
263
|
+
output += chunk.text.slice(offset, end);
|
|
264
|
+
offset = end;
|
|
265
|
+
continue;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const tokenEnd = Math.min(chunk.text.length, token.end - chunk.plainStart);
|
|
269
|
+
output += styleCommand(chunk.text.slice(offset, tokenEnd));
|
|
270
|
+
offset = tokenEnd;
|
|
271
|
+
}
|
|
272
|
+
return output;
|
|
273
|
+
}).join("");
|
|
274
|
+
}
|
|
275
|
+
|
|
185
276
|
class PiCodeEditor extends CustomEditor {
|
|
186
277
|
private readonly appKeybindings: KeybindingsManager;
|
|
187
278
|
private readonly runtimeTheme: Theme;
|
|
188
279
|
private readonly suggestion: string;
|
|
280
|
+
private readonly commandResolver: SlashCommandResolver;
|
|
189
281
|
|
|
190
282
|
constructor(
|
|
191
283
|
tui: TUI,
|
|
@@ -193,11 +285,13 @@ class PiCodeEditor extends CustomEditor {
|
|
|
193
285
|
appKeybindings: KeybindingsManager,
|
|
194
286
|
runtimeTheme: Theme,
|
|
195
287
|
suggestion: string,
|
|
288
|
+
commandResolver: SlashCommandResolver,
|
|
196
289
|
) {
|
|
197
290
|
super(tui, theme, appKeybindings);
|
|
198
291
|
this.appKeybindings = appKeybindings;
|
|
199
292
|
this.runtimeTheme = runtimeTheme;
|
|
200
293
|
this.suggestion = suggestion;
|
|
294
|
+
this.commandResolver = commandResolver;
|
|
201
295
|
}
|
|
202
296
|
|
|
203
297
|
override handleInput(data: string): void {
|
|
@@ -248,6 +342,13 @@ class PiCodeEditor extends CustomEditor {
|
|
|
248
342
|
const cursorMarker = this.focused ? CURSOR_MARKER : "";
|
|
249
343
|
content = `${cursorMarker}\x1B[7m${dim(first)}\x1B[27m${dim(rest)}`;
|
|
250
344
|
}
|
|
345
|
+
if (this.getText() !== "") {
|
|
346
|
+
content = highlightSlashCommands(
|
|
347
|
+
content,
|
|
348
|
+
(name) => this.commandResolver.isValidCommand(name),
|
|
349
|
+
(token) => this.runtimeTheme.fg("mdLink", token),
|
|
350
|
+
);
|
|
351
|
+
}
|
|
251
352
|
rendered.push(`${prefix}${padRight(content, innerWidth)}`);
|
|
252
353
|
}
|
|
253
354
|
|
|
@@ -272,7 +373,10 @@ const ACTIVITY_FRAME_INTERVAL_MS = 120;
|
|
|
272
373
|
|
|
273
374
|
let killerosEditorFactory: ReturnType<ExtensionContext["ui"]["getEditorComponent"]>;
|
|
274
375
|
|
|
275
|
-
export function registerShellUi(
|
|
376
|
+
export function registerShellUi(
|
|
377
|
+
pi: ExtensionAPI,
|
|
378
|
+
commandResolver: SlashCommandResolver = createSlashCommandResolver(pi),
|
|
379
|
+
): void {
|
|
276
380
|
let activeHeader: PiStartupHeader | undefined;
|
|
277
381
|
|
|
278
382
|
pi.on("session_start", (_event, ctx) => {
|
|
@@ -294,7 +398,7 @@ export function registerShellUi(pi: ExtensionAPI): void {
|
|
|
294
398
|
if (!existingEditorFactory || existingEditorFactory === killerosEditorFactory) {
|
|
295
399
|
const editorSuggestion = nextEditorSuggestion();
|
|
296
400
|
killerosEditorFactory = (tui, editorTheme, keybindings) =>
|
|
297
|
-
new PiCodeEditor(tui, editorTheme, keybindings, ctx.ui.theme, editorSuggestion);
|
|
401
|
+
new PiCodeEditor(tui, editorTheme, keybindings, ctx.ui.theme, editorSuggestion, commandResolver);
|
|
298
402
|
ctx.ui.setEditorComponent(killerosEditorFactory);
|
|
299
403
|
}
|
|
300
404
|
} catch (error) {
|
|
@@ -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.9",
|
|
4
4
|
"description": "TUI, goals, and workflow automation for the Pi coding agent",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"keywords": [
|
|
@@ -48,9 +48,9 @@
|
|
|
48
48
|
"typebox": ">=1.1.38 <2"
|
|
49
49
|
},
|
|
50
50
|
"devDependencies": {
|
|
51
|
-
"@earendil-works/pi-ai": "0.84.
|
|
52
|
-
"@earendil-works/pi-coding-agent": "0.84.
|
|
53
|
-
"@earendil-works/pi-tui": "0.84.
|
|
51
|
+
"@earendil-works/pi-ai": "0.84.2",
|
|
52
|
+
"@earendil-works/pi-coding-agent": "0.84.2",
|
|
53
|
+
"@earendil-works/pi-tui": "0.84.2",
|
|
54
54
|
"@types/node": "24.12.4",
|
|
55
55
|
"typebox": "1.1.38",
|
|
56
56
|
"typescript": "5.9.3"
|