pi-goal-x 0.32.3 → 0.33.0

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.
Files changed (34) hide show
  1. package/docs/advanced-usage.md +7 -1
  2. package/docs/architecture.md +22 -0
  3. package/docs/controls.md +140 -0
  4. package/docs/prompt-caching.md +3 -1
  5. package/extensions/auditor-selector.ts +4 -2
  6. package/extensions/goal-auditor.ts +2 -2
  7. package/extensions/goal-commands.ts +85 -37
  8. package/extensions/goal-controls.ts +202 -0
  9. package/extensions/goal-core-tools.ts +6 -5
  10. package/extensions/goal-dialog-controls.ts +31 -0
  11. package/extensions/goal-draft.ts +4 -3
  12. package/extensions/goal-drafting.ts +10 -9
  13. package/extensions/goal-events.ts +57 -13
  14. package/extensions/goal-format.ts +3 -2
  15. package/extensions/goal-interruption.ts +53 -0
  16. package/extensions/goal-prompt-profile.ts +77 -0
  17. package/extensions/goal-questionnaire.ts +33 -29
  18. package/extensions/goal-recovery.ts +5 -2
  19. package/extensions/goal-scheduler-state.ts +17 -5
  20. package/extensions/goal-scheduler.ts +89 -13
  21. package/extensions/goal-session-safety.ts +21 -8
  22. package/extensions/goal-settings.ts +112 -79
  23. package/extensions/goal-state.ts +19 -9
  24. package/extensions/goal-status.ts +10 -3
  25. package/extensions/goal-task-confirmation.ts +7 -6
  26. package/extensions/goal-tool-names.ts +2 -4
  27. package/extensions/goal-widget.ts +51 -78
  28. package/extensions/prompts/goal-prompts.ts +7 -6
  29. package/extensions/widgets/goal-controls-dialog.ts +329 -0
  30. package/extensions/widgets/goal-dashboard-model.ts +9 -2
  31. package/extensions/widgets/goal-dashboard-renderer.ts +15 -12
  32. package/extensions/widgets/goal-escape-dialog.ts +8 -7
  33. package/extensions/widgets/goal-widget.ts +17 -5
  34. package/package.json +17 -9
