pi-goal-x 0.31.0 → 0.31.1

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/README.md CHANGED
@@ -35,6 +35,12 @@ Sisyphus goals work well for migrations, staged refactors, release procedures, d
35
35
 
36
36
  The `/goal` and `/sisyphus` commands start a guided drafting process. The agent can ask focused questions, clarify the objective, and propose a task plan for confirmation.
37
37
 
38
+ RPC and browser hosts use native select/input dialogs when the terminal overlay is unavailable. Questions retain their context, and proposal confirmation includes a choice of completion auditor. Cancelling or losing a dialog never confirms an unseen proposal; the draft stays available.
39
+
40
+ When used with `pi-subagents`, goal mode stays in the parent session. Fresh, forked, resumed, and nested children inherit their assignment context without goal tools, goal state writes, or automatic goal continuations. Ordinary interactive forks retain goal mode.
41
+
42
+ Audit progress remains visible in the dashboard. Audit transcript messages appear after the agent settles and are excluded from model input; existing sessions containing interleaved audit events can resume after loading the updated extension, without rewriting their history.
43
+
38
44
  The proposal is written to the conversation as a durable summary (objective, plan, verification, automatic continuation, auditor state); confirming it creates and focuses the goal and starts working automatically.
39
45
 
40
46
  ### Direct goal creation
@@ -1,5 +1,5 @@
1
1
  import { type AgentToolResult, type ExtensionContext } from "@earendil-works/pi-coding-agent";
