killeros 2.1.26 → 2.1.28

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,15 +1,24 @@
1
- import { type ExtensionAPI, type ExtensionCommandContext, type ExtensionContext } from "@earendil-works/pi-coding-agent";
1
+ import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
2
  import { reportError } from "./errors.ts";
3
- import { beginGoalTurnState, checkpointActiveGoalState, GOAL_VERSION, parseGoalState, pauseGoalState, transitionGoalState, type GoalTransitionOptions } from "./goal-state.ts";
3
+ import {
4
+ beginGoalTurnState,
5
+ boundGoalText,
6
+ checkpointActiveGoalState,
7
+ GOAL_VERSION,
8
+ parseGoalState,
9
+ pauseGoalState,
10
+ transitionGoalState,
11
+ type GoalTransitionOptions,
12
+ } from "./goal-state.ts";
4
13
  import { resolvePersonalInstructions } from "./personal-instructions.ts";
5
- import type { GoalRuntime, GoalState, GoalStatus, InitRuntime } from "./runtime.ts";
14
+ import type { GoalRuntime, GoalState, GoalStatus } from "./runtime.ts";
6
15
  import { safeTerminalText } from "./safe-terminal-text.ts";
7
16
 
8
17
  export const GOAL_ENTRY_TYPE = "killeros-goal";
9
18
  const GOAL_CONTINUATION_TYPE = "killeros-goal-continuation";
10
19
  export const GOAL_UPDATE_TOOL = "killeros_goal_update";
11
20
 
