@prevalentware/opencode-goal-plugin 0.1.42 → 0.1.44

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 +5 -5
  2. package/dist/server.js +139 -24
  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
 
package/dist/server.js CHANGED
@@ -691,6 +691,12 @@ async function setGoalStatus(sessionID, status, agent) {
691
691
  const goal = state.goals[sessionID];
692
692
  if (!goal)
693
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);
694
700
  accountWallClock(goal);
695
701
  goal.status = status;
696
702
  goal.updatedAt = nowSeconds();
@@ -1285,10 +1291,60 @@ Use the goal tools to handle this command:
1285
1291
 
1286
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.`;
1287
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
+ }
1288
1342
  function commandNameFromOptions(options) {
1289
1343
  const name = options?.command_name?.trim() || DEFAULT_COMMAND_NAME;
1290
1344
  if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(name))
1291
1345
  return DEFAULT_COMMAND_NAME;
1346
+ if (name.toLowerCase() === "pause_goal" || name.toLowerCase() === "resume_goal")
1347
+ return DEFAULT_COMMAND_NAME;
1292
1348
  return name;
1293
1349
  }
1294
1350
  function positiveIntegerOrNull2(value) {
@@ -1302,14 +1358,25 @@ function timeoutMillisecondsFromSeconds(value) {
1302
1358
  return null;
1303
1359
  return Math.min(Math.ceil(value * 1000), MAX_TIMER_DELAY_MS);
1304
1360
  }
1305
- function registerDesktopCommand(config, commandName) {
1361
+ function registerDesktopCommands(config, commandName) {
1306
1362
  config.command ??= {};
1307
- if (config.command[commandName])
1308
- return;
1309
- config.command[commandName] = {
1310
- description: "Set or view the long-running session goal",
1311
- template: goalCommandTemplate(commandName)
1312
- };
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;
1313
1380
  }
1314
1381
  function textFromPart(part) {
1315
1382
  if (!part || typeof part !== "object")
@@ -2287,7 +2354,7 @@ var server = async ({ client }, options) => {
2287
2354
  async config(config) {
2288
2355
  if (!registerCommand)
2289
2356
  return;
2290
- registerDesktopCommand(config, commandName);
2357
+ registerDesktopCommands(config, commandName);
2291
2358
  },
2292
2359
  tool: {
2293
2360
  get_goal: {
@@ -2383,6 +2450,20 @@ var server = async ({ client }, options) => {
2383
2450
  toolAttempts.set(toolAttemptKey(sessionID, callID), goal?.pendingAttempt?.id ?? null);
2384
2451
  }
2385
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
+ },
2386
2467
  async "tool.execute.after"(input, output) {
2387
2468
  taskTracker.noteTaskOutput(input, output);
2388
2469
  const sessionID = typeof input?.sessionID === "string" ? input.sessionID : undefined;
@@ -3070,23 +3151,57 @@ async function setupV2(context) {
3070
3151
  }
3071
3152
  }
3072
3153
  if (registerCommand) {
3154
+ const existingCommands = new Set((await context.command.list()).data.map((command) => command.name));
3073
3155
  registrations.push(await context.command.transform((draft) => {
3074
- draft.add({
3075
- name: commandName,
3076
- description: "Set or view the long-running session goal",
3077
- execute: async (input) => {
3078
- const stripMention = ({ mention: _mention, ...attachment }) => attachment;
3079
- await context.session.prompt({
3080
- ...input.prompt,
3081
- files: input.prompt.files?.map(stripMention),
3082
- agents: input.prompt.agents?.map(stripMention),
3083
- skills: input.prompt.skills?.map(stripMention),
3084
- sessionID: input.sessionID,
3085
- text: goalCommandTemplate(commandName).replaceAll("$ARGUMENTS", () => input.prompt.text.trim()),
3086
- delivery: input.delivery
3087
- });
3088
- }
3089
- });
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
+ }
3187
+ }));
3188
+ registrations.push(await context.session.hook("prompt", async (input) => {
3189
+ const pauseTemplate = goalStatusCommandTemplate("pause_goal");
3190
+ const resumeTemplate = goalStatusCommandTemplate("resume_goal");
3191
+ const template = input.prompt.text.startsWith(pauseTemplate) ? pauseTemplate : input.prompt.text.startsWith(resumeTemplate) ? resumeTemplate : null;
3192
+ if (!template)
3193
+ return;
3194
+ input.prompt.text = template;
3195
+ delete input.prompt.files;
3196
+ delete input.prompt.agents;
3197
+ delete input.prompt.skills;
3198
+ if (template !== pauseTemplate)
3199
+ return;
3200
+ const goal = await getGoal(input.sessionID);
3201
+ if (goal?.status === "active")
3202
+ await setGoalStatus(input.sessionID, "paused");
3203
+ cancelScheduledContinuation(input.sessionID);
3204
+ clearTurnWatchdog(input.sessionID);
3090
3205
  }));
3091
3206
  }
3092
3207
  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.42",
3
+ "version": "0.1.44",
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",