2
- import { GOAL_AUDIT_ENTRY, detailedSummary, goalDetails, type GoalAuditEventDetails } from "./goal-format.ts";
2
+ import { GOAL_AUDIT_ENTRY, detailedSummary, goalDetails } from "./goal-format.ts";
3
3
  import {
4
4
  buildCompletionReport,
5
5
  buildTaskSummary,
@@ -22,7 +22,6 @@ import type { GoalMutationOutcome } from "./goal-service.ts";
22
22
  // actual workspace evidence. An optional completion_summary is forwarded as an
23
23
  // UNTRUSTED executor claim — never evidence and never an approval bypass.
24
24
  export async function runGoalCompletionFlow(core: GoalCore, ctx: ExtensionContext, completionSummary?: string): Promise<AgentToolResult<unknown>> {
25
- const { pi } = core;
26
25
  const flushError = core.goalService.flushForAudit(ctx);
27
26
  if (flushError) return {content: [{type: "text", text: flushError}], details: goalDetails(core.state.goal)};
28
27
  core.reconcileFocusedGoalFromDisk(ctx);
@@ -131,7 +130,7 @@ function commitGoalCompletion(core: GoalCore, ctx: ExtensionContext, opts: {
131
130
  // records remain readable and honored for compatibility; no model tool or
132
131
  // task dialog creates new per-goal bypass state).
133
132
  if (auditTarget.skipAuditor) {
134
- pi.sendMessage<GoalAuditEventDetails>({
133
+ core.auditMessages.enqueue(ctx, {
135
134
  customType: GOAL_AUDIT_ENTRY,
136
135
  content: `Goal completed — per-goal auditor disabled.`,
137
136
  display: true,
@@ -161,7 +160,7 @@ if (auditTarget.skipAuditor) {
161
160
  // the auditor, records audit_skipped, and proceeds through the normal
162
161
  // deferred-completion path. No model-side bypass flag is required.
163
162
  if (settings.disabled === true) {
164
- pi.sendMessage<GoalAuditEventDetails>({
163
+ core.auditMessages.enqueue(ctx, {
165
164
  customType: GOAL_AUDIT_ENTRY,
166
165
  content: `Goal completed — auditor disabled in settings.`,
167
166
  display: true,
@@ -188,7 +187,7 @@ if (settings.disabled === true) {
188
187
  }
189
188
 
190
189
  // Auditor is enabled — run the normal audit flow
191
- await pi.sendMessage<GoalAuditEventDetails>({
190
+ core.auditMessages.enqueue(ctx, {
192
191
  customType: GOAL_AUDIT_ENTRY,
193
192
  content: [
194
193
  "Auditor: I am starting the independent completion audit.",
@@ -197,7 +196,7 @@ if (settings.disabled === true) {
197
196
  ].filter((line): line is string => line !== undefined).join("\n"),
198
197
  display: true,
199
198
  details: { phase: "started", goalId: auditTarget.id, auditor: auditorLabel },
200
- }, { triggerTurn: false });
199
+ });
201
200
  if (!core.isFocusedOperationCurrent(completionFocus)) {
202
201
  return core.focusedOperationCancelledResult("Goal completion", completionFocus);
203
202
  }
@@ -298,7 +297,7 @@ if (settings.disabled === true) {
298
297
 
299
298
  if (userChoice === "complete_without_audit") {
300
299
  // ── Mark complete without audit ────────────────────────────
301
- pi.sendMessage<GoalAuditEventDetails>({
300
+ core.auditMessages.enqueue(ctx, {
302
301
  customType: GOAL_AUDIT_ENTRY,
303
302
  content: `Goal completed — user bypassed audit via Escape.`,
304
303
  display: true,
@@ -379,7 +378,7 @@ if (settings.disabled === true) {
379
378
  "",
380
379
  auditor.output || "Auditor produced no approval marker.",
381
380
  ].filter((line): line is string => line !== undefined).join("\n");
382
- pi.sendMessage<GoalAuditEventDetails>({
381
+ core.auditMessages.enqueue(ctx, {
383
382
  customType: GOAL_AUDIT_ENTRY,
384
383
  content: rejectionText,
385
384
  display: true,
@@ -396,7 +395,7 @@ if (settings.disabled === true) {
396
395
  "",
397
396
  auditor.output || "Auditor approved completion.",
398
397
  ].filter((line): line is string => line !== undefined).join("\n");
399
- pi.sendMessage<GoalAuditEventDetails>({
398
+ core.auditMessages.enqueue(ctx, {
400
399
  customType: GOAL_AUDIT_ENTRY,
401
400
  content: approvalText,
402
401
  display: true,
@@ -8,7 +8,7 @@ import { deriveTasksFromObjective } from "./goal-task-derive.ts";
8
8
  import { goalDetails, renderGoalResult } from "./goal-format.ts";
9
9
  import { buildGoalCreatedReport } from "./goal-policy.ts";
10
10
  import { loadGoalSettings } from "./goal-settings.ts";
11
- import { formatQuestionnaireAnswers, runGoalQuestionnaire, shouldAutoConfirmProposal, showProposalDialog, type GoalQuestionnaireQuestion, type ProposalDecision } from "./goal-questionnaire.ts";
11
+ import { DIALOG_UNAVAILABLE_HINT, proposalDialogFailureMessage, formatQuestionnaireAnswers, runGoalQuestionnaire, shouldAutoConfirmProposal, showProposalDialog, type GoalQuestionnaireQuestion, type ProposalDecision } from "./goal-questionnaire.ts";
12
12
  import { currentTaskIdIsPending, nowIso, type GoalRecord, type GoalTaskList } from "./goal-record.ts";
13
13
  import type { GoalCore } from "./goal-state.ts";
14
14
  import { convertFlatTasks, countTasks, mergeTasksWithExisting, type FlatTaskInput } from "./goal-task-tools.ts";
@@ -250,7 +250,10 @@ export function registerDraftingTools(core: GoalCore): void {
250
250
  core.enterGoalModal();
251
251
  try {
252
252
  const result = await runGoalQuestionnaire(ctx, [{ id: "question", question: params.question, options: params.options ?? [], recommended: params.recommended, allowCustom: params.allow_custom }]);
253
+ if (result.unavailable === true) return { content: [{ type: "text", text: `${DIALOG_UNAVAILABLE_HINT} Ask the question in chat instead.` }], details: goalDetails(core.state.goal) };
253
254
  return { content: [{ type: "text", text: result.cancelled ? "The user cancelled the question. Continue drafting conversationally." : formatQuestionnaireAnswers(result) }], details: goalDetails(core.state.goal) };
255
+ } catch (error) {
256
+ return { content: [{ type: "text", text: `Goal drafting dialog failed: ${error instanceof Error ? error.message : String(error)}. Drafting remains active; ask in chat instead of retrying the dialog.` }], details: goalDetails(core.state.goal) };
254
257
  } finally {
255
258
  core.exitGoalModal();
256
259
  }
@@ -284,7 +287,10 @@ export function registerDraftingTools(core: GoalCore): void {
284
287
  const active = activeDraft(core);
285
288
  if (active) active.questionnaireEcho = formatQuestionnaireAnswers(result); // E5
286
289
  }
290
+ if (result.unavailable === true) return { content: [{ type: "text", text: `${DIALOG_UNAVAILABLE_HINT} Ask the questions in chat instead.` }], details: goalDetails(core.state.goal) };
287
291
  return { content: [{ type: "text", text: result.cancelled ? "The user cancelled the questionnaire. Continue drafting conversationally." : formatQuestionnaireAnswers(result) }], details: goalDetails(core.state.goal) };
292
+ } catch (error) {
293
+ return { content: [{ type: "text", text: `Goal drafting dialog failed: ${error instanceof Error ? error.message : String(error)}. Drafting remains active; ask in chat instead of retrying the dialog.` }], details: goalDetails(core.state.goal) };
288
294
  } finally {
289
295
  core.exitGoalModal();
290
296
  }
@@ -326,16 +332,15 @@ export function registerDraftingTools(core: GoalCore): void {
326
332
  const target = draft.mode === "tweak" ? core.state.goal : undefined;
327
333
  if (draft.mode === "tweak" && (!target || target.id !== draft.targetGoalId)) return { content: [{ type: "text", text: "The goal changed while drafting; review it and start /goal-tweak again." }], details: goalDetails(core.state.goal) };
328
334
  if (draft.mode === "sisyphus" && !sisyphusObjectiveSufficient(objective)) return { content: [{ type: "text", text: "A Sisyphus goal needs ordered steps with explicit per-step done criteria. Refine the objective with numbered steps (1) ..., 2) ...) or Step N: blocks before proposing again." }], details: goalDetails(core.state.goal) };
329
- const auditorLine = draft.auditorEnabled
330
- ? "\n\nAuditor for this goal: enabled (independent approval required before completion)."
331
- : "\n\nAuditor for this goal: disabled (completion skips the audit).";
332
- let confirmation: { decision: ProposalDecision; auditorEnabled: boolean };
335
+ let confirmation: { decision: ProposalDecision; auditorEnabled: boolean; unavailable: boolean };
333
336
  if (shouldAutoConfirmProposal({ hasUI: ctx.hasUI, autoConfirmEnv: process.env.PI_GOAL_AUTO_CONFIRM })) {
334
- confirmation = { decision: "confirm" as const, auditorEnabled: draft.auditorEnabled };
337
+ confirmation = { decision: "confirm" as const, auditorEnabled: draft.auditorEnabled, unavailable: false };
335
338
  } else {
336
339
  core.enterGoalModal();
337
340
  try {
338
- confirmation = await showProposalDialog(ctx, proposalText(draft, objective, params.auto_continue !== false, taskResult.value, target ?? undefined) + auditorLine, draft.mode === "sisyphus" ? "sisyphus" : "goal", draft.auditorEnabled);
341
+ confirmation = await showProposalDialog(ctx, proposalText(draft, objective, params.auto_continue !== false, taskResult.value, target ?? undefined), draft.mode === "sisyphus" ? "sisyphus" : "goal", draft.auditorEnabled);
342
+ } catch (error) {
343
+ return { content: [{ type: "text", text: `${proposalDialogFailureMessage(error)} Do not retry the dialog until the host issue is resolved.` }], details: goalDetails(core.state.goal) };
339
344
  } finally {
340
345
  core.exitGoalModal();
341
346
  }
@@ -350,12 +355,15 @@ export function registerDraftingTools(core: GoalCore): void {
350
355
  taskList: taskResult.value,
351
356
  verificationContract: extracted.verificationContract,
352
357
  autoContinue: params.auto_continue !== false,
353
- auditorEnabled: draft.auditorEnabled,
358
+ auditorEnabled: confirmation.auditorEnabled,
354
359
  });
355
360
  if (confirmation.decision === "cancel") {
356
361
  clearGoalDrafting(core, ctx);
357
362
  return { content: [{ type: "text", text: `${summary}\n\nDraft cancelled; no goal was created. Run /goal or /sisyphus to start a new draft.` }], details: goalDetails(core.state.goal) };
358
363
  }
364
+ if (confirmation.unavailable) {
365
+ return { content: [{ type: "text", text: `${summary}\n\n${DIALOG_UNAVAILABLE_HINT} The goal was NOT created and drafting remains active; do not retry the dialog until the host supports it or the user explicitly restarts with PI_GOAL_AUTO_CONFIRM=1.` }], details: goalDetails(core.state.goal) };
366
+ }
359
367
  if (confirmation.decision !== "confirm") {
360
368
  // Continue refining: preserve the user's auditor choice for the
361
369
  // next proposal (memory and the durable session entry).
@@ -31,6 +31,7 @@ import { consumeOracleFollowupMarker, hasPendingOracleAdviceForFocusedGoal } fro
31
31
  import { hasActiveDraft, rehydrateDraft } from "./goal-drafting.ts";
32
32
  import { syncTerminalInputPause } from "./goal-widget.ts";
33
33
  import type { GoalCore } from "./goal-state.ts";
34
+ import { filterGoalSessionContext } from "./goal-session-safety.ts";
34
35
  import type { GoalMutationOutcome } from "./goal-service.ts";
35
36
 
36
37
  /**
@@ -87,8 +88,8 @@ export function registerGoalEvents(core: GoalCore): void {
87
88
  let networkErrorRecoveryAfterSettleFor: string | null = null;
88
89
 
89
90
  pi.on("context", async (event) => {
90
- const messages = compactGoalCheckpointContext(event.messages, core.state.goal);
91
- // Reference equality means no goal-event messages existed at all.
91
+ const filtered = filterGoalSessionContext(event.messages);
92
+ const messages = compactGoalCheckpointContext(filtered ?? event.messages, core.state.goal) ?? filtered;
92
93
  return messages === null ? undefined : { messages: messages as typeof event.messages };
93
94
  });
94
95
 
@@ -270,6 +271,7 @@ export function registerGoalEvents(core: GoalCore): void {
270
271
  });
271
272
 
272
273
  pi.on("session_start", async (event, ctx) => {
274
+ core.auditMessages.clear();
273
275
  // NAF: the zero-op read caches are session-scoped — a new session always
274
276
  // re-reads settings/pool/ledger fresh from disk (cross-process and
275
277
  // hand-edited changes are picked up at the session boundary).
@@ -327,6 +329,7 @@ export function registerGoalEvents(core: GoalCore): void {
327
329
  });
328
330
 
329
331
  pi.on("session_tree", async (_event, ctx) => {
332
+ core.auditMessages.clear();
330
333
  core.goalService.flushTurn(ctx); // P1-3: persist any buffered transaction before reload
331
334
  await core.loadState(ctx);
332
335
  rehydrateDraft(core, ctx);
@@ -530,6 +533,7 @@ export function registerGoalEvents(core: GoalCore): void {
530
533
  });
531
534
 
532
535
  pi.on("agent_settled", async (_event, ctx) => {
536
+ core.auditMessages.flush(ctx, pi);
533
537
  const goalId = continuationAfterSettleFor;
534
538
  continuationAfterSettleFor = null;
535
539
  const networkErrorGoalId = networkErrorRecoveryAfterSettleFor;
@@ -560,6 +564,7 @@ export function registerGoalEvents(core: GoalCore): void {
560
564
  });
561
565
 
562
566
  pi.on("session_shutdown", async (_event, ctx) => {
567
+ core.auditMessages.clear();
563
568
  continuationAfterSettleFor = null;
564
569
  networkErrorRecoveryAfterSettleFor = null;
565
570
  core.accountProgress(ctx);
@@ -26,6 +26,13 @@ export interface GoalQuestionnaireResult {
26
26
  answers: GoalQuestionnaireAnswer[];
27
27
  cancelled: boolean;
28
28
  auditorEnabled?: boolean;
29
+ /**
30
+ * The host could not present the dialog at all, so `cancelled` reflects an
31
+ * absent UI rather than a decision the user made. Hosts that expose a
32
+ * non-terminal UI keep `ctx.hasUI` true while `ctx.ui.custom` stays the
33
+ * SDK's headless default, which resolves to `undefined`.
34
+ */
35
+ unavailable?: boolean;
29
36
  }
30
37
 
31
38
  export type ProposalDecision = "confirm" | "continue" | "cancel";
@@ -298,6 +305,60 @@ export function isHeadlessQuestionSufficientForDraft(args: { topic: string; ques
298
305
  return !vagueTopic;
299
306
  }
300
307
 
308
+ const CUSTOM_ANSWER_LABEL = "Write your own answer...";
309
+
310
+ /** Sequential native dialogs for hosts that cannot render terminal components. */
311
+ async function runQuestionnaireWithBasicDialogs(
312
+ ctx: ExtensionContext,
313
+ questions: GoalQuestionnaireQuestion[],
314
+ auditorToggleInit?: { defaultEnabled: boolean },
315
+ ): Promise<GoalQuestionnaireResult> {
316
+ const unavailable = (): GoalQuestionnaireResult => ({ questions: [], answers: [], cancelled: true, unavailable: true, ...(auditorToggleInit ? { auditorEnabled: auditorToggleInit.defaultEnabled } : {}) });
317
+ // Require only the primitives this questionnaire can actually use.
318
+ if ((auditorToggleInit || questions.some(q => q.options.length > 0)) && typeof ctx.ui.select !== "function") return unavailable();
319
+ if (questions.some(q => q.options.length === 0 || q.allowCustom !== false) && typeof ctx.ui.input !== "function") return unavailable();
320
+ let auditorEnabled = auditorToggleInit?.defaultEnabled;
321
+ const cancelled = (): GoalQuestionnaireResult => ({ questions, answers: [], cancelled: true, auditorEnabled });
322
+ if (auditorToggleInit) {
323
+ const enabled = "Enabled — require independent approval";
324
+ const disabled = "Disabled — skip the completion audit";
325
+ const choices = auditorEnabled ? [enabled, disabled] : [disabled, enabled];
326
+ const picked = await ctx.ui.select(`Completion auditor (currently ${auditorEnabled ? "enabled" : "disabled"})`, choices);
327
+ if (picked === undefined) return cancelled();
328
+ if (!choices.includes(picked)) throw new Error("The host returned an unknown auditor choice");
329
+ auditorEnabled = picked === enabled;
330
+ }
331
+ const answers: GoalQuestionnaireAnswer[] = [];
332
+ for (const question of questions) {
333
+ let prompt = question.context ? `${question.question}\n\n${question.context}` : question.question;
334
+ if (auditorToggleInit) prompt += `\n\nAuditor for this goal: ${auditorEnabled ? "enabled (independent approval required)" : "disabled (completion skips the audit)"}.`;
335
+ let answer: string | undefined;
336
+ let wasCustom = question.options.length === 0;
337
+ if (!wasCustom) {
338
+ // Numbered labels keep duplicate/reserved option text unambiguous.
339
+ const labels = question.options.map((option, i) => `${i + 1}. ${option}${question.recommended === i ? " (Recommended)" : ""}`);
340
+ const customLabel = `${labels.length + 1}. ${CUSTOM_ANSWER_LABEL}`;
341
+ const options = question.allowCustom === false ? labels : [...labels, customLabel];
342
+ const picked = await ctx.ui.select(prompt, options);
343
+ if (picked === undefined) return cancelled();
344
+ if (!options.includes(picked)) throw new Error("The host returned an unknown questionnaire choice");
345
+ wasCustom = picked === customLabel && question.allowCustom !== false;
346
+ if (!wasCustom) answer = question.options[labels.indexOf(picked)];
347
+ }
348
+ if (wasCustom) {
349
+ // Match the terminal editor: whitespace alone is not a submitted answer.
350
+ do {
351
+ answer = (await ctx.ui.input(prompt, "Write your answer"))?.trim();
352
+ } while (answer === "");
353
+ }
354
+ if (answer === undefined) return cancelled();
355
+ answers.push({ id: question.id, question: question.question, answer, wasCustom });
356
+ }
357
+ return { questions, answers, cancelled: false, auditorEnabled };
358
+ }
359
+
360
+ export const DIALOG_UNAVAILABLE_HINT = "This host cannot display the required goal-drafting dialogs. Use a host with select/input support or the pi TUI, or explicitly restart with PI_GOAL_AUTO_CONFIRM=1 to confirm proposals without a dialog.";
361
+
301
362
  export function proposalDialogFailureMessage(error: unknown): string {
302
363
  const detail = error instanceof Error ? error.message : String(error);
303
364
  return `Goal draft confirmation failed: ${detail}. The goal was NOT created; drafting remains active.`;
@@ -314,10 +375,18 @@ export async function runGoalQuestionnaire(ctx: ExtensionContext, rawQuestions:
314
375
  }
315
376
 
316
377
  const questions = normalizeQuestionnaireQuestions(rawQuestions);
378
+ if (ctx.mode === "rpc" || typeof ctx.ui.custom !== "function") {
379
+ return runQuestionnaireWithBasicDialogs(ctx, questions, auditorToggleInit);
380
+ }
317
381
  const isMulti = questions.length > 1;
318
382
  const totalTabs = questions.length + 1;
319
383
 
320
- return await ctx.ui.custom<GoalQuestionnaireResult>((tui, theme, _kb, done) => {
384
+ const result = await ctx.ui.custom<GoalQuestionnaireResult | undefined>((tui, theme, _kb, done) => {
385
+ // Some web hosts invoke the factory with a render callback in place of a TUI.
386
+ if (!tui || typeof tui.getShowHardwareCursor !== "function" || typeof tui.setShowHardwareCursor !== "function" || typeof tui.requestRender !== "function") {
387
+ done(undefined);
388
+ return { render: () => [], invalidate: () => {} };
389
+ }
321
390
  // Suppress hardware cursor during dialog to reduce TUI auto-scroll
322
391
  // (the TUI render loop runs at ~60fps and writes ANSI cursor positioning
323
392
  // sequences every cycle, which can cause terminal viewport snapping).
@@ -947,6 +1016,8 @@ function advanceAfterAnswer() {
947
1016
  },
948
1017
  };
949
1018
  });
1019
+ if (result !== undefined) return result;
1020
+ return runQuestionnaireWithBasicDialogs(ctx, questions, auditorToggleInit);
950
1021
  }
951
1022
 
952
1023
  /**
@@ -958,7 +1029,7 @@ export async function showProposalDialog(
958
1029
  confirmationText: string,
959
1030
  focus: GoalDraftingFocus,
960
1031
  defaultAuditorEnabled?: boolean,
961
- ): Promise<{ decision: ProposalDecision; auditorEnabled: boolean }> {
1032
+ ): Promise<{ decision: ProposalDecision; auditorEnabled: boolean; unavailable: boolean }> {
962
1033
  const headerTitle = focus === "sisyphus" ? "Confirm Sisyphus Goal Draft" : "Confirm Goal Draft";
963
1034
  const result = await runGoalQuestionnaire(ctx, [{
964
1035
  id: "confirm",
@@ -972,5 +1043,5 @@ export async function showProposalDialog(
972
1043
  cancelled: result.cancelled,
973
1044
  answer: result.answers[0]?.answer,
974
1045
  });
975
- return { decision, auditorEnabled: result.auditorEnabled ?? true };
1046
+ return { decision, auditorEnabled: result.auditorEnabled ?? true, unavailable: result.unavailable === true };
976
1047
  }
@@ -0,0 +1,48 @@
1
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import { GOAL_AUDIT_ENTRY, goalEventMessageId } from "./goal-format.ts";
3
+
4
+ /** pi-subagents marks fresh, forked, resumed and nested worker processes. */
5
+ export function isDelegatedGoalSession(env: Record<string, string | undefined> = process.env): boolean {
6
+ const depth = Number(env.PI_SUBAGENT_DEPTH);
7
+ return env.PI_SUBAGENT_CHILD === "1" || (Number.isFinite(depth) && depth > 0);
8
+ }
9
+
10
+ /** Display-only audits must never become user messages between a tool and its result. */
11
+ export function filterGoalSessionContext<T>(messages: readonly T[], child = false): T[] | null {
12
+ let filtered: T[] | null = null;
13
+ for (let i = 0; i < messages.length; i++) {
14
+ const message = messages[i]!;
15
+ const entry = message as { role?: string; customType?: string; details?: unknown; content?: unknown } | null;
16
+ const remove = entry && typeof entry === "object" && entry.role === "custom"
17
+ && (entry.customType === GOAL_AUDIT_ENTRY || (child && goalEventMessageId(entry) !== null));
18
+ if (remove) filtered ??= messages.slice(0, i);
19
+ else filtered?.push(message);
20
+ }
21
+ return filtered;
22
+ }
23
+
24
+ type TranscriptMessage = Parameters<ExtensionAPI["sendMessage"]>[0];
25
+ type SessionKey = string | ExtensionContext["sessionManager"];
26
+ const sessionKey = (ctx: ExtensionContext): SessionKey => ctx.sessionManager.getSessionId?.() ?? ctx.sessionManager;
27
+
28
+ /** Transcript output waits for the entire agent run, including tool results, to settle. */
29
+ export class GoalAuditMessages {
30
+ private pending: { session: SessionKey; message: TranscriptMessage }[] = [];
31
+
32
+ enqueue(ctx: ExtensionContext, message: TranscriptMessage): void {
33
+ this.pending.push({ session: sessionKey(ctx), message });
34
+ }
35
+
36
+ clear(): void {
37
+ this.pending = [];
38
+ }
39
+
40
+ flush(ctx: ExtensionContext, pi: ExtensionAPI): void {
41
+ const session = sessionKey(ctx);
42
+ this.pending = this.pending.filter(item => item.session === session);
43
+ if (!ctx.isIdle()) return;
44
+ const pending = this.pending;
45
+ this.pending = [];
46
+ for (const item of pending) pi.sendMessage(item.message, { triggerTurn: false });
47
+ }
48
+ }
@@ -32,6 +32,7 @@ import { GoalService } from "./goal-service.ts";
32
32
  import { goalActivityEvents } from "./goal-ledger.ts";
33
33
  import { GoalAccounting } from "./goal-accounting.ts";
34
34
  import { GoalRuntime } from "./goal-runtime.ts";
35
+ import { GoalAuditMessages } from "./goal-session-safety.ts";
35
36
  import {
36
37
  focusedGoalFromPool,
37
38
  openGoalsFromPool,
@@ -78,6 +79,7 @@ export interface GoalCore {
78
79
  goalWidgetComponentRef: { current: GoalWidgetComponent | null };
79
80
  goalService: GoalService;
80
81
  runtime: GoalRuntime;
82
+ auditMessages: GoalAuditMessages;
81
83
  accounting: GoalAccounting;
82
84
 
83
85
  assignFocusedGoalId(goalId: string | null): void;
@@ -1000,6 +1002,7 @@ export function createGoalCore(
1000
1002
  goalWidgetComponentRef,
1001
1003
  goalService,
1002
1004
  runtime,
1005
+ auditMessages: new GoalAuditMessages(),
1003
1006
  accounting,
1004
1007
  assignFocusedGoalId,
1005
1008
  focusedOperationToken,
@@ -12,6 +12,7 @@ import {
12
12
  import type { GoalEventDetails } from "./goal-record.ts";
13
13
  import { createGoalCore } from "./goal-state.ts";
14
14
  import { registerGoalTools } from "./goal-tools.ts";
15
+ import { filterGoalSessionContext, isDelegatedGoalSession } from "./goal-session-safety.ts";
15
16
 
16
17
  /**
17
18
  * pi-goal thin installer. All state lives in the GoalCore (goal-state.ts);
@@ -23,6 +24,14 @@ export default function goalExtension(
23
24
  pi: ExtensionAPI,
24
25
  dependencies: { runCompletionAuditor?: typeof runGoalCompletionAuditor } = {},
25
26
  ): void {
27
+ if (isDelegatedGoalSession()) {
28
+ // Inherit conversation without inheriting ownership of the parent's goal.
29
+ pi.on("context", async event => {
30
+ const messages = filterGoalSessionContext(event.messages, true);
31
+ return messages === null ? undefined : { messages };
32
+ });
33
+ return;
34
+ }
26
35
  pi.registerMessageRenderer<GoalEventDetails>(GOAL_EVENT_ENTRY, renderGoalEvent);
27
36
  pi.registerMessageRenderer<GoalAuditEventDetails>(GOAL_AUDIT_ENTRY, renderGoalAuditEvent);
28
37
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-goal-x",
3
- "version": "0.31.0",
3
+ "version": "0.31.1",
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",