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/killeros/goals.ts CHANGED
@@ -1,4 +1,6 @@
1
1
  import { StringEnum } from "@earendil-works/pi-ai";
2
+ import { lstatSync } from "node:fs";
3
+ import path from "node:path";
2
4
  import { type ExtensionAPI, type ExtensionCommandContext, type ExtensionContext, type ThemeColor } from "@earendil-works/pi-coding-agent";
3
5
  import { Text } from "@earendil-works/pi-tui";
4
6
  import { Type } from "typebox";
@@ -7,10 +9,11 @@ import { BoundedText } from "./bounded-text.ts";
7
9
  import { formatTime, formatTokens } from "./display.ts";
8
10
  import { reportError } from "./errors.ts";
9
11
  import { resolvePersonalInstructions } from "./personal-instructions.ts";
10
- import type { GoalBlockerAudit, GoalRuntime, GoalState, GoalStatus, InitRuntime } from "./runtime.ts";
12
+ import type { GoalBlockerAudit, GoalFileVerification, GoalRuntime, GoalState, GoalStatus, InitRuntime } from "./runtime.ts";
11
13
 
12
14
  const GOAL_ENTRY_TYPE = "killeros-goal";
13
15
  const GOAL_CONTINUATION_TYPE = "killeros-goal-continuation";
16
+ const GOAL_UPDATE_TOOL = "killeros_goal_update";
14
17
  const GOAL_OBJECTIVE_LIMIT = 4_000;
15
18
  const GOAL_VERSION = 1;
16
19
 
