@prevalentware/opencode-goal-plugin 0.1.24 → 0.1.25

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
@@ -86,6 +86,7 @@ Server options can be configured in `opencode.json`:
86
86
  "defer_while_tasks_active": true,
87
87
  "max_auto_turns": 25,
88
88
  "min_continue_interval_seconds": 3,
89
+ "max_turn_time": 300,
89
90
  "max_prompt_failures": 3,
90
91
  "default_token_budget": 200000,
91
92
  "max_goal_duration_seconds": 1800,
@@ -105,6 +106,7 @@ Defaults:
105
106
  - `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.
106
107
  - `max_auto_turns`: `25`
107
108
  - `min_continue_interval_seconds`: `3`
109
+ - `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 do not consume auto-turn, no-progress, or prompt-failure budgets.
108
110
  - `max_prompt_failures`: `3`
109
111
  - `default_token_budget`: unset by default; when set, new goals inherit this token budget.
110
112
  - `max_goal_duration_seconds`: unset by default; when set, new goals inherit this elapsed-time safety limit.
@@ -174,7 +176,7 @@ The state file is written atomically with owner-only permissions when the host f
174
176
 
175
177
  ## Credits
176
178
 
177
- This plugin follows Codex's native goal-mode semantics where OpenCode plugin hooks allow it. Several hardening ideas were adapted from Willy Topete's [`willytop8/OpenCode-goal-plugin`](https://github.com/willytop8/OpenCode-goal-plugin), especially lifecycle history, checkpoints, no-progress safeguards, budget wrap-up behavior, and strict-provider-safe system prompt merging. Thank you, Willy.
179
+ This plugin follows Codex's native goal-mode semantics where OpenCode plugin hooks allow it. Several hardening ideas were adapted from William Ricchiuti's [`willytop8/OpenCode-goal-plugin`](https://github.com/willytop8/OpenCode-goal-plugin), especially lifecycle history, checkpoints, no-progress safeguards, budget wrap-up behavior, and strict-provider-safe system prompt merging. Thank you, William.
178
180
 
179
181
  ## Development
180
182
 
@@ -213,6 +215,6 @@ OpenCode plugin modules are target-specific. This package exports separate modul
213
215
  }
214
216
  ```
215
217
 
216
- 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. 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.
218
+ 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.
217
219
 
218
220
  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
@@ -792,6 +792,7 @@ var DEFAULT_RESTRICTED_AGENTS = ["plan"];
792
792
  var GOAL_SYSTEM_MARKER = "OpenCode goal mode";
793
793
  var TASK_SETTLE_DELAY_MS = 25;
794
794
  var SNAPSHOT_IDLE_HOLD_MS = 250;
795
+ var MAX_TIMER_DELAY_MS = 2147483647;
795
796
  var TASK_TERMINAL_STATES = new Set(["completed", "error", "cancelled"]);
796
797
  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.';
797
798
  var activeContinuations = new Set;
@@ -833,6 +834,11 @@ function commandNameFromOptions(options) {
833
834
  function positiveIntegerOrNull2(value) {
834
835
  return typeof value === "number" && Number.isSafeInteger(value) && value > 0 ? value : null;
835
836
  }
837
+ function timeoutMillisecondsFromSeconds(value) {
838
+ if (typeof value !== "number" || !Number.isFinite(value) || value <= 0)
839
+ return null;
840
+ return Math.min(Math.ceil(value * 1000), MAX_TIMER_DELAY_MS);
841
+ }
836
842
  function registerDesktopCommand(config, commandName) {
837
843
  config.command ??= {};
838
844
  if (config.command[commandName])
@@ -1002,8 +1008,12 @@ function sessionIDFromEvent(event) {
1002
1008
  if (typeof direct === "string")
1003
1009
  return direct;
1004
1010
  const info = event.properties?.info;
1005
- if (typeof info === "object" && info !== null && typeof info.sessionID === "string") {
1006
- return info.sessionID;
1011
+ if (typeof info === "object" && info !== null) {
1012
+ if (typeof info.sessionID === "string")
1013
+ return info.sessionID;
1014
+ if (event.type === "session.deleted" && typeof info.id === "string") {
1015
+ return info.id;
1016
+ }
1007
1017
  }
1008
1018
  return;
1009
1019
  }
@@ -1304,12 +1314,14 @@ var server = async ({ client }, options) => {
1304
1314
  const deferWhileTasksActive = options?.defer_while_tasks_active ?? true;
1305
1315
  const maxAutoTurns = positiveIntegerOrNull2(options?.max_auto_turns) ?? DEFAULT_MAX_AUTO_TURNS;
1306
1316
  const minInterval = positiveIntegerOrNull2(options?.min_continue_interval_seconds) ?? DEFAULT_CONTINUE_INTERVAL_SECONDS;
1317
+ const maxTurnTimeMs = timeoutMillisecondsFromSeconds(options?.max_turn_time);
1307
1318
  const maxPromptFailures = positiveIntegerOrNull2(options?.max_prompt_failures) ?? DEFAULT_MAX_PROMPT_FAILURES;
1308
1319
  const registerCommand = options?.register_command ?? true;
1309
1320
  const commandName = commandNameFromOptions(options);
1310
1321
  const taskTracker = new TaskTracker;
1311
1322
  const taskDeferredSessions = new Set;
1312
1323
  const scheduledContinuations = new Map;
1324
+ const turnWatchdogs = new Map;
1313
1325
  const busySessions = new Set;
1314
1326
  const planAgents = restrictedAgentSet(options);
1315
1327
  const isPlanAgent = (agent) => typeof agent === "string" && planAgents.has(agent.trim().toLowerCase());
@@ -1335,6 +1347,75 @@ var server = async ({ client }, options) => {
1335
1347
  retryAt: taskTracker.nextSnapshotIdleRetryAt(sessionID)
1336
1348
  };
1337
1349
  }
1350
+ function clearTurnWatchdog(sessionID) {
1351
+ const watchdog = turnWatchdogs.get(sessionID);
1352
+ if (!watchdog)
1353
+ return;
1354
+ clearTimeout(watchdog.timer);
1355
+ turnWatchdogs.delete(sessionID);
1356
+ }
1357
+ function armTurnWatchdog(sessionID) {
1358
+ if (maxTurnTimeMs == null)
1359
+ return;
1360
+ clearTurnWatchdog(sessionID);
1361
+ const watchdog = {
1362
+ timer: setTimeout(() => void runTurnWatchdog(sessionID, watchdog), maxTurnTimeMs)
1363
+ };
1364
+ const maybeUnref = watchdog.timer;
1365
+ if (typeof maybeUnref.unref === "function")
1366
+ maybeUnref.unref();
1367
+ turnWatchdogs.set(sessionID, watchdog);
1368
+ }
1369
+ async function runTurnWatchdog(sessionID, watchdog) {
1370
+ let claimedContinuation = false;
1371
+ try {
1372
+ if (turnWatchdogs.get(sessionID) !== watchdog || !busySessions.has(sessionID))
1373
+ return;
1374
+ const goal = await getGoal(sessionID);
1375
+ if (turnWatchdogs.get(sessionID) !== watchdog || !busySessions.has(sessionID))
1376
+ return;
1377
+ if (goal?.status !== "active" || isPlanAgent(goal.lastPromptAgent))
1378
+ return;
1379
+ const latestAssistant = await fetchLatestAssistant(client, sessionID);
1380
+ if (turnWatchdogs.get(sessionID) !== watchdog || !busySessions.has(sessionID))
1381
+ return;
1382
+ const latestTurnAgent = agentFromMessage(latestAssistant);
1383
+ if (isPlanAgent(latestTurnAgent))
1384
+ return;
1385
+ const taskStatus = await taskBlockStatus(sessionID);
1386
+ if (turnWatchdogs.get(sessionID) !== watchdog || !busySessions.has(sessionID))
1387
+ return;
1388
+ if (taskStatus && taskStatus.blocked)
1389
+ return;
1390
+ const current = await getGoal(sessionID);
1391
+ if (turnWatchdogs.get(sessionID) !== watchdog || !busySessions.has(sessionID))
1392
+ return;
1393
+ if (current?.status !== "active" || isPlanAgent(current.lastPromptAgent) || activeContinuations.has(sessionID))
1394
+ return;
1395
+ turnWatchdogs.delete(sessionID);
1396
+ activeContinuations.add(sessionID);
1397
+ claimedContinuation = true;
1398
+ await sendContinuation(client, sessionID, continuationPrompt(current), current.lastPromptAgent ?? latestTurnAgent ?? null);
1399
+ } catch (error) {
1400
+ try {
1401
+ await client.app?.log?.({
1402
+ body: {
1403
+ service: "opencode-goal-plugin",
1404
+ level: "error",
1405
+ message: "Turn watchdog retry failed",
1406
+ extra: { error: error instanceof Error ? error.message : String(error) }
1407
+ }
1408
+ });
1409
+ } catch {
1410
+ return;
1411
+ }
1412
+ } finally {
1413
+ if (claimedContinuation)
1414
+ activeContinuations.delete(sessionID);
1415
+ if (turnWatchdogs.get(sessionID) === watchdog)
1416
+ turnWatchdogs.delete(sessionID);
1417
+ }
1418
+ }
1338
1419
  function scheduleSettledContinuation(sessionID, delayMs = TASK_SETTLE_DELAY_MS) {
1339
1420
  if (scheduledContinuations.has(sessionID))
1340
1421
  return;
@@ -1406,6 +1487,9 @@ var server = async ({ client }, options) => {
1406
1487
  for (const timer of scheduledContinuations.values())
1407
1488
  clearTimeout(timer);
1408
1489
  scheduledContinuations.clear();
1490
+ for (const watchdog of turnWatchdogs.values())
1491
+ clearTimeout(watchdog.timer);
1492
+ turnWatchdogs.clear();
1409
1493
  },
1410
1494
  async config(config) {
1411
1495
  if (!registerCommand)
@@ -1560,17 +1644,30 @@ var server = async ({ client }, options) => {
1560
1644
  if (isRecord(status) && typeof status.type === "string") {
1561
1645
  if (status.type === "busy")
1562
1646
  busySessions.add(sessionID);
1563
- if (status.type === "idle")
1647
+ if (status.type === "busy")
1648
+ armTurnWatchdog(sessionID);
1649
+ if (status.type === "idle") {
1564
1650
  busySessions.delete(sessionID);
1651
+ clearTurnWatchdog(sessionID);
1652
+ }
1653
+ if (status.type === "retry")
1654
+ clearTurnWatchdog(sessionID);
1565
1655
  taskTracker.observeSessionStatus(sessionID, status.type);
1566
1656
  }
1567
1657
  }
1568
1658
  if (sessionID && eventType === "session.idle") {
1569
1659
  busySessions.delete(sessionID);
1660
+ clearTurnWatchdog(sessionID);
1570
1661
  taskTracker.observeSessionStatus(sessionID, "idle");
1571
1662
  }
1572
1663
  if (sessionID && eventType === "session.deleted") {
1573
1664
  busySessions.delete(sessionID);
1665
+ clearTurnWatchdog(sessionID);
1666
+ const scheduled = scheduledContinuations.get(sessionID);
1667
+ if (scheduled)
1668
+ clearTimeout(scheduled);
1669
+ scheduledContinuations.delete(sessionID);
1670
+ taskDeferredSessions.delete(sessionID);
1574
1671
  taskTracker.observeSessionDeleted(sessionID);
1575
1672
  }
1576
1673
  if (sessionID && event.type === "message.updated") {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@prevalentware/opencode-goal-plugin",
3
- "version": "0.1.24",
3
+ "version": "0.1.25",
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",