@tt-a1i/openpi 0.1.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/README.md +643 -0
- package/SETUP.md +74 -0
- package/THIRD_PARTY_NOTICES.md +16 -0
- package/assets/openpi-package.png +0 -0
- package/assets/readme-hero-mobile.svg +72 -0
- package/assets/readme-hero.svg +118 -0
- package/assets/readme-runtime-mobile.svg +91 -0
- package/assets/readme-runtime.svg +111 -0
- package/extensions/ask-user/handoff.ts +205 -0
- package/extensions/ask-user/index.ts +1110 -0
- package/extensions/ask-user/limits.ts +89 -0
- package/extensions/ask-user/prompt.ts +76 -0
- package/extensions/background-terminals/index.ts +653 -0
- package/extensions/background-terminals/src/domain.ts +99 -0
- package/extensions/background-terminals/src/manager.ts +989 -0
- package/extensions/background-terminals/src/output.ts +84 -0
- package/extensions/background-terminals/src/prompt.ts +195 -0
- package/extensions/background-terminals/src/result-delivery.ts +43 -0
- package/extensions/background-terminals/src/runtime.ts +36 -0
- package/extensions/background-terminals/src/ui/output-view.ts +55 -0
- package/extensions/background-terminals/src/ui/ps.ts +642 -0
- package/extensions/background-terminals/src/ui/tool-result.ts +146 -0
- package/extensions/background-terminals/src/watch.ts +192 -0
- package/extensions/context-pivot/index.ts +222 -0
- package/extensions/copy-all/index.ts +65 -0
- package/extensions/cron/index.ts +173 -0
- package/extensions/cron/schedule.ts +127 -0
- package/extensions/file-mutation-display/index.ts +105 -0
- package/extensions/file-mutation-display/render.ts +107 -0
- package/extensions/file-search/index.ts +515 -0
- package/extensions/file-search/src/args.ts +129 -0
- package/extensions/file-search/src/binaries.ts +419 -0
- package/extensions/file-search/src/output.ts +142 -0
- package/extensions/file-search/src/process.ts +309 -0
- package/extensions/file-search/src/prompt.ts +53 -0
- package/extensions/git-info/index.ts +272 -0
- package/extensions/git-info/src/changed-files-view.ts +414 -0
- package/extensions/git-info/src/process.ts +107 -0
- package/extensions/git-info/src/refresh-coordinator.ts +13 -0
- package/extensions/git-info/src/runtime.ts +28 -0
- package/extensions/goal/controller.ts +794 -0
- package/extensions/goal/index.ts +521 -0
- package/extensions/goal/prompts.ts +122 -0
- package/extensions/goal/state.ts +763 -0
- package/extensions/goal/ui.ts +158 -0
- package/extensions/model-info/index.ts +234 -0
- package/extensions/plan-mode/bash-policy.ts +313 -0
- package/extensions/plan-mode/index.ts +539 -0
- package/extensions/post-edit/index.ts +129 -0
- package/extensions/sessions/LICENSE.upstream +21 -0
- package/extensions/sessions/git-stats.ts +226 -0
- package/extensions/sessions/index.ts +1092 -0
- package/extensions/sessions/sessions.ts +385 -0
- package/extensions/setup/index.ts +408 -0
- package/extensions/shared/activity-status.ts +65 -0
- package/extensions/shared/below-editor-navigation.ts +343 -0
- package/extensions/shared/child-session.ts +352 -0
- package/extensions/shared/context-utilization.ts +47 -0
- package/extensions/shared/dashboard-state.ts +102 -0
- package/extensions/shared/plan-mode-state.ts +65 -0
- package/extensions/shared/setup-config.ts +971 -0
- package/extensions/shared/subagent-roles.ts +22 -0
- package/extensions/shared/terminal-text.ts +38 -0
- package/extensions/shared/tool-call-timeout.ts +104 -0
- package/extensions/shared/worktree.ts +526 -0
- package/extensions/subagents/index.ts +1225 -0
- package/extensions/subagents/navigation.ts +121 -0
- package/extensions/subagents/src/agent-types.ts +543 -0
- package/extensions/subagents/src/backend.ts +63 -0
- package/extensions/subagents/src/backends/pi.ts +493 -0
- package/extensions/subagents/src/backends/stub.ts +296 -0
- package/extensions/subagents/src/by-the-way.ts +21 -0
- package/extensions/subagents/src/domain.ts +271 -0
- package/extensions/subagents/src/format.ts +48 -0
- package/extensions/subagents/src/manager.ts +769 -0
- package/extensions/subagents/src/prompt.ts +190 -0
- package/extensions/subagents/src/result-delivery.ts +20 -0
- package/extensions/subagents/src/runtime.ts +51 -0
- package/extensions/subagents/src/ui/takeover.ts +615 -0
- package/extensions/subagents/src/ui/transcript.ts +293 -0
- package/extensions/subagents/src/ui/wait-result.ts +89 -0
- package/extensions/suggestions/index.ts +172 -0
- package/extensions/suggestions/src/config.ts +12 -0
- package/extensions/suggestions/src/predictor.ts +147 -0
- package/extensions/suggestions/src/prompt.ts +20 -0
- package/extensions/suggestions/src/transcript.ts +233 -0
- package/extensions/suggestions/src/ui.ts +224 -0
- package/extensions/tasks/index.ts +512 -0
- package/extensions/tasks/tasks.ts +649 -0
- package/extensions/tasks/ui.ts +421 -0
- package/extensions/turn-time/index.ts +61 -0
- package/extensions/ui-customization/footer.ts +512 -0
- package/extensions/ui-customization/index.ts +217 -0
- package/extensions/workflows/acceptance.ts +298 -0
- package/extensions/workflows/artifacts.ts +225 -0
- package/extensions/workflows/controller.ts +210 -0
- package/extensions/workflows/dashboard.ts +1226 -0
- package/extensions/workflows/index.ts +1884 -0
- package/extensions/workflows/journal.ts +188 -0
- package/extensions/workflows/meta.ts +250 -0
- package/extensions/workflows/model.ts +423 -0
- package/extensions/workflows/navigation.ts +93 -0
- package/extensions/workflows/prompt.ts +212 -0
- package/extensions/workflows/replay-safety.ts +577 -0
- package/extensions/workflows/runner.ts +786 -0
- package/extensions/workflows/sandbox-child.cjs +402 -0
- package/extensions/workflows/sandbox.ts +397 -0
- package/extensions/workflows/serialization.ts +162 -0
- package/extensions/workflows/worktree-handoff.ts +216 -0
- package/package.json +87 -0
- package/scripts/prepare-effect-tsgo.mjs +16 -0
- package/skills/background-terminals/SKILL.md +30 -0
- package/skills/subagents/SKILL.md +15 -0
- package/themes/github-dark-default.json +89 -0
|
@@ -0,0 +1,794 @@
|
|
|
1
|
+
import type { ImageContent } from "@earendil-works/pi-ai";
|
|
2
|
+
import type {
|
|
3
|
+
ExtensionAPI,
|
|
4
|
+
ExtensionContext,
|
|
5
|
+
} from "@earendil-works/pi-coding-agent";
|
|
6
|
+
import { sanitizeTerminalText } from "../shared/terminal-text.ts";
|
|
7
|
+
import {
|
|
8
|
+
budgetLimitPrompt,
|
|
9
|
+
continuationPrompt,
|
|
10
|
+
objectiveUpdatedPrompt,
|
|
11
|
+
} from "./prompts.ts";
|
|
12
|
+
import {
|
|
13
|
+
GOAL_ENTRY_TYPE,
|
|
14
|
+
GoalRestoreError,
|
|
15
|
+
acknowledgeGoalCompletion,
|
|
16
|
+
budgetLimitTransition,
|
|
17
|
+
canResumeGoal,
|
|
18
|
+
clearBlockedAudit,
|
|
19
|
+
editGoalObjective,
|
|
20
|
+
emergencyLimitTransition,
|
|
21
|
+
isGoalActive,
|
|
22
|
+
isGoalVisible,
|
|
23
|
+
markContinuationDispatched,
|
|
24
|
+
normalizeGoalReason,
|
|
25
|
+
recordBlockedAudit,
|
|
26
|
+
recordGoalProgress,
|
|
27
|
+
restoreGoalState,
|
|
28
|
+
resumeGoal,
|
|
29
|
+
setContinuationDeferred,
|
|
30
|
+
transitionGoal,
|
|
31
|
+
validateGoalSnapshot,
|
|
32
|
+
type GoalSnapshot,
|
|
33
|
+
} from "./state.ts";
|
|
34
|
+
|
|
35
|
+
export const GOAL_CONTINUATION_TYPE = "goal-continuation";
|
|
36
|
+
|
|
37
|
+
type AssistantStopReason = "stop" | "length" | "toolUse" | "error" | "aborted";
|
|
38
|
+
|
|
39
|
+
const BLOCKED_GOAL_TURN_MINIMUM = 3;
|
|
40
|
+
|
|
41
|
+
export interface GoalControllerOptions {
|
|
42
|
+
now?: () => number;
|
|
43
|
+
createId?: () => string;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface GoalModelUpdate {
|
|
47
|
+
goal: GoalSnapshot;
|
|
48
|
+
message: string;
|
|
49
|
+
blockedAudit?: {
|
|
50
|
+
blocker: string;
|
|
51
|
+
consecutiveTurns: number;
|
|
52
|
+
requiredTurns: number;
|
|
53
|
+
accepted: boolean;
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function countAssistantTokens(messages: readonly unknown[]) {
|
|
58
|
+
let total = 0;
|
|
59
|
+
for (const item of messages) {
|
|
60
|
+
const message = unwrapMessage(item);
|
|
61
|
+
if (!message || message.role !== "assistant") continue;
|
|
62
|
+
const tokens = assistantGoalTokens(message);
|
|
63
|
+
total += tokens;
|
|
64
|
+
if (!Number.isSafeInteger(total)) return 0;
|
|
65
|
+
}
|
|
66
|
+
return total;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function lastAssistantStopReason(messages: readonly unknown[]) {
|
|
70
|
+
return lastAssistantMessage(messages)?.stopReason as
|
|
71
|
+
AssistantStopReason | undefined;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function isUsageLimitError(messages: readonly unknown[]) {
|
|
75
|
+
const message = lastAssistantMessage(messages);
|
|
76
|
+
if (!message || message.stopReason !== "error") return false;
|
|
77
|
+
const diagnosticText = Array.isArray(message.diagnostics)
|
|
78
|
+
? message.diagnostics
|
|
79
|
+
.flatMap((diagnostic) =>
|
|
80
|
+
isRecord(diagnostic) && isRecord(diagnostic.error)
|
|
81
|
+
? [diagnostic.error.message, diagnostic.error.code]
|
|
82
|
+
: [],
|
|
83
|
+
)
|
|
84
|
+
.join(" ")
|
|
85
|
+
: "";
|
|
86
|
+
const text = `${String(message.errorMessage ?? "")} ${diagnosticText}`;
|
|
87
|
+
return /usage(?:_|\s+)limit|insufficient_quota|quota (?:has been )?exceeded|billing limit/iu.test(
|
|
88
|
+
text,
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export class GoalController {
|
|
93
|
+
private readonly pi: ExtensionAPI;
|
|
94
|
+
private goal: GoalSnapshot | undefined;
|
|
95
|
+
private lockedReason: string | undefined;
|
|
96
|
+
private automationEnabled = false;
|
|
97
|
+
private continuationPending = false;
|
|
98
|
+
private completionAcknowledgementPending = false;
|
|
99
|
+
private readonly completionInputMarkers = new Set<string>();
|
|
100
|
+
private trackedGoalId: string | undefined;
|
|
101
|
+
private trackedStartedAt: number | undefined;
|
|
102
|
+
private trackedTokens = 0;
|
|
103
|
+
private currentRunObservedTokens = 0;
|
|
104
|
+
private currentRunFlushedTokens = 0;
|
|
105
|
+
private trackedFromRunStart = false;
|
|
106
|
+
private accountingGoalId: string | undefined;
|
|
107
|
+
private elapsedRemainderMs = 0;
|
|
108
|
+
private lastStopReason: AssistantStopReason | undefined;
|
|
109
|
+
private lastErrorWasUsageLimit = false;
|
|
110
|
+
private suppressAbortedStop = false;
|
|
111
|
+
private readonly now: () => number;
|
|
112
|
+
readonly createId: () => string;
|
|
113
|
+
|
|
114
|
+
constructor(pi: ExtensionAPI, options: GoalControllerOptions = {}) {
|
|
115
|
+
this.pi = pi;
|
|
116
|
+
this.now = options.now ?? Date.now;
|
|
117
|
+
this.createId = options.createId ?? (() => crypto.randomUUID());
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
snapshot() {
|
|
121
|
+
return isGoalVisible(this.goal) ? structuredClone(this.goal) : undefined;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
footerSnapshot() {
|
|
125
|
+
const current = this.snapshot();
|
|
126
|
+
if (
|
|
127
|
+
current?.status === "complete" &&
|
|
128
|
+
this.completionAcknowledgementPending
|
|
129
|
+
) {
|
|
130
|
+
return { ...current, completionAcknowledged: true as const };
|
|
131
|
+
}
|
|
132
|
+
return current;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
revision() {
|
|
136
|
+
return this.goal?.revision ?? 0;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
problem() {
|
|
140
|
+
return this.lockedReason;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
restore(ctx: ExtensionContext, deferActive = false) {
|
|
144
|
+
this.resetRuntime();
|
|
145
|
+
this.automationEnabled = ctx.mode !== "print" && ctx.mode !== "json";
|
|
146
|
+
try {
|
|
147
|
+
const restored = restoreGoalState(ctx.sessionManager.getBranch());
|
|
148
|
+
this.goal = restored.snapshot;
|
|
149
|
+
this.lockedReason = undefined;
|
|
150
|
+
if (restored.migrated && this.goal) this.persist(this.goal);
|
|
151
|
+
if (deferActive && this.goal?.status === "active") {
|
|
152
|
+
this.persist(setContinuationDeferred(this.goal, true, this.now()));
|
|
153
|
+
}
|
|
154
|
+
} catch (error) {
|
|
155
|
+
this.goal = undefined;
|
|
156
|
+
this.lockedReason = sanitizeTerminalText(
|
|
157
|
+
error instanceof GoalRestoreError || error instanceof Error
|
|
158
|
+
? error.message
|
|
159
|
+
: String(error),
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
replace(candidate: GoalSnapshot) {
|
|
165
|
+
this.assertUnlocked();
|
|
166
|
+
this.continuationPending = false;
|
|
167
|
+
this.completionInputMarkers.clear();
|
|
168
|
+
this.completionAcknowledgementPending = false;
|
|
169
|
+
if (this.goal?.id !== candidate.id) this.resetAccountingClock();
|
|
170
|
+
this.persist(candidate);
|
|
171
|
+
return this.snapshot();
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
edit(objective: string, ctx: ExtensionContext) {
|
|
175
|
+
this.assertUnlocked();
|
|
176
|
+
const current = this.requireVisibleGoal();
|
|
177
|
+
this.flushTrackedProgress();
|
|
178
|
+
this.persist(
|
|
179
|
+
editGoalObjective(this.goal ?? current, objective, this.now()),
|
|
180
|
+
);
|
|
181
|
+
if (this.goal?.status === "active") {
|
|
182
|
+
this.continuationPending = false;
|
|
183
|
+
if (!ctx.isIdle()) this.beginTracking(this.goal);
|
|
184
|
+
this.dispatchPrompt(
|
|
185
|
+
ctx,
|
|
186
|
+
ctx.isIdle() ? "continuation" : "objective_updated",
|
|
187
|
+
true,
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
return this.snapshot();
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
pause(reason = "Paused by user.") {
|
|
194
|
+
this.assertUnlocked();
|
|
195
|
+
const current = this.requireVisibleGoal();
|
|
196
|
+
if (current.status === "paused") return this.snapshot();
|
|
197
|
+
if (current.status !== "active") {
|
|
198
|
+
throw new Error(`Goal cannot pause from ${current.status}.`);
|
|
199
|
+
}
|
|
200
|
+
this.flushTrackedProgress();
|
|
201
|
+
this.continuationPending = false;
|
|
202
|
+
this.persist(
|
|
203
|
+
transitionGoal(this.goal ?? current, "paused", this.now(), reason),
|
|
204
|
+
);
|
|
205
|
+
this.resetAccountingClock();
|
|
206
|
+
return this.snapshot();
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
resume() {
|
|
210
|
+
this.assertUnlocked();
|
|
211
|
+
const current = this.requireVisibleGoal();
|
|
212
|
+
if (!canResumeGoal(current)) {
|
|
213
|
+
throw new Error(`Goal cannot resume from ${current.status}.`);
|
|
214
|
+
}
|
|
215
|
+
this.continuationPending = false;
|
|
216
|
+
this.resetAccountingClock();
|
|
217
|
+
this.persist(resumeGoal(current, this.now(), "Resumed by user."));
|
|
218
|
+
return this.snapshot();
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
updateFromModel(status: "complete" | "blocked", blocker?: unknown) {
|
|
222
|
+
this.assertUnlocked();
|
|
223
|
+
const current = this.requireVisibleGoal();
|
|
224
|
+
if (status === "blocked" && blocker === undefined) {
|
|
225
|
+
throw new Error("Blocked goal updates require a blocker description.");
|
|
226
|
+
}
|
|
227
|
+
const normalizedBlocker =
|
|
228
|
+
status === "blocked" ? normalizeGoalReason(blocker) : undefined;
|
|
229
|
+
if (current.status === status) {
|
|
230
|
+
return this.modelUpdate(current, `Goal is already ${status}.`);
|
|
231
|
+
}
|
|
232
|
+
if (current.status === "budget_limited" && status === "blocked") {
|
|
233
|
+
return this.modelUpdate(
|
|
234
|
+
current,
|
|
235
|
+
"Goal remains limited by budget; blocked cannot replace that status.",
|
|
236
|
+
);
|
|
237
|
+
}
|
|
238
|
+
if (status === "blocked") {
|
|
239
|
+
const recorded = recordBlockedAudit(
|
|
240
|
+
current,
|
|
241
|
+
normalizedBlocker,
|
|
242
|
+
this.now(),
|
|
243
|
+
);
|
|
244
|
+
if (recorded.recorded) this.persist(recorded.snapshot);
|
|
245
|
+
if (recorded.audit.consecutiveTurns < BLOCKED_GOAL_TURN_MINIMUM) {
|
|
246
|
+
return this.modelUpdate(
|
|
247
|
+
this.goal ?? recorded.snapshot,
|
|
248
|
+
`Blocker recorded for goal turn ${recorded.audit.consecutiveTurns} of ${BLOCKED_GOAL_TURN_MINIMUM}; the goal remains active. Continue working until the same blocker recurs on ${BLOCKED_GOAL_TURN_MINIMUM} consecutive distinct goal turns.`,
|
|
249
|
+
{
|
|
250
|
+
blocker: recorded.audit.blocker,
|
|
251
|
+
consecutiveTurns: recorded.audit.consecutiveTurns,
|
|
252
|
+
requiredTurns: BLOCKED_GOAL_TURN_MINIMUM,
|
|
253
|
+
accepted: false,
|
|
254
|
+
},
|
|
255
|
+
);
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
this.flushTrackedProgress();
|
|
259
|
+
this.continuationPending = false;
|
|
260
|
+
this.persist(
|
|
261
|
+
transitionGoal(
|
|
262
|
+
this.goal ?? current,
|
|
263
|
+
status,
|
|
264
|
+
this.now(),
|
|
265
|
+
status === "complete"
|
|
266
|
+
? "Marked complete by the goal agent."
|
|
267
|
+
: normalizedBlocker,
|
|
268
|
+
),
|
|
269
|
+
);
|
|
270
|
+
this.resetAccountingClock();
|
|
271
|
+
return this.modelUpdate(
|
|
272
|
+
this.goal!,
|
|
273
|
+
status === "complete" ? "Goal marked complete." : "Goal marked blocked.",
|
|
274
|
+
status === "blocked"
|
|
275
|
+
? {
|
|
276
|
+
blocker: normalizedBlocker!,
|
|
277
|
+
consecutiveTurns: BLOCKED_GOAL_TURN_MINIMUM,
|
|
278
|
+
requiredTurns: BLOCKED_GOAL_TURN_MINIMUM,
|
|
279
|
+
accepted: true,
|
|
280
|
+
}
|
|
281
|
+
: undefined,
|
|
282
|
+
);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
clear() {
|
|
286
|
+
this.assertUnlocked();
|
|
287
|
+
if (!isGoalVisible(this.goal)) return false;
|
|
288
|
+
this.flushTrackedProgress();
|
|
289
|
+
this.continuationPending = false;
|
|
290
|
+
this.persist(
|
|
291
|
+
transitionGoal(this.goal, "cleared", this.now(), "Cleared by user."),
|
|
292
|
+
);
|
|
293
|
+
this.resetAccountingClock();
|
|
294
|
+
return true;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
kickoff(ctx: ExtensionContext) {
|
|
298
|
+
this.assertUnlocked();
|
|
299
|
+
const current = this.requireVisibleGoal();
|
|
300
|
+
if (current.status !== "active") {
|
|
301
|
+
throw new Error(`Goal cannot start from ${current.status}.`);
|
|
302
|
+
}
|
|
303
|
+
if (!ctx.isIdle()) this.beginTracking(current);
|
|
304
|
+
return this.dispatchPrompt(ctx, "continuation", true);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
continueWhenIdle(ctx: ExtensionContext) {
|
|
308
|
+
return this.dispatchPrompt(ctx, "continuation", false);
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
sanitizeCompletionMarkerImages(images: readonly ImageContent[] = []) {
|
|
312
|
+
let changed = false;
|
|
313
|
+
const sanitized = images.filter((image) => {
|
|
314
|
+
if (image.mimeType !== GOAL_COMPLETION_MARKER_MIME) return true;
|
|
315
|
+
changed = true;
|
|
316
|
+
this.completionInputMarkers.delete(image.data);
|
|
317
|
+
return false;
|
|
318
|
+
});
|
|
319
|
+
return { images: sanitized, changed };
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
prepareExplicitInput() {
|
|
323
|
+
let marker: ImageContent | undefined;
|
|
324
|
+
if (this.goal?.status === "complete" && !this.goal.completionAcknowledged) {
|
|
325
|
+
const data = crypto.randomUUID();
|
|
326
|
+
if (this.completionInputMarkers.size >= 32) {
|
|
327
|
+
const oldest = this.completionInputMarkers.values().next().value;
|
|
328
|
+
if (oldest !== undefined) this.completionInputMarkers.delete(oldest);
|
|
329
|
+
}
|
|
330
|
+
this.completionInputMarkers.add(data);
|
|
331
|
+
marker = {
|
|
332
|
+
type: "image",
|
|
333
|
+
data,
|
|
334
|
+
mimeType: GOAL_COMPLETION_MARKER_MIME,
|
|
335
|
+
};
|
|
336
|
+
}
|
|
337
|
+
if (this.goal?.status === "active" && this.goal.deferContinuation) {
|
|
338
|
+
this.persist(setContinuationDeferred(this.goal, false, this.now()));
|
|
339
|
+
}
|
|
340
|
+
return marker;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
releaseDeferredContinuation() {
|
|
344
|
+
if (this.goal?.status !== "active" || !this.goal.deferContinuation) {
|
|
345
|
+
return false;
|
|
346
|
+
}
|
|
347
|
+
this.persist(setContinuationDeferred(this.goal, false, this.now()));
|
|
348
|
+
return true;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
agentStarted() {
|
|
352
|
+
this.continuationPending = false;
|
|
353
|
+
this.lastStopReason = undefined;
|
|
354
|
+
this.lastErrorWasUsageLimit = false;
|
|
355
|
+
this.suppressAbortedStop = false;
|
|
356
|
+
this.currentRunObservedTokens = 0;
|
|
357
|
+
this.currentRunFlushedTokens = 0;
|
|
358
|
+
if (!this.goal || !isGoalActive(this.goal) || this.goal.deferContinuation) {
|
|
359
|
+
if (!this.trackedGoalId) this.resetTrackedRun();
|
|
360
|
+
else this.trackedFromRunStart = true;
|
|
361
|
+
return;
|
|
362
|
+
}
|
|
363
|
+
this.beginTracking(this.goal, true);
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
messageEnded(message: unknown) {
|
|
367
|
+
const unwrapped = unwrapMessage(message);
|
|
368
|
+
if (!unwrapped) return undefined;
|
|
369
|
+
if (unwrapped.role === "user") {
|
|
370
|
+
const sanitized = stripKnownMarkers(
|
|
371
|
+
unwrapped,
|
|
372
|
+
this.completionInputMarkers,
|
|
373
|
+
);
|
|
374
|
+
if (!sanitized) return undefined;
|
|
375
|
+
if (sanitized.matchedTrackedMarker) {
|
|
376
|
+
this.completionAcknowledgementPending = true;
|
|
377
|
+
}
|
|
378
|
+
return {
|
|
379
|
+
message: sanitized.message,
|
|
380
|
+
footerChanged: sanitized.matchedTrackedMarker,
|
|
381
|
+
};
|
|
382
|
+
}
|
|
383
|
+
if (unwrapped.role !== "assistant") return undefined;
|
|
384
|
+
this.currentRunObservedTokens = safeTokenAdd(
|
|
385
|
+
this.currentRunObservedTokens,
|
|
386
|
+
assistantGoalTokens(unwrapped),
|
|
387
|
+
);
|
|
388
|
+
return undefined;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
turnEnded() {
|
|
392
|
+
this.persistPendingCompletionAcknowledgement();
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
settledWithoutAcknowledgement() {
|
|
396
|
+
this.completionInputMarkers.clear();
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
agentEnded(messages: readonly unknown[]) {
|
|
400
|
+
if (!this.trackedGoalId) return;
|
|
401
|
+
const observed = this.trackedFromRunStart
|
|
402
|
+
? Math.max(this.currentRunObservedTokens, countAssistantTokens(messages))
|
|
403
|
+
: this.currentRunObservedTokens;
|
|
404
|
+
this.trackedTokens = safeTokenAdd(
|
|
405
|
+
this.trackedTokens,
|
|
406
|
+
Math.max(0, observed - this.currentRunFlushedTokens),
|
|
407
|
+
);
|
|
408
|
+
this.currentRunObservedTokens = 0;
|
|
409
|
+
this.currentRunFlushedTokens = 0;
|
|
410
|
+
this.lastStopReason = lastAssistantStopReason(messages);
|
|
411
|
+
this.lastErrorWasUsageLimit = isUsageLimitError(messages);
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
toolFinished(ctx: ExtensionContext) {
|
|
415
|
+
if (
|
|
416
|
+
!this.trackedGoalId ||
|
|
417
|
+
!this.goal ||
|
|
418
|
+
this.goal.id !== this.trackedGoalId ||
|
|
419
|
+
this.goal.status !== "active"
|
|
420
|
+
) {
|
|
421
|
+
return;
|
|
422
|
+
}
|
|
423
|
+
this.flushTrackedProgress();
|
|
424
|
+
if (!this.goal || this.goal.status !== "active") return;
|
|
425
|
+
const budgetLimited = budgetLimitTransition(this.goal, this.now());
|
|
426
|
+
if (!budgetLimited) return;
|
|
427
|
+
this.persist(budgetLimited);
|
|
428
|
+
this.resetAccountingClock();
|
|
429
|
+
this.dispatchPrompt(ctx, "budget_limit", true);
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
compacted(willRetry: boolean) {
|
|
433
|
+
if (willRetry && this.lastStopReason === "aborted") {
|
|
434
|
+
this.suppressAbortedStop = true;
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
settled(ctx: ExtensionContext) {
|
|
439
|
+
const trackedGoalId = this.trackedGoalId;
|
|
440
|
+
if (trackedGoalId) this.continuationPending = false;
|
|
441
|
+
const stopReason = this.lastStopReason;
|
|
442
|
+
const errorWasUsageLimit = this.lastErrorWasUsageLimit;
|
|
443
|
+
const suppressAbortedStop = this.suppressAbortedStop;
|
|
444
|
+
|
|
445
|
+
if (!trackedGoalId || !this.goal || this.goal.id !== trackedGoalId) {
|
|
446
|
+
this.resetTrackedRun();
|
|
447
|
+
return;
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
this.flushTrackedProgress();
|
|
451
|
+
this.resetTrackedRun();
|
|
452
|
+
if (!this.goal || this.goal.id !== trackedGoalId) return;
|
|
453
|
+
if (
|
|
454
|
+
this.goal.status === "budget_limited" &&
|
|
455
|
+
stopReason === "error" &&
|
|
456
|
+
errorWasUsageLimit
|
|
457
|
+
) {
|
|
458
|
+
this.persist(
|
|
459
|
+
transitionGoal(
|
|
460
|
+
this.goal,
|
|
461
|
+
"usage_limited",
|
|
462
|
+
this.now(),
|
|
463
|
+
"Stopped after the active turn hit a usage limit.",
|
|
464
|
+
),
|
|
465
|
+
);
|
|
466
|
+
this.resetAccountingClock();
|
|
467
|
+
return;
|
|
468
|
+
}
|
|
469
|
+
if (this.goal.status !== "active") return;
|
|
470
|
+
|
|
471
|
+
this.resetUnreportedBlockedAudit();
|
|
472
|
+
const budgetLimited = budgetLimitTransition(this.goal, this.now());
|
|
473
|
+
if (budgetLimited) {
|
|
474
|
+
this.persist(budgetLimited);
|
|
475
|
+
this.resetAccountingClock();
|
|
476
|
+
this.dispatchPrompt(ctx, "budget_limit", true);
|
|
477
|
+
return;
|
|
478
|
+
}
|
|
479
|
+
if (stopReason === "aborted" && !suppressAbortedStop) {
|
|
480
|
+
this.persist(
|
|
481
|
+
transitionGoal(
|
|
482
|
+
this.goal,
|
|
483
|
+
"paused",
|
|
484
|
+
this.now(),
|
|
485
|
+
"Paused after the active turn was interrupted.",
|
|
486
|
+
),
|
|
487
|
+
);
|
|
488
|
+
this.resetAccountingClock();
|
|
489
|
+
return;
|
|
490
|
+
}
|
|
491
|
+
if (stopReason === "error") {
|
|
492
|
+
this.persist(
|
|
493
|
+
transitionGoal(
|
|
494
|
+
this.goal,
|
|
495
|
+
errorWasUsageLimit ? "usage_limited" : "blocked",
|
|
496
|
+
this.now(),
|
|
497
|
+
errorWasUsageLimit
|
|
498
|
+
? "Stopped after the active turn hit a usage limit."
|
|
499
|
+
: "Blocked after the active turn ended with an error.",
|
|
500
|
+
),
|
|
501
|
+
);
|
|
502
|
+
this.resetAccountingClock();
|
|
503
|
+
return;
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
const emergencyLimited = emergencyLimitTransition(this.goal, this.now());
|
|
507
|
+
if (emergencyLimited) {
|
|
508
|
+
this.persist(emergencyLimited);
|
|
509
|
+
this.resetAccountingClock();
|
|
510
|
+
return;
|
|
511
|
+
}
|
|
512
|
+
this.continueWhenIdle(ctx);
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
shutdown() {
|
|
516
|
+
this.automationEnabled = false;
|
|
517
|
+
this.resetRuntime();
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
private modelUpdate(
|
|
521
|
+
goal: GoalSnapshot,
|
|
522
|
+
message: string,
|
|
523
|
+
blockedAudit?: GoalModelUpdate["blockedAudit"],
|
|
524
|
+
) {
|
|
525
|
+
return {
|
|
526
|
+
goal: structuredClone(goal),
|
|
527
|
+
message,
|
|
528
|
+
...(blockedAudit === undefined ? {} : { blockedAudit }),
|
|
529
|
+
};
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
private resetUnreportedBlockedAudit() {
|
|
533
|
+
if (!this.goal?.blockedAudit) return;
|
|
534
|
+
if (
|
|
535
|
+
this.goal.blockedAudit.lastTurn === (this.goal.continuationCount ?? 0)
|
|
536
|
+
) {
|
|
537
|
+
return;
|
|
538
|
+
}
|
|
539
|
+
this.persist(clearBlockedAudit(this.goal, this.now()));
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
private persistPendingCompletionAcknowledgement() {
|
|
543
|
+
if (
|
|
544
|
+
!this.completionAcknowledgementPending ||
|
|
545
|
+
this.goal?.status !== "complete" ||
|
|
546
|
+
this.goal.completionAcknowledged
|
|
547
|
+
) {
|
|
548
|
+
this.completionAcknowledgementPending = false;
|
|
549
|
+
return;
|
|
550
|
+
}
|
|
551
|
+
this.persist(acknowledgeGoalCompletion(this.goal, this.now()));
|
|
552
|
+
this.completionAcknowledgementPending = false;
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
private dispatchPrompt(
|
|
556
|
+
ctx: ExtensionContext,
|
|
557
|
+
kind: "continuation" | "objective_updated" | "budget_limit",
|
|
558
|
+
allowBusy: boolean,
|
|
559
|
+
) {
|
|
560
|
+
if (!this.automationEnabled || !this.goal || this.continuationPending) {
|
|
561
|
+
return false;
|
|
562
|
+
}
|
|
563
|
+
if (!allowBusy && !ctx.isIdle()) return false;
|
|
564
|
+
if (kind !== "budget_limit") {
|
|
565
|
+
if (this.goal.status !== "active" || this.goal.deferContinuation) {
|
|
566
|
+
return false;
|
|
567
|
+
}
|
|
568
|
+
const emergencyLimited = emergencyLimitTransition(this.goal, this.now());
|
|
569
|
+
if (emergencyLimited) {
|
|
570
|
+
this.persist(emergencyLimited);
|
|
571
|
+
return false;
|
|
572
|
+
}
|
|
573
|
+
} else if (this.goal.status !== "budget_limited") {
|
|
574
|
+
return false;
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
if (kind === "budget_limit") this.beginTracking(this.goal);
|
|
578
|
+
const goal = this.goal;
|
|
579
|
+
const content =
|
|
580
|
+
kind === "continuation"
|
|
581
|
+
? continuationPrompt(goal)
|
|
582
|
+
: kind === "objective_updated"
|
|
583
|
+
? objectiveUpdatedPrompt(goal)
|
|
584
|
+
: budgetLimitPrompt(goal);
|
|
585
|
+
this.continuationPending = true;
|
|
586
|
+
try {
|
|
587
|
+
this.pi.sendMessage(
|
|
588
|
+
{
|
|
589
|
+
customType: GOAL_CONTINUATION_TYPE,
|
|
590
|
+
display: true,
|
|
591
|
+
content,
|
|
592
|
+
details: { kind, goalId: goal.id, revision: goal.revision },
|
|
593
|
+
},
|
|
594
|
+
{
|
|
595
|
+
triggerTurn: true,
|
|
596
|
+
deliverAs: ctx.isIdle() ? "followUp" : "steer",
|
|
597
|
+
},
|
|
598
|
+
);
|
|
599
|
+
} catch (error) {
|
|
600
|
+
this.continuationPending = false;
|
|
601
|
+
if (this.goal?.status === "active") {
|
|
602
|
+
this.persist(
|
|
603
|
+
transitionGoal(
|
|
604
|
+
this.goal,
|
|
605
|
+
"paused",
|
|
606
|
+
this.now(),
|
|
607
|
+
"Paused because the next autonomous turn could not be dispatched.",
|
|
608
|
+
),
|
|
609
|
+
);
|
|
610
|
+
}
|
|
611
|
+
throw error;
|
|
612
|
+
}
|
|
613
|
+
// Count the continuation only after the turn was actually dispatched, so a
|
|
614
|
+
// failed send never erodes the emergency safety budget with zero productive
|
|
615
|
+
// turns. The in-memory continuationPending guard (set above) is what blocks
|
|
616
|
+
// a concurrent dispatch in the window before this persist lands.
|
|
617
|
+
if (kind !== "budget_limit") {
|
|
618
|
+
this.persist(markContinuationDispatched(this.goal, this.now()));
|
|
619
|
+
}
|
|
620
|
+
return true;
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
private persist(candidate: GoalSnapshot) {
|
|
624
|
+
const checked = validateGoalSnapshot(candidate);
|
|
625
|
+
this.pi.appendEntry(GOAL_ENTRY_TYPE, checked);
|
|
626
|
+
this.goal = checked;
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
private beginTracking(goal: GoalSnapshot, fromRunStart = false) {
|
|
630
|
+
if (this.accountingGoalId !== goal.id) {
|
|
631
|
+
this.accountingGoalId = goal.id;
|
|
632
|
+
this.elapsedRemainderMs = 0;
|
|
633
|
+
}
|
|
634
|
+
if (this.trackedGoalId !== goal.id) {
|
|
635
|
+
this.trackedGoalId = goal.id;
|
|
636
|
+
this.trackedStartedAt = this.now();
|
|
637
|
+
this.trackedTokens = 0;
|
|
638
|
+
this.currentRunFlushedTokens = fromRunStart
|
|
639
|
+
? 0
|
|
640
|
+
: this.currentRunObservedTokens;
|
|
641
|
+
this.trackedFromRunStart = fromRunStart;
|
|
642
|
+
} else if (fromRunStart) {
|
|
643
|
+
this.trackedFromRunStart = true;
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
private flushTrackedProgress() {
|
|
648
|
+
if (
|
|
649
|
+
!this.trackedGoalId ||
|
|
650
|
+
!this.goal ||
|
|
651
|
+
this.goal.id !== this.trackedGoalId ||
|
|
652
|
+
this.goal.status === "cleared"
|
|
653
|
+
) {
|
|
654
|
+
return;
|
|
655
|
+
}
|
|
656
|
+
const unflushedCurrentTokens = Math.max(
|
|
657
|
+
0,
|
|
658
|
+
this.currentRunObservedTokens - this.currentRunFlushedTokens,
|
|
659
|
+
);
|
|
660
|
+
const tokens = safeTokenAdd(this.trackedTokens, unflushedCurrentTokens);
|
|
661
|
+
const now = this.now();
|
|
662
|
+
const elapsedMs =
|
|
663
|
+
this.trackedStartedAt === undefined
|
|
664
|
+
? 0
|
|
665
|
+
: Math.max(0, now - this.trackedStartedAt);
|
|
666
|
+
const totalElapsedMs = this.elapsedRemainderMs + elapsedMs;
|
|
667
|
+
const elapsedSeconds = Math.floor(totalElapsedMs / 1_000);
|
|
668
|
+
this.elapsedRemainderMs = totalElapsedMs % 1_000;
|
|
669
|
+
if (tokens > 0 || elapsedSeconds > 0) {
|
|
670
|
+
this.persist(recordGoalProgress(this.goal, tokens, elapsedSeconds, now));
|
|
671
|
+
}
|
|
672
|
+
this.trackedTokens = 0;
|
|
673
|
+
this.currentRunFlushedTokens = this.currentRunObservedTokens;
|
|
674
|
+
this.trackedStartedAt = now;
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
private requireVisibleGoal() {
|
|
678
|
+
if (!isGoalVisible(this.goal)) throw new Error("No goal is currently set.");
|
|
679
|
+
return this.goal;
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
private resetRuntime() {
|
|
683
|
+
this.continuationPending = false;
|
|
684
|
+
this.completionInputMarkers.clear();
|
|
685
|
+
this.completionAcknowledgementPending = false;
|
|
686
|
+
this.resetTrackedRun();
|
|
687
|
+
this.resetAccountingClock();
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
private resetTrackedRun() {
|
|
691
|
+
this.trackedGoalId = undefined;
|
|
692
|
+
this.trackedStartedAt = undefined;
|
|
693
|
+
this.trackedTokens = 0;
|
|
694
|
+
this.currentRunObservedTokens = 0;
|
|
695
|
+
this.currentRunFlushedTokens = 0;
|
|
696
|
+
this.trackedFromRunStart = false;
|
|
697
|
+
this.lastStopReason = undefined;
|
|
698
|
+
this.lastErrorWasUsageLimit = false;
|
|
699
|
+
this.suppressAbortedStop = false;
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
private resetAccountingClock() {
|
|
703
|
+
this.accountingGoalId = undefined;
|
|
704
|
+
this.elapsedRemainderMs = 0;
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
private assertUnlocked() {
|
|
708
|
+
if (this.lockedReason) {
|
|
709
|
+
throw new Error(
|
|
710
|
+
`Session goal is locked by malformed branch history: ${this.lockedReason}`,
|
|
711
|
+
);
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
function assistantGoalTokens(message: Record<string, unknown>) {
|
|
717
|
+
const usage = message.usage;
|
|
718
|
+
if (!isRecord(usage)) return 0;
|
|
719
|
+
const input = usage.input;
|
|
720
|
+
const output = usage.output;
|
|
721
|
+
if (
|
|
722
|
+
Number.isSafeInteger(input) &&
|
|
723
|
+
(input as number) >= 0 &&
|
|
724
|
+
Number.isSafeInteger(output) &&
|
|
725
|
+
(output as number) >= 0
|
|
726
|
+
) {
|
|
727
|
+
return safeTokenAdd(input as number, output as number);
|
|
728
|
+
}
|
|
729
|
+
const total = usage.totalTokens;
|
|
730
|
+
return Number.isSafeInteger(total) && (total as number) >= 0
|
|
731
|
+
? (total as number)
|
|
732
|
+
: 0;
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
function lastAssistantMessage(messages: readonly unknown[]) {
|
|
736
|
+
for (let index = messages.length - 1; index >= 0; index--) {
|
|
737
|
+
const message = unwrapMessage(messages[index]);
|
|
738
|
+
if (!message || message.role !== "assistant") continue;
|
|
739
|
+
if (
|
|
740
|
+
message.stopReason === "stop" ||
|
|
741
|
+
message.stopReason === "length" ||
|
|
742
|
+
message.stopReason === "toolUse" ||
|
|
743
|
+
message.stopReason === "error" ||
|
|
744
|
+
message.stopReason === "aborted"
|
|
745
|
+
) {
|
|
746
|
+
return message;
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
return undefined;
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
function unwrapMessage(value: unknown) {
|
|
753
|
+
if (!isRecord(value)) return undefined;
|
|
754
|
+
if (value.role) return value;
|
|
755
|
+
return isRecord(value.message) ? value.message : undefined;
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
export const GOAL_COMPLETION_MARKER_MIME =
|
|
759
|
+
"application/x-pi-goal-completion-ack";
|
|
760
|
+
|
|
761
|
+
function stripKnownMarkers(
|
|
762
|
+
message: Record<string, unknown>,
|
|
763
|
+
markers: Set<string>,
|
|
764
|
+
) {
|
|
765
|
+
if (!Array.isArray(message.content)) return undefined;
|
|
766
|
+
let matchedTrackedMarker = false;
|
|
767
|
+
let foundPrivateMarker = false;
|
|
768
|
+
const content = message.content.filter((part) => {
|
|
769
|
+
if (
|
|
770
|
+
!isRecord(part) ||
|
|
771
|
+
part.type !== "image" ||
|
|
772
|
+
part.mimeType !== GOAL_COMPLETION_MARKER_MIME
|
|
773
|
+
) {
|
|
774
|
+
return true;
|
|
775
|
+
}
|
|
776
|
+
foundPrivateMarker = true;
|
|
777
|
+
if (typeof part.data === "string" && markers.delete(part.data)) {
|
|
778
|
+
matchedTrackedMarker = true;
|
|
779
|
+
}
|
|
780
|
+
return false;
|
|
781
|
+
});
|
|
782
|
+
return foundPrivateMarker
|
|
783
|
+
? { message: { ...message, content }, matchedTrackedMarker }
|
|
784
|
+
: undefined;
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
function safeTokenAdd(left: number, right: number) {
|
|
788
|
+
const result = left + right;
|
|
789
|
+
return Number.isSafeInteger(result) && result >= 0 ? result : left;
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
793
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
794
|
+
}
|