killeros 2.0.4 → 2.0.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,26 @@ All notable changes to KillerOS are documented here.
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [2.0.5] - 2026-08-11
8
+
9
+ ### Added
10
+
11
+ - Added shuffled session-stable suggestions to the empty three-line prompt editor.
12
+
13
+ ### Fixed
14
+
15
+ - Preserved complete UTF-8 characters in truncated `/init` evidence and chunked lifecycle-hook output, and kept filesystem-root paths visible in notification titles.
16
+ - Kept the startup-tip shuffle deck across session rebinds so every tip appears before the bank repeats.
17
+ - Rejected matchers on `agent_settled` lifecycle hooks instead of silently bypassing their tool-name guard.
18
+ - Made the goal update status schema compatible with Google models while preserving strict completion and blocking values.
19
+ - Kept multiline custom-answer drafts within tiny terminal row limits by rendering a clipped single-line preview.
20
+ - Preserved existing spaces and tabs after the cursor when applying slash-command autocomplete.
21
+ - Made `/init` abort safely when Git ignore inspection fails or returns untrusted output, preventing ignored files from entering model evidence.
22
+ - Wrapped long interactive questions to the current terminal width while keeping the question UI within the terminal height.
23
+ - Made `/goal pause` and `/goal clear` save terminal state before immediately stopping active goal work.
24
+ - Kept failed goal edits and replacements visible and fail-closed without restarting a stale objective.
25
+ - Required one durable blocker key on three consecutive goal turns before a goal can be marked blocked.
26
+
7
27
  ## [2.0.4] - 2026-08-10
8
28
 
9
29
  ### Changed
package/README.md CHANGED
@@ -31,7 +31,7 @@ pi install git:github.com/KyrosHendrix/pi-KillerOS
31
31
  Pin an install to a release:
32
32
 
33
33
  ```bash
34
- pi install git:github.com/KyrosHendrix/pi-KillerOS@v2.0.4
34
+ pi install git:github.com/KyrosHendrix/pi-KillerOS@v2.0.5
35
35
  ```
36
36
 
37
37
  Add `-l` to either command for a project-only install. Restart Pi after installing.
@@ -41,12 +41,12 @@ Add `-l` to either command for a project-only install. Restart Pi after installi
41
41
  - 52-column Compact startup card with inline version, polished model/provider identity, adjacent `/model`, directory, conditional Git branch, and a shuffled session-stable tip
42
42
  - Cohesive dark theme with coral accents and neutral tool-call containers across pending, success, and error states
43
43
  - Animated orange 12-frame activity glyph loop at 120 ms per frame, with orange shuffled Claude-adjacent verbs changing every 2.5 seconds, a gray `(esc to interrupt · thinking)` status with bold `esc`, and a quiet hidden-thinking label
44
- - Framed multiline editor with Shift+Enter support and slash-command autocomplete; KillerOS preserves an editor factory configured by another extension
44
+ - Full-width framed multiline editor with a shuffled session-stable empty-state suggestion, Shift+Enter support, and slash-command autocomplete; KillerOS preserves an editor factory configured by another extension
45
45
  - Responsive footer with polished model/provider identity, plain-language context, and active goal state remaining; reasoning, Git branch, elapsed time, cost, and path cut down by available width
46
46
  - Pi-owned context compaction with active goals continuing from Pi's settled boundary after manual, threshold, and overflow compaction
47
47
  - Optional completion sounds after successful or failed settled requests, excluding manual aborts
48
48
  - `/variants` selector and direct reasoning-level arguments
49
- - Codex-style `/goal` with an interactive status/action panel, durable objectives, pause, resume, edit, confirmed panel clearing, automatic continuation, and explicit completion
49
+ - Codex-style `/goal` with an interactive status/action panel, durable objectives, immediate pause and clear cancellation, automatic continuation, explicit completion, and durable blocker audits
50
50
  - Automatic `/init` guideline synthesis with a frozen safe evidence map, protected existing policy, and the four packaged behavioral sections adapted from `writing-great-guidelines`
51
51
  - `question` tool with height-bounded option windows, configured Pi keybindings, live option/input progress, proposal previews, custom answers, history, cancellation, and compact expandable transcript rendering
52
52
  - Mid-prompt slash completion with current Pi `0.82.1` commands, extensions, prompts, and skills; paths, URLs, and invalid commands remain plain text
@@ -60,9 +60,9 @@ Add `-l` to either command for a project-only install. Restart Pi after installi
60
60
  /goal Open current goal status and valid actions
61
61
  /goal <objective> Set an objective and start working
62
62
  /goal edit Edit and reactivate the current goal
63
- /goal pause Stop automatic continuation
63
+ /goal pause Stop the current goal turn and automatic continuation
64
64
  /goal resume Resume automatic continuation
65
- /goal clear Remove the current goal
65
+ /goal clear Stop current goal work and remove the goal
66
66
  /variants Open the reasoning-level selector
67
67
  /variants high Set a reasoning level directly
68
68
  /notification Configure the completion sound
@@ -70,7 +70,9 @@ Add `-l` to either command for a project-only install. Restart Pi after installi
70
70
  /exit Quit Pi gracefully
