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.
@@ -1,9 +1,9 @@
1
- import { DynamicBorder, keyHint, type ExtensionAPI, type ExtensionContext, type ThemeColor } from "@earendil-works/pi-coding-agent";
2
- import { Container, SelectList, Text, truncateToWidth } from "@earendil-works/pi-tui";
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 = "off" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max";
4
+ export type ThinkingLevel = ReturnType<ExtensionAPI["getThinkingLevel"]>;
5
5
 
6
- const ALL_LEVELS: readonly ThinkingLevel[] = ["off", "minimal", "low", "medium", "high", "xhigh", "max"];
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 (ALL_LEVELS as readonly string[]).includes(value);
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() as ThinkingLevel;
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, _keybindings, done) => {
106
- const container = new Container();
107
- container.addChild(new DynamicBorder((text: string) => theme.fg("accent", text)));
108
- container.addChild(new Text(theme.fg("accent", theme.bold("Thinking variants")), 1, 0));
109
- container.addChild(new Text(theme.fg("dim", `Model: ${modelLabel(ctx.model)}`), 1, 0));
110
- container.addChild(new Text("", 0, 0));
111
- const selectList = new SelectList(items, Math.min(items.length, 10), {
112
- selectedPrefix: (text) => theme.fg("accent", text),
113
- selectedText: (text) => theme.fg("accent", text),
114
- description: (text) => theme.fg("muted", text),
115
- scrollInfo: (text) => theme.fg("dim", text),
116
- noMatch: (text) => theme.fg("warning", text),
117
- });
118
- selectList.onSelect = (item) => done(isThinkingLevel(item.value) ? item.value : null);
119
- selectList.onCancel = () => done(null);
120
- container.addChild(selectList);
121
- container.addChild(new Text("", 0, 0));
122
- container.addChild(new Text(
123
- theme.fg("dim", `${keyHint("tui.select.up", "up")} • ${keyHint("tui.select.down", "down")} • ${keyHint("tui.select.confirm", "select")} • ${keyHint("tui.select.cancel", "cancel")}`),
124
- 1,
125
- 0,
126
- ));
127
- container.addChild(new DynamicBorder((text: string) => theme.fg("accent", text)));
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: (width) => container.render(width).map((line) => truncateToWidth(line, width, "")),
130
- invalidate: () => container.invalidate(),
171
+ render,
172
+ invalidate: () => selectList?.invalidate(),
131
173
  handleInput: (data) => {
132
- selectList.handleInput(data);
174
+ ensureSelectList(visibleRowsFor(Math.max(1, tui.terminal.rows))).handleInput(data);
133
175
  tui.requestRender();
134
176
  },
135
177
  };
@@ -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 WorkedForEntryData {
8
- version: typeof WORKED_FOR_ENTRY_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 isWorkedForEntryData(data: unknown): data is WorkedForEntryData {
26
- if (!data || typeof data !== "object") return false;
27
- const candidate = data as Partial<WorkedForEntryData>;
28
- return candidate.version === WORKED_FOR_ENTRY_VERSION
29
- && typeof candidate.milliseconds === "number"
30
- && Number.isFinite(candidate.milliseconds)
31
- && candidate.milliseconds >= 0;
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
- if (!isWorkedForEntryData(entry.data)) return undefined;
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", `✻ Worked for ${formatWorkedForDuration(entry.data.milliseconds)}`),
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<WorkedForEntryData>(WORKED_FOR_ENTRY_TYPE, {
69
- version: WORKED_FOR_ENTRY_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.6",
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.82.1",
46
- "@earendil-works/pi-coding-agent": ">=0.82.1",
47
- "@earendil-works/pi-tui": ">=0.82.1",
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-coding-agent": "0.82.1",
52
- "@earendil-works/pi-tui": "0.82.1",
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"