@@ -29,7 +32,6 @@ interface GoalTransitionOptions {
29
32
 
30
33
  interface RestoredGoalState {
31
34
  state?: GoalState;
32
- recoveryProven: boolean;
33
35
  }
34
36
 
35
37
  const GoalUpdateParams = Type.Object({
@@ -52,6 +54,7 @@ const GoalUpdateParams = Type.Object({
52
54
  interface GoalUpdateDetails {
53
55
  status: "complete" | "blocked" | "blocker-audit";
54
56
  evidence: string;
57
+ verification?: "file" | "model-reported";
55
58
  blockerKey?: string;
56
59
  streak?: number;
57
60
  }
@@ -64,6 +67,41 @@ function finiteNonNegative(value: unknown): value is number {
64
67
  return typeof value === "number" && Number.isFinite(value) && value >= 0;
65
68
  }
66
69
 
70
+ function isGoalFileVerification(value: unknown): value is GoalFileVerification {
71
+ if (!value || typeof value !== "object") return false;
72
+ const candidate = value as Partial<GoalFileVerification>;
73
+ return candidate.kind === "file"
74
+ && typeof candidate.path === "string"
75
+ && candidate.path === candidate.path.trim()
76
+ && isAbsoluteFilePath(candidate.path);
77
+ }
78
+
79
+ function isAbsoluteFilePath(value: string): boolean {
80
+ if (!value || /^(?:https?|file):\/\//iu.test(value) || /[\\\/]$/u.test(value)) return false;
81
+ return path.isAbsolute(value) || path.win32.isAbsolute(value);
82
+ }
83
+
84
+ function inferGoalVerification(objective: string): GoalFileVerification | undefined {
85
+ const destination = /\b(?:create|write|save|generate)\b[^\r\n]{0,160}?\b(?:file|document|markdown|report|spreadsheet|presentation|image)\b\s+(?:to|at|as|destination(?:\s+is)?|output(?:\s+(?:to|at))?)\b\s*(?:`([^`\r\n]+)`|"([^"\r\n]+)"|'([^'\r\n]+)'|([A-Za-z]:\\[^\s,;]+|\/[^\s,;]+))/giu;
86
+ const paths = [...objective.matchAll(destination)]
87
+ .map((match) => (match[1] ?? match[2] ?? match[3] ?? match[4] ?? "").trim())
88
+ .filter(isAbsoluteFilePath);
89
+ const unique = [...new Set(paths)];
90
+ return unique.length === 1 ? { kind: "file", path: unique[0]! } : undefined;
91
+ }
92
+
93
+ function verifyGoalDeliverable(verification: GoalFileVerification): void {
94
+ let artifact: ReturnType<typeof lstatSync>;
95
+ try {
96
+ artifact = lstatSync(verification.path);
97
+ } catch {
98
+ throw new Error(`Goal deliverable is not a regular file at the required path: ${verification.path}`);
99
+ }
100
+ if (!artifact.isFile()) {
101
+ throw new Error(`Goal deliverable is not a regular file at the required path: ${verification.path}`);
102
+ }
103
+ }
104
+
67
105
  function isGoalBlockerAudit(value: unknown, turns: number, status: GoalStatus): value is GoalBlockerAudit {
68
106
  if (!value || typeof value !== "object") return false;
69
107
  const candidate = value as Partial<GoalBlockerAudit>;
@@ -92,6 +130,7 @@ function parseGoalState(value: unknown): GoalState | undefined {
92
130
  || !finiteNonNegative(candidate.baselineTokens)
93
131
  || candidate.activeStartedAt !== undefined && !finiteNonNegative(candidate.activeStartedAt)
94
132
  || candidate.result !== undefined && typeof candidate.result !== "string"
133
+ || candidate.verification !== undefined && !isGoalFileVerification(candidate.verification)
95
134
  || candidate.blockerAudit !== undefined && !isGoalBlockerAudit(candidate.blockerAudit, candidate.turns!, candidate.status)
96
135
  || candidate.resumeAfterManualCompaction !== undefined && candidate.resumeAfterManualCompaction !== true
97
136
  || candidate.resumeAfterManualCompaction === true && candidate.status !== "paused") {
@@ -112,6 +151,7 @@ function parseGoalState(value: unknown): GoalState | undefined {
112
151
  result: candidate.result,
113
152
  resumeAfterManualCompaction: candidate.resumeAfterManualCompaction,
114
153
  blockerAudit: candidate.blockerAudit,
154
+ verification: candidate.verification,
115
155
  };
116
156
  }
117
157
 
@@ -130,19 +170,17 @@ function restoreGoalState(ctx: ExtensionContext): RestoredGoalState {
130
170
  if (entry?.type !== "custom" || entry.customType !== GOAL_ENTRY_TYPE) continue;
131
171
  const data = entry.data as Partial<GoalEntryData> | undefined;
132
172
  if (!data || data.version !== GOAL_VERSION || data.state === null) {
133
- return { state: undefined, recoveryProven: false };
173
+ return { state: undefined };
134
174
  }
135
175
  const restored = parseGoalState(data.state);
136
- if (!restored) return { state: undefined, recoveryProven: false };
176
+ if (!restored) return { state: undefined };
137
177
  const state = restored.status === "active"
138
178
  ? { ...restored, activeStartedAt: Date.now() }
139
179
  : { ...restored, activeStartedAt: undefined };
140
- const recoveryProven = state.status === "paused"
141
- && state.resumeAfterManualCompaction === true
142
- && entries.slice(index + 1).some((candidate) => candidate.type === "compaction");
143
- return { state, recoveryProven };
180
+ if (state.status === "paused") state.resumeAfterManualCompaction = undefined;
181
+ return { state };
144
182
  }
145
- return { state: undefined, recoveryProven: false };
183
+ return { state: undefined };
146
184
  }
147
185
 
148
186
  export function goalElapsedMilliseconds(state: GoalState, now = Date.now()): number {
@@ -173,6 +211,21 @@ function sumGoalTokens(ctx: ExtensionContext): number {
173
211
  return total;
174
212
  }
175
213
 
214
+ function setGoalUpdateToolActive(pi: ExtensionAPI, active: boolean): void {
215
+ const api = pi as ExtensionAPI & { getActiveTools?: () => string[]; setActiveTools?: (names: string[]) => void };
216
+ if (!api.getActiveTools || !api.setActiveTools) return;
217
+ const activeTools = api.getActiveTools();
218
+ const isActive = activeTools.includes(GOAL_UPDATE_TOOL);
219
+ if (active === isActive) return;
220
+ api.setActiveTools(active
221
+ ? [...activeTools, GOAL_UPDATE_TOOL]
222
+ : activeTools.filter((name) => name !== GOAL_UPDATE_TOOL));
223
+ }
224
+
225
+ function syncGoalUpdateTool(pi: ExtensionAPI, runtime: GoalRuntime): void {
226
+ setGoalUpdateToolActive(pi, runtime.state?.status === "active");
227
+ }
228
+
176
229
  function persistGoalState(
177
230
  pi: ExtensionAPI,
178
231
  runtime: GoalRuntime,
@@ -182,6 +235,7 @@ function persistGoalState(
182
235
  const data: GoalEntryData = { version: GOAL_VERSION, event, state: state ?? null };
183
236
  pi.appendEntry(GOAL_ENTRY_TYPE, data);
184
237
  runtime.state = state;
238
+ syncGoalUpdateTool(pi, runtime);
185
239
  runtime.persistenceRetryNeeded = false;
186
240
  runtime.requestRender?.();
187
241
  }
@@ -276,6 +330,7 @@ export function pauseGoalAfterFailure(
276
330
  result: reason,
277
331
  resumeAfterManualCompaction: undefined,
278
332
  } : undefined;
333
+ syncGoalUpdateTool(pi, runtime);
279
334
  runtime.persistenceRetryNeeded = true;
280
335
  runtime.continuationScheduled = false;
281
336
  runtime.requestRender?.();
@@ -301,6 +356,7 @@ function pauseGoalForPossibleManualCompaction(
301
356
  result: reason,
302
357
  resumeAfterManualCompaction: true,
303
358
  } : undefined;
359
+ syncGoalUpdateTool(pi, runtime);
304
360
  runtime.persistenceRetryNeeded = true;
305
361
  runtime.continuationScheduled = false;
306
362
  runtime.requestRender?.();
@@ -465,7 +521,7 @@ export function registerGoal(
465
521
  });
466
522
 
467
523
  pi.registerTool<typeof GoalUpdateParams, GoalUpdateDetails>({
468
- name: "killeros_goal_update",
524
+ name: GOAL_UPDATE_TOOL,
469
525
  label: "Goal update",
470
526
  description: "Mark the active KillerOS long-running goal complete after verification, or record the same blocker key on three consecutive goal turns before blocking it.",
471
527
  parameters: GoalUpdateParams,
@@ -478,10 +534,14 @@ export function registerGoal(
478
534
  const evidence = params.evidence.trim();
479
535
  if (!evidence) throw new Error("Goal evidence must not be empty");
480
536
  if (params.status === "complete") {
537
+ if (state.verification) verifyGoalDeliverable(state.verification);
538
+ const verification = state.verification ? "file" : "model-reported";
481
539
  transitionGoal(pi, runtime, "complete", "complete", evidence, { resetBlockedAudit: true });
482
540
  return {
483
- content: [{ type: "text", text: `Goal marked complete: ${evidence}` }],
484
- details: { status: "complete", evidence },
541
+ content: [{ type: "text", text: state.verification
542
+ ? `Goal verified complete at ${state.verification.path}: ${evidence}`
543
+ : `Goal marked complete (model-reported): ${evidence}` }],
544
+ details: { status: "complete", evidence, verification },
485
545
  };
486
546
  }
487
547
  if (!runtime.goalTurnInFlight) throw new Error("A blocker audit can only be recorded during an active KillerOS goal turn");
@@ -516,7 +576,12 @@ export function registerGoal(
516
576
  renderCall(args, theme) {
517
577
  return new Text(`${theme.fg("toolTitle", theme.bold("goal "))}${theme.fg("muted", args.status)}`, 0, 0);
518
578
  },
519
- renderResult(result, options, theme) {
579
+ renderResult(result, options, theme, context) {
580
+ if (context?.isError) {
581
+ const first = result.content[0];
582
+ const message = first?.type === "text" ? first.text : "Goal update failed";
583
+ return new BoundedText(theme.fg("error", message), options.expanded ? undefined : 3);
584
+ }
520
585
  const details = result.details;
521
586
  if (!details) return new BoundedText(theme.fg("dim", "Goal updated"));
522
587
  const label = details.status === "complete" ? "✓ Complete" : details.status === "blocked" ? "! Blocked" : `! Blocker audit ${details.streak}/3`;
@@ -527,7 +592,8 @@ export function registerGoal(
527
592
 
528
593
  const restoreGoal = (ctx: ExtensionContext): void => {
529
594
  const restored = restoreGoalState(ctx);
530
- runtime.state = restored.state;
595
+ runtime.state = isGoalModeSupported(ctx) ? restored.state : undefined;
596
+ syncGoalUpdateTool(pi, runtime);
531
597
  runtime.continuationScheduled = false;
532
598
  runtime.continuationHeld = false;
533
599
  runtime.goalTurnInFlight = false;
@@ -536,9 +602,7 @@ export function registerGoal(
536
602
  runtime.lastStopReason = undefined;
537
603
  runtime.lastError = undefined;
538
604
  runtime.requestRender?.();
539
- if (restored.recoveryProven) {
540
- recoverGoalAfterManualCompaction(pi, runtime, initState, ctx);
541
- } else if (runtime.state?.status === "active") {
605
+ if (runtime.state?.status === "active") {
542
606
  setImmediate(() => scheduleGoalContinuation(pi, runtime, initState, ctx));
543
607
  }
544
608
  };
@@ -561,6 +625,7 @@ export function registerGoal(
561
625
  }
562
626
  }
563
627
  runtime.state = undefined;
628
+ syncGoalUpdateTool(pi, runtime);
564
629
  runtime.continuationScheduled = false;
565
630
  runtime.continuationHeld = false;
566
631
  runtime.goalTurnInFlight = false;
@@ -680,6 +745,7 @@ export function registerGoal(
680
745
  ctx.ui.notify("Goal pause saved. Goal remains paused. Automatic compaction recovery is off.", "info");
681
746
  } catch (error) {
682
747
  runtime.state = checkpoint;
748
+ syncGoalUpdateTool(pi, runtime);
683
749
  runtime.persistenceRetryNeeded = true;
684
750
  runtime.continuationScheduled = false;
685
751
  runtime.requestRender?.();
@@ -798,6 +864,7 @@ export function registerGoal(
798
864
  activeStartedAt: now,
799
865
  blockedAuditStartTurn: current.turns,
800
866
  blockerAudit: undefined,
867
+ verification: inferGoalVerification(objective),
801
868
  result: undefined,
802
869
  resumeAfterManualCompaction: undefined,
803
870
  };
@@ -869,6 +936,7 @@ export function registerGoal(
869
936
  turns: 0,
870
937
  blockedAuditStartTurn: 0,
871
938
  baselineTokens: sumGoalTokens(ctx),
939
+ verification: inferGoalVerification(objective),
872
940
  };
873
941
  try {
874
942
  persistGoalState(pi, runtime, unfinished ? "replace" : "set", state);
package/killeros/init.ts CHANGED
@@ -133,8 +133,10 @@ export function registerInitCommand(pi: ExtensionAPI, initState: InitRuntime, go
133
133
 
134
134
  pi.on("session_start", () => setInitTools(pi, initState, false));
135
135
  pi.on("session_shutdown", () => {
136
+ const settle = initState.settle;
136
137
  setInitTools(pi, initState, false);
137
138
  resetInitRuntime(initState);
139
+ settle?.({ kind: "cancelled" });
138
140
  });
139
141
  pi.on("before_agent_start", () => {
140
142
  if (initState.active) setInitTools(pi, initState, true);
@@ -160,7 +162,7 @@ export function registerInitCommand(pi: ExtensionAPI, initState: InitRuntime, go
160
162
  ctx.ui.notify("/init requires interactive TUI mode", "error");
161
163
  return;
162
164
  }
163
- if (initState.active) {
165
+ if (initState.active || initState.starting) {
164
166
  ctx.ui.notify("/init is already running", "warning");
165
167
  return;
166
168
  }
@@ -172,28 +174,45 @@ export function registerInitCommand(pi: ExtensionAPI, initState: InitRuntime, go
172
174
  ctx.ui.notify("Trust this project before running /init", "error");
173
175
  return;
174
176
  }
175
- await ctx.waitForIdle();
177
+ const starting = Symbol();
178
+ initState.starting = starting;
179
+ try {
180
+ await ctx.waitForIdle();
181
+ } catch (error) {
182
+ if (initState.starting !== starting) return;
183
+ initState.starting = undefined;
184
+ reportError(ctx, "/init could not wait for active work", error);
185
+ return;
186
+ }
187
+ if (initState.starting !== starting) return;
176
188
 
177
189
  let projectRoot: string;
178
190
  try {
179
191
  projectRoot = await fs.realpath(ctx.cwd);
180
192
  } catch (error) {
193
+ if (initState.starting !== starting) return;
194
+ initState.starting = undefined;
181
195
  reportError(ctx, "/init could not resolve the project root", error);
182
196
  return;
183
197
  }
198
+ if (initState.starting !== starting) return;
184
199
  const targetPath = path.join(projectRoot, "AGENTS.md");
185
200
  try {
186
201
  const [{ index: evidence }, baseline] = await Promise.all([
187
202
  buildInitEvidence(projectRoot),
188
203
  captureInitTargetBaseline(targetPath),
189
204
  ]);
205
+ if (initState.starting !== starting) return;
190
206
  initState.active = true;
191
207
  initState.projectRoot = projectRoot;
192
208
  initState.targetPath = targetPath;
193
209
  initState.evidence = evidence;
194
210
  initState.baseline = baseline;
195
211
  initState.outcome = { kind: "pending" };
212
+ initState.starting = undefined;
196
213
  } catch (error) {
214
+ if (initState.starting !== starting) return;
215
+ initState.starting = undefined;
197
216
  reportError(ctx, "/init could not capture safe repository evidence", error);
198
217
  return;
199
218
  }
@@ -234,6 +253,8 @@ export function registerInitCommand(pi: ExtensionAPI, initState: InitRuntime, go
234
253
  case "policy-conflict":
235
254
  ctx.ui.notify(`/init left AGENTS.md unchanged: ${outcome.reason}`, "warning");
236
255
  break;
256
+ case "cancelled":
257
+ break;
237
258
  default:
238
259
  reportError(ctx, "/init did not generate AGENTS.md", "the model completed without a write or policy-conflict outcome");
239
260
  }
@@ -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,
@@ -9,7 +14,7 @@ import {
9
14
  wrapTextWithAnsi,
10
15
  type EditorTheme,
11
16
  } from "@earendil-works/pi-tui";
12
- import { Type } from "typebox";
17
+ import { Type, type Static } from "typebox";
13
18
  import { BoundedText } from "./bounded-text.ts";
14
19
 
15
20
  const OptionSchema = Type.Object({
@@ -31,16 +36,42 @@ const QuestionParams = Type.Object({
31
36
  minSelections: Type.Optional(Type.Integer({
32
37
  minimum: 1,
33
38
  maximum: 10,
34
- description: "Minimum answers required in multiple mode; defaults to 1",
39
+ description: "Minimum answers required in multiple mode; defaults to 1. Single mode accepts only an explicit 1/1 bounds pair",
35
40
  })),
36
41
  maxSelections: Type.Optional(Type.Integer({
37
42
  minimum: 1,
38
43
  maximum: 10,
39
- description: "Maximum answers allowed in multiple mode; defaults to all options plus one custom answer",
44
+ description: "Maximum answers allowed in multiple mode; defaults to all options plus one custom answer. Single mode accepts only an explicit 1/1 bounds pair",
40
45
  })),
41
46
  });
42
47
 
43
- type QuestionMode = "single" | "multiple";
48
+ export type QuestionParamsValue = Static<typeof QuestionParams>;
49
+
50
+ type NormalizedQuestionSelection =
51
+ | { mode: "single"; minSelections: 1; maxSelections: 1 }
52
+ | { mode: "multiple"; minSelections: number; maxSelections: number };
53
+
54
+ function normalizeQuestionSelection(params: QuestionParamsValue): NormalizedQuestionSelection {
55
+ const mode = params.mode ?? "single";
56
+ if (mode === "single") {
57
+ const hasSelectionBounds = params.minSelections !== undefined || params.maxSelections !== undefined;
58
+ if (hasSelectionBounds && (params.minSelections !== 1 || params.maxSelections !== 1)) {
59
+ throw new Error("Single-select question bounds must be omitted or both be 1");
60
+ }
61
+ return { mode, minSelections: 1, maxSelections: 1 };
62
+ }
63
+
64
+ const maximumAvailable = params.options.length + 1;
65
+ const minSelections = params.minSelections ?? 1;
66
+ const maxSelections = params.maxSelections ?? maximumAvailable;
67
+ if (minSelections > maxSelections) {
68
+ throw new Error("Question minimum selections cannot exceed maximum selections");
69
+ }
70
+ if (maxSelections > maximumAvailable) {
71
+ throw new Error(`Question allows at most ${maximumAvailable} selections including one custom answer`);
72
+ }
73
+ return { mode, minSelections, maxSelections };
74
+ }
44
75
 
45
76
  interface DisplayOption {
46
77
  label: string;
@@ -69,7 +100,11 @@ interface MultipleQuestionDetails {
69
100
  cancelled?: boolean;
70
101
  }
71
102
 
72
- 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
+ }
73
108
 
74
109
  type QuestionSelection =
75
110
  | { kind: "selected"; answer: string; originalIndex: number }
@@ -184,7 +219,7 @@ class MultipleResultText {
184
219
  invalidate(): void {}
185
220
  }
186
221
 
187
- export function registerQuestionTool(pi: ExtensionAPI): void {
222
+ export function registerQuestionTool(pi: ExtensionAPI): QuestionRunner {
188
223
  const customInputHistory: string[] = [];
189
224
  let customInputHistoryBytes = 0;
190
225
  const clearCustomInputHistory = (): void => {
@@ -216,7 +251,7 @@ export function registerQuestionTool(pi: ExtensionAPI): void {
216
251
  pi.on("session_tree", clearCustomInputHistory);
217
252
  pi.on("session_shutdown", clearCustomInputHistory);
218
253
 
219
- pi.registerTool<typeof QuestionParams, QuestionDetails>({
254
+ const questionTool: ToolDefinition<typeof QuestionParams, QuestionDetails> = {
220
255
  name: "question",
221
256
  label: "Question",
222
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.`,
@@ -229,20 +264,7 @@ export function registerQuestionTool(pi: ExtensionAPI): void {
229
264
  executionMode: "sequential",
230
265
 
231
266
  async execute(_toolCallId, params, signal, _onUpdate, ctx) {
232
- const mode: QuestionMode = params.mode ?? "single";
233
- const hasSelectionBounds = params.minSelections !== undefined || params.maxSelections !== undefined;
234
- if (mode === "single" && hasSelectionBounds) {
235
- throw new Error("Question selection bounds require mode \"multiple\"");
236
- }
237
- const maximumAvailable = params.options.length + 1;
238
- const minSelections = params.minSelections ?? 1;
239
- const maxSelections = params.maxSelections ?? maximumAvailable;
240
- if (minSelections > maxSelections) {
241
- throw new Error("Question minimum selections cannot exceed maximum selections");
242
- }
243
- if (maxSelections > maximumAvailable) {
244
- throw new Error(`Question allows at most ${maximumAvailable} selections including one custom answer`);
245
- }
267
+ const { mode, minSelections, maxSelections } = normalizeQuestionSelection(params);
246
268
  if (ctx.mode !== "tui") throw new Error("The question tool requires interactive TUI mode");
247
269
  if (signal?.aborted) throw new Error("Question cancelled before it opened");
248
270
 
@@ -703,9 +725,8 @@ export function registerQuestionTool(pi: ExtensionAPI): void {
703
725
  },
704
726
 
705
727
  renderCall(args, theme, context) {
706
- const multiple = args.mode === "multiple";
707
- const minimum = args.minSelections ?? 1;
708
- const maximum = args.maxSelections ?? args.options.length + 1;
728
+ const { mode, minSelections: minimum, maxSelections: maximum } = normalizeQuestionSelection(args);
729
+ const multiple = mode === "multiple";
709
730
  if (!context.expanded) {
710
731
  const title = multiple ? "question (multi-select) " : "question ";
711
732
  const detail = multiple ? `${args.options.length} options · choose ${minimum}–${maximum}` : `${args.options.length} option${args.options.length === 1 ? "" : "s"}`;
@@ -740,5 +761,14 @@ export function registerQuestionTool(pi: ExtensionAPI): void {
740
761
  }
741
762
  return new BoundedText(`${theme.fg("success", "✓ ")}${theme.fg("accent", answer)}`);
742
763
  },
743
- });
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
+ };
744
774
  }
@@ -5,10 +5,12 @@ export type InitOutcome =
5
5
  | { kind: "pending" }
6
6
  | { kind: "written" }
7
7
  | { kind: "policy-conflict"; reason: string }
8
+ | { kind: "cancelled" }
8
9
  | { kind: "no-outcome" };
9
10
 
10
11
  export interface InitRuntime {
11
12
  active: boolean;
13
+ starting?: symbol;
12
14
  targetPath?: string;
13
15
  projectRoot?: string;
14
16
  activeTools?: string[];
@@ -26,6 +28,11 @@ export interface GoalBlockerAudit {
26
28
  lastTurn: number;
27
29
  }
28
30
 
31
+ export interface GoalFileVerification {
32
+ kind: "file";
33
+ path: string;
34
+ }
35
+
29
36
  export interface GoalState {
30
37
  version: 1;
31
38
  revision: number;
@@ -41,6 +48,7 @@ export interface GoalState {
41
48
  result?: string;
42
49
  resumeAfterManualCompaction?: true;
43
50
  blockerAudit?: GoalBlockerAudit;
51
+ verification?: GoalFileVerification;
44
52
  }
45
53
 
46
54
  export interface GoalRuntime {
@@ -71,6 +79,7 @@ export function createGoalRuntime(): GoalRuntime {
71
79
 
72
80
  export function resetInitRuntime(state: InitRuntime): void {
73
81
  state.active = false;
82
+ state.starting = undefined;
74
83
  state.targetPath = undefined;
75
84
  state.projectRoot = undefined;
76
85
  state.activeTools = undefined;
@@ -2,7 +2,6 @@ import { execFile } from "node:child_process";
2
2
  import { readFileSync } from "node:fs";
3
3
  import {
4
4
  CustomEditor,
5
- DynamicBorder,
6
5
  VERSION,
7
6
  type ExtensionAPI,
8
7
  type ExtensionContext,
@@ -10,9 +9,7 @@ import {
10
9
  type Theme,
11
10
  } from "@earendil-works/pi-coding-agent";
12
11
  import {
13
- Container,
14
12
  CURSOR_MARKER,
15
- Text,
16
13
  truncateToWidth,
17
14
  visibleWidth,
18
15
  wrapTextWithAnsi,
@@ -182,7 +179,7 @@ function isBorderLine(line: string): boolean {
182
179
 
183
180
  function isScrolledTopBorder(line: string): boolean {
184
181
  const unstyled = stripAnsi(line);
185
- return unstyled.includes("↑") || unstyled.includes(".");
182
+ return unstyled.includes("↑");
186
183
  }
187
184
 
188
185
  class PiCodeEditor extends CustomEditor {
@@ -219,10 +216,9 @@ class PiCodeEditor extends CustomEditor {
219
216
 
220
217
  override render(width: number): string[] {
221
218
  if (width <= 0) return [];
222
- if (width < 4) return super.render(width).map((line) => truncateToWidth(line, width, ""));
223
- const innerWidth = width - 2;
219
+ const innerWidth = Math.max(1, width - 2);
224
220
  const lines = super.render(innerWidth);
225
- if (lines.length < 2) return lines.map((line) => truncateToWidth(line, width, ""));
221
+ if (lines.length < 2) return ["", ...lines.map((line) => truncateToWidth(line, width, ""))];
226
222
  let bottomBorderIndex = lines.length - 1;
227
223
  for (let index = lines.length - 1; index >= 1; index -= 1) {
228
224
  if (isBorderLine(lines[index] ?? "")) {
@@ -231,44 +227,40 @@ class PiCodeEditor extends CustomEditor {
231
227
  }
232
228
  }
233
229
 
234
- const gray = (text: string): string => this.runtimeTheme.fg("dim", text);
235
- const framed: string[] = [];
230
+ const dim = (text: string): string => this.runtimeTheme.fg("dim", text);
231
+ const rendered: string[] = [];
236
232
  const top = stripAnsi(lines[0] ?? "");
237
233
  const isScrolledHeader = isScrolledTopBorder(lines[0] ?? "");
238
234
  if (isScrolledHeader) {
239
235
  const count = top.match(/↑\s*(\d+)/)?.[1] ?? "";
240
- const indicator = `${gray("─── ")}${count}${gray(" more ")}${gray("─".repeat(Math.max(0, width - 12 - count.length)))}`;
241
- framed.push(truncateToWidth(indicator, width, ""));
242
- } else {
243
- framed.push(gray("─".repeat(width)));
236
+ rendered.push(truncateToWidth(dim(` ↑ ${count} more`), width, ""));
244
237
  }
245
238
 
246
239
  for (let index = 1; index < bottomBorderIndex; index += 1) {
247
240
  const isPromptLine = index === 1 && !isScrolledHeader;
248
- const prefix = isPromptLine ? gray("❯\u00A0") : " ";
241
+ const prefix = isPromptLine
242
+ ? this.runtimeTheme.fg(this.focused ? "accent" : "dim", "❯\u00A0")
243
+ : " ";
249
244
  let content = lines[index] ?? "";
250
245
  if (isPromptLine && this.getText() === "") {
251
246
  const first = this.suggestion.slice(0, 1);
252
247
  const rest = this.suggestion.slice(1);
253
248
  const cursorMarker = this.focused ? CURSOR_MARKER : "";
254
- content = `${cursorMarker}\x1B[7m${gray(first)}\x1B[27m${gray(rest)}`;
249
+ content = `${cursorMarker}\x1B[7m${dim(first)}\x1B[27m${dim(rest)}`;
255
250
  }
256
- framed.push(`${prefix}${padRight(content, innerWidth)}`);
251
+ rendered.push(`${prefix}${padRight(content, innerWidth)}`);
257
252
  }
258
253
 
259
254
  const bottom = stripAnsi(lines[bottomBorderIndex] ?? "");
260
255
  if (bottom.includes("↓")) {
261
256
  const count = bottom.match(/↓\s*(\d+)/)?.[1] ?? "";
262
- const indicator = `${gray("─── ")}${count}${gray(" more ")}${gray("─".repeat(Math.max(0, width - 12 - count.length)))}`;
263
- framed.push(truncateToWidth(indicator, width, ""));
264
- } else {
265
- framed.push(gray("─".repeat(width)));
257
+ rendered.push(truncateToWidth(dim(` ↓ ${count} more`), width, ""));
266
258
  }
267
259
 
268
260
  for (let index = bottomBorderIndex + 1; index < lines.length; index += 1) {
269
- framed.push(` ${padRight(lines[index] ?? "", innerWidth)}`);
261
+ rendered.push(` ${padRight(lines[index] ?? "", innerWidth)}`);
270
262
  }
271
- return framed.map((line) => truncateToWidth(line, width, ""));
263
+ return ["", ...rendered.map((line) => truncateToWidth(line, width, ""))];
272
264
  }
273
265
  }
274
266
 
@@ -277,39 +269,11 @@ const ACTIVITY_FRAMES = [
277
269
  "✽", "✻", "✶", "✱", "✢", "·",
278
270
  ] as const;
279
271
  const ACTIVITY_FRAME_INTERVAL_MS = 120;
280
- const ACTIVITY_WORDS = ["Brewing", "Pondering", "Tinkering", "Wrangling", "Noodling", "Cooking"] as const;
281
-
282
- function formatActivityMessage(word: string, theme: Theme): string {
283
- return `${theme.fg("accent", `${word}…`)} ${theme.fg("dim", `(${theme.bold("esc")} to interrupt · thinking)`)}`;
284
- }
285
272
 
286
273
  let killerosEditorFactory: ReturnType<ExtensionContext["ui"]["getEditorComponent"]>;
287
274
 
288
275
  export function registerShellUi(pi: ExtensionAPI): void {
289
276
  let activeHeader: PiStartupHeader | undefined;
290
- let activityDeck: string[] = [];
291
- let lastActivityWord: string | undefined;
292
- let activityTimer: ReturnType<typeof setInterval> | undefined;
293
- const refillActivityDeck = (): void => {
294
- activityDeck = [...ACTIVITY_WORDS];
295
- for (let index = activityDeck.length - 1; index > 0; index -= 1) {
296
- const swapIndex = Math.floor(Math.random() * (index + 1));
297
- [activityDeck[index], activityDeck[swapIndex]] = [activityDeck[swapIndex]!, activityDeck[index]!];
298
- }
299
- if (activityDeck.length > 1 && activityDeck.at(-1) === lastActivityWord) {
300
- [activityDeck[0], activityDeck[activityDeck.length - 1]] = [activityDeck.at(-1)!, activityDeck[0]!];
301
- }
302
- };
303
- const nextActivityWord = (): string => {
304
- if (activityDeck.length === 0) refillActivityDeck();
305
- const word = activityDeck.pop() ?? ACTIVITY_WORDS[0];
306
- lastActivityWord = word;
307
- return word;
308
- };
309
- const clearActivityTimer = (): void => {
310
- if (activityTimer) clearInterval(activityTimer);
311
- activityTimer = undefined;
312
- };
313
277
 
314
278
  pi.on("session_start", (_event, ctx) => {
315
279
  if (ctx.mode !== "tui") return;
@@ -321,7 +285,6 @@ export function registerShellUi(pi: ExtensionAPI): void {
321
285
  activeHeader = new PiStartupHeader(pi, ctx, startupTip, tui);
322
286
  return activeHeader;
323
287
  });
324
- clearActivityTimer();
325
288
  ctx.ui.setWorkingIndicator({
326
289
  frames: ACTIVITY_FRAMES.map((frame) => ctx.ui.theme.fg("accent", frame)),
327
290
  intervalMs: ACTIVITY_FRAME_INTERVAL_MS,
@@ -339,25 +302,8 @@ export function registerShellUi(pi: ExtensionAPI): void {
339
302
  }
340
303
  });
341
304
 
342
- pi.on("agent_start", (_event, ctx) => {
343
- if (ctx.mode !== "tui") return;
344
- clearActivityTimer();
345
- const updateWorkingWord = (): void => ctx.ui.setWorkingMessage(formatActivityMessage(nextActivityWord(), ctx.ui.theme));
346
- updateWorkingWord();
347
- activityTimer = setInterval(updateWorkingWord, 2_500);
348
- activityTimer.unref?.();
349
- });
350
-
351
- pi.on("agent_end", (_event, ctx) => {
352
- clearActivityTimer();
353
- if (ctx.mode === "tui") ctx.ui.setWorkingMessage();
354
- });
355
-
356
305
  pi.on("session_shutdown", () => {
357
- clearActivityTimer();
358
306
  activeHeader?.dispose();
359
307
  activeHeader = undefined;
360
- activityDeck = [];
361
- lastActivityWord = undefined;
362
308
  });
363
309
  }