@prevalentware/opencode-goal-plugin 0.1.36 → 0.1.38

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 +2 -2
  2. package/dist/server.js +100 -29
  3. package/package.json +2 -1
package/README.md CHANGED
@@ -18,7 +18,7 @@ The OpenCode Goal Plugin adds:
18
18
 
19
19
  - `/goal <objective>` as an OpenCode command for TUI, desktop, and web.
20
20
  - A sidebar goal indicator with status, elapsed time, and objective.
21
- - Agent tools: `get_goal`, `get_goal_history`, `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`, 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.
@@ -239,7 +239,7 @@ This plugin follows Codex's native goal-mode semantics where OpenCode plugin hoo
239
239
 
240
240
  ```bash
241
241
  bun install
242
- bun test
242
+ bun run test
243
243
  bun run lint
244
244
  bun run typecheck
245
245
  bun run build
package/dist/server.js CHANGED
@@ -127,6 +127,7 @@ class StateWriteError extends Data.TaggedError("StateWriteError") {
127
127
  }
128
128
  var MAX_HISTORY_ENTRIES = 50;
129
129
  var MAX_CHECKPOINTS = 8;
130
+ var MAX_LISTED_GOALS = 50;
130
131
  var CHECKPOINT_CHAR_LIMIT = 280;
131
132
  var DEFAULT_NO_PROGRESS_TOKEN_THRESHOLD = 50;
132
133
  var DEFAULT_MAX_NO_PROGRESS_TURNS = 2;
@@ -241,8 +242,7 @@ function parseStateText(raw, file) {
241
242
  function decodeState(value) {
242
243
  return Schema.decodeUnknown(StateSchema)(value).pipe(Effect.map(mutableState), Effect.map(normalizeState), Effect.mapError((cause) => new StateDecodeError({ cause })));
243
244
  }
244
- function readStateEffect() {
245
- const file = statePath();
245
+ function readStateEffect(file = statePath()) {
246
246
  return Effect.tryPromise({
247
247
  try: () => readFile(file, "utf8"),
248
248
  catch: (cause) => new StateReadError({ cause })
@@ -251,10 +251,9 @@ function readStateEffect() {
251
251
  catch: (cause) => new StateDecodeError({ cause })
252
252
  })), Effect.flatMap(decodeState), Effect.catchAll((error) => error._tag === "StateReadError" && isMissingStateFile(error.cause) ? Effect.succeed(emptyState()) : Effect.fail(error)));
253
253
  }
254
- function writeStateEffect(state) {
254
+ function writeStateEffect(state, file = statePath()) {
255
255
  return Effect.tryPromise({
256
256
  try: async () => {
257
- const file = statePath();
258
257
  await mkdir(dirname2(file), { recursive: true, mode: 448 });
259
258
  await atomicWriteFile(file, JSON.stringify(state, null, 2) + `
