@prevalentware/opencode-goal-plugin 0.1.41 → 0.1.43
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 +7 -5
- package/dist/server.js +245 -55
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -16,9 +16,9 @@ Links:
|
|
|
16
16
|
|
|
17
17
|
The OpenCode Goal Plugin adds:
|
|
18
18
|
|
|
19
|
-
- `/goal <objective
|
|
19
|
+
- `/goal <objective>`, `/pause_goal`, and `/resume_goal` as OpenCode commands for TUI, desktop, web, and remote integrations that expose the server command catalog.
|
|
20
20
|
- A sidebar goal indicator with status, elapsed time, and objective.
|
|
21
|
-
- Agent tools: `get_goal`, `get_goal_history`, `list_all_goals`, `create_goal`, `set_goal`, `update_goal_objective`, `update_goal`, and `clear_goal`.
|
|
21
|
+
- Agent tools: `get_goal`, `get_goal_history`, `list_all_goals`, `create_goal`, `set_goal`, `update_goal_objective`, `update_goal_status`, `update_goal`, and `clear_goal`.
|
|
22
22
|
- Goal close evidence: `complete` requires verified evidence, and `unmet` requires a concrete blocker.
|
|
23
23
|
- Persistent per-session goal state with history, checkpoints, budgets, and owner-only file permissions.
|
|
24
24
|
- Optional automatic continuation on `session.idle` / `session.status`, with no-progress pause and budget wrap-up safeguards.
|
|
@@ -165,8 +165,8 @@ Defaults:
|
|
|
165
165
|
- `max_goal_duration_seconds`: unset by default; when set, new goals inherit this elapsed-time safety limit.
|
|
166
166
|
- `no_progress_token_threshold`: `50`; output-token floor used to judge whether a goal continuation turn made progress.
|
|
167
167
|
- `max_no_progress_turns`: `2`; consecutive low-progress goal continuation turns before pausing. Only turns produced by a reserved goal continuation count — ordinary low-output assistant messages (for example short tool-call-only turns from PTY or status checks) never increment this counter.
|
|
168
|
-
- `register_command`: `true
|
|
169
|
-
- `command_name`: `"goal"`
|
|
168
|
+
- `register_command`: `true`; registers `/goal`, `/pause_goal`, and `/resume_goal`.
|
|
169
|
+
- `command_name`: `"goal"`; renames the main goal command only. The reserved names `pause_goal` and `resume_goal` fall back to `goal` so the standalone controls remain available.
|
|
170
170
|
- `restricted_agents`: `["plan"]`; agents (matched case-insensitively) treated as planning-only for goal execution.
|
|
171
171
|
- `allow_goal_execution_from_plan`: `false`; when `true`, disables Plan-mode goal restrictions entirely.
|
|
172
172
|
|
|
@@ -178,7 +178,7 @@ Use `/goal <objective>` in a fresh OpenCode chat to create a long-running goal:
|
|
|
178
178
|
/goal review the frontend and translate visible English UI text to Spanish
|
|
179
179
|
```
|
|
180
180
|
|
|
181
|
-
Bare `/goal` reports the current goal state. `/goal history` reports lifecycle history and recent checkpoints. `/goal edit <objective>` updates the current objective. `/goal pause` pauses the goal without clearing it, and `/goal resume` resumes it. `/goal clear` clears the goal; `/goal stop`, `/goal off`, `/goal reset`, `/goal none`, and `/goal cancel` are clear aliases. The TUI also includes a `Goal` command-palette entry for viewing, refreshing, pausing, resuming, showing history, or clearing the current goal state without creating a new goal.
|
|
181
|
+
Bare `/goal` reports the current goal state. `/goal history` reports lifecycle history and recent checkpoints. `/goal edit <objective>` updates the current objective. `/goal pause` pauses the goal without clearing it, and `/goal resume` resumes it. The standalone `/pause_goal` and `/resume_goal` controls are discoverable by remote integrations that expose OpenCode's server command catalog. Their arguments and resolved attachments are removed before composing the goal-control prompt, although OpenCode V1 may evaluate its own command syntax before plugin hooks run. `/pause_goal` persists the pause before its acknowledgement turn starts, preventing a later idle event from starting another continuation. It cannot cancel a continuation that was already delivered or whose delivery was already in flight when the pause was committed. Pausing a goal that is already `budgetLimited` or `usageLimited` preserves that safety status; resuming a closed `complete` or `unmet` goal is rejected. `/goal clear` clears the goal; `/goal stop`, `/goal off`, `/goal reset`, `/goal none`, and `/goal cancel` are clear aliases. The TUI also includes a `Goal` command-palette entry for viewing, refreshing, pausing, resuming, showing history, or clearing the current goal state without creating a new goal.
|
|
182
182
|
|
|
183
183
|
You can also ask the agent to formulate the objective and call `set_goal` itself, for example: "set your own goal to finish this refactor safely." The tool uses the agent-written objective but still only creates a goal when explicitly requested.
|
|
184
184
|
|
|
@@ -231,6 +231,8 @@ Ordinary fsync improves crash consistency but is not `F_FULLFSYNC`, so sudden po
|
|
|
231
231
|
|
|
232
232
|
If the rename succeeds but syncing the parent directory reports a genuine I/O error, the mutation reports a write failure even though the new valid state may already be present. This avoids claiming durability that the filesystem did not confirm.
|
|
233
233
|
|
|
234
|
+
If a non-empty state file contains only whitespace, a UTF-8 BOM, or NUL bytes after an interrupted write, the next mutation preserves its exact contents beside the state file as `goals.json.corrupt-<timestamp>-<uuid>` before writing recovered state. If another process replaces the state during recovery, the mutation refuses to overwrite that newer content. If the quarantine copy itself cannot be created, the plugin reports the failure and continues recovery rather than making every prompt fail indefinitely. OpenCode 1 also records the quarantine outcome and path through its application log so the data-loss event remains discoverable.
|
|
235
|
+
|
|
234
236
|
## Credits
|
|
235
237
|
|
|
236
238
|
This plugin follows Codex's native goal-mode semantics where OpenCode plugin hooks allow it. Several hardening ideas were adapted from William Ricchiuti's [`willytop8/OpenCode-goal-plugin`](https://github.com/willytop8/OpenCode-goal-plugin), especially lifecycle history, checkpoints, no-progress safeguards, budget wrap-up behavior, and strict-provider-safe system prompt merging. Thank you, William.
|
package/dist/server.js
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
import { z } from "zod";
|
|
4
4
|
|
|
5
5
|
// src/state.ts
|
|
6
|
+
import { randomUUID as randomUUID2 } from "crypto";
|
|
6
7
|
import { mkdir, readFile } from "fs/promises";
|
|
7
8
|
import { homedir } from "os";
|
|
8
9
|
import { dirname as dirname2, join } from "path";
|
|
@@ -220,6 +221,23 @@ function mutableState(state) {
|
|
|
220
221
|
return JSON.parse(JSON.stringify(state));
|
|
221
222
|
}
|
|
222
223
|
var warnedEmptyStatePaths = new Set;
|
|
224
|
+
var stateRecoveryListeners = new Set;
|
|
225
|
+
function onStateRecovery(stateFile, report) {
|
|
226
|
+
const listener = { stateFile, report };
|
|
227
|
+
stateRecoveryListeners.add(listener);
|
|
228
|
+
return () => stateRecoveryListeners.delete(listener);
|
|
229
|
+
}
|
|
230
|
+
function notifyStateRecovery(notice) {
|
|
231
|
+
for (const listener of stateRecoveryListeners) {
|
|
232
|
+
if (listener.stateFile !== notice.stateFile)
|
|
233
|
+
continue;
|
|
234
|
+
Promise.resolve().then(() => listener.report(notice)).catch((error) => {
|
|
235
|
+
try {
|
|
236
|
+
console.error(`[opencode-goal-plugin] Failed to report quarantined state at ${notice.quarantineFile}:`, error instanceof Error ? error.message : String(error));
|
|
237
|
+
} catch {}
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
}
|
|
223
241
|
function isStatePadding(character) {
|
|
224
242
|
return character === "\x00" || character.trim() === "";
|
|
225
243
|
}
|
|
@@ -232,24 +250,53 @@ function parseStateText(raw, file) {
|
|
|
232
250
|
end -= 1;
|
|
233
251
|
const content = raw.slice(start, end);
|
|
234
252
|
if (content)
|
|
235
|
-
return JSON.parse(content);
|
|
253
|
+
return { value: JSON.parse(content), recoveryContent: null };
|
|
236
254
|
if (!warnedEmptyStatePaths.has(file)) {
|
|
237
255
|
warnedEmptyStatePaths.add(file);
|
|
238
256
|
console.warn(`[opencode-goal-plugin] Empty or zero-filled state file at ${file}; recovering with empty state.`);
|
|
239
257
|
}
|
|
240
|
-
return emptyState();
|
|
258
|
+
return { value: emptyState(), recoveryContent: raw || null };
|
|
241
259
|
}
|
|
242
260
|
function decodeState(value) {
|
|
243
261
|
return Schema.decodeUnknown(StateSchema)(value).pipe(Effect.map(mutableState), Effect.map(normalizeState), Effect.mapError((cause) => new StateDecodeError({ cause })));
|
|
244
262
|
}
|
|
245
|
-
function
|
|
263
|
+
function readStateResultEffect(file = statePath()) {
|
|
246
264
|
return Effect.tryPromise({
|
|
247
265
|
try: () => readFile(file, "utf8"),
|
|
248
266
|
catch: (cause) => new StateReadError({ cause })
|
|
249
267
|
}).pipe(Effect.flatMap((raw) => Effect.try({
|
|
250
268
|
try: () => parseStateText(raw, file),
|
|
251
269
|
catch: (cause) => new StateDecodeError({ cause })
|
|
252
|
-
})), Effect.flatMap(decodeState), Effect.catchAll((error) => error._tag === "StateReadError" && isMissingStateFile(error.cause) ? Effect.succeed(emptyState()) : Effect.fail(error)));
|
|
270
|
+
})), Effect.flatMap(({ value, recoveryContent }) => decodeState(value).pipe(Effect.map((state) => ({ state, recoveryContent })))), Effect.catchAll((error) => error._tag === "StateReadError" && isMissingStateFile(error.cause) ? Effect.succeed({ state: emptyState(), recoveryContent: null }) : Effect.fail(error)));
|
|
271
|
+
}
|
|
272
|
+
function readStateEffect(file = statePath()) {
|
|
273
|
+
return readStateResultEffect(file).pipe(Effect.map(({ state }) => state));
|
|
274
|
+
}
|
|
275
|
+
function quarantineStateEffect(file, content) {
|
|
276
|
+
return Effect.promise(async () => {
|
|
277
|
+
const quarantineFile = `${file}.corrupt-${Date.now()}-${randomUUID2()}`;
|
|
278
|
+
try {
|
|
279
|
+
await mkdir(dirname2(file), { recursive: true, mode: 448 });
|
|
280
|
+
await atomicWriteFile(quarantineFile, content);
|
|
281
|
+
return { quarantineFile, error: null };
|
|
282
|
+
} catch (error) {
|
|
283
|
+
return { quarantineFile, error: error instanceof Error ? error.message : String(error) };
|
|
284
|
+
}
|
|
285
|
+
});
|
|
286
|
+
}
|
|
287
|
+
function verifyRecoverySourceEffect(file, expectedContent, quarantineFile) {
|
|
288
|
+
return Effect.promise(async () => {
|
|
289
|
+
try {
|
|
290
|
+
return await readFile(file, "utf8") === expectedContent;
|
|
291
|
+
} catch (error) {
|
|
292
|
+
if (!isMissingStateFile(error)) {
|
|
293
|
+
try {
|
|
294
|
+
console.error(`[opencode-goal-plugin] Could not re-read ${file} after preserving it at ${quarantineFile}; continuing recovery:`, error instanceof Error ? error.message : String(error));
|
|
295
|
+
} catch {}
|
|
296
|
+
}
|
|
297
|
+
return true;
|
|
298
|
+
}
|
|
299
|
+
});
|
|
253
300
|
}
|
|
254
301
|
function writeStateEffect(state, file = statePath()) {
|
|
255
302
|
return Effect.tryPromise({
|
|
@@ -278,11 +325,46 @@ async function mutate(fn) {
|
|
|
278
325
|
return enqueueMutation(() => {
|
|
279
326
|
const file = statePath();
|
|
280
327
|
return Effect.runPromise(Effect.gen(function* () {
|
|
281
|
-
const state = yield*
|
|
328
|
+
const { state, recoveryContent } = yield* readStateResultEffect(file);
|
|
282
329
|
const result = yield* Effect.tryPromise({
|
|
283
330
|
try: () => Promise.resolve(fn(state)),
|
|
284
331
|
catch: (cause) => cause instanceof Error ? cause : new Error(String(cause))
|
|
285
332
|
});
|
|
333
|
+
if (recoveryContent != null) {
|
|
334
|
+
const quarantine = yield* quarantineStateEffect(file, recoveryContent);
|
|
335
|
+
if (quarantine.error != null) {
|
|
336
|
+
const notice = {
|
|
337
|
+
stateFile: file,
|
|
338
|
+
quarantineFile: quarantine.quarantineFile,
|
|
339
|
+
outcome: "quarantineFailed",
|
|
340
|
+
error: quarantine.error
|
|
341
|
+
};
|
|
342
|
+
try {
|
|
343
|
+
console.error(`[opencode-goal-plugin] Could not quarantine corrupt state at ${file}; continuing recovery:`, quarantine.error);
|
|
344
|
+
} catch {}
|
|
345
|
+
notifyStateRecovery(notice);
|
|
346
|
+
} else {
|
|
347
|
+
const unchanged = yield* verifyRecoverySourceEffect(file, recoveryContent, quarantine.quarantineFile);
|
|
348
|
+
if (!unchanged) {
|
|
349
|
+
const message = "goal state changed while recovery was being quarantined; refusing to overwrite it";
|
|
350
|
+
notifyStateRecovery({
|
|
351
|
+
stateFile: file,
|
|
352
|
+
quarantineFile: quarantine.quarantineFile,
|
|
353
|
+
outcome: "sourceChanged",
|
|
354
|
+
error: message
|
|
355
|
+
});
|
|
356
|
+
return yield* Effect.fail(new StateWriteError({ cause: new Error(message) }));
|
|
357
|
+
}
|
|
358
|
+
try {
|
|
359
|
+
console.warn(`[opencode-goal-plugin] Preserved corrupt state from ${file} at ${quarantine.quarantineFile}; continuing recovery.`);
|
|
360
|
+
} catch {}
|
|
361
|
+
notifyStateRecovery({
|
|
362
|
+
stateFile: file,
|
|
363
|
+
quarantineFile: quarantine.quarantineFile,
|
|
364
|
+
outcome: "quarantined"
|
|
365
|
+
});
|
|
366
|
+
}
|
|
367
|
+
}
|
|
286
368
|
yield* writeStateEffect(state, file);
|
|
287
369
|
return result;
|
|
288
370
|
}));
|
|
@@ -609,6 +691,12 @@ async function setGoalStatus(sessionID, status, agent) {
|
|
|
609
691
|
const goal = state.goals[sessionID];
|
|
610
692
|
if (!goal)
|
|
611
693
|
throw new Error("cannot update goal because this session has no goal");
|
|
694
|
+
if (isClosed(goal.status))
|
|
695
|
+
throw new Error("cannot update goal status because this goal is closed");
|
|
696
|
+
if (goal.status === status)
|
|
697
|
+
return snapshot(goal);
|
|
698
|
+
if (status === "paused" && goal.status !== "active")
|
|
699
|
+
return snapshot(goal);
|
|
612
700
|
accountWallClock(goal);
|
|
613
701
|
goal.status = status;
|
|
614
702
|
goal.updatedAt = nowSeconds();
|
|
@@ -903,8 +991,8 @@ async function markPendingContinuationStarted(sessionID) {
|
|
|
903
991
|
return current ? snapshotInternal(current) : null;
|
|
904
992
|
if (current.pendingAttempt == null || current.pendingAttempt.started)
|
|
905
993
|
return snapshotInternal(current);
|
|
906
|
-
return mutate((
|
|
907
|
-
const goal =
|
|
994
|
+
return mutate((state) => {
|
|
995
|
+
const goal = state.goals[sessionID];
|
|
908
996
|
if (!goal || goal.status !== "active")
|
|
909
997
|
return goal ? snapshotInternal(goal) : null;
|
|
910
998
|
if (goal.pendingAttempt == null || goal.pendingAttempt.started)
|
|
@@ -1203,10 +1291,60 @@ Use the goal tools to handle this command:
|
|
|
1203
1291
|
|
|
1204
1292
|
Create a goal only from these explicit command arguments. Do not infer a goal from unrelated session context. After create_goal succeeds or returns an existing matching goal, never call it again for this command; continue working from the returned goal state.`;
|
|
1205
1293
|
}
|
|
1294
|
+
function goalStatusCommandTemplate(commandName) {
|
|
1295
|
+
if (commandName === "pause_goal") {
|
|
1296
|
+
return `OpenCode goal mode command "/pause_goal" was invoked.
|
|
1297
|
+
|
|
1298
|
+
The command handler pauses an active goal before this acknowledgement turn when possible. Ignore any command arguments, call get_goal first, then handle only this pause request:
|
|
1299
|
+
|
|
1300
|
+
- If there is no goal, briefly report that no goal is set.
|
|
1301
|
+
- If the goal is paused, do not mutate it again; briefly confirm "Goal paused."
|
|
1302
|
+
- If the goal is still active, call update_goal_status with status "paused" and briefly report the result.
|
|
1303
|
+
- If the goal is budgetLimited or usageLimited, do not mutate it; briefly report that it remains stopped by its safety limit.
|
|
1304
|
+
- If the goal is complete or unmet, do not mutate it; briefly report that it is closed.
|
|
1305
|
+
|
|
1306
|
+
Do not create, resume, or continue a goal. Do not edit, clear, complete, or mark a goal unmet.`;
|
|
1307
|
+
}
|
|
1308
|
+
return `OpenCode goal mode command "/resume_goal" was invoked.
|
|
1309
|
+
|
|
1310
|
+
Ignore any command arguments. Call get_goal first, then handle only this resume request:
|
|
1311
|
+
|
|
1312
|
+
- If there is no goal, briefly report that no goal is set.
|
|
1313
|
+
- If the goal is complete or unmet, do not mutate it; you must not reopen it.
|
|
1314
|
+
- If the goal is already active, do not mutate it; continue working toward its existing objective.
|
|
1315
|
+
- If the goal is paused, budgetLimited, or usageLimited, call update_goal_status with status "active", then continue working toward its existing objective.
|
|
1316
|
+
- If Plan mode or another restricted agent prevents resuming, report that the user must switch to Build mode instead of retrying.
|
|
1317
|
+
|
|
1318
|
+
Do not create, edit, clear, complete, or mark a goal unmet.`;
|
|
1319
|
+
}
|
|
1320
|
+
function goalCommandDefinitions(commandName) {
|
|
1321
|
+
return [
|
|
1322
|
+
{
|
|
1323
|
+
name: commandName,
|
|
1324
|
+
description: "Set or view the long-running session goal",
|
|
1325
|
+
template: goalCommandTemplate(commandName),
|
|
1326
|
+
action: "goal"
|
|
1327
|
+
},
|
|
1328
|
+
{
|
|
1329
|
+
name: "pause_goal",
|
|
1330
|
+
description: "Pause the current long-running session goal",
|
|
1331
|
+
template: goalStatusCommandTemplate("pause_goal"),
|
|
1332
|
+
action: "pause"
|
|
1333
|
+
},
|
|
1334
|
+
{
|
|
1335
|
+
name: "resume_goal",
|
|
1336
|
+
description: "Resume the current long-running session goal",
|
|
1337
|
+
template: goalStatusCommandTemplate("resume_goal"),
|
|
1338
|
+
action: "resume"
|
|
1339
|
+
}
|
|
1340
|
+
];
|
|
1341
|
+
}
|
|
1206
1342
|
function commandNameFromOptions(options) {
|
|
1207
1343
|
const name = options?.command_name?.trim() || DEFAULT_COMMAND_NAME;
|
|
1208
1344
|
if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(name))
|
|
1209
1345
|
return DEFAULT_COMMAND_NAME;
|
|
1346
|
+
if (name.toLowerCase() === "pause_goal" || name.toLowerCase() === "resume_goal")
|
|
1347
|
+
return DEFAULT_COMMAND_NAME;
|
|
1210
1348
|
return name;
|
|
1211
1349
|
}
|
|
1212
1350
|
function positiveIntegerOrNull2(value) {
|
|
@@ -1220,14 +1358,25 @@ function timeoutMillisecondsFromSeconds(value) {
|
|
|
1220
1358
|
return null;
|
|
1221
1359
|
return Math.min(Math.ceil(value * 1000), MAX_TIMER_DELAY_MS);
|
|
1222
1360
|
}
|
|
1223
|
-
function
|
|
1361
|
+
function registerDesktopCommands(config, commandName) {
|
|
1224
1362
|
config.command ??= {};
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1363
|
+
const commands = goalCommandDefinitions(commandName);
|
|
1364
|
+
for (const command of commands) {
|
|
1365
|
+
if (config.command[command.name])
|
|
1366
|
+
continue;
|
|
1367
|
+
config.command[command.name] = {
|
|
1368
|
+
description: command.description,
|
|
1369
|
+
template: command.template
|
|
1370
|
+
};
|
|
1371
|
+
}
|
|
1372
|
+
}
|
|
1373
|
+
function sanitizeGoalStatusCommandParts(output, template) {
|
|
1374
|
+
const text = output.parts.find((part) => part.type === "text" && part.text?.startsWith(template));
|
|
1375
|
+
if (!text)
|
|
1376
|
+
return false;
|
|
1377
|
+
text.text = template;
|
|
1378
|
+
output.parts.splice(0, output.parts.length, text);
|
|
1379
|
+
return true;
|
|
1231
1380
|
}
|
|
1232
1381
|
function textFromPart(part) {
|
|
1233
1382
|
if (!part || typeof part !== "object")
|
|
@@ -1851,10 +2000,10 @@ async function updateGoalObjectiveFromTool(input, context, services) {
|
|
|
1851
2000
|
}
|
|
1852
2001
|
async function closeGoalFromTool(input, context) {
|
|
1853
2002
|
if (input.status === "complete") {
|
|
1854
|
-
const
|
|
1855
|
-
const budget =
|
|
1856
|
-
const
|
|
1857
|
-
return JSON.stringify({ goal
|
|
2003
|
+
const goal = await completeGoal(context.sessionID, input.evidence ?? "");
|
|
2004
|
+
const budget = goal.tokenBudget == null ? "" : ` Token usage: ${goal.tokensUsed}/${goal.tokenBudget}.`;
|
|
2005
|
+
const report = `Goal achieved. Time used: ${goal.timeUsedSeconds} seconds.${budget} Evidence: ${goal.completionEvidence}.`;
|
|
2006
|
+
return JSON.stringify({ goal, completion_report: report }, null, 2);
|
|
1858
2007
|
}
|
|
1859
2008
|
const goal = await markGoalUnmet(context.sessionID, input.blocker ?? "");
|
|
1860
2009
|
const report = `Goal unmet. Time used: ${goal.timeUsedSeconds} seconds. Blocker: ${goal.blocker}.`;
|
|
@@ -1932,6 +2081,16 @@ var server = async ({ client }, options) => {
|
|
|
1932
2081
|
const planAgents = restrictedAgentSet(options);
|
|
1933
2082
|
const isPlanAgent = (agent) => typeof agent === "string" && planAgents.has(agent.trim().toLowerCase());
|
|
1934
2083
|
const goalServices = { options: options ?? {}, isPlanAgent };
|
|
2084
|
+
const stopStateRecoveryReporting = onStateRecovery(statePath(), async ({ stateFile, quarantineFile, outcome, error }) => {
|
|
2085
|
+
await client.app?.log?.({
|
|
2086
|
+
body: {
|
|
2087
|
+
service: "opencode-goal-plugin",
|
|
2088
|
+
level: "error",
|
|
2089
|
+
message: outcome === "quarantined" ? "Corrupt goal state quarantined before recovery" : outcome === "sourceChanged" ? "Goal state changed during recovery; refusing to overwrite it" : "Corrupt goal state could not be quarantined; continuing recovery",
|
|
2090
|
+
extra: { stateFile, quarantineFile, outcome, ...error ? { error } : {} }
|
|
2091
|
+
}
|
|
2092
|
+
});
|
|
2093
|
+
});
|
|
1935
2094
|
let disposed = false;
|
|
1936
2095
|
async function taskBlockStatus(sessionID) {
|
|
1937
2096
|
if (!deferWhileTasksActive)
|
|
@@ -2180,6 +2339,7 @@ var server = async ({ client }, options) => {
|
|
|
2180
2339
|
return {
|
|
2181
2340
|
async dispose() {
|
|
2182
2341
|
disposed = true;
|
|
2342
|
+
stopStateRecoveryReporting();
|
|
2183
2343
|
for (const scheduled of scheduledContinuations.values())
|
|
2184
2344
|
clearTimeout(scheduled.timer);
|
|
2185
2345
|
scheduledContinuations.clear();
|
|
@@ -2194,7 +2354,7 @@ var server = async ({ client }, options) => {
|
|
|
2194
2354
|
async config(config) {
|
|
2195
2355
|
if (!registerCommand)
|
|
2196
2356
|
return;
|
|
2197
|
-
|
|
2357
|
+
registerDesktopCommands(config, commandName);
|
|
2198
2358
|
},
|
|
2199
2359
|
tool: {
|
|
2200
2360
|
get_goal: {
|
|
@@ -2290,6 +2450,20 @@ var server = async ({ client }, options) => {
|
|
|
2290
2450
|
toolAttempts.set(toolAttemptKey(sessionID, callID), goal?.pendingAttempt?.id ?? null);
|
|
2291
2451
|
}
|
|
2292
2452
|
},
|
|
2453
|
+
async "command.execute.before"(input, output) {
|
|
2454
|
+
if (input.command !== "pause_goal" && input.command !== "resume_goal")
|
|
2455
|
+
return;
|
|
2456
|
+
const template = goalStatusCommandTemplate(input.command);
|
|
2457
|
+
if (!sanitizeGoalStatusCommandParts(output, template))
|
|
2458
|
+
return;
|
|
2459
|
+
if (input.command !== "pause_goal")
|
|
2460
|
+
return;
|
|
2461
|
+
const goal = await getGoal(input.sessionID);
|
|
2462
|
+
if (goal?.status === "active")
|
|
2463
|
+
await setGoalStatus(input.sessionID, "paused");
|
|
2464
|
+
cancelScheduledContinuation(input.sessionID);
|
|
2465
|
+
clearTurnWatchdog(input.sessionID);
|
|
2466
|
+
},
|
|
2293
2467
|
async "tool.execute.after"(input, output) {
|
|
2294
2468
|
taskTracker.noteTaskOutput(input, output);
|
|
2295
2469
|
const sessionID = typeof input?.sessionID === "string" ? input.sessionID : undefined;
|
|
@@ -2496,10 +2670,10 @@ async function setupV2(context) {
|
|
|
2496
2670
|
};
|
|
2497
2671
|
const registrations = [];
|
|
2498
2672
|
let disposed = false;
|
|
2499
|
-
function stepKey(sessionID,
|
|
2500
|
-
return `${sessionID}\x00${
|
|
2673
|
+
function stepKey(sessionID, messageID) {
|
|
2674
|
+
return `${sessionID}\x00${messageID}`;
|
|
2501
2675
|
}
|
|
2502
|
-
async function
|
|
2676
|
+
async function sendContinuation(sessionID, prompt, agent) {
|
|
2503
2677
|
await context.session.prompt({
|
|
2504
2678
|
sessionID,
|
|
2505
2679
|
text: prompt,
|
|
@@ -2564,7 +2738,7 @@ async function setupV2(context) {
|
|
|
2564
2738
|
activeContinuationsV2.add(sessionID);
|
|
2565
2739
|
claimedContinuation = true;
|
|
2566
2740
|
watchdogRescuedSessions.add(sessionID);
|
|
2567
|
-
await
|
|
2741
|
+
await sendContinuation(sessionID, continuationPrompt(current), current.lastPromptAgent ?? latestStep?.agent ?? null);
|
|
2568
2742
|
await recordContinuationResult(sessionID, "success", maxPromptFailures, { armNoProgress: false, started: true });
|
|
2569
2743
|
locallyDeliveredPendingSessions.add(sessionID);
|
|
2570
2744
|
clearTurnWatchdog(sessionID);
|
|
@@ -2716,7 +2890,7 @@ async function setupV2(context) {
|
|
|
2716
2890
|
await rollbackContinuationAttempt(sessionID);
|
|
2717
2891
|
return;
|
|
2718
2892
|
}
|
|
2719
|
-
await
|
|
2893
|
+
await sendContinuation(sessionID, goal.status === "active" ? continuationPrompt(goal) : limitPrompt(goal), goal.lastPromptAgent ?? latestTurnAgent ?? null);
|
|
2720
2894
|
if (disposed) {
|
|
2721
2895
|
await rollbackContinuationAttempt(sessionID);
|
|
2722
2896
|
return;
|
|
@@ -2861,16 +3035,16 @@ async function setupV2(context) {
|
|
|
2861
3035
|
case "session.step.started": {
|
|
2862
3036
|
if (!sessionID || typeof data.assistantMessageID !== "string")
|
|
2863
3037
|
return;
|
|
2864
|
-
const
|
|
3038
|
+
const messageID = data.assistantMessageID;
|
|
2865
3039
|
const agent = typeof data.agent === "string" ? data.agent : undefined;
|
|
2866
3040
|
if (agent)
|
|
2867
3041
|
await recordPromptAgent(sessionID, agent);
|
|
2868
3042
|
taskTracker.observeAssistantMessage(sessionID, {
|
|
2869
|
-
info: { id:
|
|
3043
|
+
info: { id: messageID, role: "assistant", time: { completed: event.created } }
|
|
2870
3044
|
});
|
|
2871
|
-
if (!stepTextBuffers.has(stepKey(sessionID,
|
|
2872
|
-
stepTextBuffers.set(stepKey(sessionID,
|
|
2873
|
-
latestStepBySession.set(sessionID, { messageID
|
|
3045
|
+
if (!stepTextBuffers.has(stepKey(sessionID, messageID)))
|
|
3046
|
+
stepTextBuffers.set(stepKey(sessionID, messageID), "");
|
|
3047
|
+
latestStepBySession.set(sessionID, { messageID, agent, text: "", outputTokens: null, completedAt: event.created });
|
|
2874
3048
|
return;
|
|
2875
3049
|
}
|
|
2876
3050
|
case "session.text.delta": {
|
|
@@ -2889,7 +3063,7 @@ async function setupV2(context) {
|
|
|
2889
3063
|
case "session.step.ended": {
|
|
2890
3064
|
if (!sessionID || typeof data.assistantMessageID !== "string")
|
|
2891
3065
|
return;
|
|
2892
|
-
const
|
|
3066
|
+
const messageID = data.assistantMessageID;
|
|
2893
3067
|
const tokens = tokensFromRecord(data.tokens);
|
|
2894
3068
|
if (typeof tokens === "number") {
|
|
2895
3069
|
const sum = (stepTokenSums.get(sessionID) ?? 0) + tokens;
|
|
@@ -2900,11 +3074,11 @@ async function setupV2(context) {
|
|
|
2900
3074
|
initialBaseline: Math.ceil(sum - tokens)
|
|
2901
3075
|
});
|
|
2902
3076
|
}
|
|
2903
|
-
const text = stepTextBuffers.get(stepKey(sessionID,
|
|
2904
|
-
stepTextBuffers.delete(stepKey(sessionID,
|
|
3077
|
+
const text = stepTextBuffers.get(stepKey(sessionID, messageID)) ?? "";
|
|
3078
|
+
stepTextBuffers.delete(stepKey(sessionID, messageID));
|
|
2905
3079
|
const outputTokens = outputTokensFromRecord(data.tokens) ?? null;
|
|
2906
3080
|
const afterStep = await recordAssistantProgress(sessionID, {
|
|
2907
|
-
messageID
|
|
3081
|
+
messageID,
|
|
2908
3082
|
text,
|
|
2909
3083
|
outputTokens,
|
|
2910
3084
|
noProgressTokenThreshold: positiveIntegerOrNull2(options.no_progress_token_threshold),
|
|
@@ -2918,7 +3092,7 @@ async function setupV2(context) {
|
|
|
2918
3092
|
cancelScheduledContinuation(sessionID);
|
|
2919
3093
|
}
|
|
2920
3094
|
latestStepBySession.set(sessionID, {
|
|
2921
|
-
messageID
|
|
3095
|
+
messageID,
|
|
2922
3096
|
agent: latestStepBySession.get(sessionID)?.agent,
|
|
2923
3097
|
text,
|
|
2924
3098
|
outputTokens,
|
|
@@ -2929,7 +3103,7 @@ async function setupV2(context) {
|
|
|
2929
3103
|
case "session.step.failed": {
|
|
2930
3104
|
if (!sessionID || typeof data.assistantMessageID !== "string")
|
|
2931
3105
|
return;
|
|
2932
|
-
const
|
|
3106
|
+
const messageID = data.assistantMessageID;
|
|
2933
3107
|
const tokens = tokensFromRecord(data.tokens);
|
|
2934
3108
|
if (typeof tokens === "number") {
|
|
2935
3109
|
const sum = (stepTokenSums.get(sessionID) ?? 0) + tokens;
|
|
@@ -2940,11 +3114,11 @@ async function setupV2(context) {
|
|
|
2940
3114
|
initialBaseline: Math.ceil(sum - tokens)
|
|
2941
3115
|
});
|
|
2942
3116
|
}
|
|
2943
|
-
const text = stepTextBuffers.get(stepKey(sessionID,
|
|
2944
|
-
stepTextBuffers.delete(stepKey(sessionID,
|
|
3117
|
+
const text = stepTextBuffers.get(stepKey(sessionID, messageID)) ?? "";
|
|
3118
|
+
stepTextBuffers.delete(stepKey(sessionID, messageID));
|
|
2945
3119
|
const outputTokens = outputTokensFromRecord(data.tokens) ?? null;
|
|
2946
3120
|
const afterStep = await recordAssistantProgress(sessionID, {
|
|
2947
|
-
messageID
|
|
3121
|
+
messageID,
|
|
2948
3122
|
text,
|
|
2949
3123
|
outputTokens,
|
|
2950
3124
|
noProgressTokenThreshold: positiveIntegerOrNull2(options.no_progress_token_threshold),
|
|
@@ -2958,7 +3132,7 @@ async function setupV2(context) {
|
|
|
2958
3132
|
cancelScheduledContinuation(sessionID);
|
|
2959
3133
|
}
|
|
2960
3134
|
latestStepBySession.set(sessionID, {
|
|
2961
|
-
messageID
|
|
3135
|
+
messageID,
|
|
2962
3136
|
agent: latestStepBySession.get(sessionID)?.agent,
|
|
2963
3137
|
text,
|
|
2964
3138
|
outputTokens,
|
|
@@ -2977,23 +3151,39 @@ async function setupV2(context) {
|
|
|
2977
3151
|
}
|
|
2978
3152
|
}
|
|
2979
3153
|
if (registerCommand) {
|
|
3154
|
+
const existingCommands = new Set((await context.command.list()).data.map((command) => command.name));
|
|
2980
3155
|
registrations.push(await context.command.transform((draft) => {
|
|
2981
|
-
|
|
2982
|
-
|
|
2983
|
-
|
|
2984
|
-
|
|
2985
|
-
|
|
2986
|
-
|
|
2987
|
-
|
|
2988
|
-
|
|
2989
|
-
|
|
2990
|
-
|
|
2991
|
-
|
|
2992
|
-
|
|
2993
|
-
|
|
2994
|
-
|
|
2995
|
-
|
|
2996
|
-
|
|
3156
|
+
const claimedCommands = new Set(existingCommands);
|
|
3157
|
+
for (const command of goalCommandDefinitions(commandName)) {
|
|
3158
|
+
if (claimedCommands.has(command.name))
|
|
3159
|
+
continue;
|
|
3160
|
+
claimedCommands.add(command.name);
|
|
3161
|
+
draft.add({
|
|
3162
|
+
name: command.name,
|
|
3163
|
+
description: command.description,
|
|
3164
|
+
execute: async (input) => {
|
|
3165
|
+
if (command.action === "pause") {
|
|
3166
|
+
const goal = await getGoal(input.sessionID);
|
|
3167
|
+
if (goal?.status === "active")
|
|
3168
|
+
await setGoalStatus(input.sessionID, "paused");
|
|
3169
|
+
cancelScheduledContinuation(input.sessionID);
|
|
3170
|
+
clearTurnWatchdog(input.sessionID);
|
|
3171
|
+
}
|
|
3172
|
+
const stripMention = ({ mention: _mention, ...attachment }) => attachment;
|
|
3173
|
+
await context.session.prompt({
|
|
3174
|
+
...command.action === "goal" ? {
|
|
3175
|
+
...input.prompt,
|
|
3176
|
+
files: input.prompt.files?.map(stripMention),
|
|
3177
|
+
agents: input.prompt.agents?.map(stripMention),
|
|
3178
|
+
skills: input.prompt.skills?.map(stripMention)
|
|
3179
|
+
} : {},
|
|
3180
|
+
sessionID: input.sessionID,
|
|
3181
|
+
text: command.template.replaceAll("$ARGUMENTS", () => input.prompt.text.trim()),
|
|
3182
|
+
delivery: input.delivery
|
|
3183
|
+
});
|
|
3184
|
+
}
|
|
3185
|
+
});
|
|
3186
|
+
}
|
|
2997
3187
|
}));
|
|
2998
3188
|
}
|
|
2999
3189
|
registrations.push(await context.tool.transform((draft) => {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@prevalentware/opencode-goal-plugin",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.43",
|
|
4
4
|
"description": "OpenCode goal plugin that adds Codex-style long-running goal mode, /goal commands, persistence, and TUI status for AI coding agents.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"opencode",
|