@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/settings.ts
ADDED
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { basename, dirname, join } from "node:path";
|
|
4
|
+
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
5
|
+
|
|
6
|
+
export const GOAL_SETTINGS_FILE = "pi-goal.json";
|
|
7
|
+
export const GOAL_TOOL_VISIBILITIES = ["always", "after-first-goal"] as const;
|
|
8
|
+
|
|
9
|
+
export type GoalToolVisibility = (typeof GOAL_TOOL_VISIBILITIES)[number];
|
|
10
|
+
export type ContinuationLimit = number | null;
|
|
11
|
+
|
|
12
|
+
export interface GoalSettings {
|
|
13
|
+
toolVisibility: GoalToolVisibility;
|
|
14
|
+
experimental: {
|
|
15
|
+
goals: boolean;
|
|
16
|
+
};
|
|
17
|
+
rpc: {
|
|
18
|
+
enabled: boolean;
|
|
19
|
+
};
|
|
20
|
+
continuationLimits: {
|
|
21
|
+
automaticTurns: ContinuationLimit;
|
|
22
|
+
noProgressTurns: ContinuationLimit;
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export const DEFAULT_GOAL_SETTINGS: GoalSettings = {
|
|
27
|
+
toolVisibility: "always",
|
|
28
|
+
experimental: { goals: false },
|
|
29
|
+
rpc: { enabled: false },
|
|
30
|
+
continuationLimits: { automaticTurns: 25, noProgressTurns: 3 },
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export type GoalSettingsLoadResult =
|
|
34
|
+
| { kind: "missing" }
|
|
35
|
+
| { kind: "invalid"; reason: string }
|
|
36
|
+
| { kind: "loaded"; settings: GoalSettings };
|
|
37
|
+
|
|
38
|
+
export type GoalSettingsLoadIssue = Extract<GoalSettingsLoadResult, { kind: "invalid" }>;
|
|
39
|
+
|
|
40
|
+
interface GoalSettingsSaveFileSystem {
|
|
41
|
+
mkdirSync: typeof mkdirSync;
|
|
42
|
+
writeFileSync: typeof writeFileSync;
|
|
43
|
+
renameSync: typeof renameSync;
|
|
44
|
+
rmSync: typeof rmSync;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function normalizeGoalSettings(value: unknown): GoalSettings | undefined {
|
|
48
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) return undefined;
|
|
49
|
+
const toolVisibility = Object.hasOwn(value, "toolVisibility")
|
|
50
|
+
? Reflect.get(value, "toolVisibility")
|
|
51
|
+
: DEFAULT_GOAL_SETTINGS.toolVisibility;
|
|
52
|
+
if (!GOAL_TOOL_VISIBILITIES.includes(toolVisibility as GoalToolVisibility)) return undefined;
|
|
53
|
+
|
|
54
|
+
const experimentalValue = Object.hasOwn(value, "experimental") ? Reflect.get(value, "experimental") : undefined;
|
|
55
|
+
if (
|
|
56
|
+
experimentalValue !== undefined &&
|
|
57
|
+
(typeof experimentalValue !== "object" || experimentalValue === null || Array.isArray(experimentalValue))
|
|
58
|
+
) {
|
|
59
|
+
return undefined;
|
|
60
|
+
}
|
|
61
|
+
const goals =
|
|
62
|
+
experimentalValue && Object.hasOwn(experimentalValue, "goals")
|
|
63
|
+
? Reflect.get(experimentalValue, "goals")
|
|
64
|
+
: DEFAULT_GOAL_SETTINGS.experimental.goals;
|
|
65
|
+
if (typeof goals !== "boolean") return undefined;
|
|
66
|
+
|
|
67
|
+
const rpcValue = Object.hasOwn(value, "rpc") ? Reflect.get(value, "rpc") : undefined;
|
|
68
|
+
if (rpcValue !== undefined && (typeof rpcValue !== "object" || rpcValue === null || Array.isArray(rpcValue))) {
|
|
69
|
+
return undefined;
|
|
70
|
+
}
|
|
71
|
+
const rpcEnabled =
|
|
72
|
+
rpcValue && Object.hasOwn(rpcValue, "enabled")
|
|
73
|
+
? Reflect.get(rpcValue, "enabled")
|
|
74
|
+
: DEFAULT_GOAL_SETTINGS.rpc.enabled;
|
|
75
|
+
if (typeof rpcEnabled !== "boolean") return undefined;
|
|
76
|
+
|
|
77
|
+
const continuationLimitsValue = Object.hasOwn(value, "continuationLimits")
|
|
78
|
+
? Reflect.get(value, "continuationLimits")
|
|
79
|
+
: undefined;
|
|
80
|
+
if (
|
|
81
|
+
continuationLimitsValue !== undefined &&
|
|
82
|
+
(typeof continuationLimitsValue !== "object" ||
|
|
83
|
+
continuationLimitsValue === null ||
|
|
84
|
+
Array.isArray(continuationLimitsValue))
|
|
85
|
+
) {
|
|
86
|
+
return undefined;
|
|
87
|
+
}
|
|
88
|
+
const automaticTurns = continuationLimitsValue
|
|
89
|
+
? normalizeContinuationLimit(
|
|
90
|
+
Reflect.get(continuationLimitsValue, "automaticTurns"),
|
|
91
|
+
DEFAULT_GOAL_SETTINGS.continuationLimits.automaticTurns,
|
|
92
|
+
)
|
|
93
|
+
: DEFAULT_GOAL_SETTINGS.continuationLimits.automaticTurns;
|
|
94
|
+
const noProgressTurns = continuationLimitsValue
|
|
95
|
+
? normalizeContinuationLimit(
|
|
96
|
+
Reflect.get(continuationLimitsValue, "noProgressTurns"),
|
|
97
|
+
DEFAULT_GOAL_SETTINGS.continuationLimits.noProgressTurns,
|
|
98
|
+
)
|
|
99
|
+
: DEFAULT_GOAL_SETTINGS.continuationLimits.noProgressTurns;
|
|
100
|
+
if (automaticTurns === undefined || noProgressTurns === undefined) return undefined;
|
|
101
|
+
|
|
102
|
+
return {
|
|
103
|
+
toolVisibility: toolVisibility as GoalToolVisibility,
|
|
104
|
+
experimental: { goals },
|
|
105
|
+
rpc: { enabled: rpcEnabled },
|
|
106
|
+
continuationLimits: { automaticTurns, noProgressTurns },
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function normalizeContinuationLimit(value: unknown, fallback: ContinuationLimit): ContinuationLimit | undefined {
|
|
111
|
+
if (value === undefined) return fallback;
|
|
112
|
+
if (value === null) return null;
|
|
113
|
+
return typeof value === "number" && Number.isSafeInteger(value) && value > 0 ? value : undefined;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function saveGoalSettings(
|
|
117
|
+
settings: GoalSettings,
|
|
118
|
+
settingsPath = join(getAgentDir(), GOAL_SETTINGS_FILE),
|
|
119
|
+
overrides: Partial<GoalSettingsSaveFileSystem> = {},
|
|
120
|
+
) {
|
|
121
|
+
const normalized = normalizeGoalSettings(settings);
|
|
122
|
+
if (!normalized) throw new Error("Refusing to save invalid pi-goal settings.");
|
|
123
|
+
|
|
124
|
+
let raw: Record<string, unknown> = {};
|
|
125
|
+
try {
|
|
126
|
+
const contents = readFileSync(settingsPath, "utf8");
|
|
127
|
+
const parsed = JSON.parse(contents) as unknown;
|
|
128
|
+
if (!normalizeGoalSettings(parsed)) {
|
|
129
|
+
throw new Error(`${settingsPath}: invalid settings shape`);
|
|
130
|
+
}
|
|
131
|
+
raw = ownRecord(parsed) ?? {};
|
|
132
|
+
} catch (error) {
|
|
133
|
+
if (!isNodeError(error) || error.code !== "ENOENT") {
|
|
134
|
+
throw new Error(`Cannot save invalid settings file: ${formatError(error)}`);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const experimental = ownRecord(raw.experimental) ?? {};
|
|
139
|
+
const rpc = ownRecord(raw.rpc) ?? {};
|
|
140
|
+
const continuationLimits = ownRecord(raw.continuationLimits) ?? {};
|
|
141
|
+
const document = `${JSON.stringify(
|
|
142
|
+
{
|
|
143
|
+
...raw,
|
|
144
|
+
toolVisibility: normalized.toolVisibility,
|
|
145
|
+
experimental: { ...experimental, goals: normalized.experimental.goals },
|
|
146
|
+
rpc: { ...rpc, enabled: normalized.rpc.enabled },
|
|
147
|
+
continuationLimits: {
|
|
148
|
+
...continuationLimits,
|
|
149
|
+
automaticTurns: normalized.continuationLimits.automaticTurns,
|
|
150
|
+
noProgressTurns: normalized.continuationLimits.noProgressTurns,
|
|
151
|
+
},
|
|
152
|
+
},
|
|
153
|
+
null,
|
|
154
|
+
2,
|
|
155
|
+
)}\n`;
|
|
156
|
+
const fs = { mkdirSync, writeFileSync, renameSync, rmSync, ...overrides };
|
|
157
|
+
const temporaryPath = join(dirname(settingsPath), `.${basename(settingsPath)}.${randomUUID()}.tmp`);
|
|
158
|
+
try {
|
|
159
|
+
fs.mkdirSync(dirname(settingsPath), { recursive: true });
|
|
160
|
+
fs.writeFileSync(temporaryPath, document, { encoding: "utf8", flag: "wx" });
|
|
161
|
+
fs.renameSync(temporaryPath, settingsPath);
|
|
162
|
+
} finally {
|
|
163
|
+
try {
|
|
164
|
+
fs.rmSync(temporaryPath, { force: true });
|
|
165
|
+
} catch {
|
|
166
|
+
// Best-effort cleanup must not replace the save result.
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export function readGoalSettings(settingsPath = join(getAgentDir(), GOAL_SETTINGS_FILE)): GoalSettingsLoadResult {
|
|
172
|
+
let contents: string;
|
|
173
|
+
try {
|
|
174
|
+
contents = readFileSync(settingsPath, "utf8");
|
|
175
|
+
} catch (error: unknown) {
|
|
176
|
+
if (isNodeError(error) && error.code === "ENOENT") return { kind: "missing" };
|
|
177
|
+
return { kind: "invalid", reason: `${settingsPath}: ${formatError(error)}` };
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
try {
|
|
181
|
+
const settings = normalizeGoalSettings(JSON.parse(contents) as unknown);
|
|
182
|
+
return settings
|
|
183
|
+
? { kind: "loaded", settings }
|
|
184
|
+
: { kind: "invalid", reason: `${settingsPath}: invalid settings shape` };
|
|
185
|
+
} catch (error: unknown) {
|
|
186
|
+
return { kind: "invalid", reason: `${settingsPath}: ${formatError(error)}` };
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function ownRecord(value: unknown): Record<string, unknown> | undefined {
|
|
191
|
+
return value && typeof value === "object" && !Array.isArray(value) ? (value as Record<string, unknown>) : undefined;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function isNodeError(error: unknown): error is NodeJS.ErrnoException {
|
|
195
|
+
return error instanceof Error && "code" in error;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function formatError(error: unknown) {
|
|
199
|
+
return error instanceof Error ? error.message : String(error);
|
|
200
|
+
}
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import type { GoalToolVisibility } from "./settings.js";
|
|
3
|
+
|
|
4
|
+
export const GOAL_COMPLETE_TOOL = "goal_complete";
|
|
5
|
+
export const GOAL_BLOCKED_TOOL = "goal_blocked";
|
|
6
|
+
export const GOAL_TOOL_NAMES = [GOAL_COMPLETE_TOOL, GOAL_BLOCKED_TOOL] as const;
|
|
7
|
+
|
|
8
|
+
export interface GoalToolVisibilitySnapshot {
|
|
9
|
+
activeTools: string[];
|
|
10
|
+
goalToolsUnlocked: boolean;
|
|
11
|
+
goalToolsHiddenByPolicy: string[];
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
interface ToolPolicyContext {
|
|
15
|
+
isIdle?: () => boolean;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export class GoalToolPolicy {
|
|
19
|
+
private unlocked = false;
|
|
20
|
+
private readonly hiddenByPolicy = new Set<string>();
|
|
21
|
+
private readonly pi: ExtensionAPI;
|
|
22
|
+
|
|
23
|
+
constructor(pi: ExtensionAPI) {
|
|
24
|
+
this.pi = pi;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
isUnlocked() {
|
|
28
|
+
return this.unlocked;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
hasHiddenTools() {
|
|
32
|
+
return this.hiddenByPolicy.size > 0;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
isGoalToolName(name: string) {
|
|
36
|
+
return (GOAL_TOOL_NAMES as readonly string[]).includes(name);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
toolsAvailable() {
|
|
40
|
+
const active = new Set(this.pi.getActiveTools());
|
|
41
|
+
return GOAL_TOOL_NAMES.every((name) => active.has(name));
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
lock() {
|
|
45
|
+
this.unlocked = false;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
unlock() {
|
|
49
|
+
this.unlocked = true;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
unlockAndForgetHidden() {
|
|
53
|
+
this.unlocked = true;
|
|
54
|
+
this.hiddenByPolicy.clear();
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
hideIfLocked() {
|
|
58
|
+
if (this.unlocked) return;
|
|
59
|
+
const active = this.pi.getActiveTools();
|
|
60
|
+
const hidden = active.filter((name) => this.isGoalToolName(name));
|
|
61
|
+
if (hidden.length === 0) return;
|
|
62
|
+
this.pi.setActiveTools(active.filter((name) => !this.isGoalToolName(name)));
|
|
63
|
+
for (const name of hidden) this.hiddenByPolicy.add(name);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
restoreHidden() {
|
|
67
|
+
const activeBeforeRestore = this.pi.getActiveTools();
|
|
68
|
+
const activeSet = new Set(activeBeforeRestore);
|
|
69
|
+
const missingOwnedTools = [...this.hiddenByPolicy].filter((name) => !activeSet.has(name));
|
|
70
|
+
if (missingOwnedTools.length === 0) {
|
|
71
|
+
this.hiddenByPolicy.clear();
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
try {
|
|
75
|
+
this.pi.setActiveTools([...activeBeforeRestore, ...missingOwnedTools]);
|
|
76
|
+
const restored = new Set(this.pi.getActiveTools());
|
|
77
|
+
if (missingOwnedTools.some((name) => !restored.has(name))) {
|
|
78
|
+
throw new Error("the active tool policy rejected a previously hidden goal tool");
|
|
79
|
+
}
|
|
80
|
+
this.hiddenByPolicy.clear();
|
|
81
|
+
} catch (error) {
|
|
82
|
+
this.pi.setActiveTools(activeBeforeRestore);
|
|
83
|
+
throw error;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
prepareActivation(visibility: GoalToolVisibility, ctx: ToolPolicyContext) {
|
|
88
|
+
if (visibility === "after-first-goal") {
|
|
89
|
+
if (!this.toolsAvailable() && ctx.isIdle?.() !== true) {
|
|
90
|
+
throw new Error("wait until Pi is idle before revealing the goal tools");
|
|
91
|
+
}
|
|
92
|
+
this.reveal();
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
this.assertAvailable();
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
prepareSessionStart(visibility: GoalToolVisibility, previous: GoalToolVisibility) {
|
|
99
|
+
if (visibility === "after-first-goal" && previous === "always") this.lock();
|
|
100
|
+
if (visibility !== "always") return;
|
|
101
|
+
try {
|
|
102
|
+
if (this.hasHiddenTools()) this.restoreHidden();
|
|
103
|
+
} finally {
|
|
104
|
+
// Always mode remains unlocked even when an external restrictive policy
|
|
105
|
+
// prevents restoration; retained ownership lets a later session retry.
|
|
106
|
+
this.unlock();
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
reconcileRestoredState(visibility: GoalToolVisibility, hasUnfinishedGoal: boolean) {
|
|
111
|
+
if (visibility !== "after-first-goal") return;
|
|
112
|
+
if (hasUnfinishedGoal) this.unlockAndForgetHidden();
|
|
113
|
+
else if (!this.unlocked) this.hideIfLocked();
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
applyVisibilityChange(
|
|
117
|
+
previous: GoalToolVisibility,
|
|
118
|
+
next: GoalToolVisibility,
|
|
119
|
+
hasUnfinishedGoal: boolean,
|
|
120
|
+
ctx: ToolPolicyContext,
|
|
121
|
+
) {
|
|
122
|
+
if (previous === next) return;
|
|
123
|
+
if (next === "always") {
|
|
124
|
+
if (this.hasHiddenTools() && ctx.isIdle?.() !== true) {
|
|
125
|
+
throw new Error("Wait for Pi to become idle before revealing Goal tools.");
|
|
126
|
+
}
|
|
127
|
+
this.restoreHidden();
|
|
128
|
+
this.unlock();
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
if (hasUnfinishedGoal) {
|
|
132
|
+
this.unlockAndForgetHidden();
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
if (ctx.isIdle?.() !== true) {
|
|
136
|
+
throw new Error("Wait for Pi to become idle before hiding Goal tools.");
|
|
137
|
+
}
|
|
138
|
+
this.lock();
|
|
139
|
+
this.hideIfLocked();
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
snapshot(): GoalToolVisibilitySnapshot {
|
|
143
|
+
return {
|
|
144
|
+
activeTools: this.pi.getActiveTools(),
|
|
145
|
+
goalToolsUnlocked: this.unlocked,
|
|
146
|
+
goalToolsHiddenByPolicy: [...this.hiddenByPolicy],
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
restore(snapshot: GoalToolVisibilitySnapshot) {
|
|
151
|
+
this.pi.setActiveTools(snapshot.activeTools);
|
|
152
|
+
this.unlocked = snapshot.goalToolsUnlocked;
|
|
153
|
+
this.hiddenByPolicy.clear();
|
|
154
|
+
for (const name of snapshot.goalToolsHiddenByPolicy) this.hiddenByPolicy.add(name);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
private reveal() {
|
|
158
|
+
const snapshot = this.snapshot();
|
|
159
|
+
try {
|
|
160
|
+
const active = this.pi.getActiveTools();
|
|
161
|
+
const activeSet = new Set(active);
|
|
162
|
+
const missing = GOAL_TOOL_NAMES.filter((name) => !activeSet.has(name));
|
|
163
|
+
if (missing.length > 0) this.pi.setActiveTools([...active, ...missing]);
|
|
164
|
+
this.assertAvailable();
|
|
165
|
+
this.unlockAndForgetHidden();
|
|
166
|
+
} catch (error) {
|
|
167
|
+
this.restore(snapshot);
|
|
168
|
+
throw error;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
private assertAvailable() {
|
|
173
|
+
if (this.toolsAvailable()) return;
|
|
174
|
+
throw new Error(
|
|
175
|
+
"goal_complete and goal_blocked are unavailable; include them in the active tool allowlist or leave the restrictive tool mode first.",
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
}
|