260
259
  `);
@@ -276,15 +275,18 @@ function enqueueMutation(operation) {
276
275
  return current;
277
276
  }
278
277
  async function mutate(fn) {
279
- return enqueueMutation(() => Effect.runPromise(Effect.gen(function* () {
280
- const state = yield* readStateEffect();
281
- const result = yield* Effect.tryPromise({
282
- try: () => Promise.resolve(fn(state)),
283
- catch: (cause) => cause instanceof Error ? cause : new Error(String(cause))
284
- });
285
- yield* writeStateEffect(state);
286
- return result;
287
- })));
278
+ return enqueueMutation(() => {
279
+ const file = statePath();
280
+ return Effect.runPromise(Effect.gen(function* () {
281
+ const state = yield* readStateEffect(file);
282
+ const result = yield* Effect.tryPromise({
283
+ try: () => Promise.resolve(fn(state)),
284
+ catch: (cause) => cause instanceof Error ? cause : new Error(String(cause))
285
+ });
286
+ yield* writeStateEffect(state, file);
287
+ return result;
288
+ }));
289
+ });
288
290
  }
289
291
  function validateObjective(objective) {
290
292
  const value = objective.trim();
@@ -456,6 +458,30 @@ async function getGoal(sessionID) {
456
458
  const goal = state.goals[sessionID];
457
459
  return goal ? snapshot(goal) : null;
458
460
  }
461
+ async function getAllGoals() {
462
+ const state = await readState();
463
+ const sorted = Object.values(state.goals).sort((left, right) => right.updatedAt - left.updatedAt || (left.sessionID < right.sessionID ? -1 : left.sessionID > right.sessionID ? 1 : 0));
464
+ const goals = sorted.slice(0, MAX_LISTED_GOALS).map(goalListItem);
465
+ return { goals, total: sorted.length, truncated: sorted.length > goals.length };
466
+ }
467
+ function goalListItem(goal) {
468
+ return {
469
+ sessionID: goal.sessionID,
470
+ objective: goal.objective,
471
+ status: goal.status,
472
+ tokenBudget: goal.tokenBudget,
473
+ tokensUsed: goal.tokensUsed,
474
+ timeUsedSeconds: goal.timeUsedSeconds,
475
+ createdAt: goal.createdAt,
476
+ updatedAt: goal.updatedAt,
477
+ closedAt: goal.closedAt ?? null,
478
+ maxAutoTurns: goal.maxAutoTurns,
479
+ maxDurationSeconds: goal.maxDurationSeconds,
480
+ autoTurns: goal.autoTurns,
481
+ stopReason: goal.stopReason,
482
+ remainingTokens: remainingTokens(goal)
483
+ };
484
+ }
459
485
  async function getGoalInternal(sessionID) {
460
486
  const state = await readState();
461
487
  const goal = state.goals[sessionID];
@@ -1140,10 +1166,13 @@ var STALE_PENDING_MS = 30000;
1140
1166
  var RETRY_SETTLE_MS = 25;
1141
1167
  var TRANSPORT_ERROR_PATTERN = /\b(?:network|fetch|socket|connect|connection|timeout|timed out|ECONNRESET|ECONNREFUSED|ETIMEDOUT|EAI_AGAIN|ENOTFOUND|EPIPE|transport|stream|websocket|offline|internet|request failed|proxy)\b/i;
1142
1168
  var NON_TRANSPORT_TERMINAL_PATTERN = /\b(?:abort(?:ed)?|interrupt(?:ed|ion)?)\b/i;
1143
- var NON_PROGRESS_TOOLS = new Set(["get_goal", "get_goal_history"]);
1169
+ var NON_PROGRESS_TOOLS = new Set(["get_goal", "get_goal_history", "list_all_goals"]);
1144
1170
  var TASK_TERMINAL_STATES = new Set(["completed", "error", "cancelled"]);
1145
1171
  var PLAN_MODE_CREATE_NOTICE = 'Goal recorded while the session is in Plan mode, so execution is paused. Do not start implementation work now. Ask the user to switch to Build mode and resume the goal (for example with "/goal resume") to begin execution.';
1146
1172
  var LIMITED_GOAL_NOTICE = "Safety limit reached. Do not start or continue substantive work for this goal. Summarize useful progress, remaining work, and blockers, then wait for the user to resume or edit the goal.";
1173
+ var DUPLICATE_GOAL_NOTICE = "This non-closed goal already exists. Do not call create_goal or set_goal again. The existing objective and limits were preserved; repeated-call arguments were not applied. Use the returned goal state and continue only when its status permits execution.";
1174
+ var CONFLICTING_GOAL_NOTICE = "A different non-closed goal already exists. Do not call create_goal or set_goal again. Report the conflict instead of replacing the goal; edit, clear, complete, or mark it unmet only when explicitly requested.";
1175
+ var RESTRICTED_GOAL_NOTICE = "Goal execution is not allowed from the current restricted agent or while the goal is paused for Plan mode. Switch to Build mode and resume the goal before doing substantive work.";
1147
1176
  var activeContinuations = new Set;
1148
1177
  function restrictedAgentSet(options) {
1149
1178
  if (options?.allow_goal_execution_from_plan === true)
@@ -1170,9 +1199,9 @@ Use the goal tools to handle this command:
1170
1199
  - If the arguments start with "edit ", update the current goal objective by calling update_goal_objective with the remaining text.
1171
1200
  - If the arguments start with "complete " or "done ", perform a completion audit against real artifacts and command output. Call update_goal with status "complete" only if the goal is achieved, using concise evidence from the audit.
1172
1201
  - If the arguments start with "unmet ", "blocked ", or "blocker ", call update_goal with status "unmet" only when the goal cannot be achieved or needs external input, using the remaining arguments as the blocker.
1173
- - Otherwise, create a new goal with create_goal. Use the full arguments as the objective. If the user includes explicit budget instructions, pass token_budget, max_auto_turns, or max_duration_seconds to create_goal rather than leaving those words in the objective.
1202
+ - Otherwise, call get_goal first. If it returns a non-closed goal with the same objective, do not create it again; continue working from the returned state. If it returns a different non-closed goal, report that conflict instead of replacing it. Only when there is no non-closed goal, call create_goal once. Use the full arguments as the objective. If the user includes explicit budget instructions, pass token_budget, max_auto_turns, or max_duration_seconds to create_goal rather than leaving those words in the objective.
1174
1203
 
1175
- Create a goal only from these explicit command arguments. Do not infer a goal from unrelated session context. After create_goal succeeds, continue working toward the new goal.`;
1204
+ 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.`;
1176
1205
  }
1177
1206
  function commandNameFromOptions(options) {
1178
1207
  const name = options?.command_name?.trim() || DEFAULT_COMMAND_NAME;
@@ -1773,18 +1802,44 @@ function getGoalToolResult(goal) {
1773
1802
  }
1774
1803
  async function createGoalFromTool(input, context, services) {
1775
1804
  const planningOnly = services.isPlanAgent(context.agent);
1776
- const goal = await createGoal(context.sessionID, input.objective, {
1777
- tokenBudget: input.token_budget ?? services.options.default_token_budget ?? null,
1778
- maxAutoTurns: input.max_auto_turns ?? null,
1779
- maxDurationSeconds: input.max_duration_seconds ?? services.options.max_goal_duration_seconds ?? null,
1780
- noProgressTokenThreshold: services.options.no_progress_token_threshold ?? null,
1781
- maxNoProgressTurns: services.options.max_no_progress_turns ?? null,
1782
- agent: typeof context.agent === "string" ? context.agent : null,
1783
- initialStatus: planningOnly ? "paused" : "active"
1784
- });
1805
+ const objective = validateObjective(input.objective);
1806
+ const existing = await getGoal(context.sessionID);
1807
+ if (existing && !isClosedGoal(existing))
1808
+ return existingGoalResult(existing, objective, planningOnly);
1809
+ let goal;
1810
+ try {
1811
+ goal = await createGoal(context.sessionID, input.objective, {
1812
+ tokenBudget: input.token_budget ?? services.options.default_token_budget ?? null,
1813
+ maxAutoTurns: input.max_auto_turns ?? null,
1814
+ maxDurationSeconds: input.max_duration_seconds ?? services.options.max_goal_duration_seconds ?? null,
1815
+ noProgressTokenThreshold: services.options.no_progress_token_threshold ?? null,
1816
+ maxNoProgressTurns: services.options.max_no_progress_turns ?? null,
1817
+ agent: typeof context.agent === "string" ? context.agent : null,
1818
+ initialStatus: planningOnly ? "paused" : "active"
1819
+ });
1820
+ } catch (error) {
1821
+ if (!(error instanceof Error) || !error.message.includes("non-closed goal"))
1822
+ throw error;
1823
+ const raced = await getGoal(context.sessionID);
1824
+ if (raced && !isClosedGoal(raced))
1825
+ return existingGoalResult(raced, objective, planningOnly);
1826
+ throw error;
1827
+ }
1785
1828
  await services.initializeUsage?.(context.sessionID);
1786
1829
  return JSON.stringify(planningOnly ? { goal, plan_mode_notice: PLAN_MODE_CREATE_NOTICE } : { goal }, null, 2);
1787
1830
  }
1831
+ function isClosedGoal(goal) {
1832
+ return goal.status === "complete" || goal.status === "unmet";
1833
+ }
1834
+ function existingGoalResult(goal, requestedObjective, planningOnly) {
1835
+ const reused = goal.objective === requestedObjective;
1836
+ return JSON.stringify({
1837
+ goal,
1838
+ ...reused ? { goal_reused: true, duplicate_goal_notice: DUPLICATE_GOAL_NOTICE } : { goal_conflict: true, goal_conflict_notice: CONFLICTING_GOAL_NOTICE },
1839
+ ...goal.status === "budgetLimited" || goal.status === "usageLimited" ? { goal_mode_notice: LIMITED_GOAL_NOTICE } : {},
1840
+ ...planningOnly || goal.stopReason === PLAN_MODE_STOP_REASON ? { plan_mode_notice: RESTRICTED_GOAL_NOTICE } : {}
1841
+ }, null, 2);
1842
+ }
1788
1843
  async function updateGoalObjectiveFromTool(input, context, services) {
1789
1844
  const requested = input.status ?? "active";
1790
1845
  const planningOnly = requested === "active" && services.isPlanAgent(context.agent);
@@ -2142,8 +2197,15 @@ var server = async ({ client }, options) => {
2142
2197
  return JSON.stringify({ goal, history_report: formatGoalHistory(goal) }, null, 2);
2143
2198
  }
2144
2199
  },
2200
+ list_all_goals: {
2201
+ description: "List up to 50 public goal summaries across all sessions in this state file, ordered by most recently updated first. Elapsed time is the last persisted value; total and truncated report omitted older goals.",
2202
+ args: {},
2203
+ async execute() {
2204
+ return JSON.stringify(await getAllGoals(), null, 2);
2205
+ }
2206
+ },
2145
2207
  create_goal: {
2146
- description: "Create a goal only when explicitly requested by the user or system/developer instructions; do not infer goals from ordinary tasks. Fails if a non-complete goal exists. While the session is in Plan mode, the goal is recorded as paused and execution requires the user to switch to Build mode.",
2208
+ description: "Create a goal only when explicitly requested by the user or system/developer instructions; do not infer goals from ordinary tasks. If any non-closed goal exists, this returns the existing goal as either reused or conflicting and must not be retried. While the session is in Plan mode, the goal is recorded as paused and execution requires the user to switch to Build mode.",
2147
2209
  args: {
2148
2210
  objective: z.string().min(1).max(4000).describe("The concrete objective to start pursuing."),
2149
2211
  token_budget: z.number().int().positive().nullable().optional().describe("Optional positive token budget."),
@@ -2155,7 +2217,7 @@ var server = async ({ client }, options) => {
2155
2217
  }
2156
2218
  },
2157
2219
  set_goal: {
2158
- description: "Set a new goal when the user explicitly asks the agent to formulate and set its own goal. The model should write the objective itself based on the user's explicit request. Fails if a non-complete goal exists. While the session is in Plan mode, the goal is recorded as paused and execution requires the user to switch to Build mode.",
2220
+ description: "Set a new goal when the user explicitly asks the agent to formulate and set its own goal. The model should write the objective itself based on the user's explicit request. If any non-closed goal exists, this returns the existing goal as either reused or conflicting and must not be retried. While the session is in Plan mode, the goal is recorded as paused and execution requires the user to switch to Build mode.",
2159
2221
  args: {
2160
2222
  objective: z.string().min(1).max(4000).describe("The model-formulated concrete objective to start pursuing."),
2161
2223
  token_budget: z.number().int().positive().nullable().optional().describe("Optional positive token budget."),
@@ -3017,9 +3079,18 @@ function goalToolsV2(services) {
3017
3079
  return { content: JSON.stringify({ goal, history_report: formatGoalHistory(goal) }, null, 2) };
3018
3080
  }
3019
3081
  },
3082
+ {
3083
+ name: "list_all_goals",
3084
+ description: "List up to 50 public goal summaries across all sessions in this state file, ordered by most recently updated first. Elapsed time is the last persisted value; total and truncated report omitted older goals.",
3085
+ input: v2ObjectSchema({}),
3086
+ options: { codemode: false },
3087
+ execute: async () => ({
3088
+ content: JSON.stringify(await getAllGoals(), null, 2)
3089
+ })
3090
+ },
3020
3091
  {
3021
3092
  name: "create_goal",
3022
- description: "Create a goal only when explicitly requested by the user or system/developer instructions; do not infer goals from ordinary tasks. Fails if a non-complete goal exists. While the session is in Plan mode, the goal is recorded as paused and execution requires the user to switch to Build mode.",
3093
+ description: "Create a goal only when explicitly requested by the user or system/developer instructions; do not infer goals from ordinary tasks. If any non-closed goal exists, this returns the existing goal as either reused or conflicting and must not be retried. While the session is in Plan mode, the goal is recorded as paused and execution requires the user to switch to Build mode.",
3023
3094
  input: v2ObjectSchema({
3024
3095
  objective: { type: "string", minLength: 1, maxLength: 4000, description: "The concrete objective to start pursuing." },
3025
3096
  token_budget: { type: ["integer", "null"], minimum: 1, description: "Optional positive token budget." },
@@ -3033,7 +3104,7 @@ function goalToolsV2(services) {
3033
3104
  },
3034
3105
  {
3035
3106
  name: "set_goal",
3036
- description: "Set a new goal when the user explicitly asks the agent to formulate and set its own goal. The model should write the objective itself based on the user's explicit request. Fails if a non-complete goal exists. While the session is in Plan mode, the goal is recorded as paused and execution requires the user to switch to Build mode.",
3107
+ description: "Set a new goal when the user explicitly asks the agent to formulate and set its own goal. The model should write the objective itself based on the user's explicit request. If any non-closed goal exists, this returns the existing goal as either reused or conflicting and must not be retried. While the session is in Plan mode, the goal is recorded as paused and execution requires the user to switch to Build mode.",
3037
3108
  input: v2ObjectSchema({
3038
3109
  objective: {
3039
3110
  type: "string",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@prevalentware/opencode-goal-plugin",
3
- "version": "0.1.36",
3
+ "version": "0.1.38",
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",
@@ -49,6 +49,7 @@
49
49
  "lint": "eslint .",
50
50
  "pack:dry-run": "npm pack --dry-run",
51
51
  "test": "bun test",
52
+ "test:coverage": "bun test --coverage",
52
53
  "typecheck": "tsc --noEmit",
53
54
  "prepublishOnly": "bun run test && bun run build"
54
55
  },