@@ -4,7 +4,13 @@ For installation, goal workflows, commands and the settings overview, see the [R
4
4
 
5
5
  ## Automatic continuation and optional execution contracts
6
6
 
7
- Active goals continue automatically after successful executions, including reasoning-only responses and final-task verification. No tool call, task update, scheduling declaration, or cooldown is required. Unproductive loops remain possible; optional run limits and token budgets still apply.
7
+ Active goals continue automatically after successful executions, including reasoning-only responses and final-task verification. No tool call, task update, or scheduling declaration is required.
8
+
9
+ Default continuation is immediate. To slow repeated replies without recognized work, enable **slow repeated idle replies (opt-in)** under Advanced execution in `/goal-settings` (`idleContinuationBackoff: true`). Use the scope switch to choose a global default or a project override. An explicit project `false` overrides global `true`; removing the override restores inheritance.
10
+
11
+ When enabled, one execution without recognized work continues immediately. Further such executions wait 30, 60, 120, 240, then 300 seconds between attempts. Work observed anywhere in the execution or a change to the objective, requirements or tasks resets the delay. Goal inspection, echo-only calls, accounting and scheduling declarations alone do not reset it. This heuristic may delay reasoning or unfamiliar custom tools, and repeated recognized tool calls can bypass it. It reduces request rate; it does not guarantee progress or finite spending. Use a run allowance or token budget for a hard limit.
12
+
13
+ The delay survives reloads and shows a relative time, local clock time and resume command. Waiting spends no autonomous runs or active time. Turning the option off cancels a pending delay promptly without renewing spent runs or resuming a paused goal. Turning it on starts observing a subsequent execution, rather than classifying work retroactively. Old saved delay metadata has no effect without opt-in. `/goal-resume` renews the run allowance and continues now; incoming user/producer messages remain ordinary host work outside that allowance. Strict waits and provider-error recovery keep their separate timing.
8
14
 
9
15
  Enable `strictExecutionContract: true` in `/goal-settings` or your global/project settings to require explicit ready/wait decisions. In that mode, a missing decision permits one repair prompt within the remaining allowance, then pauses. This is a user preference; agents should not enable it merely to continue.
10
16
 
@@ -249,6 +249,28 @@ token/time intervals per turn; a goal never double-charges the same interval.
249
249
  `GoalRuntime` (goal-runtime.ts) owns continuation scheduling, the stale
250
250
  checkpoint state, the turn-stop guard, and one-shot steering reminders.
251
251
 
252
+ `GoalScheduler` persists dispatch intent and allowance consumption. Default
253
+ continuation is immediate. Explicit `idleContinuationBackoff=true` also records a
254
+ bounded idle streak and earliest next attempt: one immediate continuation, then
255
+ 30 seconds doubling to five minutes. Work accumulates over the whole logical run;
256
+ content snapshots exclude usage, revisions and scheduler writes. Disabled pacing
257
+ skips those snapshots. Settings are rechecked at scheduling and admission; opting
258
+ out removes saved delay metadata without renewing consumption. Resume and genuine
259
+ host input reset pacing; strict waits and provider recovery remain independent.
260
+
261
+ `goal-prompt-profile.ts` detects Pi's native custom-turn startup protocol and
262
+ leaves its structured prompt/tool deltas intact on Pi 1.2 and newer. On older
263
+ affected structured hosts, it projects the current public prompt and resolved
264
+ tools through `context_with_system`, or the full `context` event where necessary.
265
+ That fallback fixes the first request after direct creation, drafting, resume or
266
+ reload without changing history or tool/result pairs. Classic hosts keep their
267
+ native path. Current `agent_settled.aborted` events stop continuation/recovery and
268
+ pause the associated goal, including cancellation before a provider request.
269
+
270
+ Audit display messages flush only at idle settlement and remain scoped to their
271
+ session. If Pi has invalidated a settlement context, the handler skips it before
272
+ consuming continuation intent or changing the replacement session's queue.
273
+
252
274
  An optional `token_budget` may be set at creation. When accounted usage
253
275
  reaches the budget, `accountProgress` transitions the goal to the distinct
254
276
  `budget_limited` status exactly once (status leaves `active`, so accounting
@@ -0,0 +1,140 @@
1
+ # Commands and keys
2
+
3
+ Open `/goal-settings` and choose **Controls**, or run `/goal-settings controls`.
4
+ The screen has three sections: **Commands**, **Keys** and **Pausing**. Type to
5
+ filter, use the arrow keys and Enter to edit, and Tab to switch sections.
6
+ Page Up/Down moves through long lists; Home/End jumps to the first/last row.
7
+ Ctrl+S switches between **This project** and **All projects**. The selected row shows
8
+ whether its value comes from the project, global settings or the default.
9
+
10
+ Changes are saved when you choose Save or confirm a recorded key. Escape backs
11
+ out of an edit without saving. **Use inherited value** removes a project
12
+ override; **Remove global override** restores the default. **Use default** pins
13
+ the default in the selected scope. Each section also has a reset action that
14
+ removes only that section's overrides in the selected scope.
15
+
16
+ ## Commands
17
+
18
+ Each action has a preferred command followed by any aliases. Choose **Edit
19
+ command paths** to replace them, **Add an alias** to keep the current spellings,
20
+ or **Disable** to remove its command invocations. For example, add `/goal edit`
21
+ to **Revise the goal** to use the existing confirmation workflow with that
22
+ spelling. Arguments after the command path are passed through to the action.
23
+ Subcommands appear in argument completion; the longest matching complete path
24
+ wins. A routing-only root lists its available subcommands when called without
25
+ a matching action.
26
+
27
+ Command changes need a reload. The screen shows when changes are pending;
28
+ Ctrl+R applies them while Pi is idle when controls were opened through a slash
29
+ command. If controls were opened from a shortcut, close them and run `/reload`.
30
+ Keys and pause order take effect immediately.
31
+
32
+ `/goal-settings` always remains available as a recovery entry. You can give the
33
+ settings action other spellings, but this entry cannot be removed. Pi command
34
+ roots such as `/reload` and `/settings` are reserved. The editor checks conflicts
35
+ with other commands before saving.
36
+ For settings, **Recovery entry only** removes additional aliases while keeping
37
+ `/goal-settings` available.
38
+
39
+ The settings keys below are stable action IDs, even after renaming an invocation:
40
+
41
+ | Action ID | Action |
42
+ | --- | --- |
43
+ | `goal` | Guided goal draft |
44
+ | `sisyphus` | Guided Sisyphus draft |
45
+ | `goal-direct` | Immediate goal creation |
46
+ | `sisyphus-direct` | Immediate Sisyphus creation |
47
+ | `goal-tweak` | Confirmed revision |
48
+ | `goal-pause` | Pause goal and stop turn |
49
+ | `goal-resume` | Resume and renew the run allowance |
50
+ | `goal-clear` | Confirmed archival |
51
+ | `goal-list` | List open goals |
52
+ | `goal-status` | Status; also accepts `verbose` or `health` |
53
+ | `goal-refresh` | Read state and settings again |
54
+ | `goal-recovery` | Recovery report; also accepts `repair` |
55
+ | `goal-focus` | Choose a goal |
56
+ | `goal-unfocus` | Detach this session |
57
+ | `goal-settings` | Settings and controls |
58
+ | `goal-cancel` | Cancel a draft |
59
+
60
+ ## Keys
61
+
62
+ Choose an action under **Keys**, then record a key or edit its Pi key names.
63
+ **Record another key** adds a second binding. **Disable** leaves it unbound.
64
+ Conflicts are checked before saving; errors leave your input available to fix.
65
+ Use a free key or change a conflicting Pi binding in its `keybindings.json`.
66
+
67
+ Every public goal shortcut is listed: pause, audit interruption, auditor toggle,
68
+ dashboard expansion, compact scrolling, expanded navigation, and goal dialog
69
+ controls. Each command action can also have a shortcut, independently of its
70
+ slash command names. A shortcut for direct creation or revision asks for the
71
+ objective or change before invoking the usual workflow.
72
+
73
+ Goal dialog controls apply to questionnaires and confirmation dialogs. They
74
+ have their own context, so the same key can confirm a dialog and pause a live
75
+ goal. Text editing and the settings screen use Pi's usual UI controls. The
76
+ settings screen remains usable when you change goal dialog navigation.
77
+ While typing an answer, letters and Pi's editing keys keep editing text. Escape
78
+ leaves the answer editor; free custom keys such as F7 can do the same.
79
+
80
+ Pi key names include `escape`, `f6`, `ctrl+alt+p`, `pageUp` and `super+k`. Multiple
81
+ keys can be separated with commas. For a comma key combined with other bindings,
82
+ the text editor also accepts a JSON key list, for example `["ctrl+,", "f6"]`.
83
+ Existing comma-key lists reopen in this form so they can be saved unchanged.
84
+ Terminals must report a key before Pi can use it; some modifier combinations
85
+ require the Kitty keyboard protocol. See [Pi's keybinding reference](https://github.com/earendil-works/pi/blob/v1.4.0/packages/coding-agent/docs/keybindings.md).
86
+
87
+ ## Pausing
88
+
89
+ The default is still Escape, with **Goal and turn together**. An open dialog
90
+ owns its input; closing the expanded dashboard or interrupting an audit takes
91
+ priority over pausing in that view.
92
+
93
+ | Pause order | First press | Next press |
94
+ | --- | --- | --- |
95
+ | Goal and turn together | Pause the goal and stop the turn | Pi's normal interrupt |
96
+ | Goal first | Pause the goal; let the current turn finish | Stop the remaining turn |
97
+ | Turn first | Stop the turn and wait for the user, keeping the goal active | Pause the goal |
98
+
99
+ Goal first finishes the current Pi turn, then stops before another model
100
+ request. A turn-only stop holds automatic continuation, provider recovery and wake-ups.
101
+ It does not spend another run allowance. Resume or send a new user message to
102
+ continue. Reloading an interrupted autonomous run does not replay it.
103
+
104
+ When you move the pause key away from Pi's interrupt key, Pi's interrupt stops
105
+ the turn and waits for you without pausing the goal. The configured pause key
106
+ uses the selected order. The explicit **Pause the goal** command always stops
107
+ both the goal and its turn, including a turn left running by **Goal first**.
108
+
109
+ ## Settings files
110
+
111
+ The UI writes the existing sparse settings layers:
112
+
113
+ - Global: `${PI_CODING_AGENT_DIR:-~/.pi/agent}/pi-goal-x-settings.json`.
114
+ - Project: `<project>/.pi/pi-goal-x-settings.json`.
115
+
116
+ Project leaves override global leaves. Missing leaves inherit; an empty list
117
+ explicitly disables an action. Existing `keybindings.dashboard` settings keep
118
+ their meaning. For example:
119
+
120
+ ```json
121
+ {
122
+ "commands": {
123
+ "goal-tweak": ["goal-tweak", "goal edit"],
124
+ "goal-resume": ["work continue"]
125
+ },
126
+ "pauseOrder": "turn-first",
127
+ "keybindings": {
128
+ "pause": ["f6", "ctrl+alt+p"],
129
+ "toggleAuditor": "ctrl+alt+a",
130
+ "interruptAudit": "escape",
131
+ "dashboard": { "toggleExpand": "ctrl+alt+t" },
132
+ "dialog": { "confirm": "enter", "cancel": "escape" },
133
+ "commands": { "goal-settings": "f10", "goal-status": [] }
134
+ }
135
+ }
136
+ ```
137
+
138
+ Use `/goal-refresh` after external key or pause-order edits, and `/reload` after
139
+ external command edits. Verbose goal status reports the effective controls and
140
+ their sources. Developer-only `PI_GOAL_DEBUG` shortcuts are unchanged.
@@ -14,7 +14,9 @@ For Anthropic-style explicit caching, the extension moves Pi's existing cache_co
14
14
 
15
15
  Execution tools already use an idempotent profile: goal progress, focus, and lifecycle changes do not change schemas. Explicit switches into/out of drafting and changes to disableTasks can change tools and legitimately rebuild the cache. Drafting messages are appended to conversation history. Isolated auditor and Oracle sessions already use fixed instructions/tool profiles and place goal-specific evidence in their user messages; their tool loops retain history. They have separate sessions and use Pi's cache defaults. Opting these sessions into project resources also opts into any behavior from those resources.
16
16
 
17
- Cache reuse can still be interrupted by Pi compaction, branch/session changes, model/provider switches, other extensions changing earlier content, changed tool schemas, provider expiry, minimum-length or breakpoint-lookback limits, or routing. The extension cannot guarantee a hit rate or a particular cost reduction. The supported Pi range is 0.83 through 0.87. Validation covers each supported minor; development dependencies pin 0.87.0.
17
+ Cache reuse can still be interrupted by Pi compaction, branch/session changes, model/provider switches, other extensions changing earlier content, changed tool schemas, provider expiry, minimum-length or breakpoint-lookback limits, or routing. The extension cannot guarantee a hit rate or a particular cost reduction. The declared host range is in `package.json`; development dependencies pin Pi 1.4.0, which requires Node 22.19 or newer. Older supported hosts retain the extension's Node 22.15 floor.
18
+
19
+ Pi 1.2 and newer prepare custom-triggered turns through the same public startup protocol as user prompts. The extension leaves their structured prompt and tool deltas intact, including additional sections supplied by other extensions. On older affected structured hosts, goal requests use the public full-context boundary to project the current prompt and resolved tools from the first request after drafting, direct creation, resume or reload. That fallback leaves persisted history and conversation messages intact; earlier structured hosts expose the boundary through `context`, and classic hosts retain their native path. Explicit tool-profile changes can still rebuild the prefix.
18
20
 
19
21
  Validation includes consecutive request-prefix regressions; actual Anthropic, Anthropic-compatible Chat Completions, OpenAI Chat Completions and Responses serialization with short/long/disabled retention; Bedrock payload-shape checks; lifecycle/tool-pair safety tests; and full SDK parent/auditor/Oracle payload captures. These tests stop before HTTP dispatch. They verify request construction, not live provider hit rates.
20
22
 
@@ -5,9 +5,11 @@
5
5
  * current-session/default choice (which clears the explicit provider/model),
6
6
  * authenticated models from the model registry (with a ✓ marker on the exact
7
7
  * current selection), and an advanced manual `provider/model` entry. The
8
- * thinking-level row offers the six levels or `(default)`.
8
+ * thinking-level row offers Pi's seven levels or `(default)`.
9
9
  */
10
10
 
11
+ import { GOAL_THINKING_LEVELS } from "./goal-settings.ts";
12
+
11
13
  export type AuditorModelSummary = { provider: string; id: string; name?: string };
12
14
 
13
15
  export type AuditorChoice =
@@ -15,7 +17,7 @@ export type AuditorChoice =
15
17
  | { kind: "model"; provider: string; model: string; label: string }
16
18
  | { kind: "manual"; label: string };
17
19
 
18
- export const AUDITOR_THINKING_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh"] as const;
20
+ export const AUDITOR_THINKING_LEVELS = GOAL_THINKING_LEVELS;
19
21
 
20
22
  export function auditorModelLabel(model: AuditorModelSummary): string {
21
23
  return `${model.provider}/${model.id}${model.name && model.name !== model.id ? ` — ${model.name}` : ""}`;
@@ -14,7 +14,7 @@ import {
14
14
  } from "@earendil-works/pi-coding-agent";
15
15
  import type { GoalRecord, GoalTask, GoalTaskList } from "./goal-record.ts";
16
16
  import { countTaskSubtree } from "./goal-task-count.ts";
17
- import { loadGoalSettings, type GoalSettings, type ThinkingLevel } from "./goal-settings.ts";
17
+ import { GOAL_THINKING_LEVELS, loadGoalSettings, type GoalSettings, type ThinkingLevel } from "./goal-settings.ts";
18
18
  import { statusLabel } from "./goal-core.ts";
19
19
 
20
20
  export interface AuditorProgress {
@@ -47,7 +47,7 @@ export interface GoalAuditorResult {
47
47
  error?: string;
48
48
  }
49
49
 
50
- const THINKING_LEVELS = new Set(["off", "minimal", "low", "medium", "high", "xhigh"]);
50
+ const THINKING_LEVELS = new Set<string>(GOAL_THINKING_LEVELS);
51
51
 
52
52
  function asNonEmptyString(value: unknown): string | undefined {
53
53
  return typeof value === "string" && value.trim() ? value.trim() : undefined;
@@ -1,3 +1,5 @@
1
+ import { GOAL_COMMANDS, commandHint, commandPaths, commandProblems, commandRoutes, matchCommandRoute, type GoalCommandId } from "./goal-controls.ts";
2
+ import { showGoalControls } from "./widgets/goal-controls-dialog.ts";
1
3
  import { goalStoragePath, goalStorageRoot } from "./storage/goal-root.ts";
2
4
  import { existsSync } from "node:fs";
3
5
  import path from "node:path";
@@ -75,12 +77,15 @@ import {
75
77
  import type { GoalCore } from "./goal-state.ts";
76
78
 
77
79
  /**
78
- * The curated twelve-command palette. /goal and /sisyphus begin guided
79
- * drafting; -direct commands are the explicit bypass. Every frequent lifecycle action is independently
80
- * registered so it appears in slash-command tab completion. No aliases.
80
+ * Stable command actions retain their lifecycle checks. Configured invocation
81
+ * paths are registered at load time; aliases and subcommands share the same
82
+ * handler and argument completion. /goal-settings remains the recovery entry.
81
83
  */
82
84
  export function registerGoalCommands(core: GoalCore): void {
83
85
  const { pi } = core;
86
+ type Definition = { description: string; handler: (args: string, ctx: ExtensionContext) => Promise<void> };
87
+ const actions = new Map<GoalCommandId, Definition>();
88
+ const define = (id: GoalCommandId, definition: Definition): void => { actions.set(id, definition); };
84
89
 
85
90
  async function chooseOpenGoal(ctx: ExtensionContext, title: string): Promise<GoalRecord | null> {
86
91
  core.reconcileFocusedGoalFromDisk(ctx);
@@ -117,7 +122,7 @@ export function registerGoalCommands(core: GoalCore): void {
117
122
  async function focusGoalCommand(ctx: ExtensionContext): Promise<void> {
118
123
  const open = core.openGoals();
119
124
  if (open.length === 0) {
120
- ctx.ui.notify("No open goals. Use /goal to draft one, or /goal-direct <objective> to start immediately.", "warning");
125
+ ctx.ui.notify(`No open goals. Use ${commandHint("goal", core.loadedCommands)} to draft one, or ${commandHint("goal-direct", core.loadedCommands)} <objective> to start immediately.`, "warning");
121
126
  return;
122
127
  }
123
128
  if (open.length === 1) {
@@ -172,7 +177,7 @@ export function registerGoalCommands(core: GoalCore): void {
172
177
  }
173
178
  if (!current) {
174
179
  const openCount = otherOpenGoalCount(core.goalsById, null);
175
- ctx.ui.notify(openCount > 0 ? buildUnfocusedOpenGoalsSummary(openCount) : detailedSummary(null), "info");
180
+ ctx.ui.notify(openCount > 0 ? buildUnfocusedOpenGoalsSummary(openCount) : detailedSummary(null, core.loadedCommands), "info");
176
181
  return;
177
182
  }
178
183
  ctx.ui.notify(`Goal unfocused for this session. It remains open in .pi/goals: ${current.id}`, "info");
@@ -181,12 +186,12 @@ export function registerGoalCommands(core: GoalCore): void {
181
186
  function handleDirectGoalSet(rawObjective: string, ctx: ExtensionContext, mode: GoalMode): void {
182
187
  const raw = rawObjective.trim();
183
188
  if (!raw) {
184
- const command = mode === "sisyphus" ? "/sisyphus <objective>" : "/goal <objective>";
189
+ const command = `${commandHint(mode === "sisyphus" ? "sisyphus-direct" : "goal-direct", core.loadedCommands)} <objective>`;
185
190
  ctx.ui.notify(`No objective provided. Use ${command}.`, "warning");
186
191
  return;
187
192
  }
188
193
  if (mode === "sisyphus" && !sisyphusObjectiveSufficient(raw)) {
189
- ctx.ui.notify("A Sisyphus objective needs ordered steps with per-step done criteria. Use /sisyphus for guided drafting, or provide numbered steps (1) ..., 2) ...) in the objective.", "warning");
194
+ ctx.ui.notify(`A Sisyphus objective needs ordered steps with per-step done criteria. Use ${commandHint("sisyphus", core.loadedCommands)} for guided drafting, or provide numbered steps (1) ..., 2) ...) in the objective.`, "warning");
190
195
  return;
191
196
  }
192
197
  const settings = loadGoalSettings(ctx.cwd);
@@ -222,7 +227,7 @@ export function registerGoalCommands(core: GoalCore): void {
222
227
  ? `\n\n${formatCheckpointHealthReport(checkpointHealth, sessionFile)}`
223
228
  : "";
224
229
  ctx.ui.notify(
225
- formatRecoveryReport(report) + checkpointSection,
230
+ formatRecoveryReport(report, core.loadedCommands) + checkpointSection,
226
231
  report.healthy && (checkpointHealth?.legacyFull ?? 0) === 0 ? "info" : "warning",
227
232
  );
228
233
  }
@@ -262,6 +267,7 @@ export function registerGoalCommands(core: GoalCore): void {
262
267
  ? `goal-refresh: re-read caches from disk — ${changes.length} change(s):\n${changes.map((c) => ` - ${c}`).join("\n")}`
263
268
  : "goal-refresh: no changes detected — caches were already current.";
264
269
  ctx.ui.notify(text, "info");
270
+ core.scheduler.refreshSettings(ctx);
265
271
  core.updateUI(ctx);
266
272
  }
267
273
 
@@ -279,7 +285,10 @@ export function registerGoalCommands(core: GoalCore): void {
279
285
  const health = /^\s*health\b/i.test(rawArgs);
280
286
  const ledger = readGoalLedger(ctx);
281
287
  const text = buildGoalStatusText({
288
+ commands: core.loadedCommands,
289
+ keybindings: loadGoalSettings(ctx.cwd).keybindings,
282
290
  maxAutonomousRuns: loadGoalSettings(ctx.cwd).maxAutonomousRuns,
291
+ idleContinuationBackoff: loadGoalSettings(ctx.cwd).idleContinuationBackoff,
283
292
  goal: view,
284
293
  focused: view !== null && core.focusedGoalId === view.id,
285
294
  otherOpenGoals: otherCount,
@@ -323,10 +332,14 @@ export function registerGoalCommands(core: GoalCore): void {
323
332
  return;
324
333
  }
325
334
  if (currentGoal.status === "paused") {
326
- ctx.ui.notify("Goal is already paused. Use /goal-resume to continue.", "info");
335
+ ctx.ui.notify(`Goal is already paused. Use ${commandHint("goal-resume", core.loadedCommands)} to continue.`, "info");
336
+ if (!ctx.isIdle()) ctx.abort?.();
327
337
  return;
328
338
  }
339
+ core.interruption.reset();
340
+ ctx.ui.setStatus("goal-interrupt", undefined);
329
341
  core.pauseActiveGoal(ctx);
342
+ ctx.abort?.();
330
343
  }
331
344
 
332
345
  async function handleGoalResume(ctx: ExtensionContext): Promise<void> {
@@ -361,10 +374,11 @@ export function registerGoalCommands(core: GoalCore): void {
361
374
  type SettingRow = {
362
375
  key: keyof GoalSettings | string;
363
376
  label: string;
364
- section: "Goal behavior" | "Task tracking" | "Completion auditor" | "Blocker Oracle";
377
+ section: "Goal behavior" | "Task tracking" | "Completion auditor" | "Blocker Oracle" | "Advanced execution";
365
378
  kind: "boolean" | "modelSelector" | "thinking" | "positiveInteger";
366
379
  /** Settings path for the mutation (defaults to [key]). */
367
380
  path?: string[];
381
+ help?: string;
368
382
  };
369
383
 
370
384
  const SETTING_ROWS: readonly SettingRow[] = [
@@ -377,6 +391,8 @@ export function registerGoalCommands(core: GoalCore): void {
377
391
  { key: "showAutonomousRuns", label: "show autonomous runs line", section: "Goal behavior", kind: "boolean" },
378
392
  { key: "stallTimeoutMinutes", label: "stall timeout (minutes)", section: "Goal behavior", kind: "positiveInteger" },
379
393
  { key: "objectiveMaxChars", label: "max objective length (0 = none)", section: "Goal behavior", kind: "positiveInteger" },
394
+ { key: "idleContinuationBackoff", label: "slow repeated idle replies (opt-in)", section: "Advanced execution", kind: "boolean",
395
+ help: "Slows repeated replies without recognized work. May delay reasoning or custom tools. For a hard stop, set an autonomous run allowance." },
380
396
  { key: "disableTasks", label: "disableTasks", section: "Task tracking", kind: "boolean" },
381
397
  { key: "subtaskDepth", label: "subtaskDepth", section: "Task tracking", kind: "positiveInteger" },
382
398
  { key: "disabled", label: "auditor disabled", section: "Completion auditor", kind: "boolean" },
@@ -393,7 +409,7 @@ export function registerGoalCommands(core: GoalCore): void {
393
409
  ];
394
410
 
395
411
  function settingsValue(config: GoalSettings, key: keyof GoalSettings | string): string {
396
- if (key === "strictExecutionContract" || key === "disabled" || key === "disableTasks" || key === "disableContracts" || key === "autoSelectSingleGoal" || key === "auditorProjectResources" || key === "hideUnfocusedBanner" || key === "hideUnfocusedPrompt" || key === "showAutonomousRuns") {
412
+ if (key === "idleContinuationBackoff" || key === "strictExecutionContract" || key === "disabled" || key === "disableTasks" || key === "disableContracts" || key === "autoSelectSingleGoal" || key === "auditorProjectResources" || key === "hideUnfocusedBanner" || key === "hideUnfocusedPrompt" || key === "showAutonomousRuns") {
397
413
  return config[key] === true ? "true" : "false";
398
414
  }
399
415
  if (key === "subtaskDepth") return config.subtaskDepth !== undefined ? String(config.subtaskDepth) : "1";
@@ -439,6 +455,7 @@ export function registerGoalCommands(core: GoalCore): void {
439
455
  try {
440
456
  mutateSettingsLayer({ scope, cwd: ctx.cwd, mutation });
441
457
  applyEffectiveSettings();
458
+ if (mutation.path[0] === "idleContinuationBackoff") core.scheduler.refreshSettings(ctx);
442
459
  return true;
443
460
  } catch (err) {
444
461
  ctx.ui.notify(`Settings change failed: ${err instanceof Error ? err.message : String(err)}`, "warning");
@@ -457,6 +474,8 @@ export function registerGoalCommands(core: GoalCore): void {
457
474
  const localLayer = snapshot[scope].layer as Record<string, unknown>;
458
475
  const options: string[] = [];
459
476
  options.push(`─── Editing: ${scope} (${scopePath(scope)}) ───`);
477
+ options.push(`Scope: ${scope} (select to switch)`);
478
+ options.push("Controls: commands, keys and pause order…");
460
479
  let lastSection: string | null = null;
461
480
  const pathOf = (row: SettingRow): string[] => row.path ?? [String(row.key)];
462
481
  const valueAtPath = (obj: unknown, path: string[]): unknown => {
@@ -494,6 +513,7 @@ export function registerGoalCommands(core: GoalCore): void {
494
513
  options.push("Done");
495
514
  const selected = await ctx.ui.select("Goal settings", options);
496
515
  if (!selected || selected === "Done") break;
516
+ if (selected.startsWith("Controls:")) { if (await showGoalControls(core, ctx, scope)) return; continue; }
497
517
  if (selected.startsWith("───")) continue; // headers are not rows
498
518
  const trimmed = selected.trim();
499
519
  if (trimmed.startsWith("Scope:")) {
@@ -534,7 +554,7 @@ export function registerGoalCommands(core: GoalCore): void {
534
554
  ];
535
555
  if (hasLocalOverride) actions.push(inheritLabel);
536
556
  actions.push("Cancel");
537
- const action = await ctx.ui.select(`${row.label} (${scope})`, actions);
557
+ const action = await ctx.ui.select(`${row.label} (${scope})${row.help ? `\n${row.help}` : ""}`, actions);
538
558
  if (!action || action === "Cancel") continue;
539
559
  if (action === inheritLabel) {
540
560
  applyMutation(scope, { op: "unset", path: rowPath });
@@ -552,7 +572,7 @@ export function registerGoalCommands(core: GoalCore): void {
552
572
  const actions = [`Set ${scope} override...`];
553
573
  if (hasLocalOverride) actions.push(inheritLabel);
554
574
  actions.push("Cancel");
555
- const action = await ctx.ui.select(`${row.label} (${scope})`, actions);
575
+ const action = await ctx.ui.select(`${row.label} (${scope})${row.help ? `\n${row.help}` : ""}`, actions);
556
576
  if (!action || action === "Cancel") continue;
557
577
  if (action === inheritLabel) {
558
578
  applyMutation(scope, { op: "unset", path: rowPath });
@@ -658,7 +678,7 @@ export function registerGoalCommands(core: GoalCore): void {
658
678
  // Headless behavior is explicit: guidance without mutation. Clearing
659
679
  // requires an interactive confirmation (follow-up Stage 2).
660
680
  if (!ctx.hasUI) {
661
- ctx.ui.notify(`Run /goal-clear in an interactive session to confirm clearing: ${oneLineSummary(target)}`, "warning");
681
+ ctx.ui.notify(`Run ${commandHint("goal-clear", core.loadedCommands)} in an interactive session to confirm clearing: ${oneLineSummary(target)}`, "warning");
662
682
  return;
663
683
  }
664
684
  const confirmed = await ctx.ui.confirm("Clear goal?", oneLineSummary(target));
@@ -688,19 +708,19 @@ export function registerGoalCommands(core: GoalCore): void {
688
708
  const selected = await chooseOpenGoal(ctx, "Tweak which open goal?");
689
709
  if (!selected) return;
690
710
  } else {
691
- ctx.ui.notify("No goal is set. Use /goal to draft one, or /goal-direct <objective> to create one immediately.", "warning");
711
+ ctx.ui.notify(`No goal is set. Use ${commandHint("goal", core.loadedCommands)} to draft one, or ${commandHint("goal-direct", core.loadedCommands)} <objective> to create one immediately.`, "warning");
692
712
  return;
693
713
  }
694
714
  }
695
715
  const currentGoal = core.state.goal;
696
716
  if (!currentGoal) return;
697
717
  if (currentGoal.status === "complete") {
698
- ctx.ui.notify("Goal is complete. Use /goal to draft a new one, or /goal-direct <objective> to create one immediately.", "warning");
718
+ ctx.ui.notify(`Goal is complete. Use ${commandHint("goal", core.loadedCommands)} to draft a new one, or ${commandHint("goal-direct", core.loadedCommands)} <objective> to create one immediately.`, "warning");
699
719
  return;
700
720
  }
701
721
  const trimmed = replacement.trim();
702
722
  if (!trimmed) {
703
- ctx.ui.notify("Describe the requested change: /goal-tweak <change>, for example remove the token budget", "info");
723
+ ctx.ui.notify(`Describe the requested change: ${commandHint("goal-tweak", core.loadedCommands)} <change>, for example remove the token budget`, "info");
704
724
  return;
705
725
  }
706
726
  const max = loadGoalSettings(ctx.cwd).objectiveMaxChars;
@@ -714,19 +734,19 @@ export function registerGoalCommands(core: GoalCore): void {
714
734
  }
715
735
 
716
736
  // /goal and /sisyphus are the guided default. -direct commands are the explicit bypass.
717
- pi.registerCommand("goal", {
737
+ define("goal", {
718
738
  description: "Draft a regular goal with clarification, task planning, and confirmation.",
719
739
  handler: async (rawArgs, ctx) => {
720
740
  await startGoalDrafting(core, ctx, "goal", rawArgs);
721
741
  },
722
742
  });
723
- pi.registerCommand("sisyphus", {
743
+ define("sisyphus", {
724
744
  description: "Draft a Sisyphus goal with clarification, task planning, and confirmation.",
725
745
  handler: async (rawArgs, ctx) => {
726
746
  await startGoalDrafting(core, ctx, "sisyphus", rawArgs);
727
747
  },
728
748
  });
729
- pi.registerCommand("goal-cancel", {
749
+ define("goal-cancel", {
730
750
  description: "Cancel the in-progress guided draft without creating or modifying a goal.",
731
751
  handler: async (_rawArgs, ctx) => {
732
752
  if (!hasActiveDraft(core)) {
@@ -738,15 +758,15 @@ export function registerGoalCommands(core: GoalCore): void {
738
758
  ctx.ui.notify("Draft cancelled; no goal was created. The execution profile is restored.", "info");
739
759
  },
740
760
  });
741
- pi.registerCommand("goal-direct", {
761
+ define("goal-direct", {
742
762
  description: "Create and start a regular goal immediately, without drafting.",
743
763
  handler: async (rawArgs, ctx) => { handleDirectGoalSet(rawArgs, ctx, "goal"); },
744
764
  });
745
- pi.registerCommand("sisyphus-direct", {
765
+ define("sisyphus-direct", {
746
766
  description: "Create and start a Sisyphus goal immediately, without drafting.",
747
767
  handler: async (rawArgs, ctx) => { handleDirectGoalSet(rawArgs, ctx, "sisyphus"); },
748
768
  });
749
- pi.registerCommand("goal-list", {
769
+ define("goal-list", {
750
770
  description: "List all open goals and the current focus.",
751
771
  handler: async (_rawArgs, ctx) => {
752
772
  core.reconcileFocusedGoalFromDisk(ctx);
@@ -754,64 +774,92 @@ export function registerGoalCommands(core: GoalCore): void {
754
774
  core.updateUI(ctx);
755
775
  },
756
776
  });
757
- pi.registerCommand("goal-status", {
777
+ define("goal-status", {
758
778
  description: "Show the unified goal dashboard (read-only). Append \"verbose\" for full detail or \"health\" for storage/runtime checks.",
759
779
  handler: async (rawArgs, ctx) => {
760
780
  await showGoalStatus(rawArgs ?? "", ctx);
761
781
  },
762
782
  });
763
- pi.registerCommand("goal-refresh", {
783
+ define("goal-refresh", {
764
784
  description: "Re-read goal storage caches (pool, ledger, settings) from disk and report what changed. Picks up external edits to .pi files — no file watchers needed.",
765
785
  handler: async (_rawArgs, ctx) => {
766
786
  await runGoalRefresh(ctx);
767
787
  },
768
788
  });
769
- pi.registerCommand("goal-recovery", {
789
+ define("goal-recovery", {
770
790
  description: "Read-only storage/recovery report (malformed goal files, malformed ledger lines, stale locks, orphaned snapshot data). Append \"repair\" to remove stale locks and refresh the pool snapshot after confirmation (with backup).",
771
791
  handler: async (rawArgs, ctx) => {
772
792
  await runGoalRecovery((rawArgs ?? "").trim(), ctx);
773
793
  },
774
794
  });
775
- pi.registerCommand("goal-focus", {
795
+ define("goal-focus", {
776
796
  description: "Choose which open goal this session focuses on.",
777
797
  handler: async (_rawArgs, ctx) => {
778
798
  await focusGoalCommand(ctx);
779
799
  },
780
800
  });
781
- pi.registerCommand("goal-unfocus", {
801
+ define("goal-unfocus", {
782
802
  description: "Stop focusing the current goal (session only; goal stays open).",
783
803
  handler: async (_rawArgs, ctx) => {
784
804
  unfocusGoalCommand(ctx);
785
805
  },
786
806
  });
787
- pi.registerCommand("goal-settings", {
788
- description: "Open pi-goal settings (auditor provider/model/thinking level).",
789
- handler: async (_rawArgs, ctx) => {
790
- await handleSettingsMenu(ctx);
807
+ define("goal-settings", {
808
+ description: "Goal settings and controls; accepts controls for commands, keys and pause order.",
809
+ handler: async (rawArgs, ctx) => {
810
+ if (rawArgs.trim() === "controls") await showGoalControls(core, ctx);
811
+ else await handleSettingsMenu(ctx);
791
812
  },
792
813
  });
793
- pi.registerCommand("goal-tweak", {
814
+ define("goal-tweak", {
794
815
  description: "Revise the current goal or its token budget with confirmation.",
795
816
  handler: async (rawArgs, ctx) => {
796
817
  await runGoalTweak(rawArgs, ctx);
797
818
  },
798
819
  });
799
- pi.registerCommand("goal-clear", {
820
+ define("goal-clear", {
800
821
  description: "Archive the current goal after confirmation (user-owned abandonment).",
801
822
  handler: async (_rawArgs, ctx) => {
802
823
  await handleGoalClear(ctx);
803
824
  },
804
825
  });
805
- pi.registerCommand("goal-pause", {
806
- description: "Pause the currently running goal. Esc also pauses while running.",
826
+ define("goal-pause", {
827
+ description: "Pause the currently running goal and stop its turn.",
807
828
  handler: async (_rawArgs, ctx) => {
808
829
  await handleGoalPause(ctx);
809
830
  },
810
831
  });
811
- pi.registerCommand("goal-resume", {
832
+ define("goal-resume", {
812
833
  description: "Continue now and renew the configured autonomous-run allowance.",
813
834
  handler: async (_rawArgs, ctx) => {
814
835
  await handleGoalResume(ctx);
815
836
  },
816
837
  });
838
+ const configured = loadGoalSettings(process.cwd()).commands;
839
+ const problems = commandProblems(configured);
840
+ core.loadedCommands = problems.length ? undefined : configured;
841
+ core.controlWarnings = problems;
842
+ const routes = commandRoutes(core.loadedCommands);
843
+ core.invokeGoalCommand = async (id, args, ctx) => {
844
+ const action = actions.get(id);
845
+ if (!action) return;
846
+ core.enterGoalModal();
847
+ try { await action.handler(args, ctx); }
848
+ finally { core.exitGoalModal(); }
849
+ };
850
+ const roots = [...actions.keys()].flatMap(id => commandPaths(core.loadedCommands, id).map(route => route.split(" ")[0]!));
851
+ for (const root of new Set([...roots, "goal-settings"])) {
852
+ const rootRoutes = routes.filter(route => route.path.split(" ")[0] === root);
853
+ const direct = rootRoutes.find(route => route.path === root);
854
+ pi.registerCommand(root, {
855
+ description: direct ? actions.get(direct.id)!.description : `Goal actions: ${rootRoutes.map(route => route.path.slice(root.length + 1)).join(", ")}`,
856
+ getArgumentCompletions: prefix => rootRoutes.filter(route => route.path !== root && route.path.slice(root.length + 1).startsWith(prefix)).map(route => ({ value: route.path.slice(root.length + 1), label: route.path.slice(root.length + 1), description: GOAL_COMMANDS.find(([id]) => id === route.id)![1] })),
857
+ handler: async (args, ctx) => {
858
+ const match = matchCommandRoute(routes, root, args);
859
+ if (match) await core.invokeGoalCommand!(match.id, match.args, ctx);
860
+ else ctx.ui.notify(`Choose a goal command: ${rootRoutes.map(route => `/${route.path}`).join(", ")}`, "info");
861
+ },
862
+ });
863
+ }
864
+
817
865
  }