pi-goal-x 0.25.4 → 0.26.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.
@@ -19,7 +19,7 @@ handlers from their dedicated modules:
19
19
  | `goal-task-tools.ts` | `set_goal_tasks` / `update_goal_task` executors plus flat parent-linked conversion, id-stable merge, `countTasks` |
20
20
  | `goal-task-confirmation.ts` | Task-only result boundary (`{decision}`, no auditor toggle) with neutral Confirm task list / Keep current tasks labels |
21
21
  | `goal-commands.ts` | The curated fourteen-command palette and its handlers |
22
- | `goal-events.ts` | The 13 lifecycle event handlers (`context`, `turn_start`, `tool_call`, `tool_execution_end`, `turn_end`, `message_end`, `session_start`, `session_before_compact`, `session_compact`, `session_tree`, `before_agent_start`, `agent_end`, `session_shutdown`) |
22
+ | `goal-events.ts` | The 14 lifecycle event handlers (`context`, `turn_start`, `tool_call`, `tool_execution_end`, `turn_end`, `message_end`, `session_start`, `session_before_compact`, `session_compact`, `session_tree`, `before_agent_start`, `agent_end`, `agent_settled`, `session_shutdown`) |
23
23
  | `goal-widget.ts` | Terminal input keybindings (Esc pause / abort-audit, Ctrl+Shift+T overlay) and the hidden debug helpers |
24
24
  | `goal-format.ts` | Pure formatting/message-introspection helpers and renderers |
25
25
  | `goal-service.ts` | `GoalService` — the sole mutation boundary: ordered reconcile → id/focus-revision validation → clone-mutate → write/archive → ledger → memory commit → returned effects |
@@ -150,6 +150,7 @@ export function goalDraftingPrompt(topic: string, focus: GoalDraftingFocus): str
150
150
  "- For simple single-step goals, no task list is required. The `tasks` parameter can be omitted.",
151
151
  "- After goal creation, `propose_task_list` is still available for user-requested task additions or structural changes.",
152
152
  "- propose_goal_draft opens the user's Confirm / Continue Chatting dialog. Confirm creates and focuses the goal; Continue Chatting means keep refining through normal proposal cycles.",
153
+ "- Before proposing, write the COMPLETE goal into your own message — every section (objective, success criteria, boundaries, constraints, verification contract) and the full task list — so the user can scroll up and re-read the whole goal while the confirmation dialog is open. Nothing may be omitted from the presented goal.",
153
154
  "- create_goal is not a shortcut. Direct create_goal calls are rejected so the user keeps explicit say in goal creation.",
154
155
  ];
155
156
 
@@ -454,7 +454,30 @@ export function registerDraftingTools(core: GoalCore): void {
454
454
  clearGoalDrafting(core, ctx);
455
455
  return { content: [{ type: "text", text: `${summary}\n\nGoal tweak confirmed and applied.` }], details: goalDetails(result.goal), terminate: true };
456
456
  },
