@prevalentware/opencode-goal-plugin 0.1.23 → 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 +4 -2
- package/dist/server.js +100 -3
- package/package.json +3 -3
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
|
|
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
|
|
1006
|
-
|
|
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 === "
|
|
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.
|
|
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",
|
|
@@ -54,16 +54,16 @@
|
|
|
54
54
|
},
|
|
55
55
|
"dependencies": {
|
|
56
56
|
"@opencode-ai/plugin": "^1.17.1",
|
|
57
|
+
"@opentui/solid": "^0.4.0",
|
|
57
58
|
"effect": "^3.21.2",
|
|
59
|
+
"solid-js": "1.9.12",
|
|
58
60
|
"zod": "^4.1.8"
|
|
59
61
|
},
|
|
60
62
|
"devDependencies": {
|
|
61
63
|
"@eslint/js": "^10.0.1",
|
|
62
64
|
"@opentui/core": "^0.4.0",
|
|
63
|
-
"@opentui/solid": "^0.4.0",
|
|
64
65
|
"@types/bun": "^1.3.13",
|
|
65
66
|
"eslint": "^10.3.0",
|
|
66
|
-
"solid-js": "1.9.12",
|
|
67
67
|
"typescript": "^6.0.3",
|
|
68
68
|
"typescript-eslint": "^8.59.2"
|
|
69
69
|
},
|