@prevalentware/opencode-goal-plugin 0.1.46 → 0.1.48

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
@@ -122,6 +122,7 @@ In OpenCode 1, server options use the package-and-options tuple in `opencode.jso
122
122
  "max_auto_turns": 25,
123
123
  "min_continue_interval_seconds": 3,
124
124
  "max_turn_time": 300,
125
+ "max_task_block_seconds": 900,
125
126
  "max_prompt_failures": 3,
126
127
  "default_token_budget": 200000,
127
128
  "max_goal_duration_seconds": 1800,
@@ -157,7 +158,8 @@ In OpenCode 2, use the plugin object form instead:
157
158
  Defaults:
158
159
 
159
160
  - `auto_continue`: `true`
160
- - `defer_while_tasks_active`: `true`; when enabled, goal auto-continuation waits for active OpenCode Task child sessions and their orchestrator reconciliation before sending the next goal prompt.
161
+ - `defer_while_tasks_active`: `true`; when enabled, goal auto-continuation waits for active OpenCode Task child sessions and their orchestrator reconciliation before sending the next goal prompt. A deferral re-checks child sessions on a short timer, so a goal deferred by a task never depends on a further idle event to resume.
162
+ - `max_task_block_seconds`: `900`; wall-clock ceiling on how long a single Task child session may defer goal continuation. A child that stays listed but never reports a terminal state, or a terminal child whose result is never reconciled, stops blocking once the ceiling passes. Set a smaller value for shorter subagents, `0` to remove the ceiling, or disable deferral entirely with `defer_while_tasks_active: false`.
161
163
  - `max_auto_turns`: `25`
162
164
  - `min_continue_interval_seconds`: `3`
163
165
  - Fast V2 executions that finish inside this interval schedule a delayed continuation; they do not require another user message to wake up.
@@ -291,6 +293,6 @@ OpenCode plugin modules are target-specific. This package exports separate modul
291
293
  }
292
294
  ```
293
295
 
294
- Codex goal mode has deeper runtime integration for thread lifecycle control. This plugin implements the same workflow using OpenCode plugin hooks. Token usage is read from OpenCode step-finish usage when available and falls back to message token metadata or text estimation when exact usage is unavailable. Continuation is driven by V2 `session.execution.succeeded` events and legacy `session.idle` / `session.status` idle notifications, never by intermediate model-step completion. V2 execution starts arm busy tracking; native `session.retry.scheduled` events cancel plugin recovery while OpenCode retries. Terminal execution transport failures use bounded recovery, while interruptions (user, shutdown, or superseded) cancel local timers without starting another turn or charging a prompt failure. Interrupted or non-transport-failed executions remain suppressed until the host starts a new execution; this does not change the persisted goal status. Use `/pause_goal` for a durable pause. Each V2 plugin instance handles goal events only for its own location while still observing cross-location child task lifecycles. The optional `max_turn_time` watchdog can retry one goal continuation prompt when a model turn remains busy, without consuming the goal's auto-turn or no-progress budgets; recognized transport failures do count toward the prompt-failure ceiling. By default, continuation is deferred while OpenCode Task child sessions are active or their terminal result still needs an orchestrator turn. During compaction, the plugin disables OpenCode's generic synthetic auto-continue while an active goal exists so the goal-specific continuation prompt remains authoritative.
296
+ Codex goal mode has deeper runtime integration for thread lifecycle control. This plugin implements the same workflow using OpenCode plugin hooks. Token usage is read from OpenCode step-finish usage when available and falls back to message token metadata or text estimation when exact usage is unavailable. Continuation is driven by V2 `session.execution.succeeded` events and legacy `session.idle` / `session.status` idle notifications, never by intermediate model-step completion. V2 execution starts arm busy tracking; native `session.retry.scheduled` events cancel plugin recovery while OpenCode retries. Terminal execution transport failures use bounded recovery, while interruptions (user, shutdown, or superseded) cancel local timers without starting another turn or charging a prompt failure. Interrupted or non-transport-failed executions remain suppressed until the host starts a new execution; this does not change the persisted goal status. Use `/pause_goal` for a durable pause. Each V2 plugin instance handles goal events only for its own location while still observing cross-location child task lifecycles. The optional `max_turn_time` watchdog can retry one goal continuation prompt when a model turn remains busy, without consuming the goal's auto-turn or no-progress budgets; recognized transport failures do count toward the prompt-failure ceiling. By default, continuation is deferred while OpenCode Task child sessions are active or their terminal result still needs an orchestrator turn, bounded by the `max_task_block_seconds` ceiling so an unobservable child cannot stall a goal indefinitely. During compaction, the plugin disables OpenCode's generic synthetic auto-continue while an active goal exists so the goal-specific continuation prompt remains authoritative.
295
297
 
