@signalridge/pi-goal 0.49.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +13 -0
- package/LICENSE +21 -0
- package/README.md +347 -0
- package/package.json +67 -0
- package/src/accounting.ts +102 -0
- package/src/command-registration.ts +142 -0
- package/src/command.ts +170 -0
- package/src/commands.ts +693 -0
- package/src/errors.ts +170 -0
- package/src/goal.ts +52 -0
- package/src/index.ts +1 -0
- package/src/lifecycle.ts +626 -0
- package/src/markers.ts +25 -0
- package/src/menu.ts +854 -0
- package/src/persistence.ts +296 -0
- package/src/prompts.ts +93 -0
- package/src/queue.ts +102 -0
- package/src/run-protocol.ts +355 -0
- package/src/runtime.ts +1205 -0
- package/src/safety.ts +78 -0
- package/src/settings-ui.ts +605 -0
- package/src/settings.ts +200 -0
- package/src/tool-policy.ts +178 -0
- package/src/tools.ts +334 -0
package/src/safety.ts
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import type { ActiveGoal } from "./persistence.js";
|
|
3
|
+
|
|
4
|
+
export interface ToolFreeRepeatState {
|
|
5
|
+
toolFreeRepeatCount: number;
|
|
6
|
+
lastToolFreeOutputFingerprint?: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function queueGoalSafetyReset(goal: ActiveGoal): ActiveGoal {
|
|
10
|
+
return { ...goal, safetyResetPending: true };
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function resetGoalSafetyEpoch(goal: ActiveGoal): ActiveGoal {
|
|
14
|
+
return {
|
|
15
|
+
...goal,
|
|
16
|
+
automaticModelTurns: 0,
|
|
17
|
+
toolFreeRepeatCount: 0,
|
|
18
|
+
lastToolFreeOutputFingerprint: undefined,
|
|
19
|
+
safetyPauseCause: undefined,
|
|
20
|
+
safetyResetPending: undefined,
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function nextToolFreeRepeatState(
|
|
25
|
+
current: ToolFreeRepeatState,
|
|
26
|
+
messages: readonly unknown[],
|
|
27
|
+
toolAttempted: boolean,
|
|
28
|
+
): ToolFreeRepeatState {
|
|
29
|
+
if (toolAttempted) return { toolFreeRepeatCount: 0 };
|
|
30
|
+
const fingerprint = fingerprintVisibleAssistantOutput(messages);
|
|
31
|
+
return {
|
|
32
|
+
toolFreeRepeatCount:
|
|
33
|
+
fingerprint === current.lastToolFreeOutputFingerprint
|
|
34
|
+
? Math.min(Number.MAX_SAFE_INTEGER, current.toolFreeRepeatCount + 1)
|
|
35
|
+
: 1,
|
|
36
|
+
lastToolFreeOutputFingerprint: fingerprint,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function hasAssistantToolCall(messages: readonly unknown[]) {
|
|
41
|
+
for (const message of messages) {
|
|
42
|
+
if (!isRecord(message) || message.role !== "assistant" || !Array.isArray(message.content)) {
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
if (message.content.some((block) => isRecord(block) && block.type === "toolCall")) return true;
|
|
46
|
+
}
|
|
47
|
+
return false;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function fingerprintVisibleAssistantOutput(messages: readonly unknown[]) {
|
|
51
|
+
const normalized = normalizeVisibleAssistantOutput(messages);
|
|
52
|
+
return createHash("sha256").update(normalized, "utf8").digest("hex");
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function normalizeVisibleAssistantOutput(messages: readonly unknown[]) {
|
|
56
|
+
const text: string[] = [];
|
|
57
|
+
for (const message of messages) {
|
|
58
|
+
if (!isRecord(message) || message.role !== "assistant" || !Array.isArray(message.content)) {
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
for (const block of message.content) {
|
|
62
|
+
if (!isRecord(block) || block.type !== "text" || typeof block.text !== "string") continue;
|
|
63
|
+
text.push(block.text);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
const normalized = text
|
|
67
|
+
.join("\n")
|
|
68
|
+
.normalize("NFKC")
|
|
69
|
+
.toLowerCase()
|
|
70
|
+
.replace(/\s+/gu, " ")
|
|
71
|
+
.replace(/[\p{Cc}\p{Cf}]/gu, "")
|
|
72
|
+
.trim();
|
|
73
|
+
return normalized === "" || /^[\p{P}\s]+$/u.test(normalized) ? "" : normalized;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
77
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
78
|
+
}
|
|
@@ -0,0 +1,605 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
import { type ExtensionCommandContext, getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import { checkpointGoalActiveTime } from "./accounting.js";
|
|
4
|
+
import { notifyTerminal, safeTerminalText } from "./errors.js";
|
|
5
|
+
import { abortCurrentTurn, type GoalRuntime, STATUS_KEY } from "./runtime.js";
|
|
6
|
+
import { DEFAULT_GOAL_SETTINGS, GOAL_SETTINGS_FILE, type GoalSettings, saveGoalSettings } from "./settings.js";
|
|
7
|
+
|
|
8
|
+
interface GoalSettingsUiOptions {
|
|
9
|
+
settingsPath?: string;
|
|
10
|
+
initialScreen?: "settings" | "automatic";
|
|
11
|
+
save?: (settings: GoalSettings, settingsPath: string) => void;
|
|
12
|
+
onQueueUnfrozen?: (ctx: ExtensionCommandContext) => Promise<void>;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
interface GoalSettingsApplyOptions {
|
|
16
|
+
save?: (settings: GoalSettings) => void;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
type LimitField = "automaticTurns" | "noProgressTurns";
|
|
20
|
+
type LimitSelection = "unlimited" | "default" | "custom" | "off";
|
|
21
|
+
export async function showGoalSettings(
|
|
22
|
+
runtime: GoalRuntime,
|
|
23
|
+
ctx: ExtensionCommandContext,
|
|
24
|
+
options: GoalSettingsUiOptions = {},
|
|
25
|
+
) {
|
|
26
|
+
const settingsPath = options.settingsPath ?? join(getAgentDir(), GOAL_SETTINGS_FILE);
|
|
27
|
+
if (ctx.mode !== "tui") {
|
|
28
|
+
notifyTerminal(ctx.ui, `Edit pi-goal settings manually: ${safeTerminalText(settingsPath)}`, "info");
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
const generation = runtime.menuGeneration;
|
|
32
|
+
const isMenuCurrent = () => generation === runtime.menuGeneration && !runtime.menuController.signal.aborted;
|
|
33
|
+
const { defineMenu, runMenu } = await import("@narumitw/pi-tui-kit");
|
|
34
|
+
if (!isMenuCurrent()) return;
|
|
35
|
+
const invalid = runtime.settingsLoadIssue?.kind === "invalid";
|
|
36
|
+
const previewGoalIds = new Map<LimitField, string | null>();
|
|
37
|
+
type Screen = "settings" | "automatic" | "no-progress" | "invalid";
|
|
38
|
+
type Action =
|
|
39
|
+
| "open-automatic"
|
|
40
|
+
| "open-no-progress"
|
|
41
|
+
| "choose-automatic"
|
|
42
|
+
| "choose-no-progress"
|
|
43
|
+
| "set-visibility"
|
|
44
|
+
| "set-queue"
|
|
45
|
+
| "set-rpc";
|
|
46
|
+
const menu = defineMenu<undefined, Screen, Action, ExtensionCommandContext>({
|
|
47
|
+
start: invalid ? "invalid" : (options.initialScreen ?? "settings"),
|
|
48
|
+
screens: {
|
|
49
|
+
settings: () => ({
|
|
50
|
+
kind: "settings",
|
|
51
|
+
title: "Pi Goal Settings",
|
|
52
|
+
lines: [`User settings · ${safeTerminalText(settingsPath)}`],
|
|
53
|
+
items: [
|
|
54
|
+
{
|
|
55
|
+
id: "automaticTurns",
|
|
56
|
+
label: "Automatic-work limit",
|
|
57
|
+
description: "Pause automatic Goal work after a visible number of model responses.",
|
|
58
|
+
currentValue: formatAutomaticSettingValue(runtime.settings.continuationLimits.automaticTurns),
|
|
59
|
+
action: "open-automatic",
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
id: "noProgressTurns",
|
|
63
|
+
label: "No-progress guard",
|
|
64
|
+
description: "Pause after repeated or empty tool-free automatic runs.",
|
|
65
|
+
currentValue: formatNoProgressSettingValue(runtime.settings.continuationLimits.noProgressTurns),
|
|
66
|
+
action: "open-no-progress",
|
|
67
|
+
},
|
|
68
|
+
{
|
|
69
|
+
id: "toolVisibility",
|
|
70
|
+
label: "Goal tools",
|
|
71
|
+
description: "Keep terminal Goal tools visible, or reveal them after the first goal.",
|
|
72
|
+
currentValue: visibilityLabel(runtime.settings.toolVisibility),
|
|
73
|
+
values: ["Always", "After first goal"],
|
|
74
|
+
action: "set-visibility",
|
|
75
|
+
},
|
|
76
|
+
{
|
|
77
|
+
id: "experimentalGoals",
|
|
78
|
+
label: "Ordered goal queue",
|
|
79
|
+
description: "Enable experimental add, prioritize, skip, and drop-last workflows.",
|
|
80
|
+
currentValue: runtime.settings.experimental.goals ? "Experimental" : "Off",
|
|
81
|
+
values: ["Off", "Experimental"],
|
|
82
|
+
action: "set-queue",
|
|
83
|
+
},
|
|
84
|
+
{
|
|
85
|
+
id: "rpcEnabled",
|
|
86
|
+
label: "Managed run RPC",
|
|
87
|
+
description:
|
|
88
|
+
"Allow trusted installed extensions to start and cancel Goal runs; this is not an extension sandbox.",
|
|
89
|
+
currentValue: runtime.settings.rpc.enabled ? "On" : "Off",
|
|
90
|
+
values: ["Off", "On"],
|
|
91
|
+
action: "set-rpc",
|
|
92
|
+
},
|
|
93
|
+
],
|
|
94
|
+
}),
|
|
95
|
+
automatic: () => limitChoiceScreen(runtime, "automaticTurns", "choose-automatic"),
|
|
96
|
+
"no-progress": () => limitChoiceScreen(runtime, "noProgressTurns", "choose-no-progress"),
|
|
97
|
+
invalid: () => ({
|
|
98
|
+
kind: "detail",
|
|
99
|
+
title: "Pi Goal Settings · Read only",
|
|
100
|
+
lines: [
|
|
101
|
+
`Invalid settings file. Pi-goal is using built-in defaults. Fix ${safeTerminalText(settingsPath)} and run /reload. The file will not be overwritten.`,
|
|
102
|
+
`Automatic-work limit: ${formatAutomaticWork(runtime.settings.continuationLimits.automaticTurns)}`,
|
|
103
|
+
`No-progress guard: ${formatNoProgressProtection(runtime.settings.continuationLimits.noProgressTurns)}`,
|
|
104
|
+
`Goal tools: ${visibilityLabel(runtime.settings.toolVisibility)}`,
|
|
105
|
+
`Ordered goal queue: ${runtime.settings.experimental.goals ? "Experimental" : "Off"}`,
|
|
106
|
+
`Managed run RPC: ${runtime.settings.rpc.enabled ? "On" : "Off"}`,
|
|
107
|
+
],
|
|
108
|
+
hint: "back",
|
|
109
|
+
}),
|
|
110
|
+
},
|
|
111
|
+
actions: {
|
|
112
|
+
"open-automatic": async () => {
|
|
113
|
+
previewGoalIds.set("automaticTurns", runtime.activeGoal?.id ?? null);
|
|
114
|
+
return { kind: "to", screen: "automatic" };
|
|
115
|
+
},
|
|
116
|
+
"open-no-progress": async () => {
|
|
117
|
+
previewGoalIds.set("noProgressTurns", runtime.activeGoal?.id ?? null);
|
|
118
|
+
return { kind: "to", screen: "no-progress" };
|
|
119
|
+
},
|
|
120
|
+
"choose-automatic": async ({ itemId }) =>
|
|
121
|
+
applyLimitChoice(
|
|
122
|
+
runtime,
|
|
123
|
+
ctx,
|
|
124
|
+
options,
|
|
125
|
+
settingsPath,
|
|
126
|
+
"automaticTurns",
|
|
127
|
+
itemId,
|
|
128
|
+
previewGoalIds.get("automaticTurns") ?? null,
|
|
129
|
+
isMenuCurrent,
|
|
130
|
+
),
|
|
131
|
+
"choose-no-progress": async ({ itemId }) =>
|
|
132
|
+
applyLimitChoice(
|
|
133
|
+
runtime,
|
|
134
|
+
ctx,
|
|
135
|
+
options,
|
|
136
|
+
settingsPath,
|
|
137
|
+
"noProgressTurns",
|
|
138
|
+
itemId,
|
|
139
|
+
previewGoalIds.get("noProgressTurns") ?? null,
|
|
140
|
+
isMenuCurrent,
|
|
141
|
+
),
|
|
142
|
+
"set-visibility": async ({ value }) => {
|
|
143
|
+
const nextVisibility = value === "Always" ? "always" : "after-first-goal";
|
|
144
|
+
if (nextVisibility === runtime.settings.toolVisibility) return { kind: "stay" };
|
|
145
|
+
try {
|
|
146
|
+
const next = {
|
|
147
|
+
...structuredClone(runtime.settings),
|
|
148
|
+
toolVisibility: nextVisibility,
|
|
149
|
+
} satisfies GoalSettings;
|
|
150
|
+
applyGoalSettings(runtime, next, ctx, {
|
|
151
|
+
save: (settings) => (options.save ?? saveGoalSettings)(settings, settingsPath),
|
|
152
|
+
});
|
|
153
|
+
notifyTerminal(ctx.ui, `Goal tools: ${value}.`, "info");
|
|
154
|
+
return { kind: "stay" };
|
|
155
|
+
} catch (error) {
|
|
156
|
+
notifySettingsFailure(ctx, settingsPath, error);
|
|
157
|
+
return { kind: "rejected" };
|
|
158
|
+
}
|
|
159
|
+
},
|
|
160
|
+
"set-queue": async ({ value }) => {
|
|
161
|
+
const enabled = value === "Experimental";
|
|
162
|
+
if (enabled === runtime.settings.experimental.goals) return { kind: "stay" };
|
|
163
|
+
const next = await nextQueueSettings(runtime, ctx, enabled);
|
|
164
|
+
if (!next) return { kind: "rejected" };
|
|
165
|
+
const wasFrozen = runtime.queueFrozen;
|
|
166
|
+
try {
|
|
167
|
+
applyGoalSettings(runtime, next, ctx, {
|
|
168
|
+
save: (settings) => (options.save ?? saveGoalSettings)(settings, settingsPath),
|
|
169
|
+
});
|
|
170
|
+
if (wasFrozen && !runtime.queueFrozen) {
|
|
171
|
+
try {
|
|
172
|
+
await options.onQueueUnfrozen?.(ctx);
|
|
173
|
+
} catch (error) {
|
|
174
|
+
notifyTerminal(
|
|
175
|
+
ctx.ui,
|
|
176
|
+
`Goal queue enabled, but automatic resume failed: ${safeTerminalText(formatError(error))}. Reopen /goal to retry.`,
|
|
177
|
+
"warning",
|
|
178
|
+
);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
notifyTerminal(ctx.ui, `Ordered goal queue: ${enabled ? "Experimental" : "Off"}.`, "info");
|
|
182
|
+
return { kind: "stay" };
|
|
183
|
+
} catch (error) {
|
|
184
|
+
notifySettingsFailure(ctx, settingsPath, error);
|
|
185
|
+
return { kind: "rejected" };
|
|
186
|
+
}
|
|
187
|
+
},
|
|
188
|
+
"set-rpc": async ({ value }) => {
|
|
189
|
+
const enabled = value === "On";
|
|
190
|
+
if (enabled === runtime.settings.rpc.enabled) return { kind: "stay" };
|
|
191
|
+
try {
|
|
192
|
+
const next = {
|
|
193
|
+
...structuredClone(runtime.settings),
|
|
194
|
+
rpc: { enabled },
|
|
195
|
+
} satisfies GoalSettings;
|
|
196
|
+
applyGoalSettings(runtime, next, ctx, {
|
|
197
|
+
save: (settings) => (options.save ?? saveGoalSettings)(settings, settingsPath),
|
|
198
|
+
});
|
|
199
|
+
notifyTerminal(ctx.ui, `Managed run RPC: ${enabled ? "On" : "Off"}.`, "info");
|
|
200
|
+
return { kind: "stay" };
|
|
201
|
+
} catch (error) {
|
|
202
|
+
notifySettingsFailure(ctx, settingsPath, error);
|
|
203
|
+
return { kind: "rejected" };
|
|
204
|
+
}
|
|
205
|
+
},
|
|
206
|
+
},
|
|
207
|
+
});
|
|
208
|
+
await runMenu(ctx, menu, {
|
|
209
|
+
getState: () => undefined,
|
|
210
|
+
signal: runtime.menuController.signal,
|
|
211
|
+
isCurrent: isMenuCurrent,
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function limitChoiceScreen(runtime: GoalRuntime, field: LimitField, action: "choose-automatic" | "choose-no-progress") {
|
|
216
|
+
const value = runtime.settings.continuationLimits[field];
|
|
217
|
+
const goal = runtime.activeGoal;
|
|
218
|
+
return {
|
|
219
|
+
kind: "actions" as const,
|
|
220
|
+
title: field === "automaticTurns" ? "Automatic-work limit" : "No-progress guard",
|
|
221
|
+
lines: [
|
|
222
|
+
field === "automaticTurns"
|
|
223
|
+
? `Current: ${formatAutomaticWork(value)}`
|
|
224
|
+
: `Current: ${formatNoProgressProtection(value)}`,
|
|
225
|
+
...(field === "automaticTurns"
|
|
226
|
+
? [
|
|
227
|
+
`Set a whole-number response limit for each automatic-work epoch. Default: ${DEFAULT_GOAL_SETTINGS.continuationLimits.automaticTurns}.`,
|
|
228
|
+
]
|
|
229
|
+
: []),
|
|
230
|
+
...(goal
|
|
231
|
+
? [
|
|
232
|
+
field === "automaticTurns"
|
|
233
|
+
? `Active goal: ${goal.automaticModelTurns} automatic responses used`
|
|
234
|
+
: `Active goal: ${goal.toolFreeRepeatCount} repeated or empty runs detected`,
|
|
235
|
+
]
|
|
236
|
+
: []),
|
|
237
|
+
],
|
|
238
|
+
items: limitChoices(field, value, goal?.automaticModelTurns).map((item) => ({
|
|
239
|
+
id: item.value,
|
|
240
|
+
label: item.label,
|
|
241
|
+
description: item.description,
|
|
242
|
+
action,
|
|
243
|
+
})),
|
|
244
|
+
hint: "back" as const,
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function limitChoices(
|
|
249
|
+
field: LimitField,
|
|
250
|
+
value: number | null,
|
|
251
|
+
automaticTurnsUsed: number | undefined,
|
|
252
|
+
): Array<{ value: LimitSelection; label: string; description: string }> {
|
|
253
|
+
if (field === "automaticTurns") {
|
|
254
|
+
const unlimitedDescription =
|
|
255
|
+
value === null
|
|
256
|
+
? "No response-count cap. Completion, manual pause, blockers, provider limits, and other configured guards still apply."
|
|
257
|
+
: automaticTurnsUsed === undefined
|
|
258
|
+
? `Remove the current ${value}-response cap. Goal work will have no response-count cap; other configured stop conditions remain.`
|
|
259
|
+
: `Remove the current ${value}-response cap. The active goal has used ${automaticTurnsUsed} responses; other configured stop conditions remain.`;
|
|
260
|
+
return [
|
|
261
|
+
{
|
|
262
|
+
value: "custom",
|
|
263
|
+
label: "Set response limit…",
|
|
264
|
+
description: `Choose a whole-number response limit for each automatic-work epoch. Default: ${DEFAULT_GOAL_SETTINGS.continuationLimits.automaticTurns}.`,
|
|
265
|
+
},
|
|
266
|
+
{ value: "unlimited", label: "Unlimited…", description: unlimitedDescription },
|
|
267
|
+
];
|
|
268
|
+
}
|
|
269
|
+
const defaultLimit = DEFAULT_GOAL_SETTINGS.continuationLimits.noProgressTurns;
|
|
270
|
+
return [
|
|
271
|
+
{
|
|
272
|
+
value: "default",
|
|
273
|
+
label: `After ${defaultLimit} repeated runs (default)`,
|
|
274
|
+
description: "Pause after the default number of repeated or empty tool-free runs.",
|
|
275
|
+
},
|
|
276
|
+
{
|
|
277
|
+
value: "custom",
|
|
278
|
+
label: "Set threshold…",
|
|
279
|
+
description: "Choose a whole number of repeated or empty runs before pausing.",
|
|
280
|
+
},
|
|
281
|
+
{
|
|
282
|
+
value: "off",
|
|
283
|
+
label: "Off",
|
|
284
|
+
description: "Do not pause based on repeated or empty tool-free runs.",
|
|
285
|
+
},
|
|
286
|
+
];
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
async function applyLimitChoice(
|
|
290
|
+
runtime: GoalRuntime,
|
|
291
|
+
ctx: ExtensionCommandContext,
|
|
292
|
+
options: GoalSettingsUiOptions,
|
|
293
|
+
settingsPath: string,
|
|
294
|
+
field: LimitField,
|
|
295
|
+
itemId: string,
|
|
296
|
+
activeGoalId: string | null,
|
|
297
|
+
isCurrent: () => boolean,
|
|
298
|
+
) {
|
|
299
|
+
if (!isCurrent() || !isLimitSelection(itemId)) return { kind: "rejected" as const };
|
|
300
|
+
if ((runtime.activeGoal?.id ?? null) !== activeGoalId) {
|
|
301
|
+
notifyTerminal(
|
|
302
|
+
ctx.ui,
|
|
303
|
+
"The active goal changed while the safety setting was open. No settings were changed.",
|
|
304
|
+
"warning",
|
|
305
|
+
);
|
|
306
|
+
return { kind: "rejected" as const };
|
|
307
|
+
}
|
|
308
|
+
const previous = runtime.settings.continuationLimits[field];
|
|
309
|
+
const limit = await resolveLimitSelection(field, itemId, previous, ctx, isCurrent);
|
|
310
|
+
if (!isCurrent()) return { kind: "rejected" as const };
|
|
311
|
+
if (limit === undefined || limit === previous) return { kind: "back" as const };
|
|
312
|
+
if ((runtime.activeGoal?.id ?? null) !== activeGoalId) {
|
|
313
|
+
notifyTerminal(
|
|
314
|
+
ctx.ui,
|
|
315
|
+
"The active goal changed while editing the safety setting. No settings were changed.",
|
|
316
|
+
"warning",
|
|
317
|
+
);
|
|
318
|
+
return { kind: "rejected" as const };
|
|
319
|
+
}
|
|
320
|
+
const confirmation = await confirmLowerActiveLimit(runtime, ctx, field, limit);
|
|
321
|
+
if (!isCurrent() || !confirmation.apply) return { kind: "rejected" as const };
|
|
322
|
+
if (confirmation.goalId !== undefined && runtime.activeGoal?.id !== confirmation.goalId) {
|
|
323
|
+
notifyTerminal(ctx.ui, "The active goal changed while confirming the limit. No settings were changed.", "warning");
|
|
324
|
+
return { kind: "rejected" as const };
|
|
325
|
+
}
|
|
326
|
+
try {
|
|
327
|
+
applyGoalSettings(runtime, withLimit(runtime.settings, field, limit), ctx, {
|
|
328
|
+
save: (settings) => (options.save ?? saveGoalSettings)(settings, settingsPath),
|
|
329
|
+
});
|
|
330
|
+
notifyTerminal(ctx.ui, formatLimitSuccess(field, limit), "info");
|
|
331
|
+
return { kind: "back" as const };
|
|
332
|
+
} catch (error) {
|
|
333
|
+
notifySettingsFailure(ctx, settingsPath, error);
|
|
334
|
+
return { kind: "rejected" as const };
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
export function applyGoalSettings(
|
|
339
|
+
runtime: GoalRuntime,
|
|
340
|
+
next: GoalSettings,
|
|
341
|
+
ctx: ExtensionCommandContext,
|
|
342
|
+
options: GoalSettingsApplyOptions = {},
|
|
343
|
+
) {
|
|
344
|
+
const snapshot = runtime.snapshotSettingsApplicationState();
|
|
345
|
+
let fileSaved = false;
|
|
346
|
+
try {
|
|
347
|
+
runtime.settings = structuredClone(next);
|
|
348
|
+
applyToolVisibility(runtime, snapshot.settings, next, ctx);
|
|
349
|
+
options.save?.(next);
|
|
350
|
+
fileSaved = options.save !== undefined;
|
|
351
|
+
applyQueueSetting(runtime, ctx);
|
|
352
|
+
const activeGoalId = runtime.activeGoal?.id;
|
|
353
|
+
const abortOwnedRun = activeGoalId !== undefined && runtime.agentRunGoalId === activeGoalId;
|
|
354
|
+
const pausedByAutomaticLimit = runtime.enforceAutomaticTurnLimit(ctx, abortOwnedRun);
|
|
355
|
+
if (!pausedByAutomaticLimit) runtime.enforceNoProgressLimit(ctx, abortOwnedRun);
|
|
356
|
+
if (runtime.activeGoal && !runtime.queueFrozen) {
|
|
357
|
+
runtime.updateStatus(ctx, runtime.activeGoal);
|
|
358
|
+
}
|
|
359
|
+
} catch (error) {
|
|
360
|
+
const rollbackErrors: unknown[] = [];
|
|
361
|
+
try {
|
|
362
|
+
runtime.restoreSettingsApplicationState(snapshot);
|
|
363
|
+
} catch (rollbackError) {
|
|
364
|
+
rollbackErrors.push(rollbackError);
|
|
365
|
+
}
|
|
366
|
+
if (fileSaved) {
|
|
367
|
+
try {
|
|
368
|
+
options.save?.(snapshot.settings);
|
|
369
|
+
} catch (rollbackError) {
|
|
370
|
+
rollbackErrors.push(rollbackError);
|
|
371
|
+
}
|
|
372
|
+
try {
|
|
373
|
+
restorePersistedRuntime(runtime, ctx);
|
|
374
|
+
} catch (rollbackError) {
|
|
375
|
+
rollbackErrors.push(rollbackError);
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
if (rollbackErrors.length > 0) {
|
|
379
|
+
throw new AggregateError(
|
|
380
|
+
[error, ...rollbackErrors],
|
|
381
|
+
`pi-goal settings application failed and rollback was incomplete: ${formatError(error)}`,
|
|
382
|
+
);
|
|
383
|
+
}
|
|
384
|
+
throw error;
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
export function parseGoalLimit(value: string): number | undefined {
|
|
389
|
+
const normalized = value.trim();
|
|
390
|
+
if (!/^\d+$/u.test(normalized)) return undefined;
|
|
391
|
+
const parsed = Number(normalized);
|
|
392
|
+
return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : undefined;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
export function formatGoalLimit(value: number | null) {
|
|
396
|
+
return value === null ? "Unlimited" : String(value);
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
async function resolveLimitSelection(
|
|
400
|
+
field: LimitField,
|
|
401
|
+
selection: LimitSelection,
|
|
402
|
+
previous: number | null,
|
|
403
|
+
ctx: ExtensionCommandContext,
|
|
404
|
+
isCurrent: () => boolean,
|
|
405
|
+
): Promise<number | null | undefined> {
|
|
406
|
+
if (selection === "off") return null;
|
|
407
|
+
if (selection === "unlimited") {
|
|
408
|
+
if (previous === null) return null;
|
|
409
|
+
const confirmed = await ctx.ui.confirm(
|
|
410
|
+
"Allow Unlimited automatic work?",
|
|
411
|
+
"Tool loops can continue without a response-count limit and may consume substantial tokens and provider cost. Completion, manual pause, blockers, provider limits, and the no-progress guard still apply.",
|
|
412
|
+
);
|
|
413
|
+
return isCurrent() && confirmed ? null : undefined;
|
|
414
|
+
}
|
|
415
|
+
if (selection === "default") {
|
|
416
|
+
return DEFAULT_GOAL_SETTINGS.continuationLimits[field];
|
|
417
|
+
}
|
|
418
|
+
while (true) {
|
|
419
|
+
const raw =
|
|
420
|
+
field === "automaticTurns"
|
|
421
|
+
? await ctx.ui.editor(
|
|
422
|
+
`Automatic-work response limit (whole number greater than 0) · Default: ${DEFAULT_GOAL_SETTINGS.continuationLimits.automaticTurns}`,
|
|
423
|
+
String(previous ?? DEFAULT_GOAL_SETTINGS.continuationLimits.automaticTurns),
|
|
424
|
+
)
|
|
425
|
+
: await ctx.ui.input(
|
|
426
|
+
"Repeated-run threshold (whole number greater than 0)",
|
|
427
|
+
previous === null ? "Positive whole number" : String(previous),
|
|
428
|
+
);
|
|
429
|
+
if (!isCurrent() || raw === undefined) return undefined;
|
|
430
|
+
const parsed = parseGoalLimit(raw);
|
|
431
|
+
if (parsed !== undefined) return parsed;
|
|
432
|
+
notifyTerminal(
|
|
433
|
+
ctx.ui,
|
|
434
|
+
`Enter a whole number greater than 0. Choose ${field === "automaticTurns" ? "Unlimited" : "Off"} from the previous screen if you do not want a limit.`,
|
|
435
|
+
"warning",
|
|
436
|
+
);
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
async function nextQueueSettings(runtime: GoalRuntime, ctx: ExtensionCommandContext, enabled: boolean) {
|
|
441
|
+
if (runtime.settings.experimental.goals === enabled) return undefined;
|
|
442
|
+
if (enabled && !runtime.settings.experimental.goals) {
|
|
443
|
+
const confirmed = await ctx.ui.confirm(
|
|
444
|
+
"Enable experimental goal queue?",
|
|
445
|
+
"Queue behavior and persisted state may change between releases. Existing single-goal behavior remains available.",
|
|
446
|
+
);
|
|
447
|
+
if (!confirmed) return undefined;
|
|
448
|
+
}
|
|
449
|
+
if (
|
|
450
|
+
!enabled &&
|
|
451
|
+
(runtime.queuedGoals.length > 0 || runtime.pendingQueueAction !== undefined) &&
|
|
452
|
+
!(await ctx.ui.confirm(
|
|
453
|
+
"Freeze ordered goal queue?",
|
|
454
|
+
`Disabling the experiment preserves ${retainedGoalCount(runtime)} goal(s) but freezes automatic work until the setting is re-enabled. No goal data will be deleted.`,
|
|
455
|
+
))
|
|
456
|
+
) {
|
|
457
|
+
return undefined;
|
|
458
|
+
}
|
|
459
|
+
return {
|
|
460
|
+
...structuredClone(runtime.settings),
|
|
461
|
+
experimental: { goals: enabled },
|
|
462
|
+
} satisfies GoalSettings;
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
function applyToolVisibility(
|
|
466
|
+
runtime: GoalRuntime,
|
|
467
|
+
previous: GoalSettings,
|
|
468
|
+
next: GoalSettings,
|
|
469
|
+
ctx: ExtensionCommandContext,
|
|
470
|
+
) {
|
|
471
|
+
runtime.toolPolicy.applyVisibilityChange(
|
|
472
|
+
previous.toolVisibility,
|
|
473
|
+
next.toolVisibility,
|
|
474
|
+
runtime.activeGoal !== undefined,
|
|
475
|
+
ctx,
|
|
476
|
+
);
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
function applyQueueSetting(runtime: GoalRuntime, ctx: ExtensionCommandContext) {
|
|
480
|
+
const hasQueueState = runtime.queuedGoals.length > 0 || runtime.pendingQueueAction !== undefined;
|
|
481
|
+
const shouldFreeze = !runtime.settings.experimental.goals && hasQueueState;
|
|
482
|
+
// Keep the freeze guard until the aborted Goal-owned run reaches agent_settled.
|
|
483
|
+
// Releasing it earlier lets the old agent_end pause newly resumed work.
|
|
484
|
+
if (runtime.queueFrozen && !shouldFreeze && runtime.queueFreezeAwaitingSettle) return;
|
|
485
|
+
if (runtime.queueFrozen === shouldFreeze) return;
|
|
486
|
+
const activeGoal = runtime.activeGoal?.status === "active" ? runtime.activeGoal : undefined;
|
|
487
|
+
const goalOwnedRun = activeGoal && runtime.agentRunGoalId === activeGoal.id;
|
|
488
|
+
if (shouldFreeze && activeGoal) {
|
|
489
|
+
if (goalOwnedRun) runtime.recordGoalUsage(activeGoal, ctx, false);
|
|
490
|
+
else {
|
|
491
|
+
const now = Date.now();
|
|
492
|
+
checkpointGoalActiveTime(activeGoal, now, false);
|
|
493
|
+
activeGoal.updatedAt = now;
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
runtime.queueFrozen = shouldFreeze;
|
|
497
|
+
if (runtime.activeGoal) runtime.persistGoal(runtime.activeGoal);
|
|
498
|
+
if (shouldFreeze) ctx.ui.setStatus(STATUS_KEY, "queue off");
|
|
499
|
+
else if (runtime.activeGoal) runtime.updateStatus(ctx, runtime.activeGoal);
|
|
500
|
+
else ctx.ui.setStatus(STATUS_KEY, undefined);
|
|
501
|
+
if (!shouldFreeze) return;
|
|
502
|
+
|
|
503
|
+
runtime.cancelContinuationWork();
|
|
504
|
+
runtime.clearGoalRecovery();
|
|
505
|
+
runtime.clearBudgetWrapUp();
|
|
506
|
+
if (goalOwnedRun) {
|
|
507
|
+
runtime.blockStaleGoalToolCalls();
|
|
508
|
+
runtime.guardAbortGoalId = activeGoal.id;
|
|
509
|
+
runtime.queueFreezeAwaitingSettle = true;
|
|
510
|
+
runtime.clearAgentRun();
|
|
511
|
+
abortCurrentTurn(ctx);
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
function restorePersistedRuntime(runtime: GoalRuntime, ctx: ExtensionCommandContext) {
|
|
516
|
+
if (runtime.activeGoal) {
|
|
517
|
+
runtime.persistGoal(runtime.activeGoal);
|
|
518
|
+
if (runtime.queueFrozen) ctx.ui.setStatus(STATUS_KEY, "queue off");
|
|
519
|
+
else runtime.updateStatus(ctx, runtime.activeGoal);
|
|
520
|
+
return;
|
|
521
|
+
}
|
|
522
|
+
ctx.ui.setStatus(STATUS_KEY, undefined);
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
async function confirmLowerActiveLimit(
|
|
526
|
+
runtime: GoalRuntime,
|
|
527
|
+
ctx: ExtensionCommandContext,
|
|
528
|
+
field: LimitField,
|
|
529
|
+
limit: number | null,
|
|
530
|
+
) {
|
|
531
|
+
const goal = runtime.activeGoal;
|
|
532
|
+
if (goal?.status !== "active" || limit === null) return { apply: true };
|
|
533
|
+
const used = field === "automaticTurns" ? goal.automaticModelTurns : goal.toolFreeRepeatCount;
|
|
534
|
+
if (used < limit) return { apply: true };
|
|
535
|
+
return {
|
|
536
|
+
apply: await ctx.ui.confirm(
|
|
537
|
+
"Apply limit and pause now?",
|
|
538
|
+
`The active goal has already used ${used}. Setting this limit to ${limit} will pause it immediately without deleting progress.`,
|
|
539
|
+
),
|
|
540
|
+
goalId: goal.id,
|
|
541
|
+
};
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
function withLimit(settings: GoalSettings, field: LimitField, value: number | null): GoalSettings {
|
|
545
|
+
return {
|
|
546
|
+
...structuredClone(settings),
|
|
547
|
+
continuationLimits: { ...settings.continuationLimits, [field]: value },
|
|
548
|
+
};
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
function formatAutomaticSettingValue(value: number | null) {
|
|
552
|
+
return value === null ? "Unlimited" : `${value} responses`;
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
function formatNoProgressSettingValue(value: number | null) {
|
|
556
|
+
if (value === null) return "Off";
|
|
557
|
+
return `${value} ${value === 1 ? "run" : "runs"}`;
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
function formatAutomaticWork(value: number | null) {
|
|
561
|
+
return value === null ? "Unlimited" : `Up to ${value} responses`;
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
function formatNoProgressProtection(value: number | null) {
|
|
565
|
+
if (value === null) return "Off";
|
|
566
|
+
return `After ${value} repeated ${value === 1 ? "run" : "runs"}`;
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
function formatLimitSuccess(field: LimitField, value: number | null) {
|
|
570
|
+
return field === "automaticTurns"
|
|
571
|
+
? `Automatic-work limit: ${formatAutomaticWork(value)}.`
|
|
572
|
+
: `No-progress guard: ${formatNoProgressProtection(value)}.`;
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
function isLimitSelection(value: string): value is LimitSelection {
|
|
576
|
+
return value === "unlimited" || value === "default" || value === "custom" || value === "off";
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
function visibilityLabel(value: GoalSettings["toolVisibility"]) {
|
|
580
|
+
return value === "always" ? "Always" : "After first goal";
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
function retainedGoalCount(runtime: GoalRuntime) {
|
|
584
|
+
return (
|
|
585
|
+
(runtime.activeGoal ? 1 : 0) +
|
|
586
|
+
runtime.queuedGoals.length +
|
|
587
|
+
(runtime.pendingQueueAction?.kind === "prioritize" ? 1 : 0)
|
|
588
|
+
);
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
function notifySettingsFailure(ctx: ExtensionCommandContext, settingsPath: string, error: unknown) {
|
|
592
|
+
const path = safeTerminalText(settingsPath);
|
|
593
|
+
const detail = safeTerminalText(formatError(error));
|
|
594
|
+
notifyTerminal(
|
|
595
|
+
ctx.ui,
|
|
596
|
+
error instanceof AggregateError
|
|
597
|
+
? `Could not apply Goal settings, and rollback was incomplete. Check ${path}, run /reload, and verify the effective settings before retrying: ${detail}`
|
|
598
|
+
: `Could not save Goal settings; the previous value remains. Check ${path} and retry: ${detail}`,
|
|
599
|
+
"error",
|
|
600
|
+
);
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
function formatError(error: unknown) {
|
|
604
|
+
return error instanceof Error ? error.message : String(error);
|
|
605
|
+
}
|