12
- export type GoalEntryEvent = "set" | "replace" | "limit" | "turn" | "pause" | "resume" | "blocked" | "complete" | "error" | "clear" | "checkpoint" | "blocker-audit";
21
+ export type GoalEntryEvent = "set" | "replace" | "limit" | "turn" | "continue" | "pause" | "resume" | "blocked" | "complete" | "error" | "clear" | "checkpoint" | "blocker-audit";
13
22
  export interface GoalEntryData {
14
23
  version: 1;
15
24
  event: GoalEntryEvent;
@@ -75,18 +84,32 @@ export function sumGoalTokens(ctx: ExtensionContext): number {
75
84
  }
76
85
 
77
86
  function setGoalUpdateToolActive(pi: ExtensionAPI, active: boolean): void {
78
- const activeTools = pi.getActiveTools();
79
- const isActive = activeTools.includes(GOAL_UPDATE_TOOL);
80
- if (active === isActive) return;
81
- pi.setActiveTools(active
82
- ? [...activeTools, GOAL_UPDATE_TOOL]
83
- : activeTools.filter((name) => name !== GOAL_UPDATE_TOOL));
87
+ try {
88
+ const activeTools = pi.getActiveTools();
89
+ const isActive = activeTools.includes(GOAL_UPDATE_TOOL);
90
+ if (active === isActive) return;
91
+ pi.setActiveTools(active
92
+ ? [...activeTools, GOAL_UPDATE_TOOL]
93
+ : activeTools.filter((name) => name !== GOAL_UPDATE_TOOL));
94
+ } catch {
95
+ // Availability is checked again immediately before a goal request.
96
+ }
84
97
  }
85
98
 
86
99
  export function syncGoalUpdateTool(pi: ExtensionAPI, runtime: GoalRuntime): void {
87
100
  setGoalUpdateToolActive(pi, runtime.state?.status === "active");
88
101
  }
89
102
 
103
+ function goalUpdateToolIsAvailable(pi: ExtensionAPI): boolean {
104
+ try {
105
+ const active = pi.getActiveTools();
106
+ if (!active.includes(GOAL_UPDATE_TOOL)) pi.setActiveTools([...active, GOAL_UPDATE_TOOL]);
107
+ return pi.getActiveTools().includes(GOAL_UPDATE_TOOL);
108
+ } catch {
109
+ return false;
110
+ }
111
+ }
112
+
90
113
  export function persistGoalState(
91
114
  pi: ExtensionAPI,
92
115
  runtime: GoalRuntime,
@@ -94,7 +117,6 @@ export function persistGoalState(
94
117
  state: GoalState | undefined,
95
118
  ): void {
96
119
  const data: GoalEntryData = { version: GOAL_VERSION, event, state: state ?? null };
97
- runtime.automaticCompaction = undefined;
98
120
  pi.appendEntry(GOAL_ENTRY_TYPE, data);
99
121
  runtime.state = state;
100
122
  syncGoalUpdateTool(pi, runtime);
@@ -117,6 +139,15 @@ export function transitionGoal(
117
139
  if (status !== "active") {
118
140
  runtime.continuationScheduled = false;
119
141
  runtime.automaticCompaction = undefined;
142
+ if (!options.keepTurnForRecovery) {
143
+ runtime.goalTurn = undefined;
144
+ runtime.goalTurnInFlight = false;
145
+ runtime.agentEndObserved = false;
146
+ }
147
+ } else if (!options.resumeInterruptedTurn) {
148
+ runtime.goalTurn = undefined;
149
+ runtime.goalTurnInFlight = false;
150
+ runtime.agentEndObserved = false;
120
151
  }
121
152
  return next;
122
153
  }
@@ -141,6 +172,7 @@ function clearGoalExecutionFlags(runtime: GoalRuntime): void {
141
172
  runtime.continuationScheduled = false;
142
173
  runtime.goalTurnInFlight = false;
143
174
  runtime.agentEndObserved = false;
175
+ runtime.goalTurn = undefined;
144
176
  runtime.automaticCompaction = undefined;
145
177
  runtime.lastStopReason = undefined;
146
178
  runtime.lastError = undefined;
@@ -156,6 +188,10 @@ export async function stopGoalRun(runtime: GoalRuntime, ctx: ExtensionCommandCon
156
188
  }
157
189
  }
158
190
 
191
+ function safeFailureReason(reason: string): string {
192
+ return boundGoalText(reason) || "an unspecified goal failure";
193
+ }
194
+
159
195
  export function pauseGoalAfterFailure(
160
196
  pi: ExtensionAPI,
161
197
  runtime: GoalRuntime,
@@ -165,7 +201,11 @@ export function pauseGoalAfterFailure(
165
201
  notify = true,
166
202
  ): void {
167
203
  if (runtime.state?.status !== "active") return;
168
- const safeReason = safeTerminalText(reason);
204
+ const safeReason = safeFailureReason(reason);
205
+ const safeRecoveryInstruction = safeReason === "no turn decision"
206
+ && recoveryInstruction === "Run /goal resume after resolving the problem."
207
+ ? "The agent ended without choosing continue, complete, or blocked. Run /goal resume only after choosing to continue."
208
+ : safeTerminalText(recoveryInstruction);
169
209
  try {
170
210
  transitionGoal(pi, runtime, "error", "paused", safeReason);
171
211
  } catch {
@@ -173,11 +213,34 @@ export function pauseGoalAfterFailure(
173
213
  runtime.state = current ? pauseGoalState(current, safeReason, Date.now()) : undefined;
174
214
  syncGoalUpdateTool(pi, runtime);
175
215
  runtime.persistenceRetryNeeded = true;
176
- runtime.continuationScheduled = false;
177
- runtime.automaticCompaction = undefined;
216
+ clearGoalExecutionFlags(runtime);
217
+ runtime.requestRender?.();
218
+ }
219
+ if (notify) ctx.ui.notify(`Goal paused: ${safeReason}\n${safeRecoveryInstruction}`, "error");
220
+ }
221
+
222
+ function pauseGoalBeforeTurn(
223
+ pi: ExtensionAPI,
224
+ runtime: GoalRuntime,
225
+ ctx: ExtensionContext,
226
+ ): void {
227
+ const state = runtime.state;
228
+ if (state?.status !== "active") return;
229
+ const nextTurn = state.turns + 1;
230
+ const reason = "killeros_goal_update is unavailable";
231
+ try {
232
+ transitionGoal(pi, runtime, "error", "paused", reason);
233
+ } catch {
234
+ runtime.state = pauseGoalState(state, reason, Date.now());
235
+ syncGoalUpdateTool(pi, runtime);
236
+ runtime.persistenceRetryNeeded = true;
237
+ clearGoalExecutionFlags(runtime);
178
238
  runtime.requestRender?.();
179
239
  }
180
- if (notify) ctx.ui.notify(`Goal paused: ${safeReason}\n${recoveryInstruction}`, "error");
240
+ ctx.ui.notify(
241
+ `Goal paused before turn ${nextTurn}: ${reason}\nEnable the goal tool, then run /goal resume.`,
242
+ "error",
243
+ );
181
244
  }
182
245
 
183
246
  function beginGoalTurn(
@@ -186,13 +249,15 @@ function beginGoalTurn(
186
249
  ctx: ExtensionContext,
187
250
  current: Extract<GoalState, { status: "active" }>,
188
251
  ): GoalState | undefined {
189
- const next = beginGoalTurnState(current, Date.now());
252
+ let next: GoalState;
190
253
  try {
254
+ next = beginGoalTurnState(current, Date.now());
191
255
  persistGoalState(pi, runtime, "turn", next);
192
256
  } catch (error) {
193
257
  pauseGoalAfterFailure(pi, runtime, ctx, `turn state could not be saved: ${error instanceof Error ? error.message : String(error)}`);
194
258
  return undefined;
195
259
  }
260
+ runtime.goalTurn = { revision: next.revision, turn: next.turns };
196
261
  runtime.goalTurnInFlight = true;
197
262
  runtime.agentEndObserved = false;
198
263
  runtime.lastStopReason = undefined;
@@ -200,42 +265,85 @@ function beginGoalTurn(
200
265
  return next;
201
266
  }
202
267
 
203
- /** Starts one goal turn only after Pi is idle and all competing workflow gates are clear. */
204
- export function scheduleGoalContinuation(
268
+ function sendGoalTurn(
205
269
  pi: ExtensionAPI,
206
270
  runtime: GoalRuntime,
207
- initState: InitRuntime,
208
271
  ctx: ExtensionContext,
272
+ state: Extract<GoalState, { status: "active" }>,
209
273
  ): boolean {
210
- if (isGoalModeSupported(ctx) && isSavedSession(ctx) && pauseGoalAtTurnLimit(pi, runtime, ctx)) return false;
211
- if (!isGoalModeSupported(ctx)
212
- || !isSavedSession(ctx)
213
- || runtime.state?.status !== "active"
214
- || runtime.continuationScheduled
215
- || runtime.continuationHeld
216
- || runtime.goalTurnInFlight
217
- || initState.active
218
- || !ctx.isIdle()
219
- || ctx.hasPendingMessages()) return false;
220
274
  runtime.continuationScheduled = true;
221
- const next = beginGoalTurn(pi, runtime, ctx, runtime.state);
222
- if (!next) return false;
223
275
  try {
224
276
  pi.sendMessage({
225
277
  customType: GOAL_CONTINUATION_TYPE,
226
- content: goalContinuationMessage(next, ctx),
278
+ content: goalContinuationMessage(state, ctx),
227
279
  display: false,
228
280
  }, { triggerTurn: true, deliverAs: "followUp" });
229
281
  return true;
230
282
  } catch (error) {
231
283
  runtime.continuationScheduled = false;
232
284
  runtime.goalTurnInFlight = false;
285
+ runtime.goalTurn = undefined;
233
286
  pauseGoalAfterFailure(pi, runtime, ctx, `continuation could not start: ${error instanceof Error ? error.message : String(error)}`);
234
287
  return false;
235
288
  }
236
289
  }
237
290
 
291
+ export function resumeInterruptedGoalTurn(
292
+ pi: ExtensionAPI,
293
+ runtime: GoalRuntime,
294
+ ctx: ExtensionContext,
295
+ ): boolean {
296
+ const state = runtime.state;
297
+ if (state?.status !== "active"
298
+ || (state.turnPhase !== "in-flight" && state.turnPhase !== "authorized")
299
+ || !runtime.goalTurnInFlight) return false;
300
+ if (!goalUpdateToolIsAvailable(pi)) {
301
+ pauseGoalBeforeTurn(pi, runtime, ctx);
302
+ return false;
303
+ }
304
+ return sendGoalTurn(pi, runtime, ctx, state);
305
+ }
306
+
307
+ /** Starts one goal turn only after Pi is idle and all competing workflow gates are clear. */
308
+ export function scheduleGoalContinuation(
309
+ pi: ExtensionAPI,
310
+ runtime: GoalRuntime,
311
+ ctx: ExtensionContext,
312
+ guard?: { generation: number; state: GoalState },
313
+ ): boolean {
314
+ if (guard && (guard.generation !== runtime.lifecycleGeneration || runtime.state !== guard.state)) return false;
315
+ if (isGoalModeSupported(ctx) && isSavedSession(ctx) && pauseGoalAtTurnLimit(pi, runtime, ctx)) return false;
316
+ if (!isGoalModeSupported(ctx)
317
+ || !isSavedSession(ctx)
318
+ || runtime.state?.status !== "active"
319
+ || runtime.continuationScheduled
320
+ || runtime.continuationHeld
321
+ || runtime.goalTurnInFlight
322
+ || !ctx.isIdle()
323
+ || ctx.hasPendingMessages()) return false;
324
+
325
+ const current = runtime.state;
326
+ const phase = current.turnPhase;
327
+ const canStart = phase === "ready"
328
+ || phase === "authorized"
329
+ || phase === undefined && current.turns === 0;
330
+ if (!canStart || phase === "authorized" && current.turnDecision === undefined) return false;
331
+ if (!goalUpdateToolIsAvailable(pi)) {
332
+ pauseGoalBeforeTurn(pi, runtime, ctx);
333
+ return false;
334
+ }
335
+ const next = beginGoalTurn(pi, runtime, ctx, current);
336
+ return next !== undefined && sendGoalTurn(pi, runtime, ctx, next as Extract<GoalState, { status: "active" }>);
337
+ }
338
+
238
339
  function goalInstructions(state: GoalState, heading: string): string {
340
+ const previous = state.lastDecision;
341
+ const continuation = previous?.kind === "continue"
342
+ ? [
343
+ "The previous turn's accepted next action was model-reported (not independently verified):",
344
+ previous.nextAction,
345
+ ]
346
+ : [];
239
347
  return [
240
348
  `# ${heading}`,
241
349
  `Status: active · Turn: ${state.turns}`,
@@ -244,10 +352,18 @@ function goalInstructions(state: GoalState, heading: string): string {
244
352
  "",
245
353
  "Treat the exact objective above from /goal as authoritative; a compaction summary may describe it but does not replace it.",
246
354
  "If the current context contains a compaction summary, take its first concrete next step after checking the current repository state.",
355
+ ...continuation,
247
356
  "Continue making concrete progress toward this unchanged objective. Re-check repository state and prior results instead of repeating work.",
248
- "Do not stop merely because one response is complete: KillerOS will start another goal turn while the goal remains active.",
249
- "Before declaring completion, audit every part of the objective and verify the relevant results. Then call killeros_goal_update with status complete and concise evidence.",
250
- "Call killeros_goal_update with status blocked and the same lowercase blockerKey on each turn where one external impasse persists; attempts one and two record the audit, and attempt three marks the goal blocked.",
357
+ ...(state.turnDecision === undefined
358
+ ? [
359
+ "Before ending this turn, choose exactly one accepted goal decision.",
360
+ "After auditing and verifying the whole objective, call killeros_goal_update with status complete and concise evidence.",
361
+ "If useful work remains, call killeros_goal_update with status continue, concrete evidence from this turn, and one concrete nextAction toward the unchanged objective.",
362
+ "If one external impasse persists, call killeros_goal_update with status blocked, the same lowercase blockerKey, and current evidence; attempts one and two record audits and attempt three marks the goal blocked.",
363
+ ]
364
+ : ["This logical turn already has one accepted decision. Do not call killeros_goal_update again; finish the interrupted request, then let KillerOS start the authorized next turn after settlement."]),
365
+ "A normal response without one accepted decision pauses the goal. Do not restate a previous result as progress.",
366
+ "Goal evidence and nextAction are model-reported; KillerOS does not verify progress from prose or tool activity.",
251
367
  "Never use the goal tool to pause, resume, edit, replace, or clear the objective. Those transitions belong to the user.",
252
368
  ].join("\n");
253
369
  }
@@ -260,9 +376,7 @@ function goalContinuationMessage(state: GoalState, ctx: ExtensionContext): strin
260
376
  const sections = [goalInstructions(state, "KillerOS long-running goal turn")];
261
377
  if (ctx.isProjectTrusted()) {
262
378
  const personal = resolvePersonalInstructions(ctx.cwd);
263
- if (personal) {
264
- sections.push(personal);
265
- }
379
+ if (personal) sections.push(personal);
266
380
  }
267
381
  return sections.join("\n\n");
268
382
  }
@@ -282,30 +396,40 @@ export function isSavedSession(ctx: ExtensionContext): boolean {
282
396
  export function registerGoalRuntime(
283
397
  pi: ExtensionAPI,
284
398
  runtime: GoalRuntime,
285
- initState: InitRuntime,
286
399
  ): void {
287
400
  const restoreGoal = (ctx: ExtensionContext): void => {
401
+ runtime.lifecycleGeneration += 1;
402
+ const generation = runtime.lifecycleGeneration;
288
403
  const restored = restoreGoalState(ctx);
289
404
  runtime.state = isGoalModeSupported(ctx) ? restored.state : undefined;
290
405
  syncGoalUpdateTool(pi, runtime);
291
406
  runtime.continuationScheduled = false;
292
407
  runtime.continuationHeld = false;
293
408
  runtime.goalTurnInFlight = false;
409
+ runtime.goalTurn = undefined;
294
410
  runtime.agentEndObserved = false;
295
411
  runtime.automaticCompaction = undefined;
296
412
  runtime.persistenceRetryNeeded = false;
297
413
  runtime.lastStopReason = undefined;
298
414
  runtime.lastError = undefined;
299
415
  runtime.requestRender?.();
300
- if (runtime.state?.status === "active") {
301
- setImmediate(() => scheduleGoalContinuation(pi, runtime, initState, ctx));
416
+ const state = runtime.state;
417
+ if (state?.status !== "active") return;
418
+ if ((state.turnPhase === "in-flight") || (state.turnPhase === undefined && state.turns > 0)) {
419
+ pauseGoalAfterFailure(pi, runtime, ctx, "no turn decision");
420
+ return;
302
421
  }
422
+ setImmediate(() => {
423
+ if (runtime.lifecycleGeneration !== generation || runtime.state !== state) return;
424
+ scheduleGoalContinuation(pi, runtime, ctx, { generation, state });
425
+ });
303
426
  };
304
427
 
305
428
  pi.on("session_start", (_event, ctx) => restoreGoal(ctx));
306
429
  pi.on("session_tree", (_event, ctx) => restoreGoal(ctx));
307
430
 
308
431
  pi.on("session_shutdown", (_event, ctx) => {
432
+ runtime.lifecycleGeneration += 1;
309
433
  if (runtime.state?.status === "active") {
310
434
  const checkpoint = checkpointActiveGoalState(runtime.state, Date.now());
311
435
  try {
@@ -316,22 +440,41 @@ export function registerGoalRuntime(
316
440
  }
317
441
  runtime.state = undefined;
318
442
  syncGoalUpdateTool(pi, runtime);
319
- runtime.continuationScheduled = false;
443
+ clearGoalExecutionFlags(runtime);
320
444
  runtime.continuationHeld = false;
321
- runtime.goalTurnInFlight = false;
322
- runtime.agentEndObserved = false;
323
- runtime.automaticCompaction = undefined;
324
445
  runtime.persistenceRetryNeeded = false;
325
- runtime.lastStopReason = undefined;
326
- runtime.lastError = undefined;
327
446
  });
328
447
 
329
448
  pi.on("before_agent_start", (event, ctx) => {
330
449
  runtime.continuationScheduled = false;
331
450
  if (!runtime.goalTurnInFlight && isGoalModeSupported(ctx) && isSavedSession(ctx) && pauseGoalAtTurnLimit(pi, runtime, ctx)) return;
332
451
  const current = runtime.state;
333
- if (!isGoalModeSupported(ctx) || !isSavedSession(ctx) || !current || current.status !== "active" || initState.active) return;
334
- if (runtime.goalTurnInFlight) return { systemPrompt: `${event.systemPrompt}\n\n${goalSystemPrompt(current)}` };
452
+ if (!isGoalModeSupported(ctx) || !isSavedSession(ctx) || !current || current.status !== "active") return;
453
+ if (runtime.goalTurnInFlight) {
454
+ if (!goalUpdateToolIsAvailable(pi)) {
455
+ pauseGoalBeforeTurn(pi, runtime, ctx);
456
+ try {
457
+ ctx.abort();
458
+ } catch {
459
+ // The state is already paused; a host abort may be unavailable during startup.
460
+ }
461
+ return;
462
+ }
463
+ return { systemPrompt: `${event.systemPrompt}\n\n${goalSystemPrompt(current)}` };
464
+ }
465
+ if (current.turnPhase === "in-flight") {
466
+ pauseGoalAfterFailure(pi, runtime, ctx, "no turn decision");
467
+ return;
468
+ }
469
+ if (!goalUpdateToolIsAvailable(pi)) {
470
+ pauseGoalBeforeTurn(pi, runtime, ctx);
471
+ try {
472
+ ctx.abort();
473
+ } catch {
474
+ // See the in-flight branch above.
475
+ }
476
+ return;
477
+ }
335
478
  const next = beginGoalTurn(pi, runtime, ctx, current);
336
479
  if (!next) return;
337
480
  return { systemPrompt: `${event.systemPrompt}\n\n${goalSystemPrompt(next)}` };