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.
@@ -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
+ }
@@ -1,5 +1,10 @@
1
1
  import { StringEnum } from "@earendil-works/pi-ai";
2
- import { type ExtensionAPI, type ThemeColor } from "@earendil-works/pi-coding-agent";
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): void {
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
- pi.registerTool<typeof QuestionParams, QuestionDetails>({
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
  }
@@ -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.replace(ANSI_REGEX, "").trim();
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(pi: ExtensionAPI): void {
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.7",
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.1",
52
- "@earendil-works/pi-coding-agent": "0.84.1",
53
- "@earendil-works/pi-tui": "0.84.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",
54
54
  "@types/node": "24.12.4",
55
55
  "typebox": "1.1.38",
56
56
  "typescript": "5.9.3"