71
71
  ```
72
72
 
73
- `/goal` requires a saved session in TUI or RPC mode. Goal state is stored in versioned session entries on the active branch and restored after reload, resume, fork, or tree navigation. Active goals inject their unchanged objective every turn and continue one settled turn at a time. The model must use KillerOS’s private goal tool to mark verified completion or a blocker repeated across at least three goal turns; final prose alone does not end the loop. Aborted turns, provider failures, and continuation failures pause safely. Replacing unfinished work requires confirmation, and `/goal edit` requires TUI mode.
73
+ `/goal` requires a saved session in TUI or RPC mode. Goal state is stored in versioned session entries on the active branch and restored after reload, resume, fork, or tree navigation. Active goals inject their unchanged objective every turn and continue one settled turn at a time. The model must use KillerOS’s private goal tool to mark verified completion. Blocking requires one stable lowercase blocker key recorded on three consecutive goal turns; a changed key, skipped turn, resume, or edit resets the streak. Final prose alone does not end the loop.
74
+
75
+ `/goal pause` and `/goal clear` save paused or cleared state before aborting current goal work, so settlement cannot restart it. Aborted turns, provider failures, and continuation failures otherwise pause safely. Failed edit and replacement writes dispatch no edited objective; an active prior objective pauses fail-closed, while inactive durable state remains unchanged. Replacing unfinished work requires confirmation, and `/goal edit` requires TUI mode.
74
76
 
75
77
  `/init` freezes a safe project-file map and exposes only dedicated read and list operations while it generates root `AGENTS.md`. Git-ignored files, known secret paths, private-key formats, other guidance, dependencies, links, non-regular files, and files outside that map are unavailable. Existing root `AGENTS.md` is separate protected policy: compatible rules are preserved, a real policy conflict leaves it unchanged with a reason, and any concurrent target change aborts installation without replacing the newer file.
76
78
 
@@ -92,7 +94,7 @@ Manual `/compact` aborts the current goal turn before summarization, so KillerOS
92
94
 
93
95
  For trusted projects, KillerOS loads `AGENTS.local.md` after Pi's shared repository context. A one-line `@path` or `@~/path` file imports personal guidance from another location.
94
96
 
95
- Lifecycle hooks are loaded from `.pi/killeros-hooks.json` at session start. Supported event keys are `tool_call`, `tool_result`, and `agent_settled`; matchers are JavaScript regular expressions over Pi tool names. Hook commands run from the repository root with `KILLEROS_EVENT`, `KILLEROS_TOOL`, and `KILLEROS_PAYLOAD` environment variables. Failed `tool_call` hooks block the tool, while later-event failures notify the user. Aborting the parent request stops the hook process tree with bounded graceful and forced cleanup without reporting cancellation as a hook failure.
97
+ Lifecycle hooks are loaded from `.pi/killeros-hooks.json` at session start. Supported event keys are `tool_call`, `tool_result`, and `agent_settled`. Optional matchers are JavaScript regular expressions over Pi tool names, so they are valid only for `tool_call` and `tool_result`; KillerOS rejects an `agent_settled` hook that defines a matcher. Hook commands run from the repository root with `KILLEROS_EVENT`, `KILLEROS_TOOL`, and `KILLEROS_PAYLOAD` environment variables. Failed `tool_call` hooks block the tool, while later-event failures notify the user. Aborting the parent request stops the hook process tree with bounded graceful and forced cleanup without reporting cancellation as a hook failure.
96
98
 
97
99
  ## Behavior by mode
98
100
 
@@ -150,12 +150,11 @@ export function registerSlashAutocomplete(pi: ExtensionAPI): void {
150
150
  usage.set(tagged.killerosCommand, (usage.get(tagged.killerosCommand) ?? 0) + 1);
151
151
  const line = lines[cursorLine] ?? "";
152
152
  const beforeCursor = line.slice(0, cursorCol);
153
- let afterCursor = line.slice(cursorCol);
153
+ const afterCursor = line.slice(cursorCol);
154
154
  const match = beforeCursor.match(/(?:^|[ \t])\/([^\s/]*)$/);
155
155
  if (!match || match.index === undefined) return current.applyCompletion(lines, cursorLine, cursorCol, item, prefix);
156
156
  const slashIndex = match.index + (match[0].startsWith("/") ? 0 : 1);
157
157
  const newBefore = beforeCursor.slice(0, slashIndex) + item.value;
158
- if (item.value.endsWith(" ") && afterCursor.startsWith(" ")) afterCursor = afterCursor.trimStart();
159
158
  const nextLines = [...lines];
160
159
  nextLines[cursorLine] = newBefore + afterCursor;
161
160
  return { lines: nextLines, cursorLine, cursorCol: newBefore.length };
package/killeros/goals.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { StringEnum } from "@earendil-works/pi-ai";
1
2
  import { type ExtensionAPI, type ExtensionCommandContext, type ExtensionContext, type ThemeColor } from "@earendil-works/pi-coding-agent";
2
3
  import { Text } from "@earendil-works/pi-tui";
3
4
  import { Type } from "typebox";
@@ -6,14 +7,14 @@ import { BoundedText } from "./bounded-text.ts";
6
7
  import { formatTime, formatTokens } from "./display.ts";
7
8
  import { reportError } from "./errors.ts";
8
9
  import { resolvePersonalInstructions } from "./personal-instructions.ts";
9
- import type { GoalRuntime, GoalState, GoalStatus, InitRuntime } from "./runtime.ts";
10
+ import type { GoalBlockerAudit, GoalRuntime, GoalState, GoalStatus, InitRuntime } from "./runtime.ts";
10
11
 
11
12
  const GOAL_ENTRY_TYPE = "killeros-goal";
12
13
  const GOAL_CONTINUATION_TYPE = "killeros-goal-continuation";
13
14
  const GOAL_OBJECTIVE_LIMIT = 4_000;
14
15
  const GOAL_VERSION = 1;
15
16
 
16
- type GoalEntryEvent = "set" | "replace" | "edit" | "turn" | "pause" | "resume" | "blocked" | "complete" | "error" | "clear" | "checkpoint";
17
+ type GoalEntryEvent = "set" | "replace" | "edit" | "turn" | "pause" | "resume" | "blocked" | "complete" | "error" | "clear" | "checkpoint" | "blocker-audit";
17
18
  interface GoalEntryData {
18
19
  version: 1;
19
20
  event: GoalEntryEvent;
@@ -23,6 +24,7 @@ interface GoalEntryData {
23
24
  interface GoalTransitionOptions {
24
25
  resetBlockedAudit?: boolean;
25
26
  resumeAfterManualCompaction?: true;
27
+ blockerAudit?: GoalBlockerAudit;
26
28
  }
27
29
 
28
30
  interface RestoredGoalState {
@@ -31,7 +33,7 @@ interface RestoredGoalState {
31
33
  }
32
34
 
33
35
  const GoalUpdateParams = Type.Object({
34
- status: Type.Union([Type.Literal("complete"), Type.Literal("blocked")], {
36
+ status: StringEnum(["complete", "blocked"] as const, {
35
37
  description: "Mark the active goal complete or blocked",
36
38
  }),
37
39
  evidence: Type.String({
@@ -39,11 +41,19 @@ const GoalUpdateParams = Type.Object({
39
41
  maxLength: 2_000,
40
42
  description: "Concise evidence that the objective is complete, or the repeated blocker and attempted workarounds",
41
43
  }),
44
+ blockerKey: Type.Optional(Type.String({
45
+ minLength: 1,
46
+ maxLength: 120,
47
+ pattern: "^[a-z0-9][a-z0-9._-]{0,119}$",
48
+ description: "Stable lowercase key identifying the repeated blocker",
49
+ })),
42
50
  });
43
51
 
44
52
  interface GoalUpdateDetails {
45
- status: "complete" | "blocked";
53
+ status: "complete" | "blocked" | "blocker-audit";
46
54
  evidence: string;
55
+ blockerKey?: string;
56
+ streak?: number;
47
57
  }
48
58
 
49
59
  function isGoalStatus(value: unknown): value is GoalStatus {
@@ -54,6 +64,17 @@ function finiteNonNegative(value: unknown): value is number {
54
64
  return typeof value === "number" && Number.isFinite(value) && value >= 0;
55
65
  }
56
66
 
67
+ function isGoalBlockerAudit(value: unknown, turns: number, status: GoalStatus): value is GoalBlockerAudit {
68
+ if (!value || typeof value !== "object") return false;
69
+ const candidate = value as Partial<GoalBlockerAudit>;
70
+ if (typeof candidate.key !== "string"
71
+ || !/^[a-z0-9][a-z0-9._-]{0,119}$/u.test(candidate.key)
72
+ || !Number.isInteger(candidate.streak) || (candidate.streak ?? 0) < 1 || (candidate.streak ?? 0) > 3
73
+ || !Number.isInteger(candidate.lastTurn) || (candidate.lastTurn ?? 0) < 1 || (candidate.lastTurn ?? 0) > turns) return false;
74
+ if (status === "complete") return false;
75
+ return status === "blocked" ? candidate.streak === 3 : candidate.streak! < 3;
76
+ }
77
+
57
78
  function parseGoalState(value: unknown): GoalState | undefined {
58
79
  if (!value || typeof value !== "object") return undefined;
59
80
  const candidate = value as Partial<GoalState>;
@@ -71,6 +92,7 @@ function parseGoalState(value: unknown): GoalState | undefined {
71
92
  || !finiteNonNegative(candidate.baselineTokens)
72
93
  || candidate.activeStartedAt !== undefined && !finiteNonNegative(candidate.activeStartedAt)
73
94
  || candidate.result !== undefined && typeof candidate.result !== "string"
95
+ || candidate.blockerAudit !== undefined && !isGoalBlockerAudit(candidate.blockerAudit, candidate.turns!, candidate.status)
74
96
  || candidate.resumeAfterManualCompaction !== undefined && candidate.resumeAfterManualCompaction !== true
75
97
  || candidate.resumeAfterManualCompaction === true && candidate.status !== "paused") {
76
98
  return undefined;
@@ -89,6 +111,7 @@ function parseGoalState(value: unknown): GoalState | undefined {
89
111
  baselineTokens: candidate.baselineTokens,
90
112
  result: candidate.result,
91
113
  resumeAfterManualCompaction: candidate.resumeAfterManualCompaction,
114
+ blockerAudit: candidate.blockerAudit,
92
115
  };
93
116
  }
94
117
 
@@ -182,6 +205,7 @@ function transitionGoal(
182
205
  updatedAt: now,
183
206
  activeStartedAt: status === "active" ? now : undefined,
184
207
  blockedAuditStartTurn: options.resetBlockedAudit ? stopped.turns : stopped.blockedAuditStartTurn,
208
+ blockerAudit: options.resetBlockedAudit ? undefined : options.blockerAudit ?? stopped.blockerAudit,
185
209
  result,
186
210
  resumeAfterManualCompaction: options.resumeAfterManualCompaction,
187
211
  };
@@ -190,6 +214,24 @@ function transitionGoal(
190
214
  return next;
191
215
  }
192
216
 
217
+ function clearGoalExecutionFlags(runtime: GoalRuntime): void {
218
+ runtime.continuationScheduled = false;
219
+ runtime.goalTurnInFlight = false;
220
+ runtime.agentEndObserved = false;
221
+ runtime.lastStopReason = undefined;
222
+ runtime.lastError = undefined;
223
+ }
224
+
225
+ async function stopGoalRun(runtime: GoalRuntime, ctx: ExtensionCommandContext, shouldStop: boolean): Promise<void> {
226
+ if (!shouldStop) return;
227
+ try {
228
+ ctx.abort();
229
+ await ctx.waitForIdle();
230
+ } finally {
231
+ clearGoalExecutionFlags(runtime);
232
+ }
233
+ }
234
+
193
235
  function goalStatusLabel(status: GoalStatus): string {
194
236
  return `${status.charAt(0).toLocaleUpperCase()}${status.slice(1)}`;
195
237
  }
@@ -222,6 +264,7 @@ export function pauseGoalAfterFailure(
222
264
  ctx: ExtensionContext,
223
265
  reason: string,
224
266
  recoveryInstruction = "Run /goal resume after resolving the problem.",
267
+ notify = true,
225
268
  ): void {
226
269
  if (runtime.state?.status !== "active") return;
227
270
  try {
@@ -237,7 +280,7 @@ export function pauseGoalAfterFailure(
237
280
  runtime.continuationScheduled = false;
238
281
  runtime.requestRender?.();
239
282
  }
240
- ctx.ui.notify(`Goal paused: ${reason}\n${recoveryInstruction}`, "error");
283
+ if (notify) ctx.ui.notify(`Goal paused: ${reason}\n${recoveryInstruction}`, "error");
241
284
  }
242
285
 
243
286
  function pauseGoalForPossibleManualCompaction(
@@ -303,6 +346,7 @@ function scheduleGoalContinuation(
303
346
  || runtime.continuationHeld
304
347
  || runtime.goalTurnInFlight
305
348
  || initState.active
349
+ || !ctx.isIdle()
306
350
  || ctx.hasPendingMessages()) return false;
307
351
  const current = runtime.state;
308
352
  runtime.continuationScheduled = true;
@@ -337,7 +381,7 @@ function goalInstructions(state: GoalState, heading: string): string {
337
381
  "Continue making concrete progress toward this unchanged objective. Re-check repository state and prior results instead of repeating work.",
338
382
  "Do not stop merely because one response is complete: KillerOS will start another goal turn while the goal remains active.",
339
383
  "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.",
340
- "Call killeros_goal_update with status blocked only when the same external impasse has prevented progress for three consecutive goal turns; name the blocker and attempted workarounds.",
384
+ "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.",
341
385
  "Never use the goal tool to pause, resume, edit, replace, or clear the objective. Those transitions belong to the user.",
342
386
  ].join("\n");
343
387
  }
@@ -398,7 +442,7 @@ export function registerGoal(
398
442
  pi.registerTool<typeof GoalUpdateParams, GoalUpdateDetails>({
399
443
  name: "killeros_goal_update",
400
444
  label: "Goal update",
401
- description: "Mark the active KillerOS long-running goal complete after verification, or blocked after the same impasse persists for three consecutive goal turns.",
445
+ 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.",
402
446
  parameters: GoalUpdateParams,
403
447
  executionMode: "sequential",
404
448
  async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
@@ -408,13 +452,40 @@ export function registerGoal(
408
452
  if (!state || state.status !== "active") throw new Error("There is no active KillerOS goal to update");
409
453
  const evidence = params.evidence.trim();
410
454
  if (!evidence) throw new Error("Goal evidence must not be empty");
411
- if (params.status === "blocked" && state.turns - state.blockedAuditStartTurn < 3) {
412
- throw new Error("A goal cannot be marked blocked before three goal turns in the current audit; keep working and audit the same blocker again");
455
+ if (params.status === "complete") {
456
+ transitionGoal(pi, runtime, "complete", "complete", evidence, { resetBlockedAudit: true });
457
+ return {
458
+ content: [{ type: "text", text: `Goal marked complete: ${evidence}` }],
459
+ details: { status: "complete", evidence },
460
+ };
413
461
  }
414
- transitionGoal(pi, runtime, params.status, params.status, evidence);
462
+ if (!runtime.goalTurnInFlight) throw new Error("A blocker audit can only be recorded during an active KillerOS goal turn");
463
+ const blockerKey = params.blockerKey;
464
+ if (!blockerKey || !/^[a-z0-9][a-z0-9._-]{0,119}$/u.test(blockerKey)) {
465
+ throw new Error("A blocked goal update requires a stable lowercase blockerKey");
466
+ }
467
+ const previous = state.blockerAudit;
468
+ const sameTurn = previous?.key === blockerKey && previous.lastTurn === state.turns;
469
+ const consecutive = previous?.key === blockerKey && previous.lastTurn === state.turns - 1;
470
+ const streak = sameTurn ? previous.streak : consecutive ? previous.streak + 1 : 1;
471
+ const blockerAudit = { key: blockerKey, streak, lastTurn: state.turns };
472
+ if (streak < 3) {
473
+ const next: GoalState = {
474
+ ...state,
475
+ revision: state.revision + 1,
476
+ updatedAt: Date.now(),
477
+ blockerAudit,
478
+ };
479
+ persistGoalState(pi, runtime, "blocker-audit", next);
480
+ return {
481
+ content: [{ type: "text", text: `Blocker audit ${streak}/3 recorded; the goal remains active: ${evidence}` }],
482
+ details: { status: "blocker-audit", evidence, blockerKey, streak },
483
+ };
484
+ }
485
+ transitionGoal(pi, runtime, "blocked", "blocked", evidence, { blockerAudit });
415
486
  return {
416
- content: [{ type: "text", text: `Goal marked ${params.status}: ${evidence}` }],
417
- details: { status: params.status, evidence },
487
+ content: [{ type: "text", text: `Goal marked blocked: ${evidence}` }],
488
+ details: { status: "blocked", evidence, blockerKey, streak },
418
489
  };
419
490
  },
420
491
  renderCall(args, theme) {
@@ -423,7 +494,8 @@ export function registerGoal(
423
494
  renderResult(result, options, theme) {
424
495
  const details = result.details;
425
496
  if (!details) return new BoundedText(theme.fg("dim", "Goal updated"));
426
- const text = `${theme.fg(details.status === "complete" ? "success" : "warning", details.status === "complete" ? " Complete" : "! Blocked")}${theme.fg("dim", ` · ${details.evidence}`)}`;
497
+ const label = details.status === "complete" ? "✓ Complete" : details.status === "blocked" ? "! Blocked" : `! Blocker audit ${details.streak}/3`;
498
+ const text = `${theme.fg(details.status === "complete" ? "success" : "warning", label)}${theme.fg("dim", ` · ${details.evidence}`)}`;
427
499
  return new BoundedText(text, options.expanded ? undefined : 3);
428
500
  },
429
501
  });
@@ -544,10 +616,11 @@ export function registerGoal(
544
616
  ctx.ui.notify("No goal is set", "info");
545
617
  return;
546
618
  }
619
+ const shouldStopGoalRun = runtime.goalTurnInFlight || runtime.continuationScheduled;
620
+ let saved = false;
547
621
  try {
548
622
  persistGoalState(pi, runtime, "clear", undefined);
549
- runtime.continuationScheduled = false;
550
- ctx.ui.notify("Goal cleared", "info");
623
+ saved = true;
551
624
  } catch (error) {
552
625
  if (runtime.state?.status === "active") {
553
626
  pauseGoalAfterFailure(
@@ -556,11 +629,24 @@ export function registerGoal(
556
629
  ctx,
557
630
  `the requested clear could not be saved: ${error instanceof Error ? error.message : String(error)}`,
558
631
  "Automatic continuation is stopped. Retry /goal clear to remove the goal.",
632
+ false,
559
633
  );
560
634
  } else {
561
635
  reportError(ctx, "Goal could not be cleared", error);
636
+ return;
562
637
  }
563
638
  }
639
+ try {
640
+ await stopGoalRun(runtime, ctx, shouldStopGoalRun);
641
+ } catch (error) {
642
+ reportError(ctx, saved ? "Goal cleared, but the active goal turn could not be confirmed stopped" : "Goal paused, but the active goal turn could not be confirmed stopped", error);
643
+ return;
644
+ }
645
+ if (saved) {
646
+ ctx.ui.notify("Goal cleared", "info");
647
+ } else {
648
+ ctx.ui.notify("Goal paused: the requested clear could not be saved\nAutomatic continuation is stopped. Retry /goal clear to remove the goal.", "error");
649
+ }
564
650
  return;
565
651
  }
566
652
 
@@ -597,18 +683,34 @@ export function registerGoal(
597
683
  ctx.ui.notify(`Goal is ${runtime.state.status}; only an active goal can be paused`, "warning");
598
684
  return;
599
685
  }
686
+ const shouldStopGoalRun = runtime.goalTurnInFlight || runtime.continuationScheduled;
687
+ let saved = false;
688
+ let failureReason: string | undefined;
600
689
  try {
601
690
  transitionGoal(pi, runtime, "pause", "paused");
602
- ctx.ui.notify("Goal paused. Run /goal resume to continue.", "info");
691
+ saved = true;
603
692
  } catch (error) {
693
+ failureReason = `the requested pause could not be saved: ${error instanceof Error ? error.message : String(error)}`;
604
694
  pauseGoalAfterFailure(
605
695
  pi,
606
696
  runtime,
607
697
  ctx,
608
- `the requested pause could not be saved: ${error instanceof Error ? error.message : String(error)}`,
698
+ failureReason,
609
699
  "Automatic continuation is stopped. If session storage is still unavailable, retry /goal pause after it recovers.",
700
+ false,
610
701
  );
611
702
  }
703
+ try {
704
+ await stopGoalRun(runtime, ctx, shouldStopGoalRun);
705
+ } catch (error) {
706
+ reportError(ctx, "Goal paused, but the active goal turn could not be confirmed stopped", error);
707
+ return;
708
+ }
709
+ if (saved) {
710
+ ctx.ui.notify("Goal paused. Run /goal resume to continue.", "info");
711
+ } else {
712
+ ctx.ui.notify(`Goal paused: ${failureReason}\nAutomatic continuation is stopped. If session storage is still unavailable, retry /goal pause after it recovers.`, "error");
713
+ }
612
714
  return;
613
715
  }
614
716
 
@@ -687,6 +789,7 @@ export function registerGoal(
687
789
  updatedAt: now,
688
790
  activeStartedAt: now,
689
791
  blockedAuditStartTurn: current.turns,
792
+ blockerAudit: undefined,
690
793
  result: undefined,
691
794
  resumeAfterManualCompaction: undefined,
692
795
  };
@@ -695,13 +798,17 @@ export function registerGoal(
695
798
  runtime.continuationScheduled = false;
696
799
  if (scheduleGoalContinuation(pi, runtime, initState, ctx)) ctx.ui.notify("Goal updated and active", "info");
697
800
  } catch (error) {
698
- pauseGoalAfterFailure(
699
- pi,
700
- runtime,
701
- ctx,
702
- `Goal could not be edited: ${error instanceof Error ? error.message : String(error)}`,
703
- "Automatic continuation is stopped. Retry /goal edit after session storage recovers.",
704
- );
801
+ if (runtime.state?.status === "active") {
802
+ pauseGoalAfterFailure(
803
+ pi,
804
+ runtime,
805
+ ctx,
806
+ `Goal could not be edited: ${error instanceof Error ? error.message : String(error)}`,
807
+ "Automatic continuation is stopped. Retry /goal edit after session storage recovers.",
808
+ );
809
+ } else {
810
+ reportError(ctx, "Goal could not be edited", error);
811
+ }
705
812
  }
706
813
  return;
707
814
  }
@@ -761,8 +868,19 @@ export function registerGoal(
761
868
  ctx.ui.notify("Goal active. KillerOS will continue until completion, a repeated blocker, or pause.", "info");
762
869
  }
763
870
  } catch (error) {
764
- reportError(ctx, "Goal could not be started", error);
765
- scheduleGoalContinuation(pi, runtime, initState, ctx);
871
+ if (!unfinished) {
872
+ reportError(ctx, "Goal could not be started", error);
873
+ } else if (runtime.state?.status === "active") {
874
+ pauseGoalAfterFailure(
875
+ pi,
876
+ runtime,
877
+ ctx,
878
+ `Goal could not be replaced: ${error instanceof Error ? error.message : String(error)}`,
879
+ "Automatic continuation is stopped. Retry replacement after session storage recovers.",
880
+ );
881
+ } else {
882
+ reportError(ctx, "Goal could not be replaced", error);
883
+ }
766
884
  }
767
885
  };
768
886
 
@@ -800,6 +918,8 @@ export function registerGoalSettlement(
800
918
  if (!wasGoalTurn || runtime.state?.status !== "active" || initState.active) {
801
919
  if (continuationWasScheduled && runtime.state?.status === "active" && !initState.active) {
802
920
  pauseGoalAfterFailure(pi, runtime, ctx, "the goal continuation ended before an agent turn started");
921
+ } else if (runtime.state?.status === "active" && !initState.active) {
922
+ scheduleGoalContinuation(pi, runtime, initState, ctx);
803
923
  }
804
924
  return;
805
925
  }
package/killeros/hooks.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { spawn } from "node:child_process";
2
2
  import { existsSync, readFileSync } from "node:fs";
3
3
  import path from "node:path";
4
+ import { StringDecoder } from "node:string_decoder";
4
5
  import { CONFIG_DIR_NAME, type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent";
5
6
  import { reportError } from "./errors.ts";
6
7
  import { MAX_NODE_TIMER_MS } from "./limits.ts";
@@ -44,6 +45,10 @@ function loadKillerosHooks(ctx: ExtensionContext): KillerosHookConfig {
44
45
  const candidates = parsed.hooks?.[event];
45
46
  if (!Array.isArray(candidates)) continue;
46
47
  hooks[event] = candidates.filter((hook, index) => {
48
+ if (event === "agent_settled" && hook?.matcher !== undefined) {
49
+ ctx.ui.notify(`Ignored ${event} hook ${index + 1}: matchers are only valid for tool events`, "warning");
50
+ return false;
51
+ }
47
52
  const valid = hook
48
53
  && typeof hook.command === "string"
49
54
  && hook.command.trim().length > 0
@@ -80,9 +85,19 @@ function matchesHook(hook: KillerosHook, value: string): boolean {
80
85
  }
81
86
  }
82
87
 
83
- function appendBounded(current: string, chunk: Buffer | string): string {
84
- if (current.length >= HOOK_OUTPUT_LIMIT) return current;
85
- return (current + chunk.toString()).slice(0, HOOK_OUTPUT_LIMIT);
88
+ interface HookOutputBuffer {
89
+ bytes: number;
90
+ decoder: StringDecoder;
91
+ text: string;
92
+ }
93
+
94
+ function appendBounded(output: HookOutputBuffer, chunk: Buffer | string): void {
95
+ const remaining = HOOK_OUTPUT_LIMIT - output.bytes;
96
+ if (remaining <= 0) return;
97
+ const bytes = typeof chunk === "string" ? Buffer.from(chunk, "utf8") : chunk;
98
+ const captured = bytes.subarray(0, remaining);
99
+ output.bytes += captured.length;
100
+ output.text += output.decoder.write(captured);
86
101
  }
87
102
 
88
103
  function terminateHookProcess(child: ReturnType<typeof spawn>, force: boolean): void {
@@ -130,8 +145,8 @@ export function executeHook(
130
145
  stdio: ["ignore", "pipe", "pipe"],
131
146
  windowsHide: true,
132
147
  });
133
- let stdout = "";
134
- let stderr = "";
148
+ const stdout: HookOutputBuffer = { bytes: 0, decoder: new StringDecoder("utf8"), text: "" };
149
+ const stderr: HookOutputBuffer = { bytes: 0, decoder: new StringDecoder("utf8"), text: "" };
135
150
  let completed = false;
136
151
  let termination: "timeout" | "cancelled" | undefined;
137
152
  let timer: NodeJS.Timeout | undefined;
@@ -146,8 +161,8 @@ export function executeHook(
146
161
  signal?.removeEventListener("abort", abort);
147
162
  resolve({
148
163
  code,
149
- stdout,
150
- stderr,
164
+ stdout: stdout.text,
165
+ stderr: stderr.text,
151
166
  timedOut: termination === "timeout",
152
167
  cancelled: termination === "cancelled",
153
168
  exitUnconfirmed,
@@ -167,10 +182,10 @@ export function executeHook(
167
182
  const abort = (): void => beginTermination("cancelled");
168
183
  signal?.addEventListener("abort", abort, { once: true });
169
184
  if (signal?.aborted) beginTermination("cancelled");
170
- child.stdout.on("data", (chunk) => { stdout = appendBounded(stdout, chunk); });
171
- child.stderr.on("data", (chunk) => { stderr = appendBounded(stderr, chunk); });
185
+ child.stdout.on("data", (chunk) => appendBounded(stdout, chunk));
186
+ child.stderr.on("data", (chunk) => appendBounded(stderr, chunk));
172
187
  child.on("error", (error) => {
173
- stderr = appendBounded(stderr, error.message);
188
+ appendBounded(stderr, error.message);
174
189
  finish(termination ? terminationCode() : 1);
175
190
  });
176
191
  child.once("close", (code) => finish(termination ? terminationCode() : code ?? 1));
@@ -1,6 +1,7 @@
1
1
  import { spawn } from "node:child_process";
2
2
  import { promises as fs } from "node:fs";
3
3
  import path from "node:path";
4
+ import { StringDecoder } from "node:string_decoder";
4
5
 
5
6
  export const INIT_READ_TOOL = "killeros_init_read";
6
7
  export const INIT_LIST_TOOL = "killeros_init_list";
@@ -91,7 +92,7 @@ async function collectCandidates(projectRoot: string): Promise<string[]> {
91
92
 
92
93
  async function gitIgnoredPaths(projectRoot: string, candidates: readonly string[]): Promise<ReadonlySet<string>> {
93
94
  if (!candidates.length) return new Set();
94
- return new Promise((resolve) => {
95
+ return new Promise((resolve, reject) => {
95
96
  let settled = false;
96
97
  let stdout = Buffer.alloc(0);
97
98
  const child = spawn("git", ["-C", projectRoot, "check-ignore", "--stdin", "-z"], {
@@ -99,7 +100,13 @@ async function gitIgnoredPaths(projectRoot: string, candidates: readonly string[
99
100
  stdio: ["pipe", "pipe", "ignore"],
100
101
  windowsHide: true,
101
102
  });
102
- const finish = (value: ReadonlySet<string>): void => {
103
+ const fail = (): void => {
104
+ if (settled) return;
105
+ settled = true;
106
+ clearTimeout(timer);
107
+ reject(new Error("Git ignore inspection failed; /init did not build repository evidence"));
108
+ };
109
+ const succeed = (value: ReadonlySet<string>): void => {
103
110
  if (settled) return;
104
111
  settled = true;
105
112
  clearTimeout(timer);
@@ -107,30 +114,59 @@ async function gitIgnoredPaths(projectRoot: string, candidates: readonly string[
107
114
  };
108
115
  const timer = setTimeout(() => {
109
116
  child.kill("SIGKILL");
110
- finish(new Set());
117
+ fail();
111
118
  }, 2_000);
112
119
  child.stdout.on("data", (chunk: Buffer) => {
113
- if (stdout.length <= 256 * 1024) stdout = Buffer.concat([stdout, chunk]);
120
+ if (stdout.length + chunk.length > 256 * 1024) {
121
+ child.kill("SIGKILL");
122
+ fail();
123
+ return;
124
+ }
125
+ stdout = Buffer.concat([stdout, chunk]);
126
+ });
127
+ child.once("error", fail);
128
+ child.stdin.once("error", () => {
129
+ child.kill("SIGKILL");
130
+ fail();
114
131
  });
115
- child.once("error", () => finish(new Set()));
116
132
  child.once("close", (code) => {
117
- if (code === 1) return finish(new Set());
118
- if (code !== 0 || stdout.length > 256 * 1024 || stdout.at(-1) !== 0) return finish(new Set());
133
+ if (code === 1) {
134
+ if (stdout.length) fail();
135
+ else succeed(new Set());
136
+ return;
137
+ }
138
+ if (code !== 0 || !stdout.length || stdout.at(-1) !== 0) {
139
+ fail();
140
+ return;
141
+ }
142
+ let values: string[];
143
+ try {
144
+ values = new TextDecoder("utf-8", { fatal: true }).decode(stdout.subarray(0, -1)).split("\0");
145
+ } catch {
146
+ fail();
147
+ return;
148
+ }
119
149
  const candidateSet = new Set(candidates.map(evidenceKey));
120
- const values = stdout.subarray(0, -1).toString("utf8").split("\0");
121
- if (values.some((value) => !value || !candidateSet.has(evidenceKey(value)))) return finish(new Set());
122
- finish(new Set(values.map(evidenceKey)));
150
+ const ignored = new Set(values.map(evidenceKey));
151
+ if (values.some((value) => !value || !candidateSet.has(evidenceKey(value))) || ignored.size !== values.length) {
152
+ fail();
153
+ return;
154
+ }
155
+ succeed(ignored);
123
156
  });
124
- child.stdin.on("error", () => {});
125
157
  child.stdin.end(`${candidates.join("\0")}\0`);
126
158
  });
127
159
  }
128
160
 
161
+ function decodeCompleteUtf8(bytes: Buffer): string {
162
+ return new StringDecoder("utf8").write(bytes);
163
+ }
164
+
129
165
  function appendWithinLimit(current: string, section: string, limit: number): string {
130
166
  const remaining = limit - Buffer.byteLength(current, "utf8");
131
167
  if (remaining <= 0) return current;
132
168
  const bytes = Buffer.from(section, "utf8");
133
- return current + bytes.subarray(0, remaining).toString("utf8");
169
+ return current + decodeCompleteUtf8(bytes.subarray(0, remaining));
134
170
  }
135
171
 
136
172
  async function validateAndRead(projectRoot: string, absolutePath: string, limit: number): Promise<{ content: string; truncated: boolean }> {
@@ -157,7 +193,7 @@ async function validateAndRead(projectRoot: string, absolutePath: string, limit:
157
193
  const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0);
158
194
  const data = buffer.subarray(0, Math.min(bytesRead, limit));
159
195
  if (data.includes(0)) throw new Error("/init rejects binary files");
160
- return { content: data.toString("utf8"), truncated: bytesRead > limit };
196
+ return { content: decodeCompleteUtf8(data), truncated: bytesRead > limit };
161
197
  } finally {
162
198
  await handle.close();
163
199
  }
@@ -65,7 +65,7 @@ export function formatNotificationTitle(
65
65
  sessionName: string | undefined,
66
66
  enabled: boolean,
67
67
  ): string {
68
- const directory = basename(cwd);
68
+ const directory = basename(cwd) || cwd;
69
69
  const base = sessionName ? `π - ${sessionName} - ${directory}` : `π - ${directory}`;
70
70
  return enabled ? `${base} ${COMPLETION_BELL_GLYPH}` : base;
71
71
  }
@@ -89,12 +89,24 @@ function oneLine(value: string): string {
89
89
  return value.replace(/\s+/gu, " ").trim();
90
90
  }
91
91
 
92
+ function boundedRenderLine(value: string, width: number, suffix: string): string {
93
+ return truncateToWidth(value.replace(/\r\n|\r|\n/gu, " "), width, suffix);
94
+ }
95
+
92
96
  function visibleOptionRange(total: number, selected: number, capacity: number): { start: number; end: number } {
93
97
  const size = Math.max(1, Math.min(total, capacity));
94
98
  const start = Math.max(0, Math.min(selected - Math.floor(size / 2), total - size));
95
99
  return { start, end: Math.min(total, start + size) };
96
100
  }
97
101
 
102
+ function boundedQuestionLines(question: string, width: number, rowLimit: number): string[] {
103
+ const wrapped = wrapTextWithAnsi(question.replace(/\s+/gu, " ").trim(), width);
104
+ if (wrapped.length <= rowLimit) return wrapped;
105
+ const visible = wrapped.slice(0, rowLimit);
106
+ visible[rowLimit - 1] = truncateToWidth(visible[rowLimit - 1]!, width, "…");
107
+ return visible;
108
+ }
109
+
98
110
  export function registerQuestionTool(pi: ExtensionAPI): void {
99
111
  const customInputHistory: string[] = [];
100
112
  let customInputHistoryBytes = 0;
@@ -349,27 +361,29 @@ export function registerQuestionTool(pi: ExtensionAPI): void {
349
361
  if (optionIndex >= visibleOptions.length) optionIndex = Math.max(0, visibleOptions.length - 1);
350
362
  const selected = visibleOptions[optionIndex];
351
363
  const position = `Option ${Math.min(optionIndex + 1, visibleOptions.length)}/${visibleOptions.length}`;
352
- const answerCount = inputCharacterCount(editor.getExpandedText()).toLocaleString();
364
+ const expandedAnswer = editor.getExpandedText();
365
+ const answerCount = inputCharacterCount(expandedAnswer).toLocaleString();
353
366
  const filterCount = inputCharacterCount(filterQuery).toLocaleString();
367
+ const compactDraft = expandedAnswer.replace(/\r\n|\r|\n/gu, " ↵ ") || "Type an answer";
354
368
 
355
369
  if (rowBudget <= 2) {
356
370
  const compact = editMode
357
- ? [`Answer ${answerCount}/${CUSTOM_INPUT_MAX_CHARACTERS.toLocaleString()}`, editor.getExpandedText() || "Type an answer"]
371
+ ? [`Answer ${answerCount}/${CUSTOM_INPUT_MAX_CHARACTERS.toLocaleString()}`, compactDraft]
358
372
  : [`${selected ? `> ${selected.label}` : "No matching options"} · ${position}`];
359
373
  cachedWidth = renderWidth;
360
374
  cachedRows = rowBudget;
361
- cachedLines = compact.slice(0, rowBudget).map((line) => truncateToWidth(line, renderWidth, "…"));
375
+ cachedLines = compact.slice(0, rowBudget).map((line) => boundedRenderLine(line, renderWidth, "…"));
362
376
  return cachedLines;
363
377
  }
364
378
 
365
379
  if (rowBudget <= 5) {
366
380
  const compact = [
367
- truncateToWidth(params.question.replace(/\s+/gu, " "), renderWidth, "…"),
381
+ ...boundedQuestionLines(params.question, renderWidth, Math.max(1, rowBudget - 3)),
368
382
  editMode
369
383
  ? `Answer ${answerCount}/${CUSTOM_INPUT_MAX_CHARACTERS.toLocaleString()}`
370
384
  : `${selected ? `> ${selected.label}` : "No matching options"} · ${position}`,
371
385
  editMode
372
- ? editor.getExpandedText() || "Type an answer"
386
+ ? compactDraft
373
387
  : filterQuery
374
388
  ? `Filter ${filterCount}/${FILTER_QUERY_MAX_CHARACTERS.toLocaleString()}`
375
389
  : position,
@@ -379,11 +393,12 @@ export function registerQuestionTool(pi: ExtensionAPI): void {
379
393
  ];
380
394
  cachedWidth = renderWidth;
381
395
  cachedRows = rowBudget;
382
- cachedLines = compact.slice(0, rowBudget).map((line) => truncateToWidth(line, renderWidth, "…"));
396
+ cachedLines = compact.slice(0, rowBudget).map((line) => boundedRenderLine(line, renderWidth, "…"));
383
397
  return cachedLines;
384
398
  }
385
399
 
386
- const contentRows = rowBudget - 5;
400
+ const questionLines = boundedQuestionLines(params.question, renderWidth, Math.max(1, rowBudget - 5));
401
+ const contentRows = rowBudget - questionLines.length - 4;
387
402
  const optionCapacity = Math.max(1, Math.min(5, Math.ceil(contentRows / 2)));
388
403
  const detailCapacity = Math.max(0, contentRows - optionCapacity);
389
404
  const { start, end } = visibleOptionRange(visibleOptions.length, optionIndex, optionCapacity);
@@ -401,7 +416,7 @@ export function registerQuestionTool(pi: ExtensionAPI): void {
401
416
  : `${keyHint("tui.select.up", "up")} • ${keyHint("tui.select.down", "down")} • ${keyHint("tui.select.confirm", "select")} • ${keyHint("tui.select.cancel", filterQuery ? "clear filter" : "cancel")}`;
402
417
  const lines: string[] = [
403
418
  theme.fg("accent", "─".repeat(renderWidth)),
404
- theme.fg("text", truncateToWidth(` ${params.question.replace(/\s+/gu, " ")}`, renderWidth, "…")),
419
+ ...questionLines.map((line) => theme.fg("text", line)),
405
420
  theme.fg("muted", truncateToWidth(` ${progress}`, renderWidth, "…")),
406
421
  ];
407
422
 
@@ -459,7 +474,7 @@ export function registerQuestionTool(pi: ExtensionAPI): void {
459
474
  lines.push(theme.fg("accent", "─".repeat(renderWidth)));
460
475
  cachedWidth = renderWidth;
461
476
  cachedRows = rowBudget;
462
- cachedLines = lines.slice(0, rowBudget).map((line) => truncateToWidth(line, renderWidth, ""));
477
+ cachedLines = lines.slice(0, rowBudget).map((line) => boundedRenderLine(line, renderWidth, ""));
463
478
  return cachedLines;
464
479
  };
465
480
 
@@ -20,6 +20,12 @@ export interface InitRuntime {
20
20
 
21
21
  export type GoalStatus = "active" | "paused" | "blocked" | "complete";
22
22
 
23
+ export interface GoalBlockerAudit {
24
+ key: string;
25
+ streak: number;
26
+ lastTurn: number;
27
+ }
28
+
23
29
  export interface GoalState {
24
30
  version: 1;
25
31
  revision: number;
@@ -34,6 +40,7 @@ export interface GoalState {
34
40
  baselineTokens: number;
35
41
  result?: string;
36
42
  resumeAfterManualCompaction?: true;
43
+ blockerAudit?: GoalBlockerAudit;
37
44
  }
38
45
 
39
46
  export interface GoalRuntime {
@@ -11,6 +11,7 @@ import {
11
11
  } from "@earendil-works/pi-coding-agent";
12
12
  import {
13
13
  Container,
14
+ CURSOR_MARKER,
14
15
  Text,
15
16
  truncateToWidth,
16
17
  visibleWidth,
@@ -43,6 +44,13 @@ const STARTUP_TIPS = [
43
44
  "Run /notification to enable a terminal bell when work settles.",
44
45
  ] as const;
45
46
 
47
+ const EDITOR_SUGGESTIONS = [
48
+ 'Try "how does <filepath> work?"',
49
+ 'Try "find edge cases in <filepath>"',
50
+ 'Try "simplify <filepath> without changing behavior"',
51
+ 'Try "write tests for <filepath>"',
52
+ ] as const;
53
+
46
54
  export function resolveGitBranch(cwd: string): Promise<string | undefined> {
47
55
  return new Promise((resolve) => {
48
56
  execFile(
@@ -66,13 +74,26 @@ export function resolveGitBranch(cwd: string): Promise<string | undefined> {
66
74
  });
67
75
  }
68
76
 
69
- function shuffledTips(): string[] {
70
- const tips = [...STARTUP_TIPS];
71
- for (let index = tips.length - 1; index > 0; index -= 1) {
77
+ function shuffledDeck(values: readonly string[]): string[] {
78
+ const deck = [...values];
79
+ for (let index = deck.length - 1; index > 0; index -= 1) {
72
80
  const swapIndex = Math.floor(Math.random() * (index + 1));
73
- [tips[index], tips[swapIndex]] = [tips[swapIndex]!, tips[index]!];
81
+ [deck[index], deck[swapIndex]] = [deck[swapIndex]!, deck[index]!];
74
82
  }
75
- return tips;
83
+ return deck;
84
+ }
85
+
86
+ let tipDeck: string[] = [];
87
+ let editorSuggestionDeck: string[] = [];
88
+
89
+ function nextStartupTip(): string {
90
+ if (tipDeck.length === 0) tipDeck = shuffledDeck(STARTUP_TIPS);
91
+ return tipDeck.pop() ?? STARTUP_TIPS[0];
92
+ }
93
+
94
+ function nextEditorSuggestion(): string {
95
+ if (editorSuggestionDeck.length === 0) editorSuggestionDeck = shuffledDeck(EDITOR_SUGGESTIONS);
96
+ return editorSuggestionDeck.pop() ?? EDITOR_SUGGESTIONS[0];
76
97
  }
77
98
 
78
99
  function compactBoxLine(content: string, width: number, theme: Theme): string {
@@ -167,16 +188,19 @@ function isScrolledTopBorder(line: string): boolean {
167
188
  class PiCodeEditor extends CustomEditor {
168
189
  private readonly appKeybindings: KeybindingsManager;
169
190
  private readonly runtimeTheme: Theme;
191
+ private readonly suggestion: string;
170
192
 
171
193
  constructor(
172
194
  tui: TUI,
173
195
  theme: EditorTheme,
174
196
  appKeybindings: KeybindingsManager,
175
197
  runtimeTheme: Theme,
198
+ suggestion: string,
176
199
  ) {
177
200
  super(tui, theme, appKeybindings);
178
201
  this.appKeybindings = appKeybindings;
179
202
  this.runtimeTheme = runtimeTheme;
203
+ this.suggestion = suggestion;
180
204
  }
181
205
 
182
206
  override handleInput(data: string): void {
@@ -220,8 +244,16 @@ class PiCodeEditor extends CustomEditor {
220
244
  }
221
245
 
222
246
  for (let index = 1; index < bottomBorderIndex; index += 1) {
223
- const prefix = index === 1 && !isScrolledHeader ? gray("❯ ") : " ";
224
- framed.push(`${prefix}${padRight(lines[index] ?? "", innerWidth)}`);
247
+ const isPromptLine = index === 1 && !isScrolledHeader;
248
+ const prefix = isPromptLine ? gray("❯\u00A0") : " ";
249
+ let content = lines[index] ?? "";
250
+ if (isPromptLine && this.getText() === "") {
251
+ const first = this.suggestion.slice(0, 1);
252
+ const rest = this.suggestion.slice(1);
253
+ const cursorMarker = this.focused ? CURSOR_MARKER : "";
254
+ content = `${cursorMarker}\x1B[7m${gray(first)}\x1B[27m${gray(rest)}`;
255
+ }
256
+ framed.push(`${prefix}${padRight(content, innerWidth)}`);
225
257
  }
226
258
 
227
259
  const bottom = stripAnsi(lines[bottomBorderIndex] ?? "");
@@ -258,11 +290,6 @@ export function registerShellUi(pi: ExtensionAPI): void {
258
290
  let activityDeck: string[] = [];
259
291
  let lastActivityWord: string | undefined;
260
292
  let activityTimer: ReturnType<typeof setInterval> | undefined;
261
- let tipDeck: string[] = [];
262
- const nextStartupTip = (): string => {
263
- if (tipDeck.length === 0) tipDeck = shuffledTips();
264
- return tipDeck.pop() ?? STARTUP_TIPS[0];
265
- };
266
293
  const refillActivityDeck = (): void => {
267
294
  activityDeck = [...ACTIVITY_WORDS];
268
295
  for (let index = activityDeck.length - 1; index > 0; index -= 1) {
@@ -302,8 +329,9 @@ export function registerShellUi(pi: ExtensionAPI): void {
302
329
  ctx.ui.setHiddenThinkingLabel("└ Thinking…");
303
330
  const existingEditorFactory = ctx.ui.getEditorComponent?.();
304
331
  if (!existingEditorFactory || existingEditorFactory === killerosEditorFactory) {
332
+ const editorSuggestion = nextEditorSuggestion();
305
333
  killerosEditorFactory = (tui, editorTheme, keybindings) =>
306
- new PiCodeEditor(tui, editorTheme, keybindings, ctx.ui.theme);
334
+ new PiCodeEditor(tui, editorTheme, keybindings, ctx.ui.theme, editorSuggestion);
307
335
  ctx.ui.setEditorComponent(killerosEditorFactory);
308
336
  }
309
337
  } catch (error) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "killeros",
3
- "version": "2.0.4",
3
+ "version": "2.0.5",
4
4
  "description": "TUI, goals, and workflow automation for the Pi coding agent",
5
5
  "type": "module",
6
6
  "keywords": [