pi-goal-x 0.33.0 → 0.34.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.
@@ -69,7 +69,7 @@ After confirmation, a goal stopped only by its budget can continue if the revise
69
69
 
70
70
  Some Pi `openai-responses` configurations omit the wire-level `strict` flag. OpenAI Responses may normalize schemas into strict mode when that flag is omitted. This can conflict with optional goal arguments; it is separate from pi-goal-x's `strictExecutionContract` scheduling setting.
71
71
 
72
- For the reported OpenCode model, a narrowly scoped Pi `models.json` override makes the supported Pi 0.84.1 adapter send `strict: false` for ordinary tools:
72
+ For the reported OpenCode model, a narrowly scoped Pi `models.json` override makes the Pi adapter send `strict: false` for ordinary tools:
73
73
 
74
74
  ```json
75
75
  {
@@ -258,14 +258,12 @@ skips those snapshots. Settings are rechecked at scheduling and admission; optin
258
258
  out removes saved delay metadata without renewing consumption. Resume and genuine
259
259
  host input reset pacing; strict waits and provider recovery remain independent.
260
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.
261
+ Pi 1.4 prepares goal-triggered turns natively. The extension uses
262
+ `context_with_system` to filter its messages and add current goal state after
263
+ Pi has restored the system prompt and tool declarations. System messages stay
264
+ at their original history boundaries, including deltas from other extensions.
265
+ `agent_settled.aborted` stops continuation/recovery and pauses the associated
266
+ goal. Rejected stale checkpoints settle without changing the replacement goal.
269
267
 
270
268
  Audit display messages flush only at idle settlement and remain scoped to their
271
269
  session. If Pi has invalidated a settlement context, the handler skips it before
@@ -328,11 +326,10 @@ npm run check
328
326
  ```
329
327
 
330
328
  `test:unit`, `test:integration`, and `test:all` automatically discover test
331
- entries and run them in one Node process with small test-only adapters for the
329
+ entries and run them with small test-only adapters for the
332
330
  SDK values used by handlers. This avoids loading unrelated model-provider and
