@signalridge/pi-goal 1.4.6 → 1.4.8

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
@@ -1,5 +1,16 @@
1
1
  # @signalridge/pi-goal
2
2
 
3
+ ## 1.4.8
4
+ ### Patch Changes
5
+
6
+ - 0b24158: Report safety-pause causes and current count/limit before ambiguous ownership errors in Goal tools, and include an authoritative request-local pause notice when background notifications trigger ordinary conversation. Keep ownership, stale-ID, and abort guards intact: notifications never reset counters or bypass a safety pause, and only explicit resume restarts the Goal.
7
+
8
+ ## 1.4.7
9
+ ### Patch Changes
10
+
11
+ - 4d37546: Point package documentation to the maintained compatibility guide instead of removed historical review snapshots.
12
+ - 4d37546: Detach serialized Goal state from mutable runtime objects before appending native session metadata. Goal accounting, status, safety counters, queued objectives, wait records, and pending queue actions must not retroactively modify earlier session entries through shared references. This prevents false ancestry and ownership failures in observers such as Todo without relaxing their integrity checks, changing persisted shapes, resetting budgets, or automatically resuming stopped work. Already affected in-memory sessions should be safely closed and resumed from persisted history after updating; reloading extension code alone does not reconstruct old native entries.
13
+
3
14
  ## 1.4.6
4
15
  ### Patch Changes
5
16
 
package/README.md CHANGED
@@ -38,7 +38,7 @@ Goal mode uses Codex-like persistence instructions and sends guarded continuatio
38
38
 
39
39
  ## Install
40
40
 
