@prevalentware/opencode-goal-plugin 0.1.45 → 0.1.47

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
@@ -160,6 +160,7 @@ Defaults:
160
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
161
  - `max_auto_turns`: `25`
162
162
  - `min_continue_interval_seconds`: `3`
163
+ - Fast V2 executions that finish inside this interval schedule a delayed continuation; they do not require another user message to wake up.
163
164
  - `max_turn_time`: unset by default; set a positive number of seconds to retry one active-goal continuation prompt when a model turn remains busy for that long. Each new busy event resets the watchdog. Idle, built-in retry, session deletion, active Task children, and restricted agents suppress the retry. Watchdog retries are independent of `min_continue_interval_seconds` and never consume auto-turn or no-progress budgets, but recognized transport failures still count toward the `max_prompt_failures` ceiling.
164
165
  - `max_prompt_failures`: `3`; consecutive transport or no-response continuation failures pause the goal at this ceiling. Prompt delivery alone does not reset the count; substantive assistant or tool progress, a new goal, or an explicit resume does.
165
166
  - `default_token_budget`: unset by default; when set, new goals inherit this token budget.
@@ -253,6 +254,17 @@ bun run build
253
254
  npm pack --dry-run
254
255
  ```
255
256
 
257
+ With `opencode2` installed, run `bun run build && bun run smoke:v2` to exercise the
258
+ native V2 lifecycle, not just mocked events. The smoke test uses a private server,
259
+ isolated home/config/database/goal state, and a deterministic local model with no
260
+ real provider credentials. It invokes `/goal`, requires **two** automatic
261
+ continuations with the default minimum interval, then verifies completion. A
262
+ second loaded location checks that server-wide events do not duplicate delivery.
263
+ The test prints its temporary artifact directory and shuts down its private
264
+ server. Use `OPENCODE_V2_BIN` to select another V2 binary, or run
265
+ `bun run smoke:v2 @prevalentware/opencode-goal-plugin@<version>` to install and
266
+ verify an exact published package in the isolated environment.
267
+
256
268
  ## Publishing
257
269
 
258
270
  This package is set up for npm Trusted Publishing from GitHub Actions. On every push to `main`, CI runs typecheck, lint, and unit tests in parallel. If they all pass, the publish job computes the next patch version from the latest version on npm, builds the package, and runs `npm publish`.
@@ -279,6 +291,6 @@ OpenCode plugin modules are target-specific. This package exports separate modul
279
291
  }
280
292
  ```
281
293
 
282
- 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 OpenCode idle events, including `session.idle` and `session.status` idle notifications. 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, no-progress, or prompt-failure budgets. 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.
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.
283
295
 
284
296
  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
@@ -1353,6 +1353,9 @@ function goalCommandDefinitions(commandName) {
1353
1353
  }
1354
1354
  ];
1355
1355
  }
1356
+ function omitUndefined(value) {
1357
+ return Object.fromEntries(Object.entries(value).filter(([, entry]) => entry !== undefined));
1358
+ }
1356
1359
  function commandNameFromOptions(options) {
1357
1360
  const name = options?.command_name?.trim() || DEFAULT_COMMAND_NAME;
1358
1361
  if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(name))