296
298
  The goal sidebar shows the current status, elapsed time, token usage, auto-continue count, latest checkpoint, latest status message, stop reason, and objective when a goal is active, paused, or safety-limited. Closed goals remain visible briefly through the latest tool state as achieved or unmet.
package/dist/server.js CHANGED
@@ -1254,6 +1254,8 @@ var DEFAULT_COMMAND_NAME = "goal";
1254
1254
  var DEFAULT_RESTRICTED_AGENTS = ["plan"];
1255
1255
  var TASK_SETTLE_DELAY_MS = 25;
1256
1256
  var SNAPSHOT_IDLE_HOLD_MS = 250;
1257
+ var DEFAULT_MAX_TASK_BLOCK_SECONDS = 900;
1258
+ var TASK_BLOCK_RETRY_MS = 1000;
1257
1259
  var MAX_TIMER_DELAY_MS = 2147483647;
1258
1260
  var STALE_PENDING_MS = 30000;
1259
1261
  var RETRY_SETTLE_MS = 25;
@@ -1353,6 +1355,9 @@ function goalCommandDefinitions(commandName) {
1353
1355
  }
1354
1356
  ];
1355
1357
  }
1358
+ function omitUndefined(value) {
1359
+ return Object.fromEntries(Object.entries(value).filter(([, entry]) => entry !== undefined));
1360
+ }
1356
1361
  function commandNameFromOptions(options) {
1357
1362
  const name = options?.command_name?.trim() || DEFAULT_COMMAND_NAME;
1358
1363
  if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(name))
@@ -1644,6 +1649,12 @@ function toolOutputFailed(output) {
1644
1649
  return true;
1645
1650
  return false;
1646
1651
  }