457
- renderCall(args, theme) { return new Text(theme.fg("toolTitle", "propose_goal_draft ") + theme.fg("muted", args?.objective ?? ""), 0, 0); },
457
+ renderCall(args, theme) {
458
+ // §proposal-presentation: the tool-call display lands in the terminal
459
+ // buffer the moment the tool call starts — BEFORE the confirmation
460
+ // dialog opens — so it is the scrollable "complete presentation" of
461
+ // the goal. It must show the full objective contract AND every task
462
+ // line; the dialog frame alone cannot (its churn-guard bound keeps
463
+ // it within the terminal height). The user can scroll up and read
464
+ // everything while the dialog is open ("the user can just scroll").
465
+ const objective = String(args?.objective ?? "");
466
+ let text = theme.fg("toolTitle", "propose_goal_draft ") + theme.fg("muted", objective);
467
+ const flatTasks = Array.isArray(args?.tasks) ? (args.tasks as FlatTaskInput[]) : [];
468
+ const derived = flatTasks.length === 0 ? (deriveTasksFromObjective(objective) ?? []) : [];
469
+ const taskBlock = flatTasks.length > 0
470
+ ? { header: "Tasks proposed for confirmation:", lines: flatTasks.map((t) => `[ ] ${t.id}: ${t.title}`) }
471
+ : derived.length > 0
472
+ ? { header: "Tasks derived from the objective (confirm or ask the agent to adjust):", lines: renderConfirmationTasks(derived, 0) }
473
+ : null;
474
+ if (taskBlock) {
475
+ text += "\n\n" + theme.fg("toolTitle", theme.bold(taskBlock.header)) + "\n" + theme.fg("toolOutput", taskBlock.lines.join("\n"));
476
+ }
477
+ if (args?.auto_continue === false) text += "\n\n" + theme.fg("muted", "Automatic continuation: disabled");
478
+ if (args?.block_completion === true) text += "\n\n" + theme.fg("muted", "Block completion on pending tasks: enabled");
479
+ return new Text(text, 0, 0);
480
+ },
458
481
  renderResult(result, _opts, theme) { return renderGoalResult(result, _opts, theme); },
459
482
  }));
460
483
  }
@@ -34,10 +34,12 @@ import type { GoalMutationOutcome } from "./goal-service.ts";
34
34
  * The goal extension's lifecycle event handlers (context, turn_start,
35
35
  * tool_call, tool_execution_end, turn_end, message_end, session_start,
36
36
  * session_before_compact, session_compact, session_tree, before_agent_start,
37
- * agent_end, session_shutdown). All state flows through the GoalCore.
37
+ * agent_end, agent_settled, session_shutdown). All state flows through the
38
+ * GoalCore.
38
39
  */
