@prevalentware/opencode-goal-plugin 0.1.34 → 0.1.36

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 (2) hide show
  1. package/dist/server.js +141 -14
  2. package/package.json +1 -1
package/dist/server.js CHANGED
@@ -152,12 +152,20 @@ var PendingAttemptSchema = Schema.Struct({
152
152
  armNoProgress: Schema.Boolean,
153
153
  previousLastContinuationAt: Schema.NullOr(Schema.Number)
154
154
  });
155
+ var UsageTrackerSchema = Schema.Struct({
156
+ baseline: Schema.optionalWith(Schema.Unknown, { default: () => null }),
157
+ lastObserved: Schema.optionalWith(Schema.Unknown, { default: () => null }),
158
+ baseTokens: Schema.optionalWith(Schema.Unknown, { default: () => null }),
159
+ pendingBaseline: Schema.optionalWith(Schema.Unknown, { default: () => null }),
160
+ pendingBaseTokens: Schema.optionalWith(Schema.Unknown, { default: () => null })
161
+ });
155
162
  var GoalSchema = Schema.Struct({
156
163
  sessionID: Schema.String,
157
164
  objective: Schema.String,
158
165
  status: Schema.Literal("active", "paused", "budgetLimited", "usageLimited", "complete", "unmet"),
159
166
  tokenBudget: NullableNumber,
160
167
  tokensUsed: Schema.Number,
168
+ usageTrackers: Schema.optionalWith(Schema.Record({ key: Schema.String, value: UsageTrackerSchema }), { default: () => ({}) }),
161
169
  timeUsedSeconds: Schema.Number,
162
170
  createdAt: Schema.Number,
163
171
  updatedAt: Schema.Number,
@@ -210,15 +218,36 @@ function isMissingStateFile(error) {
210
218
  function mutableState(state) {
211
219
  return JSON.parse(JSON.stringify(state));
212
220
  }
221
+ var warnedEmptyStatePaths = new Set;
222
+ function isStatePadding(character) {
223
+ return character === "\x00" || character.trim() === "";
224
+ }
225
+ function parseStateText(raw, file) {
226
+ let start = 0;
227
+ let end = raw.length;
228
+ while (start < end && isStatePadding(raw[start]))
229
+ start += 1;
230
+ while (end > start && isStatePadding(raw[end - 1]))
231
+ end -= 1;
232
+ const content = raw.slice(start, end);
233
+ if (content)
234
+ return JSON.parse(content);
235
+ if (!warnedEmptyStatePaths.has(file)) {
236
+ warnedEmptyStatePaths.add(file);
237
+ console.warn(`[opencode-goal-plugin] Empty or zero-filled state file at ${file}; recovering with empty state.`);
238
+ }
239
+ return emptyState();
240
+ }
213
241
  function decodeState(value) {
214
242
  return Schema.decodeUnknown(StateSchema)(value).pipe(Effect.map(mutableState), Effect.map(normalizeState), Effect.mapError((cause) => new StateDecodeError({ cause })));
215
243
  }
216
244
  function readStateEffect() {
245
+ const file = statePath();
217
246
  return Effect.tryPromise({
218
- try: () => readFile(statePath(), "utf8"),
247
+ try: () => readFile(file, "utf8"),
219
248
  catch: (cause) => new StateReadError({ cause })
220
249
  }).pipe(Effect.flatMap((raw) => Effect.try({
221
- try: () => JSON.parse(raw),
250
+ try: () => parseStateText(raw, file),
222
251
  catch: (cause) => new StateDecodeError({ cause })
223
252
  })), Effect.flatMap(decodeState), Effect.catchAll((error) => error._tag === "StateReadError" && isMissingStateFile(error.cause) ? Effect.succeed(emptyState()) : Effect.fail(error)));
224
253
  }
@@ -294,12 +323,34 @@ function normalizeGoal(goal) {
294
323
  goal.maxAutoTurns = positiveIntegerOrNull(goal.maxAutoTurns);
295
324
  goal.maxDurationSeconds = positiveIntegerOrNull(goal.maxDurationSeconds);
296
325
  goal.tokenBudget = positiveIntegerOrNull(goal.tokenBudget);
326
+ goal.usageTrackers = normalizeUsageTrackers(goal.usageTrackers);
297
327
  goal.noProgressTokenThreshold = positiveIntegerOrNull(goal.noProgressTokenThreshold) ?? DEFAULT_NO_PROGRESS_TOKEN_THRESHOLD;
298
328
  goal.maxNoProgressTurns = positiveIntegerOrNull(goal.maxNoProgressTurns) ?? DEFAULT_MAX_NO_PROGRESS_TURNS;
299
329
  goal.budgetWrapupSent = goal.budgetWrapupSent === true;
300
330
  goal.stopReason ??= null;
301
331
  return goal;
302
332
  }
333
+ function normalizeUsageTrackers(trackers) {
334
+ const normalized = {};
335
+ for (const [source, rawTracker] of Object.entries(trackers ?? {})) {
336
+ const tracker = rawTracker;
337
+ const baseline = nonNegativeIntegerOrNull(tracker?.baseline);
338
+ const lastObserved = nonNegativeIntegerOrNull(tracker?.lastObserved);
339
+ const baseTokens = nonNegativeIntegerOrNull(tracker?.baseTokens);
340
+ if (source && baseline != null && lastObserved != null && baseTokens != null && lastObserved >= baseline) {
341
+ const pendingBaseline = nonNegativeIntegerOrNull(tracker.pendingBaseline);
342
+ const pendingBaseTokens = nonNegativeIntegerOrNull(tracker.pendingBaseTokens);
343
+ normalized[source] = {
344
+ baseline,
345
+ lastObserved,
346
+ baseTokens,
347
+ pendingBaseline,
348
+ pendingBaseTokens: pendingBaseline == null ? null : pendingBaseTokens
349
+ };
350
+ }
351
+ }
352
+ return normalized;
353
+ }
303
354
  function normalizePendingAttempt(attempt) {
304
355
  if (!attempt || typeof attempt !== "object")
305
356
  return null;
@@ -344,6 +395,9 @@ function positiveIntegerOrNull(value) {
344
395
  function nonNegativeInteger(value, fallback) {
345
396
  return typeof value === "number" && Number.isSafeInteger(value) && value >= 0 ? value : fallback;
346
397
  }
398
+ function nonNegativeIntegerOrNull(value) {
399
+ return typeof value === "number" && Number.isSafeInteger(value) && value >= 0 ? value : null;
400
+ }
347
401
  function isClosed(status) {
348
402
  return status === "complete" || status === "unmet";
349
403
  }
@@ -423,6 +477,7 @@ async function createGoal(sessionID, objective, options) {
423
477
  status: normalizedOptions.initialStatus,
424
478
  tokenBudget: normalizedOptions.tokenBudget,
425
479
  tokensUsed: 0,
480
+ usageTrackers: {},
426
481
  timeUsedSeconds: 0,
427
482
  createdAt: now,
428
483
  updatedAt: now,
@@ -584,14 +639,66 @@ async function clearGoal(sessionID) {
584
639
  return existed;
585
640
  });
586
641
  }
587
- async function accountUsage(sessionID, tokensUsed) {
642
+ async function accountUsage(sessionID, tokensUsed, options) {
588
643
  return mutate((state) => {
589
644
  const goal = state.goals[sessionID];
590
645
  if (!goal)
591
646
  return null;
592
647
  accountWallClock(goal);
593
648
  if (typeof tokensUsed === "number" && Number.isFinite(tokensUsed)) {
594
- goal.tokensUsed = Math.max(goal.tokensUsed, Math.max(0, Math.ceil(tokensUsed)));
649
+ const observed = Math.max(0, Math.ceil(tokensUsed));
650
+ if (options?.cumulative === true) {
651
+ const source = options.source?.trim() || "default";
652
+ let tracker = goal.usageTrackers[source];
653
+ if (!tracker) {
654
+ const initialBaseline = nonNegativeIntegerOrNull(options.initialBaseline);
655
+ tracker = initialBaseline != null && initialBaseline <= observed ? {
656
+ baseline: initialBaseline,
657
+ lastObserved: observed,
658
+ baseTokens: goal.tokensUsed,
659
+ pendingBaseline: null,
660
+ pendingBaseTokens: null
661
+ } : {
662
+ baseline: observed,
663
+ lastObserved: observed,
664
+ baseTokens: goal.tokensUsed,
665
+ pendingBaseline: null,
666
+ pendingBaseTokens: null
667
+ };
668
+ goal.usageTrackers[source] = tracker;
669
+ } else if (observed < tracker.lastObserved) {
670
+ const initialBaseline = nonNegativeIntegerOrNull(options.initialBaseline);
671
+ if (initialBaseline != null && initialBaseline <= observed) {
672
+ tracker = {
673
+ baseline: initialBaseline,
674
+ lastObserved: observed,
675
+ baseTokens: goal.tokensUsed,
676
+ pendingBaseline: null,
677
+ pendingBaseTokens: null
678
+ };
679
+ goal.usageTrackers[source] = tracker;
680
+ } else if (tracker.pendingBaseline == null || observed < tracker.pendingBaseline) {
681
+ tracker.pendingBaseline = observed;
682
+ tracker.pendingBaseTokens = goal.tokensUsed;
683
+ } else {
684
+ tracker = {
685
+ baseline: tracker.pendingBaseline,
686
+ lastObserved: observed,
687
+ baseTokens: tracker.pendingBaseTokens ?? goal.tokensUsed,
688
+ pendingBaseline: null,
689
+ pendingBaseTokens: null
690
+ };
691
+ goal.usageTrackers[source] = tracker;
692
+ }
693
+ } else {
694
+ tracker.lastObserved = observed;
695
+ tracker.pendingBaseline = null;
696
+ tracker.pendingBaseTokens = null;
697
+ }
698
+ goal.tokensUsed = Math.max(goal.tokensUsed, tracker.baseTokens + observed - tracker.baseline);
699
+ } else {
700
+ goal.tokensUsed = Math.max(goal.tokensUsed, observed);
701
+ }
595
702
  }
596
703
  maybeStopForBudget(goal);
597
704
  goal.updatedAt = nowSeconds();
@@ -1076,7 +1183,7 @@ function commandNameFromOptions(options) {
1076
1183
  function positiveIntegerOrNull2(value) {
1077
1184
  return typeof value === "number" && Number.isSafeInteger(value) && value > 0 ? value : null;
1078
1185
  }
1079
- function nonNegativeIntegerOrNull(value) {
1186
+ function nonNegativeIntegerOrNull2(value) {
1080
1187
  return typeof value === "number" && Number.isSafeInteger(value) && value >= 0 ? value : null;
1081
1188
  }
1082
1189
  function timeoutMillisecondsFromSeconds(value) {
@@ -1169,9 +1276,9 @@ function outputTokensFromMessage(message) {
1169
1276
  return outputTokensFromRecord(message.info.tokens);
1170
1277
  return;
1171
1278
  }
1172
- function tokensFromMessages(messages) {
1279
+ function usageFromMessages(messages) {
1173
1280
  const exactTotal = messages.reduce((sum, message) => sum + (exactTokensFromMessage(message) ?? 0), 0);
1174
- return exactTotal > 0 ? exactTotal : estimateMessages(messages);
1281
+ return exactTotal > 0 ? { tokens: exactTotal, source: "v1.messages.exact" } : { tokens: estimateMessages(messages), source: "v1.messages.estimated" };
1175
1282
  }
1176
1283
  function taskHeader(output) {
1177
1284
  const resultIndex = output.search(/<task_(?:result|error)>/);
@@ -1675,6 +1782,7 @@ async function createGoalFromTool(input, context, services) {
1675
1782
  agent: typeof context.agent === "string" ? context.agent : null,
1676
1783
  initialStatus: planningOnly ? "paused" : "active"
1677
1784
  });
1785
+ await services.initializeUsage?.(context.sessionID);
1678
1786
  return JSON.stringify(planningOnly ? { goal, plan_mode_notice: PLAN_MODE_CREATE_NOTICE } : { goal }, null, 2);
1679
1787
  }
1680
1788
  async function updateGoalObjectiveFromTool(input, context, services) {
@@ -1737,7 +1845,7 @@ var server = async ({ client }, options) => {
1737
1845
  const autoContinue = options?.auto_continue ?? true;
1738
1846
  const deferWhileTasksActive = options?.defer_while_tasks_active ?? true;
1739
1847
  const maxAutoTurns = positiveIntegerOrNull2(options?.max_auto_turns) ?? DEFAULT_MAX_AUTO_TURNS;
1740
- const minInterval = nonNegativeIntegerOrNull(options?.min_continue_interval_seconds) ?? DEFAULT_CONTINUE_INTERVAL_SECONDS;
1848
+ const minInterval = nonNegativeIntegerOrNull2(options?.min_continue_interval_seconds) ?? DEFAULT_CONTINUE_INTERVAL_SECONDS;
1741
1849
  const maxTurnTimeMs = timeoutMillisecondsFromSeconds(options?.max_turn_time);
1742
1850
  const maxPromptFailures = positiveIntegerOrNull2(options?.max_prompt_failures) ?? DEFAULT_MAX_PROMPT_FAILURES;
1743
1851
  const registerCommand = options?.register_command ?? true;
@@ -2146,7 +2254,8 @@ var server = async ({ client }, options) => {
2146
2254
  const sessionID = "sessionID" in input && typeof input.sessionID === "string" ? input.sessionID : output.messages.find((message) => typeof message.info.sessionID === "string")?.info.sessionID;
2147
2255
  if (!sessionID)
2148
2256
  return;
2149
- await accountUsage(sessionID, tokensFromMessages(output.messages));
2257
+ const usage = usageFromMessages(output.messages);
2258
+ await accountUsage(sessionID, usage.tokens, { cumulative: true, source: usage.source });
2150
2259
  const observed = await recordAssistantMessage(sessionID, latestAssistantMessage(output.messages), options ?? {});
2151
2260
  await reconcileLocalMarkerAfterProgress(locallyDeliveredPendingSessions, sessionID, observed.goal);
2152
2261
  const scheduled = scheduledContinuations.get(sessionID);
@@ -2277,7 +2386,7 @@ async function setupV2(context) {
2277
2386
  const autoContinue = options.auto_continue ?? true;
2278
2387
  const deferWhileTasksActive = options.defer_while_tasks_active ?? true;
2279
2388
  const maxAutoTurns = positiveIntegerOrNull2(options.max_auto_turns) ?? DEFAULT_MAX_AUTO_TURNS;
2280
- const minInterval = nonNegativeIntegerOrNull(options.min_continue_interval_seconds) ?? DEFAULT_CONTINUE_INTERVAL_SECONDS;
2389
+ const minInterval = nonNegativeIntegerOrNull2(options.min_continue_interval_seconds) ?? DEFAULT_CONTINUE_INTERVAL_SECONDS;
2281
2390
  const maxTurnTimeMs = timeoutMillisecondsFromSeconds(options.max_turn_time);
2282
2391
  const maxPromptFailures = positiveIntegerOrNull2(options.max_prompt_failures) ?? DEFAULT_MAX_PROMPT_FAILURES;
2283
2392
  const registerCommand = options.register_command ?? true;
@@ -2293,11 +2402,21 @@ async function setupV2(context) {
2293
2402
  const toolAttempts = new Map;
2294
2403
  const planAgents = restrictedAgentSet(options);
2295
2404
  const isPlanAgent = (agent) => typeof agent === "string" && planAgents.has(agent.trim().toLowerCase());
2296
- const goalServices = { options, isPlanAgent };
2297
2405
  const activeContinuationsV2 = new Set;
2298
2406
  const latestStepBySession = new Map;
2299
2407
  const stepTextBuffers = new Map;
2300
2408
  const stepTokenSums = new Map;
2409
+ const goalServices = {
2410
+ options,
2411
+ isPlanAgent,
2412
+ initializeUsage: async (sessionID) => {
2413
+ try {
2414
+ await accountUsage(sessionID, stepTokenSums.get(sessionID) ?? 0, { cumulative: true, source: "v2.steps" });
2415
+ } catch (error) {
2416
+ v2ErrorLog("Failed to initialize goal usage accounting", error);
2417
+ }
2418
+ }
2419
+ };
2301
2420
  const registrations = [];
2302
2421
  let disposed = false;
2303
2422
  function stepKey(sessionID, messageID2) {
@@ -2698,7 +2817,11 @@ async function setupV2(context) {
2698
2817
  if (typeof tokens === "number") {
2699
2818
  const sum = (stepTokenSums.get(sessionID) ?? 0) + tokens;
2700
2819
  stepTokenSums.set(sessionID, sum);
2701
- await accountUsage(sessionID, sum);
2820
+ await accountUsage(sessionID, sum, {
2821
+ cumulative: true,
2822
+ source: "v2.steps",
2823
+ initialBaseline: Math.ceil(sum - tokens)
2824
+ });
2702
2825
  }
2703
2826
  const text = stepTextBuffers.get(stepKey(sessionID, messageID2)) ?? "";
2704
2827
  stepTextBuffers.delete(stepKey(sessionID, messageID2));
@@ -2734,7 +2857,11 @@ async function setupV2(context) {
2734
2857
  if (typeof tokens === "number") {
2735
2858
  const sum = (stepTokenSums.get(sessionID) ?? 0) + tokens;
2736
2859
  stepTokenSums.set(sessionID, sum);
2737
- await accountUsage(sessionID, sum);
2860
+ await accountUsage(sessionID, sum, {
2861
+ cumulative: true,
2862
+ source: "v2.steps",
2863
+ initialBaseline: Math.ceil(sum - tokens)
2864
+ });
2738
2865
  }
2739
2866
  const text = stepTextBuffers.get(stepKey(sessionID, messageID2)) ?? "";
2740
2867
  stepTextBuffers.delete(stepKey(sessionID, messageID2));
@@ -2767,7 +2894,7 @@ async function setupV2(context) {
2767
2894
  return;
2768
2895
  const tokens = tokensFromRecord(data.tokens);
2769
2896
  if (typeof tokens === "number")
2770
- await accountUsage(sessionID, tokens);
2897
+ await accountUsage(sessionID, tokens, { cumulative: true, source: "v2.session" });
2771
2898
  return;
2772
2899
  }
2773
2900
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@prevalentware/opencode-goal-plugin",
3
- "version": "0.1.34",
3
+ "version": "0.1.36",
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",