41
- The current tested host is Pi `1.0.4`. Host-provided dependencies use `"*"` peers to identify module ownership, not to guarantee compatibility with every Pi version. See the [compatibility review](https://github.com/signalridge/pi-extensions/blob/main/docs/pi-1.0-compatibility.md) for verification details. The `agent_settled` lifecycle event this extension depends on landed in `0.80.6`.
41
+ The current tested host is Pi `1.0.4`. Host-provided dependencies use `"*"` peers to identify module ownership, not to guarantee compatibility with every Pi version. See the [compatibility guide](https://github.com/signalridge/pi-extensions/blob/main/docs/compatibility.md) for verification details. The `agent_settled` lifecycle event this extension depends on landed in `0.80.6`.
42
42
 
43
43
  ```bash
44
44
  pi install npm:@signalridge/pi-goal
@@ -167,7 +167,7 @@ Goal state is stored as Pi session state, similar to Codex's thread-owned goals.
167
167
 
168
168
  A `goal_wait` pause stores its reason and optional absolute wake deadline in the same session entry. On reload or session resume, a future deadline is re-armed; an overdue deadline gets one resume attempt after Pi is idle and pending messages have drained. No timer runs while Pi is closed. A wait without a deadline stays quiet until real interactive or RPC input is accepted for delivery, or `/goal resume` is used. Input consumed by another extension or rejected before the turn starts leaves the wait intact. Accepted real input resumes only a recorded wait and supplies the turn itself, without an extra resume prompt. A queued follow-up wakes the wait when delivered, not when queued. If delivery stays inside an existing agent run, the context hook supplies the rotated goal ID before its next response; it does not add another user turn. Extension-generated input and `/goal` commands do not count as real-input wake triggers. When Pi cannot distinguish real and extension input at delivery (for example, identical queued text or cross-transformed messages), the goal stays waiting rather than accepting a possible extension wake. That ambiguity remains attached to the waiting goal, including its deadline, until explicit `/goal resume` or reload; later input is not used to guess which earlier message was delivered. Automatic wakes do not override a restrictive active-tool policy; an overdue deadline rechecks readiness at most once per minute until Goal tools are available, while a settled boundary may wake sooner. An input whose delivery has not been confirmed also defers the deadline rather than racing a second prompt; if that input was rejected, resolve the failure and use `/goal resume` explicitly.
169
169
 
170
- Ordinary pauses, safety pauses, blockers, usage limits, exhausted budgets, and frozen queues do not auto-resume. Older paused entries without wait metadata remain paused; the extension does not infer a wait from transcript text. `/goal pause`, edit, clear, and replacement cancel the old wait. A synchronous deadline prompt-submission failure restores the stopped goal and wait metadata without a timer retry loop; use `/goal resume` or reload after resolving the problem. Pi's extension API does not acknowledge asynchronous prompt acceptance, so a later authentication or model failure cannot be rolled back by the extension; if no turn starts, inspect `/goal status` and explicitly pause/resume or reload after fixing the failure. Objective, cumulative usage, and the configured finite or Unlimited automatic-work setting are retained.
170
+ Ordinary pauses, safety pauses, blockers, usage limits, exhausted budgets, and frozen queues do not auto-resume. Background completion notifications can trigger ordinary conversation while a Goal is safety-paused; they do not resume Goal execution, rotate its ID, or reset its counters. Each model request during a safety pause receives a request-local authoritative state notice with the cause, current count/limit, and explicit-resume requirement, superseding earlier active Goal instructions. Goal tools report that safety-pause cause before ownership or stale-ID diagnostics; those guards still apply once the Goal is active. Use `/goal resume` (or the manager's Continue action) to restart. Older paused entries without wait metadata remain paused; the extension does not infer a wait from transcript text. `/goal pause`, edit, clear, and replacement cancel the old wait. A synchronous deadline prompt-submission failure restores the stopped goal and wait metadata without a timer retry loop; use `/goal resume` or reload after resolving the problem. Pi's extension API does not acknowledge asynchronous prompt acceptance, so a later authentication or model failure cannot be rolled back by the extension; if no turn starts, inspect `/goal status` and explicitly pause/resume or reload after fixing the failure. Objective, cumulative usage, and the configured finite or Unlimited automatic-work setting are retained.
171
171
 
172
172
  Ordered queues use the same canonical `goal-state` session entry as single goals. Every item owns independent usage and safety state. Shelving, priority displacement, automatic advancement, and later reactivation preserve that item's epoch rather than granting more automatic work. The legacy `{ goal }` shape remains valid, and missing safety fields normalize to zero/defaults. Queue fields are written only when needed. Sessions created by the former standalone `pi-goals` experiment can migrate their last `goals-state` array and pending `unshift` intent when the branch has never written a canonical `goal-state`; any canonical entry, including an explicit clear, takes precedence so old plural state cannot be resurrected.
173
173
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@signalridge/pi-goal",
3
- "version": "1.4.6",
3
+ "version": "1.4.8",
4
4
  "description": "Pi extension for autonomous /goal completion with opt-in experimental ordered queues.",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/lifecycle.ts CHANGED
@@ -6,8 +6,10 @@ import { type ActiveGoal, loadGoalStateFromSession } from "./persistence.js";
6
6
  import {
7
7
  buildGoalBindingUpdate,
8
8
  buildGoalPrompt,
9
+ buildGoalSafetyPauseNotice,
9
10
  buildGoalSystemPrompt,
10
11
  GOAL_BINDING_UPDATE_HEADER,
12
+ GOAL_SAFETY_PAUSE_CONTEXT_TYPE,
11
13
  } from "./prompts.js";
12
14
  import { activateQueuedGoal } from "./queue.js";
13
15
  import type { GoalRunController } from "./run-protocol.js";
@@ -28,7 +30,7 @@ import {
28
30
  type StatusContext,
29
31
  truncateNotification,
30
32
  } from "./runtime.js";
31
- import { hasAssistantToolCall } from "./safety.js";
33
+ import { formatGoalSafetyPause, hasAssistantToolCall } from "./safety.js";
32
34
  import { DEFAULT_GOAL_SETTINGS, readGoalSettings } from "./settings.js";
33
35
 
34
36
  const EXPERIMENTAL_GOALS_WARNING =
@@ -533,13 +535,31 @@ export function registerGoalLifecycle(
533
535
  });
534
536
 
535
537
  pi.on("context", (event, ctx) => {
536
- const messages = event.messages.filter((message) => runtime.keepBudgetWrapUpMessage(message));
538
+ const messages = event.messages.filter(
539
+ (message) =>
540
+ runtime.keepBudgetWrapUpMessage(message) &&
541
+ !(message.role === "custom" && message.customType === GOAL_SAFETY_PAUSE_CONTEXT_TYPE),
542
+ );
537
543
  if (runtime.activeGoal?.status === "paused" && runtime.guardAbortGoalId === runtime.activeGoal.id) {
538
544
  // A current custom follow-up clears the guard at message_start. Otherwise,
539
545
  // context transformation aborts before the provider adapter receives the signal.
540
546
  abortCurrentTurn(ctx);
541
547
  }
542
548
  const goal = runtime.activeGoal;
549
+ const safetyPause =
550
+ !runtime.queueFrozen && goal ? buildGoalSafetyPauseNotice(goal, runtime.settings.continuationLimits) : undefined;
551
+ if (safetyPause) {
552
+ // Request-local, so queued custom notifications (which can skip
553
+ // before_agent_start) also see current state without persisting stale notices.
554
+ messages.push({
555
+ role: "custom",
556
+ customType: GOAL_SAFETY_PAUSE_CONTEXT_TYPE,
557
+ content: safetyPause,
558
+ display: false,
559
+ timestamp: Date.now(),
560
+ });
561
+ return { messages };
562
+ }
543
563
  if (!runtime.queueFrozen && goal?.status === "active" && runtime.inputWakeGoalId === goal.id) {
544
564
  // Native queued input reaches message_start without before_agent_start.
545
565
  // Keep the rotated guard visible for every remaining response in this run,
@@ -595,7 +615,9 @@ export function registerGoalLifecycle(
595
615
  abortCurrentTurn(ctx);
596
616
  return {
597
617
  block: true,
598
- reason: "Blocked stale /goal tool call after the goal stopped or was interrupted.",
618
+ reason:
619
+ formatGoalSafetyPause(runtime.activeGoal, runtime.settings.continuationLimits) ??
620
+ "Blocked stale /goal tool call after the goal stopped or was interrupted.",
599
621
  };
600
622
  });
601
623
 
@@ -82,11 +82,13 @@ export function serializeGoalState(
82
82
  queue: readonly ActiveGoal[],
83
83
  pendingAction: PendingQueueAction | undefined,
84
84
  ): GoalStateEntryData {
85
- return {
85
+ // Native session entries retain data references; later runtime updates must
86
+ // not rewrite historical metadata or invalidate another extension's ancestry.
87
+ return structuredClone({
86
88
  goal: goal ?? null,
87
89
  ...(queue.length > 0 ? { queue: [...queue] } : {}),
88
90
  ...(pendingAction ? { pendingAction } : {}),
89
- };
91
+ });
90
92
  }
91
93
 
92
94
  export function loadGoalStateFromSession(ctx: SessionContext): LoadedGoalState {
package/src/prompts.ts CHANGED
@@ -1,4 +1,22 @@
1
1
  import { formatTokenCount } from "./accounting.js";
2
+ import type { ActiveGoal } from "./persistence.js";
3
+ import { formatGoalSafetyPause } from "./safety.js";
4
+ import type { GoalSettings } from "./settings.js";
5
+
6
+ export const GOAL_SAFETY_PAUSE_CONTEXT_TYPE = "goal-safety-pause-state";
7
+
8
+ export function buildGoalSafetyPauseNotice(goal: ActiveGoal, limits: GoalSettings["continuationLimits"]) {
9
+ const pause = formatGoalSafetyPause(goal, limits);
10
+ if (!pause) return undefined;
11
+ return [
12
+ "Goal runtime state (authoritative for this request):",
13
+ `goal_id: ${escapeXmlText(goal.id)}`,
14
+ pause,
15
+ "This state supersedes earlier active Goal instructions. This response is ordinary conversation or notification handling, not Goal automatic execution.",
16
+ "Do not claim the Goal is running or continuing automatically. Do not call goal_complete, goal_blocked, or goal_wait while it is paused.",
17
+ "Background completion notifications and ordinary messages do not resume this safety pause or reset its counters. Only explicit user resume can restart Goal execution.",
18
+ ].join("\n");
19
+ }
2
20
 
3
21
  export type GoalStatus = "active" | "queued" | "paused" | "blocked" | "usage_limited" | "budget_limited" | "complete";
4
22
 
@@ -67,6 +85,7 @@ function goalCompletionGuardBlock(goal: GoalPromptContext) {
67
85
  function goalModeRules(goalLabel: string) {
68
86
  return [
69
87
  "Goal-mode rules:",
88
+ "- Automatic continuation requires active Goal runtime state. A later authoritative Goal runtime state notice supersedes earlier active instructions; never treat notification handling as proof that Goal execution resumed.",
70
89
  "- Preserve the full objective across turns; do not redefine success around a narrower, safer, smaller, merely compatible, or easier-to-test result.",
71
90
  "- Derive concrete requirements from the objective and any referenced files, plans, specifications, issues, or user instructions.",
72
91
  "- Treat the current worktree, command output, tests, runtime behavior, PR state, rendered artifacts, and external state as authoritative. Previous conversation, plans, and summaries are context, not proof; inspect the current state before relying on them.",
package/src/safety.ts CHANGED
@@ -1,5 +1,14 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import type { ActiveGoal } from "./persistence.js";
3
+ import type { GoalSettings } from "./settings.js";
4
+
5
+ export function formatGoalSafetyPause(goal: ActiveGoal, limits: GoalSettings["continuationLimits"]) {
6
+ if (goal.status !== "paused" || !goal.safetyPauseCause) return undefined;
7
+ const automatic = goal.safetyPauseCause === "continuation_limit";
8
+ const used = automatic ? goal.automaticModelTurns : goal.toolFreeRepeatCount;
9
+ const limit = automatic ? limits.automaticTurns : limits.noProgressTurns;
10
+ return `Goal paused: ${goal.safetyPauseCause} (${used}/${limit ?? "Unlimited"}). Explicit resume required: run /goal resume.`;
11
+ }
3
12
 
4
13
  export interface ToolFreeRepeatState {
5
14
  toolFreeRepeatCount: number;
package/src/tools.ts CHANGED
@@ -20,6 +20,7 @@ import {
20
20
  transitionGoal,
21
21
  truncateNotification,
22
22
  } from "./runtime.js";
23
+ import { formatGoalSafetyPause } from "./safety.js";
23
24
  import {
24
25
  createGoalWait,
25
26
  MAX_GOAL_WAIT_REASON_LENGTH,
@@ -101,6 +102,15 @@ export function registerGoalTools(pi: ExtensionAPI, runtime: GoalRuntime) {
101
102
  };
102
103
  }
103
104
  const completingDuringBudgetWrapUp = runtime.hasActiveBudgetWrapUp();
105
+ const safetyPause = formatGoalSafetyPause(completedGoal, runtime.settings.continuationLimits);
106
+ if (safetyPause) {
107
+ const rejection = `Goal completion rejected: ${safetyPause}`;
108
+ notifyTerminal(ctx.ui, rejection, "warning");
109
+ return {
110
+ content: toolContent(rejection),
111
+ details: completionDetails(goal, requestedGoalId, summary),
112
+ };
113
+ }
104
114
  if (!runtime.canRecordGoalUsage() && !completingDuringBudgetWrapUp) {
105
115
  const rejection = "Goal completion rejected: current run does not own the active goal.";
106
116
  notifyTerminal(ctx.ui, rejection, "warning");
@@ -257,7 +267,7 @@ export function registerGoalTools(pi: ExtensionAPI, runtime: GoalRuntime) {
257
267
  const evidence = typeof params.evidence === "string" ? params.evidence.trim() : "";
258
268
  const repeatedTurns = typeof params.repeated_turns === "number" ? params.repeated_turns : Number.NaN;
259
269
  const reject = (rejectionReason: string, terminate = false) => {
260
- const rejection = `goal_blocked rejected: ${rejectionReason}.`;
270
+ const rejection = `goal_blocked rejected: ${rejectionReason}${rejectionReason.endsWith(".") ? "" : "."}`;
261
271
  notifyTerminal(ctx.ui, rejection, "warning");
262
272
  return {
263
273
  content: toolContent(rejection),
@@ -267,6 +277,8 @@ export function registerGoalTools(pi: ExtensionAPI, runtime: GoalRuntime) {
267
277
  };
268
278
 
269
279
  if (!blockedGoal) return reject("no active goal");
280
+ const safetyPause = formatGoalSafetyPause(blockedGoal, runtime.settings.continuationLimits);
281
+ if (safetyPause) return reject(safetyPause);
270
282
  if (!runtime.canRecordGoalUsage()) {
271
283
  return reject("current run does not own the active goal");
272
284
  }
@@ -363,6 +375,8 @@ export function registerGoalTools(pi: ExtensionAPI, runtime: GoalRuntime) {
363
375
  };
364
376
 
365
377
  if (!goal) return reject("no active goal");
378
+ const safetyPause = formatGoalSafetyPause(goal, runtime.settings.continuationLimits);
379
+ if (safetyPause) return reject(safetyPause);
366
380
  if (!runtime.canRecordGoalUsage()) return reject("current run does not own the active goal");
367
381
  if (hasPendingSkipForGoal(runtime, goal.id)) {
368
382
  runtime.recordGoalUsage(goal, ctx);