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.
- package/docs/advanced-usage.md +1 -1
- package/docs/architecture.md +9 -12
- package/docs/prompt-caching.md +4 -2
- package/extensions/goal-auditor.ts +5 -14
- package/extensions/goal-context.ts +12 -0
- package/extensions/goal-core-tools.ts +3 -2
- package/extensions/goal-events.ts +31 -34
- package/extensions/goal-ledger.ts +6 -2
- package/extensions/goal-questionnaire.ts +1 -1
- package/extensions/goal-scheduler.ts +30 -8
- package/extensions/goal-settings.ts +2 -1
- package/extensions/goal-state.ts +2 -0
- package/extensions/goal-task-tools.ts +13 -17
- package/extensions/goal-tool-inputs.ts +47 -0
- package/extensions/goal-widget.ts +3 -3
- package/extensions/goal.ts +2 -4
- package/extensions/storage/atomic-rename.ts +52 -0
- package/extensions/storage/goal-files.ts +25 -6
- package/extensions/widgets/goal-controls-dialog.ts +1 -1
- package/package.json +5 -5
- package/extensions/goal-prompt-profile.ts +0 -77
package/docs/advanced-usage.md
CHANGED
|
@@ -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
|
|
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
|
{
|
package/docs/architecture.md
CHANGED
|
@@ -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
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
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
|
|
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.
|
|
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,
|
package/docs/prompt-caching.md
CHANGED
|
@@ -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
|
|
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.
|
|
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
|
|
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
|
|
291
|
-
modelRuntime
|
|
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:
|
|
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 ||
|
|
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
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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
|
|
91
|
+
return retained;
|
|
95
92
|
}
|
|
96
93
|
liveRetention.clear(sessionKey);
|
|
97
|
-
return 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
|
-
|
|
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
|
-
|
|
378
|
-
|
|
379
|
-
|
|
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
|
-
|
|
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
|
|
571
|
-
//
|
|
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
|
-
//
|
|
584
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
|
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
|
-
|
|
1105
|
+
renameAtomicSync(temp, target);
|
|
1105
1106
|
} catch (error) {
|
|
1106
1107
|
if (fd !== undefined) {
|
|
1107
1108
|
try {
|
package/extensions/goal-state.ts
CHANGED
|
@@ -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;
|
|
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}), {
|
|
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
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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 =
|
|
149
|
+
const keys = manager.getKeys("app.interrupt");
|
|
150
150
|
if (keys.length) hostInterrupt = keys.some(key => matchesKey(data, key));
|
|
151
|
-
} catch { /*
|
|
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
|
}
|
package/extensions/goal.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
278
|
-
|
|
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 { /*
|
|
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.
|
|
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": ">=
|
|
80
|
-
"@earendil-works/pi-coding-agent": ">=
|
|
81
|
-
"@earendil-works/pi-tui": ">=
|
|
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.
|
|
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
|
-
}
|