39
40
  export function registerGoalEvents(core: GoalCore): void {
40
41
  const { pi } = core;
42
+ let continuationAfterSettleFor: string | null = null;
41
43
 
42
44
  pi.on("context", async (event): Promise<{ messages: typeof event.messages } | undefined> => {
43
45
  let changed = false;
@@ -418,6 +420,7 @@ export function registerGoalEvents(core: GoalCore): void {
418
420
  pi.on("agent_end", async (event, ctx) => {
419
421
  const endedGoalId = core.runningGoalId;
420
422
  core.runningGoalId = null;
423
+ continuationAfterSettleFor = null;
421
424
 
422
425
  // Account for any tokens from aborted in-flight assistant messages so
423
426
  // they are not silently lost (but charge them to the original goal).
@@ -446,10 +449,23 @@ export function registerGoalEvents(core: GoalCore): void {
446
449
  }
447
450
  core.persist(ctx);
448
451
  core.updateUI(ctx);
449
- core.queueContinuation(ctx);
452
+ // agent_end runs before pi finishes retries, compaction, terminating-tool
453
+ // settlement, and queued messages. Starting the continuation timer here
454
+ // can poll a stale busy context for minutes on pi 0.84. agent_settled is
455
+ // available in both supported SDK lines (0.83 and 0.84) and is the first
456
+ // point where pi guarantees no automatic work remains.
457
+ continuationAfterSettleFor = core.state.goal.id;
458
+ });
459
+
460
+ pi.on("agent_settled", async (_event, ctx) => {
461
+ const goalId = continuationAfterSettleFor;
462
+ continuationAfterSettleFor = null;
463
+ if (!goalId || !core.isActionableContinuationGoal(goalId)) return;
464
+ core.queueContinuation(ctx, true);
450
465
  });
451
466
 
452
467
  pi.on("session_shutdown", async (_event, ctx) => {
468
+ continuationAfterSettleFor = null;
453
469
  core.accountProgress(ctx);
454
470
  core.clearContinuationTimer();
455
471
  core.terminalInputUnsubscribe?.();
@@ -29,6 +29,139 @@ export interface GoalQuestionnaireResult {
29
29
 
30
30
  export type ProposalDecision = "confirm" | "continue" | "cancel";
31
31
 
32
+ /**
33
+ * Bound a custom dialog using the regular renderer's frame cache when
34
+ * available. The pi 0.84 fullscreen renderer does not expose that cache, so
35
+ * reserve four rows for host chrome and use the remaining terminal height.
36
+ */
37
+ export function computeDialogLineLimit(args: { terminalRows?: number; baseFrameLines?: number }): number | undefined {
38
+ const rows = args.terminalRows;
39
+ if (!rows || rows <= 0) return undefined;
40
+ if (args.baseFrameLines && args.baseFrameLines > 0) {
41
+ return Math.min(rows, Math.max(10, rows - args.baseFrameLines + 1));
42
+ }
43
+ return Math.min(rows, Math.max(4, rows - 4));
44
+ }
45
+
46
+ /**
47
+ * Proposal-confirmation segment descriptor: absolute line indices of the
48
+ * tasks section (header + every task line) and the start of the options tail
49
+ * inside a rendered questionnaire dialog.
50
+ */
51
+ export interface ProposalPresentationSegments {
52
+ tasksStart: number;
53
+ tasksEnd: number;
54
+ tailStart: number;
55
+ }
56
+
57
+ /**
58
+ * Locate the proposal tasks segment and the options tail in a rendered dialog.
59
+ * Returns null when the dialog is not a proposal-style confirmation (no
60
+ * "Tasks proposed for confirmation:" header / "┌─ TASKS ─" box).
61
+ */
62
+ export function findProposalPresentationSegments(lines: string[], tailStart: number): ProposalPresentationSegments | null {
63
+ if (tailStart < 0) return null;
64
+ const tasksStart = lines.findIndex((l) => l.includes("Tasks proposed for confirmation:") || l.includes("┌─ TASKS ─"));
65
+ if (tasksStart < 0 || tasksStart >= tailStart) return null;
66
+ let tasksEnd = tasksStart;
67
+ for (let i = tasksStart + 1; i < tailStart; i++) {
68
+ if (/^\s*\[[ x~]\]/.test(lines[i])) tasksEnd = i;
69
+ else break;
70
+ }
71
+ return { tasksStart, tasksEnd, tailStart };
72
+ }
73
+
74
+ /**
75
+ * Fit a rendered dialog to the terminal-height bound without ever hiding the
76
+ * question. The protected head (top border + tabs + question line) is always
77
+ * kept; the remaining budget is spent on the tail — options/footer/bottom
78
+ * border — so long context blocks (proposal confirmations) are sliced from
79
+ * their head exactly as the pre-fix tail-slice did (383ae52 surface). When the
80
+ * options block starts immediately after the head (plain agent questions with
81
+ * no context block), the TOP options are kept instead of the tail so the
82
+ * recommended/first option stays visible; the footer hint and bottom border
83
+ * are always the last rendered lines. Never returns more than maxDialogLines.
84
+ *
85
+ * Proposal confirmations (proposal segments given) keep the head, the tasks
86
+ * section (header + every task line), and the options/footer/bottom border in
87
+ * frame; only the objective-box middle is sacrificed in-frame — the full
88
+ * objective is always present in the scrollable transcript presentation
89
+ * (propose_goal_draft renderCall), so nothing of the goal is ever omitted.
90
+ */
91
+ export function fitDialogLines(
92
+ lines: string[],
93
+ maxDialogLines: number,
94
+ protectedHead: number,
95
+ optionsImmediatelyAfterHead = false,
96
+ proposal: ProposalPresentationSegments | null = null,
97
+ ): string[] {
98
+ if (maxDialogLines <= 0 || lines.length <= maxDialogLines) return lines;
99
+ const keepHead = Math.min(protectedHead, maxDialogLines);
100
+ const budget = maxDialogLines - keepHead;
101
+ if (budget <= 0) return lines.slice(0, keepHead);
102
+ const rest = lines.slice(protectedHead);
103
+ if (rest.length <= budget) return [...lines.slice(0, keepHead), ...rest];
104
+ if (proposal) {
105
+ return fitProposalPresentation(lines, maxDialogLines, keepHead, proposal);
106
+ }
107
+ if (optionsImmediatelyAfterHead) {
108
+ // Plain select-mode question: keep the footer hint + bottom border (as
109
+ // many as fit), then spend the remaining room on the TOP options so the
110
+ // recommended/first option stays visible; leading blank separators are
111
+ // dropped first when room is short. Never exceeds maxDialogLines.
112
+ const headLines = lines.slice(0, keepHead);
113
+ const firstContent = rest.findIndex((l) => l.trim().length > 0);
114
+ const lead = firstContent > 0 ? rest.slice(0, firstContent) : [];
115
+ const contentStart = firstContent > 0 ? firstContent : 0;
116
+ const tailBudget = Math.min(2, budget); // footer hint + bottom border
117
+ const tail = rest.slice(-tailBudget);
118
+ const room = budget - tailBudget;
119
+ const keepContent = Math.max(0, Math.min(rest.length - contentStart, room));
120
+ const keepLead = Math.max(0, Math.min(lead.length, room - keepContent));
121
+ const content = rest.slice(contentStart, contentStart + keepContent);
122
+ return [...headLines, ...lead.slice(0, keepLead), ...content, ...tail];
123
+ }
124
+ // Context-heavy / input / submit dialogs: keep the head and the tail
125
+ // (options, footer, bottom border) exactly as the pre-fix tail-slice did.
126
+ return [...lines.slice(0, keepHead), ...rest.slice(rest.length - budget)];
127
+ }
128
+
129
+ /**
130
+ * Proposal confirmation fit: keep the protected head, the tasks section, and
131
+ * the options/footer/bottom border; the objective-box middle is sacrificed
132
+ * in-frame (it stays fully readable in the scrollback presentation). Interior
133
+ * blank spacing lines are dropped first when room is short; task lines are
134
+ * only dropped after that, from the end, when the bound is exhausted (those
135
+ * lines remain in the scrollback presentation). Never exceeds maxDialogLines.
136
+ */
137
+ function fitProposalPresentation(
138
+ lines: string[],
139
+ maxDialogLines: number,
140
+ keepHead: number,
141
+ proposal: ProposalPresentationSegments,
142
+ ): string[] {
143
+ const { tasksStart, tasksEnd, tailStart } = proposal;
144
+ const head = lines.slice(0, keepHead);
145
+ const tasks = lines.slice(Math.max(tasksStart, keepHead), Math.min(tasksEnd, tailStart) + 1);
146
+ const tail = lines.slice(Math.max(tailStart, keepHead));
147
+ const candidate = [...head, ...tasks, ...tail];
148
+ if (candidate.length <= maxDialogLines) return candidate;
149
+ // Tight: drop blank spacing lines below the head (e.g. the blank between
150
+ // the options and the footer hint) before touching any content line.
151
+ const stripped = candidate.filter((l, i) => i < keepHead || l.trim() !== "");
152
+ if (stripped.length <= maxDialogLines) return stripped;
153
+ // Bound still exhausted: keep the head, then spend the room on the tail
154
+ // first (options/footer/bottom border — the actionable decision surface —
155
+ // kept from its end so the border and footer never drop), then on the
156
+ // tasks from the start. The dropped task lines remain fully readable in
157
+ // the scrollback presentation. Never exceeds maxDialogLines.
158
+ const tailNoBlanks = tail.filter((l) => l.trim() !== "");
159
+ const keepTail = Math.min(tailNoBlanks.length, Math.max(0, maxDialogLines - keepHead));
160
+ const tailKept = tailNoBlanks.slice(tailNoBlanks.length - keepTail);
161
+ const keepTasks = Math.min(tasks.length, Math.max(0, maxDialogLines - keepHead - keepTail));
162
+ return [...head, ...tasks.slice(0, keepTasks), ...tailKept];
163
+ }
164
+
32
165
  export function normalizeQuestionnaireQuestions(rawQuestions: GoalQuestionnaireQuestion[]): GoalQuestionnaireQuestion[] {
33
166
  const seenIds = new Set<string>();
34
167
  return rawQuestions.map((q, i) => {
@@ -108,18 +241,20 @@ export async function runGoalQuestionnaire(ctx: ExtensionContext, rawQuestions:
108
241
  // the frame never exceeds the terminal height — without this, closing a dialog
109
242
  // taller than the terminal triggers pi-tui's generic shrink full-render
110
243
  // (\x1b[2J\x1b[H\x1b[3J), erasing terminal scrollback and yanking the viewport
111
- // so the window takes ~10s to scroll back to the bottom. The tail slice keeps
112
- // the actionable options/footer in view; content that fits renders exactly as
113
- // the pre-regression (383ae52) UI. Only applies with real TUI dimensions.
244
+ // so the window takes ~10s to scroll back to the bottom. The slice keeps
245
+ // the question and the actionable options/footer in view (see
246
+ // fitDialogLines); content that fits renders exactly as the
247
+ // pre-regression (383ae52) UI. Only applies with real TUI dimensions.
114
248
  const tuiInfo = tui as unknown as { terminal?: { rows?: number }; previousLines?: string[] };
115
249
  const terminalRows = tuiInfo.terminal?.rows;
116
250
  const baseFrame = tuiInfo.previousLines?.length;
117
- const maxDialogLines = terminalRows && baseFrame ? Math.max(10, terminalRows - baseFrame + 1) : undefined;
251
+ const maxDialogLines = computeDialogLineLimit({ terminalRows, baseFrameLines: baseFrame });
118
252
  let currentTab = 0;
119
253
  let optionIndex = 0;
120
254
  let inputMode = false;
121
255
  let inputQuestionId: string | null = null;
122
256
  let cachedLines: string[] | undefined;
257
+ let optionsStartIndex = -1;
123
258
  let auditorEnabled = auditorToggleInit?.defaultEnabled ?? true;
124
259
  const answers = new Map<string, GoalQuestionnaireAnswer>();
125
260
  const drafts = new Map<string, string>();
@@ -439,6 +574,9 @@ export async function runGoalQuestionnaire(ctx: ExtensionContext, rawQuestions:
439
574
  };
440
575
 
441
576
  add(theme.fg("accent", "─".repeat(safeWidth)));
577
+ // Lines up to the question line (top border, tabs, question incl.
578
+ // wraps) are the protected head — the slice below must never hide them.
579
+ let protectedCount = lines.length;
442
580
  if (isMulti) {
443
581
  const tabs: string[] = ["← "];
444
582
  for (let i = 0; i < questions.length; i++) {
@@ -453,9 +591,11 @@ export async function runGoalQuestionnaire(ctx: ExtensionContext, rawQuestions:
453
591
  tabs.push(" →");
454
592
  add(` ${tabs.join("")}`);
455
593
  lines.push("");
594
+ protectedCount = lines.length;
456
595
  }
457
596
 
458
597
  function renderOptions() {
598
+ optionsStartIndex = lines.length;
459
599
  for (let i = 0; i < opts.length; i++) {
460
600
  const opt = opts[i];
461
601
  const selected = i === optionIndex;
@@ -467,6 +607,7 @@ export async function runGoalQuestionnaire(ctx: ExtensionContext, rawQuestions:
467
607
 
468
608
  if (inputMode && q) {
469
609
  addWrapped(theme.fg("text", ` ${q.question}`));
610
+ protectedCount = lines.length;
470
611
  if (q.context) renderContextLines(q.context);
471
612
  lines.push("");
472
613
  if (q.options.length > 0) {
@@ -479,6 +620,7 @@ export async function runGoalQuestionnaire(ctx: ExtensionContext, rawQuestions:
479
620
  add(theme.fg("dim", " Enter to submit • Esc to cancel"));
480
621
  } else if (currentTab === questions.length) {
481
622
  add(theme.fg("accent", theme.bold(" Ready to submit")));
623
+ protectedCount = lines.length;
482
624
  lines.push("");
483
625
  for (const question of questions) {
484
626
  const answer = answers.get(question.id);
@@ -488,6 +630,7 @@ export async function runGoalQuestionnaire(ctx: ExtensionContext, rawQuestions:
488
630
  add(allAnswered() ? theme.fg("success", " Press Enter to submit") : theme.fg("warning", ` Unanswered: ${questions.filter((qq) => !answers.has(qq.id)).map((qq) => qq.id).join(", ")}`));
489
631
  } else if (q) {
490
632
  addWrapped(theme.fg("text", ` ${q.question}`));
633
+ protectedCount = lines.length;
491
634
  if (q.context) renderContextLines(q.context);
492
635
  // Auditor toggle line between context and options
493
636
  if (auditorToggleInit) {
@@ -516,9 +659,21 @@ export async function runGoalQuestionnaire(ctx: ExtensionContext, rawQuestions:
516
659
  lines[i] = truncateToWidth(lines[i], safeWidth);
517
660
  }
518
661
  }
519
- // Churn guard: tail-slice to the terminal-height bound (see factory top).
662
+ // Churn guard: bound to the terminal height (see factory top) so the
663
+ // opened frame never exceeds the screen. Never slice the question:
664
+ // keep the protected head, then spend the rest of the budget on the
665
+ // tail (options/footer/bottom border) — or, for plain select-mode
666
+ // questions where the options start right after the head, keep the
667
+ // top options so the recommended one stays visible. Proposal
668
+ // confirmations additionally keep the tasks section in frame; the
669
+ // objective-box middle is sacrificed there because the full objective
670
+ // is always in the scrollable transcript presentation (renderCall).
520
671
  if (maxDialogLines !== undefined && lines.length > maxDialogLines) {
521
- lines = lines.slice(lines.length - maxDialogLines);
672
+ const optionsImmediatelyAfterHead = !inputMode && currentTab !== questions.length && !!q && !q.context && opts.length > 0;
673
+ const proposalSegments = !inputMode && currentTab !== questions.length && !!q && q.context
674
+ ? findProposalPresentationSegments(lines, optionsStartIndex)
675
+ : null;
676
+ lines = fitDialogLines(lines, maxDialogLines, protectedCount, optionsImmediatelyAfterHead, proposalSegments);
522
677
  }
523
678
  cachedLines = lines;
524
679
  return lines;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-goal-x",
3
- "version": "0.25.4",
3
+ "version": "0.26.0",
4
4
  "description": "Goal mode extension for pi: durable long-running objectives, five model tools, structured tasks, independent completion audit, Sisyphus mode, auto-continue, and a status overlay.",
5
5
  "license": "MIT",
6
6
  "author": "pi-goal-x contributors",
@@ -62,9 +62,9 @@
62
62
  "typebox": "*"
63
63
  },
64
64
  "devDependencies": {
65
- "@earendil-works/pi-ai": "^0.83.0",
66
- "@earendil-works/pi-coding-agent": "^0.83.0",
67
- "@earendil-works/pi-tui": "^0.83.0",
65
+ "@earendil-works/pi-ai": "^0.84.1",
66
+ "@earendil-works/pi-coding-agent": "^0.84.1",
67
+ "@earendil-works/pi-tui": "^0.84.1",
68
68
  "typebox": "^1.0.58",
69
69
  "typescript": "^5.9.3"
70
70
  },