@prevalentware/opencode-goal-plugin 0.1.18 → 0.1.19
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 +3 -1
- package/dist/server.js +393 -23
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -82,6 +82,7 @@ Server options can be configured in `opencode.json`:
|
|
|
82
82
|
"@prevalentware/opencode-goal-plugin",
|
|
83
83
|
{
|
|
84
84
|
"auto_continue": true,
|
|
85
|
+
"defer_while_tasks_active": true,
|
|
85
86
|
"max_auto_turns": 25,
|
|
86
87
|
"min_continue_interval_seconds": 3,
|
|
87
88
|
"max_prompt_failures": 3,
|
|
@@ -98,6 +99,7 @@ Server options can be configured in `opencode.json`:
|
|
|
98
99
|
Defaults:
|
|
99
100
|
|
|
100
101
|
- `auto_continue`: `true`
|
|
102
|
+
- `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.
|
|
101
103
|
- `max_auto_turns`: `25`
|
|
102
104
|
- `min_continue_interval_seconds`: `3`
|
|
103
105
|
- `max_prompt_failures`: `3`
|
|
@@ -194,6 +196,6 @@ OpenCode plugin modules are target-specific. This package exports separate modul
|
|
|
194
196
|
}
|
|
195
197
|
```
|
|
196
198
|
|
|
197
|
-
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. During compaction, the plugin disables OpenCode's generic synthetic auto-continue while an active goal exists so the goal-specific continuation prompt remains authoritative.
|
|
199
|
+
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.
|
|
198
200
|
|
|
199
201
|
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
|
@@ -701,6 +701,9 @@ var DEFAULT_CONTINUE_INTERVAL_SECONDS = 3;
|
|
|
701
701
|
var DEFAULT_MAX_PROMPT_FAILURES = 3;
|
|
702
702
|
var DEFAULT_COMMAND_NAME = "goal";
|
|
703
703
|
var GOAL_SYSTEM_MARKER = "OpenCode goal mode";
|
|
704
|
+
var TASK_SETTLE_DELAY_MS = 25;
|
|
705
|
+
var SNAPSHOT_IDLE_HOLD_MS = 250;
|
|
706
|
+
var TASK_TERMINAL_STATES = new Set(["completed", "error", "cancelled"]);
|
|
704
707
|
var activeContinuations = new Set;
|
|
705
708
|
function goalCommandTemplate(commandName) {
|
|
706
709
|
return `OpenCode goal mode command "/${commandName}" was invoked.
|
|
@@ -757,6 +760,16 @@ function textFromMessage(message) {
|
|
|
757
760
|
return (message.parts ?? []).map(textFromPart).filter(Boolean).join(`
|
|
758
761
|
`).trim();
|
|
759
762
|
}
|
|
763
|
+
function isRecord(value) {
|
|
764
|
+
return typeof value === "object" && value !== null;
|
|
765
|
+
}
|
|
766
|
+
function sessionIDFromMessage(message) {
|
|
767
|
+
if (typeof message.sessionID === "string")
|
|
768
|
+
return message.sessionID;
|
|
769
|
+
if (isRecord(message.info) && typeof message.info.sessionID === "string")
|
|
770
|
+
return message.info.sessionID;
|
|
771
|
+
return;
|
|
772
|
+
}
|
|
760
773
|
function estimateMessages(messages) {
|
|
761
774
|
return messages.reduce((sum, message) => sum + estimateTokensFromText(textFromMessage(message)), 0);
|
|
762
775
|
}
|
|
@@ -810,6 +823,52 @@ function tokensFromMessages(messages) {
|
|
|
810
823
|
const exactTotal = messages.reduce((sum, message) => sum + (exactTokensFromMessage(message) ?? 0), 0);
|
|
811
824
|
return exactTotal > 0 ? exactTotal : estimateMessages(messages);
|
|
812
825
|
}
|
|
826
|
+
function taskHeader(output) {
|
|
827
|
+
const resultIndex = output.search(/<task_(?:result|error)>/);
|
|
828
|
+
return resultIndex === -1 ? output : output.slice(0, resultIndex);
|
|
829
|
+
}
|
|
830
|
+
function parseTaskID(output) {
|
|
831
|
+
const xmlMatch = /<task\s+[^>]*\bid=["']([^"']+)["'][^>]*>/i.exec(output);
|
|
832
|
+
if (xmlMatch?.[1])
|
|
833
|
+
return xmlMatch[1];
|
|
834
|
+
for (const line of output.split(/\r?\n/)) {
|
|
835
|
+
const match = /^task_id:\s*([^\s()]+)(?:\s*\(.*)?$/i.exec(line.trim());
|
|
836
|
+
if (match?.[1])
|
|
837
|
+
return match[1];
|
|
838
|
+
}
|
|
839
|
+
return;
|
|
840
|
+
}
|
|
841
|
+
function parseTaskState(output) {
|
|
842
|
+
const xmlMatch = /<task\s+[^>]*\bstate=["'](running|completed|error|cancelled)["'][^>]*>/i.exec(output);
|
|
843
|
+
if (xmlMatch?.[1])
|
|
844
|
+
return xmlMatch[1].toLowerCase();
|
|
845
|
+
for (const line of taskHeader(output).split(/\r?\n/)) {
|
|
846
|
+
const match = /^state:\s*(running|completed|error|cancelled)\s*$/i.exec(line.trim());
|
|
847
|
+
if (match?.[1])
|
|
848
|
+
return match[1].toLowerCase();
|
|
849
|
+
}
|
|
850
|
+
return;
|
|
851
|
+
}
|
|
852
|
+
function parseTaskStatus(output) {
|
|
853
|
+
if (typeof output !== "string")
|
|
854
|
+
return;
|
|
855
|
+
const taskID = parseTaskID(output);
|
|
856
|
+
const state = parseTaskState(output);
|
|
857
|
+
return taskID && state ? { taskID, state } : undefined;
|
|
858
|
+
}
|
|
859
|
+
function messageCompletedAt(message) {
|
|
860
|
+
const time = isRecord(message.time) ? message.time : isRecord(message.info) && isRecord(message.info.time) ? message.info.time : undefined;
|
|
861
|
+
const completed = time?.completed;
|
|
862
|
+
return typeof completed === "number" && Number.isFinite(completed) ? completed : null;
|
|
863
|
+
}
|
|
864
|
+
function assistantMarker(message) {
|
|
865
|
+
if (messageRole(message) !== "assistant")
|
|
866
|
+
return;
|
|
867
|
+
return {
|
|
868
|
+
id: messageID(message) ?? null,
|
|
869
|
+
completedAt: messageCompletedAt(message)
|
|
870
|
+
};
|
|
871
|
+
}
|
|
813
872
|
async function sendContinuation(client, sessionID, prompt) {
|
|
814
873
|
await client.session.promptAsync({
|
|
815
874
|
path: { id: sessionID },
|
|
@@ -861,6 +920,235 @@ async function fetchLatestAssistant(client, sessionID) {
|
|
|
861
920
|
const data = Array.isArray(result.data) ? result.data : [];
|
|
862
921
|
return latestAssistantMessage(data);
|
|
863
922
|
}
|
|
923
|
+
|
|
924
|
+
class TaskTracker {
|
|
925
|
+
tasks = new Map;
|
|
926
|
+
pendingTaskCalls = new Map;
|
|
927
|
+
latestAssistantBySession = new Map;
|
|
928
|
+
snapshotIdleHolds = new Map;
|
|
929
|
+
settledSnapshotIdleTasks = new Set;
|
|
930
|
+
noteTaskCall(input) {
|
|
931
|
+
if (typeof input.tool !== "string" || input.tool.toLowerCase() !== "task")
|
|
932
|
+
return;
|
|
933
|
+
if (typeof input.sessionID !== "string")
|
|
934
|
+
return;
|
|
935
|
+
if (typeof input.callID === "string")
|
|
936
|
+
this.pendingTaskCalls.set(input.callID, input.sessionID);
|
|
937
|
+
}
|
|
938
|
+
noteTaskOutput(input, output) {
|
|
939
|
+
if (typeof input.tool !== "string" || input.tool.toLowerCase() !== "task")
|
|
940
|
+
return;
|
|
941
|
+
const parentSessionID = typeof input.callID === "string" ? this.pendingTaskCalls.get(input.callID) ?? input.sessionID : input.sessionID;
|
|
942
|
+
if (typeof input.callID === "string")
|
|
943
|
+
this.pendingTaskCalls.delete(input.callID);
|
|
944
|
+
if (typeof parentSessionID !== "string")
|
|
945
|
+
return;
|
|
946
|
+
const status = parseTaskStatus(output.output);
|
|
947
|
+
if (!status)
|
|
948
|
+
return;
|
|
949
|
+
if (status.state === "running") {
|
|
950
|
+
this.markRunning(parentSessionID, status.taskID);
|
|
951
|
+
return;
|
|
952
|
+
}
|
|
953
|
+
this.markTerminal(status.taskID, status.state, parentSessionID, { resetReconciled: true });
|
|
954
|
+
}
|
|
955
|
+
observeSessionCreated(event) {
|
|
956
|
+
const info = event.properties?.info;
|
|
957
|
+
if (!isRecord(info) || typeof info.id !== "string" || typeof info.parentID !== "string")
|
|
958
|
+
return;
|
|
959
|
+
this.markRunning(info.parentID, info.id);
|
|
960
|
+
}
|
|
961
|
+
observeSessionStatus(sessionID, status) {
|
|
962
|
+
const task = this.tasks.get(sessionID);
|
|
963
|
+
if (!task)
|
|
964
|
+
return;
|
|
965
|
+
if (status === "busy") {
|
|
966
|
+
this.markRunning(task.parentSessionID, sessionID);
|
|
967
|
+
return;
|
|
968
|
+
}
|
|
969
|
+
if (status === "idle")
|
|
970
|
+
this.markTerminal(sessionID, "completed", task.parentSessionID);
|
|
971
|
+
}
|
|
972
|
+
observeSessionDeleted(sessionID) {
|
|
973
|
+
this.tasks.delete(sessionID);
|
|
974
|
+
for (const task of this.tasks.values()) {
|
|
975
|
+
if (task.parentSessionID === sessionID)
|
|
976
|
+
this.tasks.delete(task.taskID);
|
|
977
|
+
}
|
|
978
|
+
this.latestAssistantBySession.delete(sessionID);
|
|
979
|
+
this.clearSnapshotIdleForSession(sessionID);
|
|
980
|
+
}
|
|
981
|
+
observeMessages(messages) {
|
|
982
|
+
for (const message of messages) {
|
|
983
|
+
const sessionID = sessionIDFromMessage(message);
|
|
984
|
+
if (!sessionID)
|
|
985
|
+
continue;
|
|
986
|
+
const marker = assistantMarker(message);
|
|
987
|
+
if (marker) {
|
|
988
|
+
this.observeAssistant(sessionID, marker);
|
|
989
|
+
continue;
|
|
990
|
+
}
|
|
991
|
+
for (const part of message.parts ?? []) {
|
|
992
|
+
const status = parseTaskStatus(textFromPart(part));
|
|
993
|
+
if (!status)
|
|
994
|
+
continue;
|
|
995
|
+
if (status.state === "running")
|
|
996
|
+
this.markRunning(sessionID, status.taskID);
|
|
997
|
+
else
|
|
998
|
+
this.markTerminal(status.taskID, status.state, sessionID, { resetReconciled: true });
|
|
999
|
+
}
|
|
1000
|
+
}
|
|
1001
|
+
}
|
|
1002
|
+
observeAssistantMessage(sessionID, message) {
|
|
1003
|
+
const marker = message ? assistantMarker(message) : undefined;
|
|
1004
|
+
if (marker)
|
|
1005
|
+
this.observeAssistant(sessionID, marker);
|
|
1006
|
+
}
|
|
1007
|
+
hasBlockingTasks(parentSessionID) {
|
|
1008
|
+
this.pruneExpiredSnapshotIdleHolds();
|
|
1009
|
+
for (const task of this.tasks.values()) {
|
|
1010
|
+
if (task.parentSessionID !== parentSessionID)
|
|
1011
|
+
continue;
|
|
1012
|
+
if (task.state === "running" || task.terminalUnreconciled)
|
|
1013
|
+
return true;
|
|
1014
|
+
}
|
|
1015
|
+
for (const hold of this.snapshotIdleHolds.values()) {
|
|
1016
|
+
if (hold.parentSessionID === parentSessionID)
|
|
1017
|
+
return true;
|
|
1018
|
+
}
|
|
1019
|
+
return false;
|
|
1020
|
+
}
|
|
1021
|
+
nextSnapshotIdleRetryAt(parentSessionID) {
|
|
1022
|
+
this.pruneExpiredSnapshotIdleHolds();
|
|
1023
|
+
let next = null;
|
|
1024
|
+
for (const hold of this.snapshotIdleHolds.values()) {
|
|
1025
|
+
if (hold.parentSessionID !== parentSessionID)
|
|
1026
|
+
continue;
|
|
1027
|
+
next = next == null ? hold.expiresAt : Math.min(next, hold.expiresAt);
|
|
1028
|
+
}
|
|
1029
|
+
return next;
|
|
1030
|
+
}
|
|
1031
|
+
async refreshLiveChildren(client, parentSessionID) {
|
|
1032
|
+
const session = client.session;
|
|
1033
|
+
if (!session.children || !session.status)
|
|
1034
|
+
return;
|
|
1035
|
+
let childIDs;
|
|
1036
|
+
try {
|
|
1037
|
+
const result = await session.children({ path: { id: parentSessionID } });
|
|
1038
|
+
const data = Array.isArray(result) ? result : Array.isArray(result.data) ? result.data : [];
|
|
1039
|
+
childIDs = data.flatMap((child) => isRecord(child) && typeof child.id === "string" ? [child.id] : []);
|
|
1040
|
+
} catch {
|
|
1041
|
+
return;
|
|
1042
|
+
}
|
|
1043
|
+
if (childIDs.length === 0)
|
|
1044
|
+
return;
|
|
1045
|
+
let statuses;
|
|
1046
|
+
try {
|
|
1047
|
+
const result = await session.status();
|
|
1048
|
+
statuses = isRecord(result) && isRecord(result.data) ? result.data : isRecord(result) ? result : {};
|
|
1049
|
+
} catch {
|
|
1050
|
+
return;
|
|
1051
|
+
}
|
|
1052
|
+
for (const childID of childIDs) {
|
|
1053
|
+
const status = statuses[childID];
|
|
1054
|
+
const statusType = isRecord(status) && typeof status.type === "string" ? status.type : undefined;
|
|
1055
|
+
if (statusType === "busy")
|
|
1056
|
+
this.markRunning(parentSessionID, childID);
|
|
1057
|
+
else if (statusType === "idle") {
|
|
1058
|
+
if (this.tasks.has(childID))
|
|
1059
|
+
this.markTerminal(childID, "completed", parentSessionID);
|
|
1060
|
+
else
|
|
1061
|
+
this.markSnapshotIdle(parentSessionID, childID);
|
|
1062
|
+
}
|
|
1063
|
+
}
|
|
1064
|
+
}
|
|
1065
|
+
markRunning(parentSessionID, taskID) {
|
|
1066
|
+
const existing = this.tasks.get(taskID);
|
|
1067
|
+
this.clearSnapshotIdle(parentSessionID, taskID);
|
|
1068
|
+
this.tasks.set(taskID, {
|
|
1069
|
+
taskID,
|
|
1070
|
+
parentSessionID,
|
|
1071
|
+
state: "running",
|
|
1072
|
+
terminalUnreconciled: false,
|
|
1073
|
+
terminalAt: null,
|
|
1074
|
+
lastAssistantMessageIDAtTerminal: existing?.lastAssistantMessageIDAtTerminal ?? null
|
|
1075
|
+
});
|
|
1076
|
+
}
|
|
1077
|
+
markTerminal(taskID, state, parentSessionID, options = {}) {
|
|
1078
|
+
if (!TASK_TERMINAL_STATES.has(state))
|
|
1079
|
+
return;
|
|
1080
|
+
const existing = this.tasks.get(taskID);
|
|
1081
|
+
const resolvedParentSessionID = existing?.parentSessionID ?? parentSessionID;
|
|
1082
|
+
if (!resolvedParentSessionID)
|
|
1083
|
+
return;
|
|
1084
|
+
this.clearSnapshotIdle(resolvedParentSessionID, taskID);
|
|
1085
|
+
if (existing && TASK_TERMINAL_STATES.has(existing.state) && !existing.terminalUnreconciled && !options.resetReconciled) {
|
|
1086
|
+
return;
|
|
1087
|
+
}
|
|
1088
|
+
this.tasks.set(taskID, {
|
|
1089
|
+
taskID,
|
|
1090
|
+
parentSessionID: resolvedParentSessionID,
|
|
1091
|
+
state,
|
|
1092
|
+
terminalUnreconciled: true,
|
|
1093
|
+
terminalAt: Date.now(),
|
|
1094
|
+
lastAssistantMessageIDAtTerminal: this.latestAssistantBySession.get(resolvedParentSessionID)?.id ?? null
|
|
1095
|
+
});
|
|
1096
|
+
}
|
|
1097
|
+
markSnapshotIdle(parentSessionID, taskID) {
|
|
1098
|
+
const key = this.snapshotIdleKey(parentSessionID, taskID);
|
|
1099
|
+
if (this.settledSnapshotIdleTasks.has(key) || this.snapshotIdleHolds.has(key))
|
|
1100
|
+
return;
|
|
1101
|
+
this.snapshotIdleHolds.set(key, {
|
|
1102
|
+
taskID,
|
|
1103
|
+
parentSessionID,
|
|
1104
|
+
expiresAt: Date.now() + SNAPSHOT_IDLE_HOLD_MS
|
|
1105
|
+
});
|
|
1106
|
+
}
|
|
1107
|
+
clearSnapshotIdle(parentSessionID, taskID) {
|
|
1108
|
+
const key = this.snapshotIdleKey(parentSessionID, taskID);
|
|
1109
|
+
this.snapshotIdleHolds.delete(key);
|
|
1110
|
+
this.settledSnapshotIdleTasks.delete(key);
|
|
1111
|
+
}
|
|
1112
|
+
clearSnapshotIdleForSession(sessionID) {
|
|
1113
|
+
for (const [key, hold] of this.snapshotIdleHolds) {
|
|
1114
|
+
if (hold.taskID === sessionID || hold.parentSessionID === sessionID)
|
|
1115
|
+
this.snapshotIdleHolds.delete(key);
|
|
1116
|
+
}
|
|
1117
|
+
for (const key of this.settledSnapshotIdleTasks) {
|
|
1118
|
+
if (key.startsWith(`${sessionID}\x00`) || key.endsWith(`\x00${sessionID}`)) {
|
|
1119
|
+
this.settledSnapshotIdleTasks.delete(key);
|
|
1120
|
+
}
|
|
1121
|
+
}
|
|
1122
|
+
}
|
|
1123
|
+
pruneExpiredSnapshotIdleHolds(now = Date.now()) {
|
|
1124
|
+
for (const [key, hold] of this.snapshotIdleHolds) {
|
|
1125
|
+
if (hold.expiresAt > now)
|
|
1126
|
+
continue;
|
|
1127
|
+
this.snapshotIdleHolds.delete(key);
|
|
1128
|
+
this.settledSnapshotIdleTasks.add(key);
|
|
1129
|
+
}
|
|
1130
|
+
}
|
|
1131
|
+
snapshotIdleKey(parentSessionID, taskID) {
|
|
1132
|
+
return `${parentSessionID}\x00${taskID}`;
|
|
1133
|
+
}
|
|
1134
|
+
observeAssistant(sessionID, marker) {
|
|
1135
|
+
this.latestAssistantBySession.set(sessionID, marker);
|
|
1136
|
+
for (const task of this.tasks.values()) {
|
|
1137
|
+
if (task.parentSessionID !== sessionID || !task.terminalUnreconciled)
|
|
1138
|
+
continue;
|
|
1139
|
+
if (this.assistantReconcilesTask(task, marker)) {
|
|
1140
|
+
this.tasks.set(task.taskID, { ...task, terminalUnreconciled: false });
|
|
1141
|
+
}
|
|
1142
|
+
}
|
|
1143
|
+
}
|
|
1144
|
+
assistantReconcilesTask(task, marker) {
|
|
1145
|
+
if (marker.id && task.lastAssistantMessageIDAtTerminal && marker.id !== task.lastAssistantMessageIDAtTerminal)
|
|
1146
|
+
return true;
|
|
1147
|
+
if (marker.completedAt != null && task.terminalAt != null && marker.completedAt >= task.terminalAt)
|
|
1148
|
+
return true;
|
|
1149
|
+
return false;
|
|
1150
|
+
}
|
|
1151
|
+
}
|
|
864
1152
|
async function recordAssistantMessage(sessionID, message, options) {
|
|
865
1153
|
if (!message)
|
|
866
1154
|
return;
|
|
@@ -887,12 +1175,86 @@ ${reminder}`;
|
|
|
887
1175
|
}
|
|
888
1176
|
var server = async ({ client }, options) => {
|
|
889
1177
|
const autoContinue = options?.auto_continue ?? true;
|
|
1178
|
+
const deferWhileTasksActive = options?.defer_while_tasks_active ?? true;
|
|
890
1179
|
const maxAutoTurns = positiveIntegerOrNull2(options?.max_auto_turns) ?? DEFAULT_MAX_AUTO_TURNS;
|
|
891
1180
|
const minInterval = positiveIntegerOrNull2(options?.min_continue_interval_seconds) ?? DEFAULT_CONTINUE_INTERVAL_SECONDS;
|
|
892
1181
|
const maxPromptFailures = positiveIntegerOrNull2(options?.max_prompt_failures) ?? DEFAULT_MAX_PROMPT_FAILURES;
|
|
893
1182
|
const registerCommand = options?.register_command ?? true;
|
|
894
1183
|
const commandName = commandNameFromOptions(options);
|
|
1184
|
+
const taskTracker = new TaskTracker;
|
|
1185
|
+
const taskDeferredSessions = new Set;
|
|
1186
|
+
const scheduledContinuations = new Map;
|
|
1187
|
+
const busySessions = new Set;
|
|
1188
|
+
async function taskBlockStatus(sessionID) {
|
|
1189
|
+
if (!deferWhileTasksActive)
|
|
1190
|
+
return false;
|
|
1191
|
+
await taskTracker.refreshLiveChildren(client, sessionID);
|
|
1192
|
+
return {
|
|
1193
|
+
blocked: taskTracker.hasBlockingTasks(sessionID),
|
|
1194
|
+
retryAt: taskTracker.nextSnapshotIdleRetryAt(sessionID)
|
|
1195
|
+
};
|
|
1196
|
+
}
|
|
1197
|
+
function scheduleSettledContinuation(sessionID, delayMs = TASK_SETTLE_DELAY_MS) {
|
|
1198
|
+
if (scheduledContinuations.has(sessionID))
|
|
1199
|
+
return;
|
|
1200
|
+
const timer = setTimeout(() => {
|
|
1201
|
+
scheduledContinuations.delete(sessionID);
|
|
1202
|
+
runAutoContinue(sessionID, true);
|
|
1203
|
+
}, Math.max(0, delayMs));
|
|
1204
|
+
const maybeUnref = timer;
|
|
1205
|
+
if (typeof maybeUnref.unref === "function")
|
|
1206
|
+
maybeUnref.unref();
|
|
1207
|
+
scheduledContinuations.set(sessionID, timer);
|
|
1208
|
+
}
|
|
1209
|
+
async function runAutoContinue(sessionID, fromTaskDeferral = false) {
|
|
1210
|
+
if (busySessions.has(sessionID))
|
|
1211
|
+
return;
|
|
1212
|
+
if (activeContinuations.has(sessionID))
|
|
1213
|
+
return;
|
|
1214
|
+
activeContinuations.add(sessionID);
|
|
1215
|
+
try {
|
|
1216
|
+
const latestAssistant = await fetchLatestAssistant(client, sessionID);
|
|
1217
|
+
taskTracker.observeAssistantMessage(sessionID, latestAssistant);
|
|
1218
|
+
const taskStatus = await taskBlockStatus(sessionID);
|
|
1219
|
+
if (taskStatus && taskStatus.blocked) {
|
|
1220
|
+
taskDeferredSessions.add(sessionID);
|
|
1221
|
+
if (taskStatus.retryAt != null)
|
|
1222
|
+
scheduleSettledContinuation(sessionID, taskStatus.retryAt - Date.now());
|
|
1223
|
+
return;
|
|
1224
|
+
}
|
|
1225
|
+
if (busySessions.has(sessionID))
|
|
1226
|
+
return;
|
|
1227
|
+
await recordAssistantMessage(sessionID, latestAssistant, options ?? {});
|
|
1228
|
+
if (!fromTaskDeferral && taskDeferredSessions.has(sessionID)) {
|
|
1229
|
+
scheduleSettledContinuation(sessionID);
|
|
1230
|
+
return;
|
|
1231
|
+
}
|
|
1232
|
+
taskDeferredSessions.delete(sessionID);
|
|
1233
|
+
const goal = await reserveContinuation(sessionID, maxAutoTurns, minInterval);
|
|
1234
|
+
if (!goal)
|
|
1235
|
+
return;
|
|
1236
|
+
await sendContinuation(client, sessionID, goal.status === "active" ? continuationPrompt(goal) : limitPrompt(goal));
|
|
1237
|
+
await recordContinuationResult(sessionID, "success", maxPromptFailures);
|
|
1238
|
+
} catch (error) {
|
|
1239
|
+
await recordContinuationResult(sessionID, "failure", maxPromptFailures);
|
|
1240
|
+
await client.app?.log?.({
|
|
1241
|
+
body: {
|
|
1242
|
+
service: "opencode-goal-plugin",
|
|
1243
|
+
level: "error",
|
|
1244
|
+
message: "Auto-continue failed",
|
|
1245
|
+
extra: { error: error instanceof Error ? error.message : String(error) }
|
|
1246
|
+
}
|
|
1247
|
+
});
|
|
1248
|
+
} finally {
|
|
1249
|
+
activeContinuations.delete(sessionID);
|
|
1250
|
+
}
|
|
1251
|
+
}
|
|
895
1252
|
return {
|
|
1253
|
+
async dispose() {
|
|
1254
|
+
for (const timer of scheduledContinuations.values())
|
|
1255
|
+
clearTimeout(timer);
|
|
1256
|
+
scheduledContinuations.clear();
|
|
1257
|
+
},
|
|
896
1258
|
async config(config) {
|
|
897
1259
|
if (!registerCommand)
|
|
898
1260
|
return;
|
|
@@ -1005,7 +1367,14 @@ var server = async ({ client }, options) => {
|
|
|
1005
1367
|
}
|
|
1006
1368
|
}
|
|
1007
1369
|
},
|
|
1370
|
+
async "tool.execute.before"(input) {
|
|
1371
|
+
taskTracker.noteTaskCall(input);
|
|
1372
|
+
},
|
|
1373
|
+
async "tool.execute.after"(input, output) {
|
|
1374
|
+
taskTracker.noteTaskOutput(input, output);
|
|
1375
|
+
},
|
|
1008
1376
|
async "experimental.chat.messages.transform"(input, output) {
|
|
1377
|
+
taskTracker.observeMessages(output.messages);
|
|
1009
1378
|
const sessionID = "sessionID" in input && typeof input.sessionID === "string" ? input.sessionID : output.messages.find((message) => typeof message.info.sessionID === "string")?.info.sessionID;
|
|
1010
1379
|
if (!sessionID)
|
|
1011
1380
|
return;
|
|
@@ -1030,38 +1399,39 @@ var server = async ({ client }, options) => {
|
|
|
1030
1399
|
},
|
|
1031
1400
|
async event({ event }) {
|
|
1032
1401
|
const sessionID = sessionIDFromEvent(event);
|
|
1402
|
+
const eventType = event.type;
|
|
1403
|
+
if (eventType === "session.created") {
|
|
1404
|
+
taskTracker.observeSessionCreated(event);
|
|
1405
|
+
}
|
|
1406
|
+
if (sessionID && eventType === "session.status") {
|
|
1407
|
+
const status = event.properties?.status;
|
|
1408
|
+
if (isRecord(status) && typeof status.type === "string") {
|
|
1409
|
+
if (status.type === "busy")
|
|
1410
|
+
busySessions.add(sessionID);
|
|
1411
|
+
if (status.type === "idle")
|
|
1412
|
+
busySessions.delete(sessionID);
|
|
1413
|
+
taskTracker.observeSessionStatus(sessionID, status.type);
|
|
1414
|
+
}
|
|
1415
|
+
}
|
|
1416
|
+
if (sessionID && eventType === "session.idle") {
|
|
1417
|
+
busySessions.delete(sessionID);
|
|
1418
|
+
taskTracker.observeSessionStatus(sessionID, "idle");
|
|
1419
|
+
}
|
|
1420
|
+
if (sessionID && eventType === "session.deleted") {
|
|
1421
|
+
busySessions.delete(sessionID);
|
|
1422
|
+
taskTracker.observeSessionDeleted(sessionID);
|
|
1423
|
+
}
|
|
1033
1424
|
if (sessionID && event.type === "message.updated") {
|
|
1034
1425
|
const props = event.properties ?? {};
|
|
1035
1426
|
const message = [props.info, props.message].find((value) => value && typeof value === "object");
|
|
1427
|
+
taskTracker.observeAssistantMessage(sessionID, message);
|
|
1036
1428
|
await recordAssistantMessage(sessionID, message, options ?? {});
|
|
1037
1429
|
}
|
|
1038
1430
|
if (!autoContinue || !isIdleEvent(event))
|
|
1039
1431
|
return;
|
|
1040
1432
|
if (!sessionID)
|
|
1041
1433
|
return;
|
|
1042
|
-
|
|
1043
|
-
return;
|
|
1044
|
-
activeContinuations.add(sessionID);
|
|
1045
|
-
try {
|
|
1046
|
-
await recordAssistantMessage(sessionID, await fetchLatestAssistant(client, sessionID), options ?? {});
|
|
1047
|
-
const goal = await reserveContinuation(sessionID, maxAutoTurns, minInterval);
|
|
1048
|
-
if (!goal)
|
|
1049
|
-
return;
|
|
1050
|
-
await sendContinuation(client, sessionID, goal.status === "active" ? continuationPrompt(goal) : limitPrompt(goal));
|
|
1051
|
-
await recordContinuationResult(sessionID, "success", maxPromptFailures);
|
|
1052
|
-
} catch (error) {
|
|
1053
|
-
await recordContinuationResult(sessionID, "failure", maxPromptFailures);
|
|
1054
|
-
await client.app?.log?.({
|
|
1055
|
-
body: {
|
|
1056
|
-
service: "opencode-goal-plugin",
|
|
1057
|
-
level: "error",
|
|
1058
|
-
message: "Auto-continue failed",
|
|
1059
|
-
extra: { error: error instanceof Error ? error.message : String(error) }
|
|
1060
|
-
}
|
|
1061
|
-
});
|
|
1062
|
-
} finally {
|
|
1063
|
-
activeContinuations.delete(sessionID);
|
|
1064
|
-
}
|
|
1434
|
+
await runAutoContinue(sessionID);
|
|
1065
1435
|
}
|
|
1066
1436
|
};
|
|
1067
1437
|
};
|
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.19",
|
|
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",
|