1652
+ function taskBlockExpired(task, maxBlockMs, now) {
1653
+ if (maxBlockMs == null)
1654
+ return false;
1655
+ const blockingSince = task.state === "running" ? task.runningSince : task.terminalAt;
1656
+ return blockingSince != null && now - blockingSince >= maxBlockMs;
1657
+ }
1647
1658
  function sessionIDFromEvent(event) {
1648
1659
  const direct = event.properties?.sessionID;
1649
1660
  if (typeof direct === "string")
@@ -1769,13 +1780,17 @@ class TaskTracker {
1769
1780
  if (marker)
1770
1781
  this.observeAssistant(sessionID, marker);
1771
1782
  }
1772
- hasBlockingTasks(parentSessionID) {
1783
+ hasBlockingTasks(parentSessionID, maxBlockMs = null) {
1773
1784
  this.pruneExpiredSnapshotIdleHolds();
1785
+ const now = Date.now();
1774
1786
  for (const task of this.tasks.values()) {
1775
1787
  if (task.parentSessionID !== parentSessionID)
1776
1788
  continue;
1777
- if (task.state === "running" || task.terminalUnreconciled)
1778
- return true;
1789
+ if (task.state !== "running" && !task.terminalUnreconciled)
1790
+ continue;
1791
+ if (taskBlockExpired(task, maxBlockMs, now))
1792
+ continue;
1793
+ return true;
1779
1794
  }
1780
1795
  for (const hold of this.snapshotIdleHolds.values()) {
1781
1796
  if (hold.parentSessionID === parentSessionID)
@@ -1836,6 +1851,7 @@ class TaskTracker {
1836
1851
  parentSessionID,
1837
1852
  state: "running",
1838
1853
  terminalUnreconciled: false,
1854
+ runningSince: existing?.state === "running" ? existing.runningSince ?? Date.now() : Date.now(),
1839
1855
  terminalAt: null,
1840
1856
  lastAssistantMessageIDAtTerminal: existing?.lastAssistantMessageIDAtTerminal ?? null
1841
1857
  });
@@ -1851,13 +1867,15 @@ class TaskTracker {
1851
1867
  if (existing && TASK_TERMINAL_STATES.has(existing.state) && !existing.terminalUnreconciled && !options.resetReconciled) {
1852
1868
  return;
1853
1869
  }
1870
+ const continuesExistingTerminal = existing != null && TASK_TERMINAL_STATES.has(existing.state) && existing.state === state && existing.terminalUnreconciled && !options.resetReconciled;
1854
1871
  this.tasks.set(taskID, {
1855
1872
  taskID,
1856
1873
  parentSessionID: resolvedParentSessionID,
1857
1874
  state,
1858
1875
  terminalUnreconciled: true,
1859
- terminalAt: Date.now(),
1860
- lastAssistantMessageIDAtTerminal: this.latestAssistantBySession.get(resolvedParentSessionID)?.id ?? null
1876
+ runningSince: null,
1877
+ terminalAt: continuesExistingTerminal ? existing.terminalAt ?? Date.now() : Date.now(),
1878
+ lastAssistantMessageIDAtTerminal: continuesExistingTerminal ? existing.lastAssistantMessageIDAtTerminal : this.latestAssistantBySession.get(resolvedParentSessionID)?.id ?? null
1861
1879
  });
1862
1880
  }
1863
1881
  markSnapshotIdle(parentSessionID, taskID) {
@@ -2010,6 +2028,13 @@ async function createGoalFromTool(input, context, services) {
2010
2028
  function isClosedGoal(goal) {
2011
2029
  return goal.status === "complete" || goal.status === "unmet";
2012
2030
  }
2031
+ function taskDeferralGoalContinuable(goal) {
2032
+ if (!goal)
2033
+ return false;
2034
+ if (goal.status === "budgetLimited" || goal.status === "usageLimited")
2035
+ return !goal.budgetWrapupSent;
2036
+ return goal.status === "active";
2037
+ }
2013
2038
  function existingGoalResult(goal, requestedObjective, planningOnly) {
2014
2039
  const reused = goal.objective === requestedObjective;
2015
2040
  return JSON.stringify({
@@ -2097,6 +2122,7 @@ var server = async ({ client }, options) => {
2097
2122
  const maxAutoTurns = positiveIntegerOrNull2(options?.max_auto_turns) ?? DEFAULT_MAX_AUTO_TURNS;
2098
2123
  const minInterval = nonNegativeIntegerOrNull2(options?.min_continue_interval_seconds) ?? DEFAULT_CONTINUE_INTERVAL_SECONDS;
2099
2124
  const maxTurnTimeMs = timeoutMillisecondsFromSeconds(options?.max_turn_time);
2125
+ const maxTaskBlockMs = timeoutMillisecondsFromSeconds(options?.max_task_block_seconds ?? DEFAULT_MAX_TASK_BLOCK_SECONDS);
2100
2126
  const maxPromptFailures = positiveIntegerOrNull2(options?.max_prompt_failures) ?? DEFAULT_MAX_PROMPT_FAILURES;
2101
2127
  const registerCommand = options?.register_command ?? true;
2102
2128
  const commandName = commandNameFromOptions(options);
@@ -2129,7 +2155,7 @@ var server = async ({ client }, options) => {
2129
2155
  return false;
2130
2156
  await taskTracker.refreshLiveChildren(client, sessionID);
2131
2157
  return {
2132
- blocked: taskTracker.hasBlockingTasks(sessionID),
2158
+ blocked: taskTracker.hasBlockingTasks(sessionID, maxTaskBlockMs),
2133
2159
  retryAt: taskTracker.nextSnapshotIdleRetryAt(sessionID)
2134
2160
  };
2135
2161
  }
@@ -2267,10 +2293,14 @@ var server = async ({ client }, options) => {
2267
2293
  taskTracker.observeAssistantMessage(sessionID, latestAssistant);
2268
2294
  const taskStatus = await taskBlockStatus(sessionID);
2269
2295
  if (taskStatus && taskStatus.blocked) {
2270
- taskDeferredSessions.add(sessionID);
2271
- if (taskStatus.retryAt != null) {
2272
- scheduleSettledContinuation(sessionID, taskStatus.retryAt - Date.now(), scheduled != null);
2296
+ const deferralGoal = await getGoalInternal(sessionID);
2297
+ if (!taskDeferralGoalContinuable(deferralGoal)) {
2298
+ taskDeferredSessions.delete(sessionID);
2299
+ cancelScheduledContinuation(sessionID);
2300
+ return;
2273
2301
  }
2302
+ taskDeferredSessions.add(sessionID);
2303
+ scheduleSettledContinuation(sessionID, taskStatus.retryAt != null ? taskStatus.retryAt - Date.now() : TASK_BLOCK_RETRY_MS, scheduled != null);
2274
2304
  return;
2275
2305
  }
2276
2306
  if (busySessions.has(sessionID))
@@ -2671,6 +2701,7 @@ async function setupV2(context) {
2671
2701
  const maxAutoTurns = positiveIntegerOrNull2(options.max_auto_turns) ?? DEFAULT_MAX_AUTO_TURNS;
2672
2702
  const minInterval = nonNegativeIntegerOrNull2(options.min_continue_interval_seconds) ?? DEFAULT_CONTINUE_INTERVAL_SECONDS;
2673
2703
  const maxTurnTimeMs = timeoutMillisecondsFromSeconds(options.max_turn_time);
2704
+ const maxTaskBlockMs = timeoutMillisecondsFromSeconds(options.max_task_block_seconds ?? DEFAULT_MAX_TASK_BLOCK_SECONDS);
2674
2705
  const maxPromptFailures = positiveIntegerOrNull2(options.max_prompt_failures) ?? DEFAULT_MAX_PROMPT_FAILURES;
2675
2706
  const registerCommand = options.register_command ?? true;
2676
2707
  const commandName = commandNameFromOptions(options);
@@ -2719,7 +2750,7 @@ async function setupV2(context) {
2719
2750
  if (!deferWhileTasksActive)
2720
2751
  return false;
2721
2752
  return {
2722
- blocked: taskTracker.hasBlockingTasks(sessionID),
2753
+ blocked: taskTracker.hasBlockingTasks(sessionID, maxTaskBlockMs),
2723
2754
  retryAt: taskTracker.nextSnapshotIdleRetryAt(sessionID)
2724
2755
  };
2725
2756
  }
@@ -2849,10 +2880,14 @@ async function setupV2(context) {
2849
2880
  }
2850
2881
  const taskStatus = taskBlockStatus(sessionID);
2851
2882
  if (taskStatus && taskStatus.blocked) {
2852
- taskDeferredSessions.add(sessionID);
2853
- if (taskStatus.retryAt != null) {
2854
- scheduleSettledContinuation(sessionID, taskStatus.retryAt - Date.now(), scheduled != null);
2883
+ const deferralGoal = await getGoalInternal(sessionID);
2884
+ if (!taskDeferralGoalContinuable(deferralGoal)) {
2885
+ taskDeferredSessions.delete(sessionID);
2886
+ cancelScheduledContinuation(sessionID);
2887
+ return;
2855
2888
  }
2889
+ taskDeferredSessions.add(sessionID);
2890
+ scheduleSettledContinuation(sessionID, taskStatus.retryAt != null ? taskStatus.retryAt - Date.now() : TASK_BLOCK_RETRY_MS, scheduled != null);
2856
2891
  return;
2857
2892
  }
2858
2893
  if (busySessions.has(sessionID))
@@ -3256,14 +3291,19 @@ async function setupV2(context) {
3256
3291
  cancelScheduledContinuation(input.sessionID);
3257
3292
  clearTurnWatchdog(input.sessionID);
3258
3293
  }
3259
- const stripMention = ({ mention: _mention, ...attachment }) => attachment;
3294
+ let forwardedPrompt = {};
3295
+ if (command.action === "goal") {
3296
+ const stripMention = ({ mention: _mention, ...attachment }) => attachment;
3297
+ const { files, agents, skills, ...promptFields } = input.prompt;
3298
+ forwardedPrompt = {
3299
+ ...omitUndefined(promptFields),
3300
+ ...files ? { files: files.map(stripMention) } : {},
3301
+ ...agents ? { agents: agents.map(stripMention) } : {},
3302
+ ...skills ? { skills: skills.map(stripMention) } : {}
3303
+ };
3304
+ }
3260
3305
  await context.session.prompt({
3261
- ...command.action === "goal" ? {
3262
- ...input.prompt,
3263
- files: input.prompt.files?.map(stripMention),
3264
- agents: input.prompt.agents?.map(stripMention),
3265
- skills: input.prompt.skills?.map(stripMention)
3266
- } : {},
3306
+ ...forwardedPrompt,
3267
3307
  sessionID: input.sessionID,
3268
3308
  text: command.template.replaceAll("$ARGUMENTS", () => input.prompt.text.trim()),
3269
3309
  delivery: input.delivery
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@prevalentware/opencode-goal-plugin",
3
- "version": "0.1.46",
3
+ "version": "0.1.48",
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",