@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.
Files changed (3) hide show
  1. package/README.md +7 -5
  2. package/dist/server.js +245 -55
  3. 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>` as an OpenCode command for TUI, desktop, and web.
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 readStateEffect(file = statePath()) {
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* readStateEffect(file);
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((state2) => {
907
- const goal = state2.goals[sessionID];
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 registerDesktopCommand(config, commandName) {
1361
+ function registerDesktopCommands(config, commandName) {
1224
1362
  config.command ??= {};
1225
- if (config.command[commandName])
1226
- return;
1227
- config.command[commandName] = {
1228
- description: "Set or view the long-running session goal",
1229
- template: goalCommandTemplate(commandName)
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 goal2 = await completeGoal(context.sessionID, input.evidence ?? "");
1855
- const budget = goal2.tokenBudget == null ? "" : ` Token usage: ${goal2.tokensUsed}/${goal2.tokenBudget}.`;
1856
- const report2 = `Goal achieved. Time used: ${goal2.timeUsedSeconds} seconds.${budget} Evidence: ${goal2.completionEvidence}.`;
1857
- return JSON.stringify({ goal: goal2, completion_report: report2 }, null, 2);
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
- registerDesktopCommand(config, commandName);
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, messageID2) {
2500
- return `${sessionID}\x00${messageID2}`;
2673
+ function stepKey(sessionID, messageID) {
2674
+ return `${sessionID}\x00${messageID}`;
2501
2675
  }
2502
- async function sendContinuation2(sessionID, prompt, agent) {
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 sendContinuation2(sessionID, continuationPrompt(current), current.lastPromptAgent ?? latestStep?.agent ?? null);
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 sendContinuation2(sessionID, goal.status === "active" ? continuationPrompt(goal) : limitPrompt(goal), goal.lastPromptAgent ?? latestTurnAgent ?? null);
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 messageID2 = data.assistantMessageID;
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: messageID2, role: "assistant", time: { completed: event.created } }
3043
+ info: { id: messageID, role: "assistant", time: { completed: event.created } }
2870
3044
  });
2871
- if (!stepTextBuffers.has(stepKey(sessionID, messageID2)))
2872
- stepTextBuffers.set(stepKey(sessionID, messageID2), "");
2873
- latestStepBySession.set(sessionID, { messageID: messageID2, agent, text: "", outputTokens: null, completedAt: event.created });
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 messageID2 = data.assistantMessageID;
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, messageID2)) ?? "";
2904
- stepTextBuffers.delete(stepKey(sessionID, messageID2));
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: messageID2,
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: messageID2,
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 messageID2 = data.assistantMessageID;
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, messageID2)) ?? "";
2944
- stepTextBuffers.delete(stepKey(sessionID, messageID2));
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: messageID2,
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: messageID2,
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
- draft.add({
2982
- name: commandName,
2983
- description: "Set or view the long-running session goal",
2984
- execute: async (input) => {
2985
- const stripMention = ({ mention: _mention, ...attachment }) => attachment;
2986
- await context.session.prompt({
2987
- ...input.prompt,
2988
- files: input.prompt.files?.map(stripMention),
2989
- agents: input.prompt.agents?.map(stripMention),
2990
- skills: input.prompt.skills?.map(stripMention),
2991
- sessionID: input.sessionID,
2992
- text: goalCommandTemplate(commandName).replaceAll("$ARGUMENTS", () => input.prompt.text.trim()),
2993
- delivery: input.delivery
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.41",
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",