333
- TUI media modules. The fast path requires Node 22.15+; `test:serial` remains
334
- the slow, real-SDK, process-isolated
335
- compatibility path. The suites cover: surface baselines (lifecycle-dependent goal tool
331
+ TUI media modules. The fast path requires Node 22.19+; `test:serial` remains
332
+ the slow, real-SDK, process-isolated alternative. The suites cover: surface baselines (lifecycle-dependent goal tool
336
333
  profile and fourteen commands), golden file/ledger fixtures, stale-continuation behavior,
337
334
  GoalService mutation boundary, runtime/accounting, token-budget transitions,
338
335
  task-tool consolidation, verification contracts, the independent auditor,
@@ -10,13 +10,15 @@ The request layout is:
10
10
 
11
11
  Changing goal state never rewrites the system prompt or historical messages. The full goal-state message is not persisted into the session. Old checkpoints remain until Pi compacts history; retaining small markers avoids shifting the prefix on every continuation. Display-only auditor messages are consistently excluded, and tool calls stay adjacent to their results.
12
12
 
13
+ Goal context filtering and live-state tails use Pi's `context_with_system` event, preserving system and tool deltas in their original positions. Parent and delegated sessions use this hook from their first request.
14
+
13
15
  For Anthropic-style explicit caching, the extension moves Pi's existing cache_control breakpoint from the transient state message onto the preceding cacheable history block. The Bedrock equivalent moves the cachePoint. Both preserve the configured TTL and do not add breakpoints. Payloads without an existing explicit marker—including caching disabled and implicit-cache providers—receive no marker changes. The extension does not set cache keys, enable paid extended retention, or replace provider defaults.
14
16
 
15
17
  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
18
 
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.
19
+ 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 host range is Pi 1.4+ within 1.x, with Node 22.19 or newer. Development dependencies pin Pi 1.4.0.
18
20
 
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.
21
+ Pi 1.4 prepares custom-triggered turns through the same public startup protocol as user prompts. The extension leaves structured prompt and tool deltas intact, including additional sections supplied by other extensions. Direct creation, drafting, resume and reload use the host's native prompt preparation from the first request. Explicit tool-profile changes can still rebuild the prefix.
20
22
 
21
23
  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.
22
24
 
@@ -10,6 +10,7 @@ import {
10
10
  SessionManager,
11
11
  SettingsManager,
12
12
  type ExtensionContext,
13
+ type ModelRuntime,
13
14
  type ResourceLoader,
14
15
  } from "@earendil-works/pi-coding-agent";
15
16
  import type { GoalRecord, GoalTask, GoalTaskList } from "./goal-record.ts";
@@ -277,25 +278,15 @@ export function resolveAuditorModel(ctx: ExtensionContext, config: GoalSettings)
277
278
  /**
278
279
  * Options that reuse the parent session's auth + registered providers for the nested auditor.
279
280
  *
280
- * Pi 0.81+ `createAgentSession` accepts `modelRuntime` (and ignores `modelRegistry`).
281
- * ExtensionContext still exposes `modelRegistry`, which wraps the live ModelRuntime.
281
+ * Pi 1.4's extension ModelRegistry wraps the live ModelRuntime.
282
282
  * Sharing that runtime keeps extension providers such as `pi-cursor-sdk`'s `cursor`
283
283
  * provider (and its auth) available. Without this, the auditor builds a fresh runtime
284
284
  * with an empty resource loader, so Cursor models fail with "No API key found for cursor"
285
285
  * even when `~/.pi/agent/auth.json` has a Cursor key.
286
- *
287
- * Older SDKs still accept `modelRegistry`; pass both for compatibility.
288
286
  */
289
- export function resolveAuditorSessionModelOptions(ctx: ExtensionContext): {
290
- modelRegistry: ExtensionContext["modelRegistry"];
291
- modelRuntime?: unknown;
292
- } {
293
- const registry = ctx.modelRegistry as unknown as { runtime?: unknown } | undefined;
294
- const runtime = registry?.runtime;
295
- if (runtime) {
296
- return { modelRegistry: ctx.modelRegistry, modelRuntime: runtime };
297
- }
298
- return { modelRegistry: ctx.modelRegistry };
287
+ export function resolveAuditorSessionModelOptions(ctx: ExtensionContext): { modelRuntime?: ModelRuntime } {
288
+ const registry = ctx.modelRegistry as unknown as { runtime?: ModelRuntime } | undefined;
289
+ return { modelRuntime: registry?.runtime };
299
290
  }
300
291
 
301
292
  // eslint-disable-next-line @typescript-eslint/no-explicit-any -- pi SDK types Model<any> idiomatically (pi-coding-agent sdk.d.ts)
@@ -0,0 +1,12 @@
1
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+
3
+ /** Filter after Pi restores system and tool state, including on the first request. */
4
+ export function registerGoalContext(
5
+ pi: ExtensionAPI,
6
+ transform: (messages: unknown[], ctx: ExtensionContext) => unknown[] | null,
7
+ ): void {
8
+ pi.on("context_with_system", (event, ctx) => {
9
+ const messages = transform(event.messages, ctx);
10
+ return messages === null ? undefined : { messages: messages as typeof event.messages };
11
+ });
12
+ }
@@ -65,6 +65,7 @@ pi.registerTool(defineTool({
65
65
  if (core.state.goal) core.syncGoalPromptFromDisk(ctx);
66
66
  const view = core.goalForDisplay() ?? core.state.goal;
67
67
  const params = (_params ?? {}) as { verbose?: boolean; include_history?: boolean; section?: "summary" | GoalDetailSection; task_id?: string; cursor?: string };
68
+ const taskId = params.task_id?.trim() || undefined;
68
69
  // PR E profile: legacy-v1 keeps pre-optimization verbose-by-default output;
69
70
  // compact-v2 (default) returns a concise state line set because the full
70
71
  // policy already lives in the injected active-goal system block.
@@ -83,10 +84,10 @@ pi.registerTool(defineTool({
83
84
  if (params.section && params.section !== "summary") {
84
85
  if (!["objective", "tasks", "history"].includes(params.section)) return {content: [{type: "text", text: "Unknown goal section."}], details: goalDetails(view)};
85
86
  const history = params.section === "history" ? readGoalLedger(ctx) : undefined;
86
- const page = goalDetailPage(view, {section: params.section, task_id: params.task_id, cursor: params.cursor}, history?.events, history?.revision);
87
+ const page = goalDetailPage(view, {section: params.section, task_id: taskId, cursor: params.cursor}, history?.events, history?.revision);
87
88
  return {content: [{type: "text", text: page.text}], details: {...goalDetails(view), ...(page.ok ? {page: {content: page.content, nextCursor: page.nextCursor, totalChars: page.totalChars}} : {})}};
88
89
  }
89
- if (params.cursor || params.task_id) return {content: [{type: "text", text: "Use section=objective, tasks, or history for detail retrieval; task_id requires tasks."}], details: goalDetails(view)};
90
+ if (params.cursor || taskId) return {content: [{type: "text", text: "Use section=objective, tasks, or history for detail retrieval; task_id requires tasks."}], details: goalDetails(view)};
90
91
  if (verbose && !params.section) {
91
92
  const lines: string[] = [`Goal ${view.id}: ${statusLabel(view)}, ${view.sisyphus ? "sisyphus" : "regular"}`, schedulerSummary(view.scheduler, loadGoalSettings(ctx.cwd).maxAutonomousRuns, loadGoalSettings(ctx.cwd).showAutonomousRuns, loadGoalSettings(ctx.cwd).idleContinuationBackoff === true, core.loadedCommands)];
92
93
  lines.push(`Objective: ${view.objective}`, "");
@@ -1,6 +1,5 @@
1
1
  import { commandHint, commandInstructions } from "./goal-controls.ts";
2
2
  import { cacheGoalHistory } from "./goal-prompt-cache.ts";
3
- import { registerGoalPromptProfile } from "./goal-prompt-profile.ts";
4
3
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
5
4
  import {
6
5
  GOAL_EVENT_ENTRY,
@@ -36,6 +35,7 @@ import { hasActiveDraft, rehydrateDraft } from "./goal-drafting.ts";
36
35
  import { syncTerminalInputPause } from "./goal-widget.ts";
37
36
  import type { GoalCore } from "./goal-state.ts";
38
37
  import { filterGoalSessionContext } from "./goal-session-safety.ts";
38
+ import { registerGoalContext } from "./goal-context.ts";
39
39
  import type { GoalMutationOutcome } from "./goal-service.ts";
40
40
 
41
41
  /** Normalize checkpoints independently: deleting old markers shifts the cached prefix. */
@@ -64,12 +64,6 @@ export function compactGoalCheckpointContext(
64
64
  */
65
65
  export function registerGoalEvents(core: GoalCore): void {
66
66
  const { pi } = core;
67
- registerGoalPromptProfile(pi, ctx => {
68
- const goal = core.state.goal;
69
- const scheduler = goal?.scheduler;
70
- return goal?.status === "active" && goal.autoContinue && !hasActiveDraft(core)
71
- && (!scheduler || scheduler.owner === ctx.sessionManager.getSessionId());
72
- });
73
67
  // Transients are keyed per session: the context hook of session B must never
74
68
  // overwrite the set that session A's provider request will consume.
75
69
  const liveRetention = new LiveTailRetention();
@@ -79,10 +73,13 @@ export function registerGoalEvents(core: GoalCore): void {
79
73
  let continuationAfterSettleFor: string | null = null;
80
74
  let networkErrorRecoveryAfterSettleFor: string | null = null;
81
75
 
82
- pi.on("context", async (event, ctx) => {
83
- const filtered = filterGoalSessionContext(event.messages);
84
- const messages = compactGoalCheckpointContext(filtered ?? event.messages, core.state.goal) ?? filtered;
85
- const base = messages ?? event.messages;
76
+ registerGoalContext(pi, (input, ctx) => {
77
+ // Reassert startup rejection at the awaited provider boundary, before
78
+ // any rejected run can send a request.
79
+ if (core.scheduler.isDenied()) ctx.abort?.();
80
+ const filtered = filterGoalSessionContext(input);
81
+ const messages = compactGoalCheckpointContext(filtered ?? input, core.state.goal) ?? filtered;
82
+ const base = messages ?? input;
86
83
  const sessionKey = sessionKeyFor(ctx);
87
84
  if (sessionKey === "unidentified") liveRetention.clear(sessionKey);
88
85
  const parts = currentGoalContextParts(ctx);
@@ -91,15 +88,16 @@ export function registerGoalEvents(core: GoalCore): void {
91
88
  // request extends the previous one instead of displacing its tail.
92
89
  // Only genuinely new state/counters content is appended.
93
90
  const { messages: retained } = liveRetention.apply(sessionKey, base, { state: parts.state, counters: parts.counters });
94
- return { messages: retained as typeof event.messages };
91
+ return retained;
95
92
  }
96
93
  liveRetention.clear(sessionKey);
97
- return messages === null ? undefined : { messages: messages as typeof event.messages };
94
+ return messages;
98
95
  });
99
96
 
100
97
  pi.on("agent_start", async (_event, ctx) => { core.scheduler.begin(ctx); });
101
98
  pi.on("message_start", async (event, ctx) => { core.scheduler.message(ctx, event.message); });
102
99
  pi.on("turn_start", async (_event, ctx) => {
100
+ if (core.scheduler.isDenied()) return;
103
101
  core.scheduler.turn(ctx);
104
102
  // Per-turn flag resets (#4 + C9 fix).
105
103
  core.advanceTurnSeq();
@@ -166,11 +164,13 @@ export function registerGoalEvents(core: GoalCore): void {
166
164
  });
167
165
 
168
166
  pi.on("tool_execution_end", async (_event, ctx) => {
167
+ if (core.scheduler.isDenied()) return;
169
168
  core.touchGoalActivity(); // F5
170
169
  core.accountProgress(ctx);
171
170
  });
172
171
 
173
172
  pi.on("turn_end", async (event, ctx) => {
173
+ if (core.scheduler.isDenied()) return;
174
174
  const message = event.message as AssistantMessageLike;
175
175
  const tokens = assistantTurnTokens(message);
176
176
  core.touchGoalActivity(); // F5
@@ -264,7 +264,7 @@ export function registerGoalEvents(core: GoalCore): void {
264
264
  pi.on("message_end", async (event, ctx) => {
265
265
  // Signal-aware: see turn_end — only user aborts pause; provider-side
266
266
  // aborts are handled by agent_end's recovery path.
267
- if (isAbortedAssistantMessage(event.message) && ctx.signal?.aborted && !core.interruption.ignoreAbort() && !core.interruption.isHeld()) core.pauseActiveGoal(ctx);
267
+ if (!core.scheduler.isDenied() && isAbortedAssistantMessage(event.message) && ctx.signal?.aborted && !core.interruption.ignoreAbort() && !core.interruption.isHeld()) core.pauseActiveGoal(ctx);
268
268
  const raw = asRecord(event.message);
269
269
  if (raw?.role === "custom" && raw.customType === GOAL_EVENT_ENTRY && raw.display !== false) {
270
270
  return { message: { ...event.message, display: false } as typeof event.message };
@@ -335,13 +335,7 @@ export function registerGoalEvents(core: GoalCore): void {
335
335
  core.queueContinuation(ctx, true);
336
336
  });
337
337
 
338
- type SessionCompactFailed = { reason: "manual" | "threshold" | "overflow"; errorMessage?: string; aborted: boolean; willRetry: boolean; fromExtension: boolean };
339
- // session_compact_failed is new in pi 1.0.0. The supported peer range also
340
- // includes 0.83 through 0.87, where this event does not exist and is never
341
- // emitted, so the registration is typed loosely enough to compile against
342
- // every supported host. On those hosts the handler is simply never called.
343
- type CompactFailedOn = (event: "session_compact_failed", handler: (event: SessionCompactFailed, ctx: ExtensionContext) => unknown) => unknown;
344
- (pi.on as unknown as CompactFailedOn)("session_compact_failed", async (event, ctx) => {
338
+ pi.on("session_compact_failed", async (event, ctx) => {
345
339
  // `session_before_compact` already charged progress to the goal, so
346
340
  // flush the buffered transaction and persist first. Otherwise the goal
347
341
  // record and the ledger can disagree about a compaction that never
@@ -374,11 +368,9 @@ export function registerGoalEvents(core: GoalCore): void {
374
368
  pi.on("before_agent_start", async (event, ctx) => {
375
369
  core.advanceTurnSeq();
376
370
  if (!hasActiveDraft(core)) core.installGoalToolProfile(!loadGoalSettings(ctx.cwd).disableTasks);
377
- // Pi 1.2+ distinguishes custom triggers from user prompts. Older hosts
378
- // expose only prompt, so read the shared public trigger shapes here.
379
- const trigger = event as unknown as { prompt?: string; message?: { customType?: string; details?: unknown; content?: unknown } };
380
- const incomingGoalId = trigger.message ? goalEventMessageId(trigger.message) : extractGoalIdFromInjectedMessage(trigger.prompt ?? "");
381
- if (!trigger.message && !incomingGoalId) core.interruption.continueWithUser(ctx);
371
+ const incomingGoalId = event.messageType === "custom"
372
+ ? goalEventMessageId(event.message) : extractGoalIdFromInjectedMessage(event.prompt ?? "");
373
+ if (event.messageType !== "custom" && !incomingGoalId) core.interruption.continueWithUser(ctx);
382
374
 
383
375
  // If this turn was triggered by a hidden goal checkpoint that no longer
384
376
  // matches the active goal, abort the whole turn instead of letting the
@@ -394,7 +386,7 @@ export function registerGoalEvents(core: GoalCore): void {
394
386
  core.clearContinuationState(false);
395
387
  if (!core.isActionableContinuationGoal(incomingGoalId)) {
396
388
  try {
397
- ctx.abort?.();
389
+ core.scheduler.rejectCheckpoint(ctx);
398
390
  } catch {}
399
391
  core.updateUI(ctx);
400
392
  return;
@@ -417,7 +409,7 @@ export function registerGoalEvents(core: GoalCore): void {
417
409
  /** Request-only goal state split into a rarely-changing policy block and per-turn counters. Other branches are stable-only. */
418
410
  function currentGoalContextParts(ctx: ExtensionContext): { state: string; counters?: string } | undefined {
419
411
  const checkpoint = core.runtime.getCheckpointGoalId();
420
- if (checkpoint !== null && !core.isActionableContinuationGoal(checkpoint)) {
412
+ if (checkpoint !== null && (core.scheduler.isDenied() || !core.isActionableContinuationGoal(checkpoint))) {
421
413
  return { state: staleContinuationPrompt(checkpoint, core.state.goal) };
422
414
  }
423
415
  // Several prompt enrichments may need the same ledger snapshot. Keep one
@@ -522,6 +514,7 @@ export function registerGoalEvents(core: GoalCore): void {
522
514
  core.runningGoalId = null;
523
515
  continuationAfterSettleFor = null;
524
516
  networkErrorRecoveryAfterSettleFor = null;
517
+ if (core.scheduler.isDenied()) return;
525
518
 
526
519
  // Account for any tokens from aborted in-flight assistant messages so
527
520
  // they are not silently lost (but charge them to the original goal).
@@ -567,9 +560,8 @@ export function registerGoalEvents(core: GoalCore): void {
567
560
  core.updateUI(ctx);
568
561
  // agent_end runs before pi finishes retries, compaction, terminating-tool
569
562
  // settlement, and queued messages. Starting the continuation timer here
570
- // can poll a stale busy context for minutes on pi 0.84. agent_settled is
571
- // available in both supported SDK lines (0.83 and 0.84) and is the first
572
- // point where pi guarantees no automatic work remains.
563
+ // can poll a stale busy context. agent_settled is the first point where
564
+ // Pi guarantees no automatic work remains.
573
565
  continuationAfterSettleFor = core.state.goal.id;
574
566
  });
575
567
 
@@ -579,10 +571,15 @@ export function registerGoalEvents(core: GoalCore): void {
579
571
  continuationAfterSettleFor = null;
580
572
  const networkErrorGoalId = networkErrorRecoveryAfterSettleFor;
581
573
  networkErrorRecoveryAfterSettleFor = null;
574
+ if (core.scheduler.isDenied()) {
575
+ core.runningGoalId = null;
576
+ core.runtime.setCheckpoint(null);
577
+ core.scheduler.settled(ctx, false);
578
+ return;
579
+ }
582
580
  const resumedInterruptedTurn = core.interruption.ignoreAbort() && !core.interruption.isHeld();
583
- // Current Pi reports startup cancellation even when no assistant message
584
- // or live request signal remains. Older hosts omit this field.
585
- if ((event as unknown as { aborted?: boolean }).aborted === true) {
581
+ // Startup cancellation may leave no assistant message or request signal.
582
+ if (event.aborted) {
586
583
  const abortedGoalId = goalId ?? networkErrorGoalId ?? core.runningGoalId;
587
584
  core.runningGoalId = null;
588
585
  if (abortedGoalId && !core.interruption.ignoreAbort(abortedGoalId) && !core.interruption.isHeld(abortedGoalId) && core.state.goal?.id === abortedGoalId && core.state.goal.status === "active") core.pauseActiveGoal(ctx);
@@ -3,6 +3,7 @@ import { indexedActivityEvents } from "./goal-activity.ts";
3
3
  import { buildLedgerIndex, indexLedgerEvent, type GoalLedgerIndex } from "./goal-ledger-index.ts";
4
4
  import * as fs from "node:fs";
5
5
  import * as path from "node:path";
6
+ import { renameAtomicSync } from "./storage/atomic-rename.ts";
6
7
  import { nowIso, safeIdPart, type GoalRecord } from "./goal-record.ts";
7
8
 
8
9
  export const GOAL_LEDGER_FILE = ".pi/goals/goal_events.jsonl";
@@ -452,12 +453,15 @@ function checkpointFromJson(value: unknown): LedgerCheckpoint | null {
452
453
 
453
454
  /** Atomic checkpoint write: temp file + rename in the ledger directory. Best-effort. */
454
455
  function writeLedgerCheckpointAtomic(filePath: string, cp: LedgerCheckpoint): void {
456
+ const tmp = `${checkpointPathFor(filePath)}.tmp-${process.pid}`;
457
+ let written = false;
455
458
  try {
456
459
  const cpPath = checkpointPathFor(filePath);
457
- const tmp = `${cpPath}.tmp-${process.pid}`;
458
460
  fs.writeFileSync(tmp, JSON.stringify(checkpointToJson(cp)), "utf8");
459
- fs.renameSync(tmp, cpPath);
461
+ written = true;
462
+ renameAtomicSync(tmp, cpPath);
460
463
  } catch (err) {
464
+ if (written) { try { fs.unlinkSync(tmp); } catch { /* best effort */ } }
461
465
  // The checkpoint is an optimization; a failed write only costs a full
462
466
  // parse on the next cold read.
463
467
  console.error("[goal-ledger] checkpoint write failed:", err);
@@ -40,7 +40,7 @@ export type ProposalDecision = "confirm" | "continue" | "cancel";
40
40
 
41
41
  /**
42
42
  * Bound a custom dialog using the regular renderer's frame cache when
43
- * available. The pi 0.84 fullscreen renderer does not expose that cache, so
43
+ * available. When the fullscreen renderer does not expose that cache,
44
44
  * reserve four rows for host chrome and use the remaining terminal height.
45
45
  */
46
46
  export function computeDialogLineLimit(args: { terminalRows?: number; baseFrameLines?: number }): number | undefined {
@@ -115,11 +115,11 @@ export class GoalScheduler {
115
115
  if (raw && this.ctx) this.signal(this.ctx, raw.goalId, raw.waitToken);
116
116
  });
117
117
  }
118
- shutdown(): void { this.cancelTimer(); this.unsubscribe?.(); this.unsubscribe = undefined; this.ctx = undefined; }
118
+ shutdown(): void { this.cancelTimer(); this.unsubscribe?.(); this.unsubscribe = undefined; this.ctx = undefined; this.denied = false; }
119
119
 
120
120
  restore(ctx: ExtensionContext): void {
121
121
  this.attach(ctx);
122
- this.inRun = false; this.declared = false; this.runGoalId = undefined;
122
+ this.inRun = false; this.declared = false; this.runGoalId = undefined; this.denied = false;
123
123
  this.cancelTimer();
124
124
  this.safe(ctx, () => {
125
125
  this.core.reconcileFocusedGoalFromDisk(ctx);
@@ -171,7 +171,9 @@ export class GoalScheduler {
171
171
  } catch (error) { ctx.ui.notify(String(error), "warning"); return false; }
172
172
  }
173
173
  kickoff(ctx: ExtensionContext): void {
174
- if (this.inRun) return; // creating a goal within a user run needs no extra run
174
+ // A goal created in ordinary user work can finish in that run. Replacing
175
+ // another run's goal needs its own saved kickoff and fresh attribution.
176
+ if (this.inRun && (!this.runGoalId || this.runGoalId === this.core.state.goal?.id)) return;
175
177
  this.safe(ctx, () => {
176
178
  const goal = this.core.state.goal;
177
179
  if (!goal || goal.status !== "active" || !goal.autoContinue) return;
@@ -219,10 +221,22 @@ export class GoalScheduler {
219
221
  } catch (error) { return { content: [{ type: "text", text: `Scheduling decision NOT saved: ${error instanceof Error ? error.message : String(error)}` }], details: { error: true }, terminate: false }; }
220
222
  }
221
223
 
224
+ /** Reject an internal dispatch without giving its cancellation to the focused goal. */
225
+ rejectCheckpoint(ctx: ExtensionContext): void {
226
+ this.denied = true;
227
+ this.cancelTimer();
228
+ this.core.runtime.clearContinuationTimer();
229
+ this.core.runningGoalId = null;
230
+ ctx.abort?.();
231
+ }
232
+
222
233
  /** One logical run can contain multiple agent_start events during native retry/compaction. */
223
234
  begin(ctx: ExtensionContext): void {
224
235
  this.ctx = ctx;
225
- if (this.inRun) { this.core.runningGoalId = this.core.state.goal?.id ?? null; return; }
236
+ // Native custom-turn preparation can reject a checkpoint before agent_start.
237
+ // Its later lifecycle events still belong to that rejected dispatch.
238
+ if (this.denied) return;
239
+ if (this.inRun) { this.core.runningGoalId = this.runGoalId ?? null; return; }
226
240
  this.inRun = true; this.declared = false; this.denied = false;
227
241
  this.runGoalId = this.core.state.goal?.id;
228
242
  this.runPacingEnabled = loadGoalSettings(ctx.cwd).idleContinuationBackoff === true;
@@ -239,6 +253,7 @@ export class GoalScheduler {
239
253
  this.safe(ctx, () => this.update(ctx, s => { s.decision = undefined; s.phase = "idle"; s.dispatch = undefined; }));
240
254
  }
241
255
  message(ctx: ExtensionContext, rawMessage: unknown): void {
256
+ if (this.denied) return;
242
257
  const msg = asRecord(rawMessage);
243
258
  if (!msg || !["user", "custom"].includes(String(msg.role))) return;
244
259
  if (msg.customType === "pi-goal-audit-event") return;
@@ -252,9 +267,8 @@ export class GoalScheduler {
252
267
  s.phase = "running";
253
268
  });
254
269
  } catch (error) {
255
- this.denied = true;
270
+ this.rejectCheckpoint(ctx);
256
271
  this.core.runtime.setCheckpoint(String(d?.goalId ?? "invalid"));
257
- ctx.abort?.();
258
272
  ctx.ui.notify(`Goal checkpoint rejected: ${String(error)}`, "warning");
259
273
  }
260
274
  return;
@@ -275,7 +289,15 @@ export class GoalScheduler {
275
289
  }
276
290
  settled(ctx: ExtensionContext, successful = true): void {
277
291
  this.inRun = false;
278
- if (this.denied || (this.runGoalId && this.core.state.goal?.id !== this.runGoalId)) return;
292
+ if (this.denied) {
293
+ this.denied = false;
294
+ this.runGoalId = undefined;
295
+ // A rejected message creates no scheduling intent. Resume only intent
296
+ // that the currently focused goal already saved before the rejection.
297
+ this.schedule(ctx);
298
+ return;
299
+ }
300
+ if (this.runGoalId && this.core.state.goal?.id !== this.runGoalId) { this.schedule(ctx); return; }
279
301
  invalidateGoalSettingsCache();
280
302
  this.safe(ctx, () => {
281
303
  const goal = this.core.state.goal;
@@ -330,7 +352,7 @@ export class GoalScheduler {
330
352
  loadGoalSettings(ctx.cwd);
331
353
  }
332
354
  schedule(ctx: ExtensionContext): void {
333
- if (this.inRun || this.core.interruption.isHeld()) return;
355
+ if (this.inRun || this.denied || this.core.interruption.isHeld()) return;
334
356
  invalidateGoalSettingsCache();
335
357
  this.safe(ctx, () => {
336
358
  this.core.reconcileFocusedGoalFromDisk(ctx);
@@ -27,6 +27,7 @@
27
27
  import * as fs from "node:fs";
28
28
  import * as os from "node:os";
29
29
  import * as path from "node:path";
30
+ import { renameAtomicSync } from "./storage/atomic-rename.ts";
30
31
  import { GOAL_COMMANDS, GOAL_KEY_ACTIONS, PAUSE_ORDERS, parseCommandPaths, parseGoalKeybinding, type GoalCommands, type GoalKeybinding, type PauseOrder } from "./goal-controls.ts";
31
32
 
32
33
  export const GOAL_THINKING_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh", "max"] as const;
@@ -1101,7 +1102,7 @@ function atomicWriteJson(target: string, value: unknown, options: { defaultMode:
1101
1102
  fs.fsyncSync(fd);
1102
1103
  fs.closeSync(fd);
1103
1104
  fd = undefined;
1104
- fs.renameSync(temp, target);
1105
+ renameAtomicSync(temp, target);
1105
1106
  } catch (error) {
1106
1107
  if (fd !== undefined) {
1107
1108
  try {
@@ -475,6 +475,7 @@ export function createGoalCore(
475
475
  }
476
476
 
477
477
  function beginAccounting(): void {
478
+ if (scheduler?.isDenied() || (runningGoalId && runningGoalId !== state.goal?.id)) { clearActiveAccounting(); return; }
478
479
  if (scheduler?.isWaiting() || interruption?.isHeld()) { clearActiveAccounting(); return; }
479
480
  if (!state.goal || (state.goal.status !== "active")) {
480
481
  clearActiveAccounting();
@@ -489,6 +490,7 @@ export function createGoalCore(
489
490
  }
490
491
 
491
492
  function accountProgress(ctx: ExtensionContext, opts: { completedTurnTokens?: number } = {}): void {
493
+ if (scheduler?.isDenied() || (runningGoalId && runningGoalId !== state.goal?.id)) { clearActiveAccounting(); return; }
492
494
  // Skip disk reconciliation for complete goals — they are pending archival at turn_end.
493
495
  if (state.goal?.activePath && state.goal?.status !== "complete" && !reconcileFocusedGoalFromDisk(ctx, { preserveMemoryUsage: true })) return;
494
496
  if (!state.goal || state.goal.status !== "active" || !accounting.isActiveFor(state.goal.id)) {
@@ -17,6 +17,8 @@ import { statusLabel, truncateText } from "./goal-core.ts";
17
17
  import { loadGoalSettings } from "./goal-settings.ts";
18
18
  import { buildTaskSummary, checkSubtasksComplete, findSubtaskDepthViolation, findTaskInTree, skipAllSubtasks } from "./goal-policy.ts";
19
19
  import { showTaskConfirmation, type TaskConfirmationResult } from "./goal-task-confirmation.ts";
20
+ import { normalizeTaskProgress, type TaskProgressInput } from "./goal-tool-inputs.ts";
21
+ export type { TaskProgressInput } from "./goal-tool-inputs.ts";
20
22
  import {
21
23
  SET_GOAL_TASKS_TOOL_NAME,
22
24
  UPDATE_GOAL_TASK_TOOL_NAME,
@@ -199,12 +201,6 @@ export function countTasks(tasks: readonly GoalTask[] | undefined): number {
199
201
  return total;
200
202
  }
201
203
 
202
- export interface TaskProgressInput {
203
- task_id: string;
204
- status: "start" | "complete" | "skipped" | "pending";
205
- evidence?: string;
206
- reason?: string;
207
- }
208
204
  function progressSpec(input: TaskProgressInput, core: import("./goal-state.ts").GoalCore, ctx: ExtensionContext): GoalTaskUpdateSpec {
209
205
  const settings = loadGoalSettings(ctx.cwd);
210
206
  const now = nowIso();
@@ -397,19 +393,19 @@ pi.registerTool(defineTool({
397
393
  promptSnippet: "Start, complete, skip, or reopen tasks; batch related progress.",
398
394
  promptGuidelines: ["start requires pending and sets current task. complete requires evidence for contracted tasks and completed/skipped non-lightweight children. skipped requires a reason and explicit user direction or a hard contradiction; never skip to avoid work. pending reopens skipped tasks only; completed tasks are immutable. Completing/skipping the current task clears focus."],
399
395
  parameters: Type.Object({
400
- task_id: Type.Optional(Type.String({ description: "Single-task form; omit with updates." })),
401
- status: Type.Optional(StringEnum(["start", "complete", "skipped", "pending"] as const)),
402
- updates: Type.Optional(Type.Array(Type.Object({task_id: Type.String(), status: StringEnum(["start", "complete", "skipped", "pending"] as const), evidence: Type.Optional(Type.String()), reason: Type.Optional(Type.String())}, {additionalProperties: false}), {minItems: 1, maxItems: 100, description: "Ordered atomic batch; omit all single-task fields."})),
396
+ task_id: Type.Optional(Type.String({ description: "Single-task form; with updates, any non-empty root fields must repeat one batch entry." })),
397
+ status: Type.Optional(StringEnum(["start", "complete", "skipped", "pending", ""] as const)),
398
+ updates: Type.Optional(Type.Array(Type.Object({task_id: Type.String(), status: StringEnum(["start", "complete", "skipped", "pending"] as const), evidence: Type.Optional(Type.String()), reason: Type.Optional(Type.String())}, {additionalProperties: false}), {maxItems: 100, description: "Ordered atomic batch. An empty array is allowed only with valid single-task fields."})),
403
399
  evidence: Type.Optional(Type.String({ description: "Completion evidence; max 200 chars." })),
404
400
  reason: Type.Optional(Type.String({ description: "Required for skipped." })),
405
401
  }, { additionalProperties: false }),
406
402
  executionMode: "sequential",
407
403
  async execute(_toolCallId, rawParams, _signal, _onUpdate, ctx) {
408
- if (rawParams.updates !== undefined) {
409
- const fail = (text: string) => ({content: [{type: "text" as const, text}], details: goalDetails(core.state.goal)});
410
- if ([rawParams.task_id, rawParams.status, rawParams.evidence, rawParams.reason].some(v => v !== undefined)) return fail("Use either updates or single-task fields, never both.");
411
- const updates = rawParams.updates;
412
- if (!Array.isArray(updates) || updates.length < 1 || updates.length > 100 || updates.some(u => !u || typeof u.task_id !== "string" || !u.task_id.trim() || !["start", "complete", "skipped", "pending"].includes(u.status) || (u.evidence !== undefined && typeof u.evidence !== "string") || (u.reason !== undefined && typeof u.reason !== "string"))) return fail("updates must contain 1–100 valid task updates.");
404
+ const fail = (text: string) => ({content: [{type: "text" as const, text}], details: goalDetails(core.state.goal)});
405
+ const normalized = normalizeTaskProgress(rawParams);
406
+ if (!normalized.ok) return fail(normalized.message);
407
+ if (normalized.batch) {
408
+ const updates = normalized.updates;
413
409
  core.reconcileFocusedGoalFromDisk(ctx);
414
410
  if (loadGoalSettings(ctx.cwd).disableTasks) return fail("update_goal_task is disabled by settings (disableTasks: true).");
415
411
  if (!core.state.goal) return fail("No goal is focused.");
@@ -419,8 +415,7 @@ pi.registerTool(defineTool({
419
415
  core.updateUI(ctx);
420
416
  return fail(`${updates.map(u => `${u.task_id} ${u.status}`).join("; ")}. ${buildTaskSummary(result.goal.taskList!)}.`);
421
417
  }
422
- if (!rawParams.task_id || !rawParams.status) return {content: [{type: "text", text: "Provide task_id and status, or an updates batch."}], details: goalDetails(core.state.goal)};
423
- const params = rawParams as TaskProgressInput;
418
+ const params = normalized.updates[0]!;
424
419
  core.reconcileFocusedGoalFromDisk(ctx);
425
420
  if (loadGoalSettings(ctx.cwd).disableTasks) {
426
421
  return {
@@ -589,7 +584,8 @@ pi.registerTool(defineTool({
589
584
  };
590
585
  },
591
586
  renderCall(args, theme) {
592
- return new Text(theme.fg("toolTitle", "update_goal_task ") + theme.fg("muted", `${args?.task_id ?? ""} ${args?.status ?? ""}`), 0, 0);
587
+ const summary = args?.updates?.length ? `${args.updates.length} updates` : `${args?.task_id ?? ""} ${args?.status ?? ""}`;
588
+ return new Text(theme.fg("toolTitle", "update_goal_task ") + theme.fg("muted", summary), 0, 0);
593
589
  },
594
590
  renderResult(result, _options, theme) {
595
591
  return renderGoalResult(result, _options, theme);
@@ -0,0 +1,47 @@
1
+ export interface TaskProgressInput {
2
+ task_id: string;
3
+ status: "start" | "complete" | "skipped" | "pending";
4
+ evidence?: string;
5
+ reason?: string;
6
+ }
7
+
8
+ type Result = {ok: true; updates: TaskProgressInput[]; batch: boolean} | {ok: false; message: string};
9
+ const fields = ["task_id", "status", "evidence", "reason"] as const;
10
+ const statuses = new Set(["start", "complete", "skipped", "pending"]);
11
+ const object = (value: unknown): value is Record<string, unknown> => !!value && typeof value === "object" && !Array.isArray(value);
12
+
13
+ /** Normalize transport noise without discarding a meaningful task instruction. */
14
+ export function normalizeTaskProgress(input: unknown): Result {
15
+ const fail = (message: string): Result => ({ok: false, message});
16
+ if (!object(input)) return fail("Provide task_id and status, or an updates batch.");
17
+ const normalize = (raw: Record<string, unknown>): Partial<TaskProgressInput> | undefined => {
18
+ const result: Record<string, string> = {};
19
+ for (const field of fields) {
20
+ const value = raw[field];
21
+ if (value === undefined) continue;
22
+ if (typeof value !== "string") return;
23
+ if (value.trim()) result[field] = value.trim();
24
+ }
25
+ if (result.status && !statuses.has(result.status)) return;
26
+ return result as Partial<TaskProgressInput>;
27
+ };
28
+ const complete = (value: Partial<TaskProgressInput>): value is TaskProgressInput => !!value.task_id && !!value.status;
29
+ const root = normalize(input);
30
+ if (!root) return fail("Task fields must be strings; status must be start, complete, skipped, or pending.");
31
+ if (input.updates === undefined || (Array.isArray(input.updates) && input.updates.length === 0)) {
32
+ return complete(root) ? {ok: true, updates: [root], batch: false}
33
+ : fail("Provide task_id and status, or a non-empty updates batch.");
34
+ }
35
+ if (!Array.isArray(input.updates) || input.updates.length > 100) return fail("updates must contain 1–100 valid task updates.");
36
+ const updates: TaskProgressInput[] = [];
37
+ for (const entry of input.updates) {
38
+ if (!object(entry) || Object.keys(entry).some(key => !fields.includes(key as typeof fields[number]))) return fail("updates must contain 1–100 valid task updates.");
39
+ const normalized = normalize(entry);
40
+ if (!normalized || !complete(normalized)) return fail("updates must contain 1–100 valid task updates.");
41
+ updates.push(normalized);
42
+ }
43
+ if (!updates.some(entry => fields.every(field => root[field] === undefined || root[field] === entry[field]))) {
44
+ return fail("Top-level task fields conflict with updates. Put each instruction in updates and omit the top-level task fields.");
45
+ }
46
+ return {ok: true, updates, batch: true};
47
+ }
@@ -142,13 +142,13 @@ export function syncTerminalInputPause(core: GoalCore, ctx: ExtensionContext): v
142
142
  // Preserve Pi's ordinary Escape handling for the default combined stop.
143
143
  return matchesKey(data, "escape") && settings.pauseOrder === "together" ? undefined : { consume: true };
144
144
  }
145
- // Follow Pi's configured interrupt key (older hosts use the legacy ID).
145
+ // Follow Pi's configured interrupt key.
146
146
  let hostInterrupt = matchesKey(data, "escape");
147
147
  try {
148
148
  const manager = getKeybindings();
149
- const keys = [...manager.getKeys("app.interrupt"), ...manager.getKeys("interrupt" as never)];
149
+ const keys = manager.getKeys("app.interrupt");
150
150
  if (keys.length) hostInterrupt = keys.some(key => matchesKey(data, key));
151
- } catch { /* Hosts without an app keymap retain Escape. */ }
151
+ } catch { /* Embeddings without an app keymap retain Escape. */ }
152
152
  if (hostInterrupt && !matches("pause") && core.state.goal?.status === "active" && core.state.goal.autoContinue) {
153
153
  core.interruption.interruptTurn(ctx); return { consume: true };
154
154
  }
@@ -13,6 +13,7 @@ import type { GoalEventDetails } from "./goal-record.ts";
13
13
  import { createGoalCore } from "./goal-state.ts";
14
14
  import { registerGoalTools } from "./goal-tools.ts";
15
15
  import { filterGoalSessionContext, isDelegatedGoalSession } from "./goal-session-safety.ts";
16
+ import { registerGoalContext } from "./goal-context.ts";
16
17
 
17
18
  /**
18
19
  * pi-goal thin installer. All state lives in the GoalCore (goal-state.ts);
@@ -26,10 +27,7 @@ export default function goalExtension(
26
27
  ): void {
27
28
  if (isDelegatedGoalSession()) {
28
29
  // 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
- });
30
+ registerGoalContext(pi, messages => filterGoalSessionContext(messages, true));
33
31
  return;
34
32
  }
35
33
  pi.registerMessageRenderer<GoalEventDetails>(GOAL_EVENT_ENTRY, renderGoalEvent);
@@ -0,0 +1,52 @@
1
+ import * as fs from "node:fs";
2
+
3
+ interface RenameIO {
4
+ renameSync(source: string, target: string): void;
5
+ rename(source: string, target: string): Promise<void>;
6
+ isDirectorySync(target: string): boolean;
7
+ isDirectory(target: string): Promise<boolean>;
8
+ sleepSync(ms: number): void;
9
+ sleep(ms: number): Promise<void>;
10
+ }
11
+
12
+ let sleeper: Int32Array | undefined;
13
+ const native: RenameIO = {
14
+ renameSync: (source, target) => fs.renameSync(source, target),
15
+ rename: (source, target) => fs.promises.rename(source, target),
16
+ isDirectorySync: target => { try { return fs.lstatSync(target).isDirectory(); } catch { return false; } },
17
+ isDirectory: async target => { try { return (await fs.promises.lstat(target)).isDirectory(); } catch { return false; } },
18
+ sleepSync: ms => { sleeper ??= new Int32Array(new SharedArrayBuffer(4)); Atomics.wait(sleeper, 0, 0, ms); },
19
+ sleep: ms => new Promise(resolve => setTimeout(resolve, ms)),
20
+ };
21
+ const transient = new Set(["EPERM", "EACCES", "EBUSY"]);
22
+ // Existing critical writers are synchronous. Cap their added sleep at 75ms;
23
+ // asynchronous metadata writers can wait longer without blocking Pi's input.
24
+ const syncDelays = [5, 10, 20, 40];
25
+ const asyncDelays = [...syncDelays, 80, 160];
26
+
27
+ /** File replacement only: no destination deletion, no retries of ordinary permission errors on POSIX. */
28
+ export function atomicRenamer(platform = process.platform, io: RenameIO = native) {
29
+ const retryable = (error: unknown) => platform === "win32" && transient.has((error as NodeJS.ErrnoException | null)?.code ?? "");
30
+ return {
31
+ renameSync(source: string, target: string): void {
32
+ for (let attempt = 0; ; attempt++) {
33
+ try { io.renameSync(source, target); return; }
34
+ catch (error) {
35
+ if (!retryable(error) || attempt >= syncDelays.length || io.isDirectorySync(target)) throw error;
36
+ io.sleepSync(syncDelays[attempt]!);
37
+ }
38
+ }
39
+ },
40
+ async rename(source: string, target: string): Promise<void> {
41
+ for (let attempt = 0; ; attempt++) {
42
+ try { await io.rename(source, target); return; }
43
+ catch (error) {
44
+ if (!retryable(error) || attempt >= asyncDelays.length || await io.isDirectory(target)) throw error;
45
+ await io.sleep(asyncDelays[attempt]!);
46
+ }
47
+ }
48
+ },
49
+ };
50
+ }
51
+
52
+ export const {renameSync: renameAtomicSync, rename: renameAtomic} = atomicRenamer();
@@ -1,6 +1,7 @@
1
1
  import { goalStoragePath, goalPoolSnapshotPath, isExternalGoalStorage, ensureGoalStorageDirectory, type GoalStorageContext } from "./goal-root.ts";
2
2
  import * as fs from "node:fs";
3
3
  import * as path from "node:path";
4
+ import { renameAtomic, renameAtomicSync } from "./atomic-rename.ts";
4
5
 
5
6
  import {
6
7
  formatDuration,
@@ -156,7 +157,8 @@ function writePoolSnapshotSync(ctx: GoalFileContext, root: string, goals: GoalRe
156
157
  const target = goalPoolSnapshotPath(ctx);
157
158
  const tempPath = `${target}.${process.pid}.${Date.now()}.tmp`;
158
159
  fs.writeFileSync(tempPath, JSON.stringify(snapshot), "utf8");
159
- fs.renameSync(tempPath, target);
160
+ try { renameAtomicSync(tempPath, target); }
161
+ catch (error) { try { fs.unlinkSync(tempPath); } catch { /* best effort */ } throw error; }
160
162
  removeLegacyPoolSnapshot(root);
161
163
  } catch {
162
164
  // best-effort: a missing/stale snapshot just costs a full scan next cold read
@@ -171,7 +173,8 @@ async function writePoolSnapshotAsync(ctx: GoalFileContext, root: string, goals:
171
173
  const target = goalPoolSnapshotPath(ctx);
172
174
  const tempPath = `${target}.${process.pid}.${Date.now()}.tmp`;
173
175
  await fs.promises.writeFile(tempPath, JSON.stringify(snapshot), "utf8");
174
- await fs.promises.rename(tempPath, target);
176
+ try { await renameAtomic(tempPath, target); }
177
+ catch (error) { try { await fs.promises.unlink(tempPath); } catch { /* best effort */ } throw error; }
175
178
  removeLegacyPoolSnapshot(root);
176
179
  } catch {
177
180
  // best-effort
@@ -190,7 +193,8 @@ function updatePoolSnapshotSync(ctx: GoalFileContext, root: string, mutate: (goa
190
193
  const target = goalPoolSnapshotPath(ctx);
191
194
  const tempPath = `${target}.${process.pid}.${Date.now()}.tmp`;
192
195
  fs.writeFileSync(tempPath, JSON.stringify(snapshot), "utf8");
193
- fs.renameSync(tempPath, target);
196
+ try { renameAtomicSync(tempPath, target); }
197
+ catch (error) { try { fs.unlinkSync(tempPath); } catch { /* best effort */ } throw error; }
194
198
  removeLegacyPoolSnapshot(root);
195
199
  } catch {
196
200
  // best-effort
@@ -270,12 +274,27 @@ export function resolveGoalPath(ctx: GoalFileContext, rootRel: string, relPath:
270
274
  export function atomicWriteGoalFile(ctx: GoalFileContext, rootRel: string, relPath: string, content: string): void {
271
275
  ensureDirectory(ctx, rootRel);
272
276
  const filePath = resolveGoalPath(ctx, rootRel, relPath);
273
- if (statIfPresent(filePath)?.isSymbolicLink()) {
277
+ const prior = statIfPresent(filePath);
278
+ if (prior?.isSymbolicLink()) {
274
279
  throw new Error(`Refusing to write symlinked goal file: ${relPath}`);
275
280
  }
276
281
  const tempPath = `${filePath}.${process.pid}.${Date.now()}.tmp`;
277
- fs.writeFileSync(tempPath, content, "utf8");
278
- fs.renameSync(tempPath, filePath);
282
+ let fd: number | undefined;
283
+ let created = false;
284
+ try {
285
+ fd = fs.openSync(tempPath, "wx", prior ? prior.mode & 0o777 : 0o666);
286
+ created = true;
287
+ // open() applies the process umask even when replacing an existing file.
288
+ if (prior && process.platform !== "win32" && (prior.mode & 0o777 & process.umask())) fs.fchmodSync(fd, prior.mode & 0o777);
289
+ fs.writeFileSync(fd, content, "utf8");
290
+ fs.closeSync(fd);
291
+ fd = undefined;
292
+ renameAtomicSync(tempPath, filePath);
293
+ } catch (error) {
294
+ if (fd !== undefined) { try { fs.closeSync(fd); } catch { /* best effort */ } }
295
+ if (created) { try { fs.unlinkSync(tempPath); } catch { /* best effort */ } }
296
+ throw error;
297
+ }
279
298
  invalidateGoalPathCaches(filePath);
280
299
  invalidateGoalDirCache(goalStoragePath(ctx, rootRel));
281
300
  }
@@ -156,7 +156,7 @@ export class GoalControlsDialog implements Component {
156
156
  const conflict = main.find(([, binding]) => bindingKeys(binding).some(bound => sameGoalKey(bound, key, isKittyProtocolActive())));
157
157
  if (conflict && row.action?.context !== "expanded" && row.action?.context !== "audit" && row.action?.context !== "dialog") problems.push(`${formatGoalKeybinding(key)} is used by Pi (${conflict[0]}). Choose a free key or change it in Pi's keybindings.json.`);
158
158
  }
159
- } catch { /* Older hosts expose fewer keymap inspection methods. */ }
159
+ } catch { /* Embeddings may omit keymap inspection. */ }
160
160
  }
161
161
  if (problems.length) { this.error = problems[0]!; return; }
162
162
  this.mutate({ op: "set", path: row.path, value });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-goal-x",
3
- "version": "0.33.0",
3
+ "version": "0.34.0",
4
4
  "description": "Adds /goal to pi: conversational goal planning, flexible or ordered goals, persistent progress, and an independent completion auditor.",
5
5
  "license": "MIT",
6
6
  "author": "pi-goal-x contributors",
@@ -76,9 +76,9 @@
76
76
  "test:controls-cli": "node experiments/controls/cli-walkthrough.mjs"
77
77
  },
78
78
  "peerDependencies": {
79
- "@earendil-works/pi-ai": ">=0.83.0 <2.0.0",
80
- "@earendil-works/pi-coding-agent": ">=0.83.0 <2.0.0",
81
- "@earendil-works/pi-tui": ">=0.83.0 <2.0.0",
79
+ "@earendil-works/pi-ai": ">=1.4.0 <2.0.0",
80
+ "@earendil-works/pi-coding-agent": ">=1.4.0 <2.0.0",
81
+ "@earendil-works/pi-tui": ">=1.4.0 <2.0.0",
82
82
  "typebox": "^1.3.11"
83
83
  },
84
84
  "devDependencies": {
@@ -112,6 +112,6 @@
112
112
  }
113
113
  },
114
114
  "engines": {
115
- "node": ">=22.15.0"
115
+ "node": ">=22.19.0"
116
116
  }
117
117
  }
@@ -1,77 +0,0 @@
1
- import * as piAI from "@earendil-works/pi-ai";
2
- import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
3
-
4
- type Message = { role: string; content?: unknown; sections?: Record<string, string | null>; [key: string]: unknown };
5
- type SystemState = Message & { toolsAdded?: unknown[] };
6
- const transcript = piAI as unknown as {
7
- getCurrentSystemMessage?: (messages: readonly Message[]) => SystemState | undefined;
8
- };
9
- const hostSections = new Set(["preamble", "tools", "rules", "docs", "addendum", "project_context", "skills", "cwd"]);
10
- const messageText = (content: unknown): string => typeof content === "string" ? content : Array.isArray(content)
11
- ? content.map(part => typeof part?.text === "string" ? part.text : "").join("") : "";
12
-
13
- /**
14
- * Pi 1.0's custom-trigger path skips prompt preparation until the first tool
15
- * response. Project the current public host prompt and transcript tool state at
16
- * its documented full-context boundary; never change persisted host history.
17
- */
18
- export function alignGoalPromptProfile(messages: readonly Message[], prompt: string): Message[] | undefined {
19
- if (!transcript.getCurrentSystemMessage || !prompt) return;
20
- const current = transcript.getCurrentSystemMessage(messages);
21
- const firstSystem = messages[0]?.role === "system" ? messages[0] : undefined;
22
- const firstText = messageText(firstSystem?.content);
23
- // A content-only head can be an explicit override from another full-context
24
- // handler. Respect it. Extra content alongside SDK sections is additive.
25
- const content = firstText && !firstSystem?.sections ? firstSystem!.content
26
- : firstText && !prompt.includes(firstText)
27
- ? Array.isArray(firstSystem?.content) ? [{ type: "text", text: `${prompt}\n\n` }, ...firstSystem.content] : `${prompt}\n\n${firstText}`
28
- : prompt;
29
- const extraSections = Object.fromEntries(Object.entries(current?.sections ?? {}).filter(([name, value]) =>
30
- !hostSections.has(name) && typeof value === "string" && !prompt.includes(value)));
31
- const head: Message = {
32
- ...firstSystem, role: "system", content, timestamp: firstSystem?.timestamp ?? 0,
33
- sections: Object.keys(extraSections).length ? extraSections : undefined,
34
- toolsAdded: current?.toolsAdded, toolsRemoved: undefined,
35
- };
36
- const output = [head];
37
- for (const message of messages) {
38
- if (message.role !== "system") { output.push(message); continue; }
39
- if (message === firstSystem) continue;
40
- // Additional instructions from other extensions stay at their original
41
- // history boundary. Only host prompt/tool deltas have been replayed.
42
- const text = messageText(message.content);
43
- if (text && !prompt.includes(text)) output.push({ ...message, sections: undefined, toolsAdded: undefined, toolsRemoved: undefined });
44
- }
45
- return output;
46
- }
47
-
48
- export function registerGoalPromptProfile(
49
- pi: ExtensionAPI,
50
- isGoalContinuation: (ctx: ExtensionContext) => boolean,
51
- ): void {
52
- // Classic hosts have no transcript helpers. Structured hosts before the
53
- // full-context event expose system messages through the ordinary hook.
54
- if (!transcript.getCurrentSystemMessage) return;
55
- let hostPreparesCustomTurns = false;
56
- pi.on("before_agent_start", event => {
57
- // Pi 1.2 introduced this public protocol together with native custom-
58
- // turn preparation. Keep its structured prompt/tool deltas untouched.
59
- const kind = (event as unknown as { messageType?: string }).messageType;
60
- if (kind === "user" || kind === "custom") hostPreparesCustomTurns = true;
61
- });
62
- type FullContextOn = (event: "context_with_system", handler: (event: { messages: Message[] }, ctx: ExtensionContext) => unknown) => unknown;
63
- (pi.on as unknown as FullContextOn)("context_with_system", (event, ctx) => {
64
- if (hostPreparesCustomTurns || !isGoalContinuation(ctx)) return;
65
- const messages = alignGoalPromptProfile(event.messages, ctx.getSystemPrompt());
66
- return messages ? { messages } : undefined;
67
- });
68
- pi.on("context", (event, ctx) => {
69
- if (hostPreparesCustomTurns) return;
70
- // Modern hosts strip system messages from this event and restore them
71
- // before context_with_system; let that public boundary own the repair.
72
- const full = event.messages as unknown as Message[];
73
- if (!full.some(message => message.role === "system") || !isGoalContinuation(ctx)) return;
74
- const messages = alignGoalPromptProfile(full, ctx.getSystemPrompt());
75
- return messages ? { messages: messages as unknown as typeof event.messages } : undefined;
76
- });
77
- }