@prevalentware/opencode-goal-plugin 0.1.20 → 0.1.22

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 CHANGED
@@ -108,8 +108,8 @@ Defaults:
108
108
  - `max_prompt_failures`: `3`
109
109
  - `default_token_budget`: unset by default; when set, new goals inherit this token budget.
110
110
  - `max_goal_duration_seconds`: unset by default; when set, new goals inherit this elapsed-time safety limit.
111
- - `no_progress_token_threshold`: `50`
112
- - `max_no_progress_turns`: `2`
111
+ - `no_progress_token_threshold`: `50`; output-token floor used to judge whether a goal continuation turn made progress.
112
+ - `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.
113
113
  - `register_command`: `true`
114
114
  - `command_name`: `"goal"`
115
115
  - `restricted_agents`: `["plan"]`; agents (matched case-insensitively) treated as planning-only for goal execution.
@@ -138,7 +138,7 @@ The plugin also uses safety states while keeping the goal available for review o
138
138
 
139
139
  - `budgetLimited` when a token budget is exhausted.
140
140
  - `usageLimited` when an auto-turn or elapsed-time budget is exhausted.
141
- - `paused` when the user pauses, auto-continue repeatedly fails, or repeated low-output/no-progress turns are detected.
141
+ - `paused` when the user pauses, auto-continue repeatedly fails, or repeated low-progress goal continuation turns are detected. No-progress accounting is scoped to goal continuation turns: each reserved continuation is evaluated once, when its turn completes, and unrelated assistant activity in the session never pauses the goal.
142
142
 
143
143
  When a safety limit is reached, the plugin sends one wrap-up prompt asking for a concise handoff instead of silently continuing forever.
144
144
 
package/dist/server.js CHANGED
@@ -63,7 +63,10 @@ var GoalSchema = Schema.Struct({
63
63
  lastCheckpoint: Schema.optionalWith(Schema.NullOr(CheckpointSchema), { default: () => null }),
64
64
  lastAssistantText: Schema.optionalWith(Schema.String, { default: () => "" }),
65
65
  lastAssistantMessageID: Schema.optionalWith(Schema.String, { default: () => "" }),
66
- lastPromptAgent: Schema.optionalWith(NullableString, { default: () => null })
66
+ lastPromptAgent: Schema.optionalWith(NullableString, { default: () => null }),
67
+ awaitingContinuationProgress: Schema.optionalWith(Schema.Boolean, { default: () => false }),
68
+ continuationBaselineMessageID: Schema.optionalWith(Schema.String, { default: () => "" }),
69
+ continuationBaselineSummary: Schema.optionalWith(Schema.String, { default: () => "" })
67
70
  });
68
71
  var StateSchema = Schema.Struct({
69
72
  version: Schema.Literal(1),
@@ -168,6 +171,9 @@ function normalizeGoal(goal) {
168
171
  goal.lastAssistantText ??= "";
169
172
  goal.lastAssistantMessageID ??= "";
170
173
  goal.lastPromptAgent ??= null;
174
+ goal.awaitingContinuationProgress = goal.awaitingContinuationProgress === true;
175
+ goal.continuationBaselineMessageID ??= "";
176
+ goal.continuationBaselineSummary ??= "";
171
177
  goal.noProgressTurns = nonNegativeInteger(goal.noProgressTurns, 0);
172
178
  goal.maxAutoTurns = positiveIntegerOrNull(goal.maxAutoTurns);
173
179
  goal.maxDurationSeconds = positiveIntegerOrNull(goal.maxDurationSeconds);
@@ -247,6 +253,9 @@ function snapshot(goal) {
247
253
  lastAssistantText: goal.lastAssistantText,
248
254
  lastAssistantMessageID: goal.lastAssistantMessageID,
249
255
  lastPromptAgent: goal.lastPromptAgent,
256
+ awaitingContinuationProgress: goal.awaitingContinuationProgress,
257
+ continuationBaselineMessageID: goal.continuationBaselineMessageID,
258
+ continuationBaselineSummary: goal.continuationBaselineSummary,
250
259
  autoTurns: goal.autoTurns,
251
260
  lastContinuationAt: goal.lastContinuationAt,
252
261
  remainingTokens: remainingTokens(goal),
@@ -297,7 +306,10 @@ async function createGoal(sessionID, objective, options) {
297
306
  lastCheckpoint: null,
298
307
  lastAssistantText: "",
299
308
  lastAssistantMessageID: "",
300
- lastPromptAgent: normalizedOptions.agent
309
+ lastPromptAgent: normalizedOptions.agent,
310
+ awaitingContinuationProgress: false,
311
+ continuationBaselineMessageID: "",
312
+ continuationBaselineSummary: ""
301
313
  };
302
314
  pushHistory(goal, "created", goalLimitSummary(goal));
303
315
  if (paused)
@@ -459,24 +471,28 @@ async function recordAssistantProgress(sessionID, input) {
459
471
  goal.lastAssistantText = text;
460
472
  if (messageID)
461
473
  goal.lastAssistantMessageID = messageID;
462
- const lowOutput = outputTokens > 0 && outputTokens < (threshold ?? DEFAULT_NO_PROGRESS_TOKEN_THRESHOLD);
463
- const stalled = lowOutput && (repeatedMessage || !changed);
464
- if (stalled) {
465
- goal.noProgressTurns += 1;
466
- if (maxNoProgressTurns && goal.noProgressTurns >= maxNoProgressTurns) {
467
- accountWallClock(goal);
468
- goal.status = "paused";
469
- goal.lastAccountedAt = null;
470
- goal.stopReason = "no progress";
471
- goal.blocker = `Auto-continue paused after ${goal.noProgressTurns} low-progress turn(s). Resume the goal to retry.`;
472
- goal.lastStatus = goal.blocker;
473
- pushHistory(goal, "warning", goal.blocker);
474
+ const continuationTurnCompleted = input.evaluateContinuation === true && goal.awaitingContinuationProgress && Boolean(messageID) && messageID !== goal.continuationBaselineMessageID;
475
+ if (continuationTurnCompleted) {
476
+ goal.awaitingContinuationProgress = false;
477
+ const lowOutput = outputTokens > 0 && outputTokens < (threshold ?? DEFAULT_NO_PROGRESS_TOKEN_THRESHOLD);
478
+ const changedSinceContinuation = Boolean(summary && summary !== goal.continuationBaselineSummary);
479
+ if (lowOutput && !changedSinceContinuation) {
480
+ goal.noProgressTurns += 1;
481
+ if (maxNoProgressTurns && goal.noProgressTurns >= maxNoProgressTurns) {
482
+ accountWallClock(goal);
483
+ goal.status = "paused";
484
+ goal.lastAccountedAt = null;
485
+ goal.stopReason = "no progress";
486
+ goal.blocker = `Auto-continue paused after ${goal.noProgressTurns} low-progress continuation turn(s). Resume the goal to retry.`;
487
+ goal.lastStatus = goal.blocker;
488
+ pushHistory(goal, "warning", goal.blocker);
489
+ } else {
490
+ goal.lastStatus = `Low-progress continuation turn detected (${goal.noProgressTurns}/${maxNoProgressTurns ?? "unbounded"}).`;
491
+ pushHistory(goal, "warning", goal.lastStatus);
492
+ }
474
493
  } else {
475
- goal.lastStatus = `Low-progress turn detected (${goal.noProgressTurns}/${maxNoProgressTurns ?? "unbounded"}).`;
476
- pushHistory(goal, "warning", goal.lastStatus);
494
+ goal.noProgressTurns = 0;
477
495
  }
478
- } else if (changed || outputTokens >= (threshold ?? DEFAULT_NO_PROGRESS_TOKEN_THRESHOLD)) {
479
- goal.noProgressTurns = 0;
480
496
  }
481
497
  goal.updatedAt = nowSeconds();
482
498
  return snapshot(goal);
@@ -499,6 +515,8 @@ async function reserveContinuation(sessionID, maxAutoTurns, minIntervalSeconds)
499
515
  return null;
500
516
  goal.autoTurns += 1;
501
517
  goal.lastContinuationAt = now;
518
+ goal.continuationBaselineMessageID = goal.lastAssistantMessageID;
519
+ goal.continuationBaselineSummary = summarizeText(goal.lastAssistantText);
502
520
  goal.lastStatus = `Auto-continue ${goal.autoTurns} reserved.`;
503
521
  pushHistory(goal, "autoContinue", goal.lastStatus);
504
522
  goal.updatedAt = now;
@@ -514,11 +532,14 @@ async function recordContinuationResult(sessionID, result, maxFailures) {
514
532
  goal.updatedAt = now;
515
533
  if (result === "success") {
516
534
  goal.continuationFailures = 0;
517
- if (goal.status === "active")
535
+ if (goal.status === "active") {
518
536
  goal.lastStatus = "Auto-continue prompt sent.";
537
+ goal.awaitingContinuationProgress = true;
538
+ }
519
539
  return snapshot(goal);
520
540
  }
521
541
  goal.continuationFailures += 1;
542
+ goal.awaitingContinuationProgress = false;
522
543
  goal.lastStatus = `Auto-continue failed ${goal.continuationFailures} time(s).`;
523
544
  pushHistory(goal, "error", goal.lastStatus);
524
545
  if (goal.continuationFailures >= maxFailures) {
@@ -883,13 +904,16 @@ function exactTokensFromMessage(message) {
883
904
  return;
884
905
  }
885
906
  function outputTokensFromMessage(message) {
907
+ let total;
886
908
  for (const part of message.parts ?? []) {
887
909
  if (part && typeof part === "object" && part.type === "step-finish") {
888
910
  const output = outputTokensFromRecord(part.tokens);
889
911
  if (output != null)
890
- return output;
912
+ total = (total ?? 0) + output;
891
913
  }
892
914
  }
915
+ if (total != null)
916
+ return total;
893
917
  if (message.info && typeof message.info === "object")
894
918
  return outputTokensFromRecord(message.info.tokens);
895
919
  return;
@@ -1239,7 +1263,7 @@ class TaskTracker {
1239
1263
  return false;
1240
1264
  }
1241
1265
  }
1242
- async function recordAssistantMessage(sessionID, message, options) {
1266
+ async function recordAssistantMessage(sessionID, message, options, evaluateContinuation = false) {
1243
1267
  if (!message)
1244
1268
  return;
1245
1269
  await recordAssistantProgress(sessionID, {
@@ -1247,7 +1271,8 @@ async function recordAssistantMessage(sessionID, message, options) {
1247
1271
  text: textFromMessage(message),
1248
1272
  outputTokens: outputTokensFromMessage(message) ?? null,
1249
1273
  noProgressTokenThreshold: positiveIntegerOrNull2(options.no_progress_token_threshold),
1250
- maxNoProgressTurns: positiveIntegerOrNull2(options.max_no_progress_turns)
1274
+ maxNoProgressTurns: positiveIntegerOrNull2(options.max_no_progress_turns),
1275
+ evaluateContinuation
1251
1276
  });
1252
1277
  }
1253
1278
  function mergeSystemReminder(output, reminder) {
@@ -1329,7 +1354,7 @@ var server = async ({ client }, options) => {
1329
1354
  }
1330
1355
  if (busySessions.has(sessionID))
1331
1356
  return;
1332
- await recordAssistantMessage(sessionID, latestAssistant, options ?? {});
1357
+ await recordAssistantMessage(sessionID, latestAssistant, options ?? {}, true);
1333
1358
  const current = await getGoal(sessionID);
1334
1359
  if (!current)
1335
1360
  return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@prevalentware/opencode-goal-plugin",
3
- "version": "0.1.20",
3
+ "version": "0.1.22",
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",
package/src/tui.tsx CHANGED
@@ -82,6 +82,27 @@ type ModernTuiApi = TuiPluginApi & {
82
82
 
83
83
  const goalCache = new Map<string, GoalSnapshot>()
84
84
 
85
+ function goalSnapshotKey(sessionID: string) {
86
+ return `goal-mode.snapshot.${sessionID}`
87
+ }
88
+
89
+ function cachedGoal(api: TuiPluginApi, sessionID: string) {
90
+ const memory = goalCache.get(sessionID)
91
+ if (memory) return memory
92
+ const persisted = api.kv?.get(goalSnapshotKey(sessionID), null)
93
+ return isGoalSnapshot(persisted) ? persisted : null
94
+ }
95
+
96
+ function cacheGoal(api: TuiPluginApi, sessionID: string, goal: GoalSnapshot | null) {
97
+ if (goal) {
98
+ goalCache.set(sessionID, goal)
99
+ api.kv?.set(goalSnapshotKey(sessionID), goal)
100
+ return
101
+ }
102
+ goalCache.delete(sessionID)
103
+ api.kv?.set(goalSnapshotKey(sessionID), null)
104
+ }
105
+
85
106
  function currentSessionID(api: TuiPluginApi) {
86
107
  const route = api.route.current
87
108
  if (route.name !== "session") return undefined
@@ -279,13 +300,12 @@ export function goalStateFromSession(api: TuiPluginApi, sessionID: string): Goal
279
300
  for (const part of parts) {
280
301
  const goal = parseGoalToolOutput(part)
281
302
  if (goal !== undefined) {
282
- if (goal) goalCache.set(sessionID, goal)
283
- else goalCache.delete(sessionID)
303
+ cacheGoal(api, sessionID, goal)
284
304
  return { goal, messageIndex }
285
305
  }
286
306
  }
287
307
  }
288
- return { goal: goalCache.get(sessionID) ?? null, messageIndex: -1 }
308
+ return { goal: cachedGoal(api, sessionID), messageIndex: -1 }
289
309
  }
290
310
 
291
311
  function goalFromSession(api: TuiPluginApi, sessionID: string) {