@@ -2687,6 +2690,7 @@ async function setupV2(context) {
2687
2690
  const planAgents = restrictedAgentSet(options);
2688
2691
  const isPlanAgent = (agent) => typeof agent === "string" && planAgents.has(agent.trim().toLowerCase());
2689
2692
  const activeContinuationsV2 = new Set;
2693
+ const stoppedExecutions = new Set;
2690
2694
  const latestStepBySession = new Map;
2691
2695
  const stepTextBuffers = new Map;
2692
2696
  const stepTokenSums = new Map;
@@ -2833,6 +2837,8 @@ async function setupV2(context) {
2833
2837
  async function runAutoContinue(sessionID, fromTaskDeferral = false, scheduled) {
2834
2838
  if (disposed)
2835
2839
  return;
2840
+ if (stoppedExecutions.has(sessionID))
2841
+ return;
2836
2842
  if (busySessions.has(sessionID))
2837
2843
  return;
2838
2844
  if (activeContinuationsV2.has(sessionID))
@@ -2913,8 +2919,13 @@ async function setupV2(context) {
2913
2919
  if (nativeRetrySessions.has(sessionID))
2914
2920
  return;
2915
2921
  const goal = await reserveContinuation(sessionID, maxAutoTurns, minInterval);
2916
- if (!goal)
2922
+ if (!goal) {
2923
+ const waiting = await getGoalInternal(sessionID);
2924
+ if (waiting?.status === "active" && waiting.pendingAttempt == null && waiting.lastContinuationAt != null && minInterval > 0) {
2925
+ scheduleSettledContinuation(sessionID, continuationDelayFromSnapshot(minInterval, waiting.lastContinuationAt), scheduled != null);
2926
+ }
2917
2927
  return;
2928
+ }
2918
2929
  attemptReservedAt = goal.pendingAttempt?.reservedAt ?? Date.now();
2919
2930
  if (nativeRetrySessions.has(sessionID)) {
2920
2931
  await rollbackContinuationAttempt(sessionID);
@@ -2955,6 +2966,23 @@ async function setupV2(context) {
2955
2966
  async function handleV2Event(event) {
2956
2967
  const data = event.data;
2957
2968
  const sessionID = typeof data.sessionID === "string" ? data.sessionID : undefined;
2969
+ if (context.location && event.location && (event.location.directory !== context.location.directory || event.location.workspaceID !== context.location.workspaceID)) {
2970
+ if (event.type === "session.created" && sessionID && typeof data.parentID === "string") {
2971
+ taskTracker.observeSessionCreated({ properties: { info: { id: sessionID, parentID: data.parentID } } });
2972
+ } else if (sessionID) {
2973
+ if (event.type === "session.execution.started")
2974
+ taskTracker.observeSessionStatus(sessionID, "busy");
2975
+ if (["session.execution.succeeded", "session.execution.failed", "session.execution.interrupted", "session.idle"].includes(event.type)) {
2976
+ taskTracker.observeSessionStatus(sessionID, "idle");
2977
+ }
2978
+ if (event.type === "session.status" && isRecord(data.status) && typeof data.status.type === "string") {
2979
+ taskTracker.observeSessionStatus(sessionID, data.status.type);
2980
+ }
2981
+ if (event.type === "session.deleted")
2982
+ taskTracker.observeSessionDeleted(sessionID);
2983
+ }
2984
+ return;
2985
+ }
2958
2986
  switch (event.type) {
2959
2987
  case "session.created": {
2960
2988
  const parentID = data.parentID;
@@ -2963,10 +2991,13 @@ async function setupV2(context) {
2963
2991
  }
2964
2992
  return;
2965
2993
  }
2994
+ case "session.execution.started":
2995
+ case "session.retry.scheduled":
2966
2996
  case "session.status": {
2967
- const status = data.status;
2997
+ const status = event.type === "session.execution.started" ? { type: "busy" } : event.type === "session.retry.scheduled" ? { type: "retry" } : data.status;
2968
2998
  if (sessionID && isRecord(status) && typeof status.type === "string") {
2969
2999
  if (status.type === "busy") {
3000
+ stoppedExecutions.delete(sessionID);
2970
3001
  busySessions.add(sessionID);
2971
3002
  nativeRetrySessions.delete(sessionID);
2972
3003
  armTurnWatchdog(sessionID);
@@ -2992,6 +3023,7 @@ async function setupV2(context) {
2992
3023
  }
2993
3024
  return;
2994
3025
  }
3026
+ case "session.execution.succeeded":
2995
3027
  case "session.idle": {
2996
3028
  if (sessionID) {
2997
3029
  busySessions.delete(sessionID);
@@ -3007,9 +3039,25 @@ async function setupV2(context) {
3007
3039
  }
3008
3040
  return;
3009
3041
  }
3042
+ case "session.execution.interrupted": {
3043
+ if (!sessionID)
3044
+ return;
3045
+ stoppedExecutions.add(sessionID);
3046
+ busySessions.delete(sessionID);
3047
+ nativeRetrySessions.delete(sessionID);
3048
+ clearTurnWatchdog(sessionID);
3049
+ watchdogRescuedSessions.delete(sessionID);
3050
+ cancelScheduledContinuation(sessionID);
3051
+ taskDeferredSessions.delete(sessionID);
3052
+ taskTracker.observeSessionStatus(sessionID, "idle");
3053
+ return;
3054
+ }
3055
+ case "session.execution.failed":
3010
3056
  case "session.error": {
3011
3057
  if (!sessionID)
3012
3058
  return;
3059
+ if (event.type === "session.execution.failed")
3060
+ nativeRetrySessions.delete(sessionID);
3013
3061
  const inNativeRetry = nativeRetrySessions.has(sessionID);
3014
3062
  busySessions.delete(sessionID);
3015
3063
  clearTurnWatchdog(sessionID);
@@ -3018,6 +3066,13 @@ async function setupV2(context) {
3018
3066
  nativeRetrySessions.delete(sessionID);
3019
3067
  watchdogRescuedSessions.delete(sessionID);
3020
3068
  const errorMessage = transportErrorMessageFromEvent(data);
3069
+ if (event.type === "session.execution.failed" && !isTransportError(errorMessage)) {
3070
+ stoppedExecutions.add(sessionID);
3071
+ cancelScheduledContinuation(sessionID);
3072
+ taskDeferredSessions.delete(sessionID);
3073
+ }
3074
+ if (event.type === "session.execution.failed")
3075
+ taskTracker.observeSessionStatus(sessionID, "idle");
3021
3076
  if (errorMessage && isTransportError(errorMessage)) {
3022
3077
  const goal = await getGoalInternal(sessionID);
3023
3078
  if (goal?.status === "active") {
@@ -3041,6 +3096,7 @@ async function setupV2(context) {
3041
3096
  case "session.deleted": {
3042
3097
  if (!sessionID)
3043
3098
  return;
3099
+ stoppedExecutions.delete(sessionID);
3044
3100
  busySessions.delete(sessionID);
3045
3101
  clearTurnWatchdog(sessionID);
3046
3102
  watchdogRescuedSessions.delete(sessionID);
@@ -3203,14 +3259,19 @@ async function setupV2(context) {
3203
3259
  cancelScheduledContinuation(input.sessionID);
3204
3260
  clearTurnWatchdog(input.sessionID);
3205
3261
  }
3206
- const stripMention = ({ mention: _mention, ...attachment }) => attachment;
3262
+ let forwardedPrompt = {};
3263
+ if (command.action === "goal") {
3264
+ const stripMention = ({ mention: _mention, ...attachment }) => attachment;
3265
+ const { files, agents, skills, ...promptFields } = input.prompt;
3266
+ forwardedPrompt = {
3267
+ ...omitUndefined(promptFields),
3268
+ ...files ? { files: files.map(stripMention) } : {},
3269
+ ...agents ? { agents: agents.map(stripMention) } : {},
3270
+ ...skills ? { skills: skills.map(stripMention) } : {}
3271
+ };
3272
+ }
3207
3273
  await context.session.prompt({
3208
- ...command.action === "goal" ? {
3209
- ...input.prompt,
3210
- files: input.prompt.files?.map(stripMention),
3211
- agents: input.prompt.agents?.map(stripMention),
3212
- skills: input.prompt.skills?.map(stripMention)
3213
- } : {},
3274
+ ...forwardedPrompt,
3214
3275
  sessionID: input.sessionID,
3215
3276
  text: command.template.replaceAll("$ARGUMENTS", () => input.prompt.text.trim()),
3216
3277
  delivery: input.delivery
@@ -3317,6 +3378,7 @@ async function setupV2(context) {
3317
3378
  clearTimeout(watchdog.timer);
3318
3379
  turnWatchdogs.clear();
3319
3380
  activeContinuationsV2.clear();
3381
+ stoppedExecutions.clear();
3320
3382
  nativeRetrySessions.clear();
3321
3383
  locallyDeliveredPendingSessions.clear();
3322
3384
  watchdogRescuedSessions.clear();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@prevalentware/opencode-goal-plugin",
3
- "version": "0.1.45",
3
+ "version": "0.1.47",
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
+ "smoke:v2": "bun scripts/smoke-v2-lifecycle.ts",
52
53
  "test:coverage": "bun test --coverage",
53
54
  "typecheck": "tsc --noEmit",
54
55
  "prepublishOnly": "bun run test && bun run build"