@prevalentware/opencode-goal-plugin 0.1.18 → 0.1.20
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 +21 -2
- package/dist/server.js +550 -66
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -22,6 +22,7 @@ The OpenCode Goal Plugin adds:
|
|
|
22
22
|
- Goal close evidence: `complete` requires verified evidence, and `unmet` requires a concrete blocker.
|
|
23
23
|
- Persistent per-session goal state with history, checkpoints, budgets, and owner-only file permissions.
|
|
24
24
|
- Optional automatic continuation on `session.idle` / `session.status`, with no-progress pause and budget wrap-up safeguards.
|
|
25
|
+
- Plan-mode safety: goals created from the `plan` agent stay paused, and auto-continue never escapes a Plan-mode session or switches agents on its own.
|
|
25
26
|
- Compaction context so active goals are preserved when OpenCode summarizes a long session.
|
|
26
27
|
|
|
27
28
|
## Why Use This OpenCode Goal Plugin?
|
|
@@ -82,13 +83,16 @@ Server options can be configured in `opencode.json`:
|
|
|
82
83
|
"@prevalentware/opencode-goal-plugin",
|
|
83
84
|
{
|
|
84
85
|
"auto_continue": true,
|
|
86
|
+
"defer_while_tasks_active": true,
|
|
85
87
|
"max_auto_turns": 25,
|
|
86
88
|
"min_continue_interval_seconds": 3,
|
|
87
89
|
"max_prompt_failures": 3,
|
|
88
90
|
"default_token_budget": 200000,
|
|
89
91
|
"max_goal_duration_seconds": 1800,
|
|
90
92
|
"no_progress_token_threshold": 50,
|
|
91
|
-
"max_no_progress_turns": 2
|
|
93
|
+
"max_no_progress_turns": 2,
|
|
94
|
+
"restricted_agents": ["plan"],
|
|
95
|
+
"allow_goal_execution_from_plan": false
|
|
92
96
|
}
|
|
93
97
|
]
|
|
94
98
|
]
|
|
@@ -98,6 +102,7 @@ Server options can be configured in `opencode.json`:
|
|
|
98
102
|
Defaults:
|
|
99
103
|
|
|
100
104
|
- `auto_continue`: `true`
|
|
105
|
+
- `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
106
|
- `max_auto_turns`: `25`
|
|
102
107
|
- `min_continue_interval_seconds`: `3`
|
|
103
108
|
- `max_prompt_failures`: `3`
|
|
@@ -107,6 +112,8 @@ Defaults:
|
|
|
107
112
|
- `max_no_progress_turns`: `2`
|
|
108
113
|
- `register_command`: `true`
|
|
109
114
|
- `command_name`: `"goal"`
|
|
115
|
+
- `restricted_agents`: `["plan"]`; agents (matched case-insensitively) treated as planning-only for goal execution.
|
|
116
|
+
- `allow_goal_execution_from_plan`: `false`; when `true`, disables Plan-mode goal restrictions entirely.
|
|
110
117
|
|
|
111
118
|
## Goal Workflow
|
|
112
119
|
|
|
@@ -135,6 +142,18 @@ The plugin also uses safety states while keeping the goal available for review o
|
|
|
135
142
|
|
|
136
143
|
When a safety limit is reached, the plugin sends one wrap-up prompt asking for a concise handoff instead of silently continuing forever.
|
|
137
144
|
|
|
145
|
+
## Plan Mode Safety
|
|
146
|
+
|
|
147
|
+
OpenCode Plan mode is a user-controlled safety boundary, and goal mode must not become an escape hatch out of it. The plugin enforces that boundary in several layers:
|
|
148
|
+
|
|
149
|
+
- Goals created with `create_goal` or `set_goal` from the `plan` agent are recorded as `paused` with stop reason `plan mode`, never as active implementation goals. The tool response tells the agent to ask the user to switch to Build mode and resume the goal.
|
|
150
|
+
- Automatic idle continuation is suppressed while the last user prompt or the latest assistant turn came from a restricted agent. If a previously active goal idles under Plan mode, it is paused visibly instead of continuing autonomously.
|
|
151
|
+
- Resuming a goal (`update_goal_status` with `active`, or `update_goal_objective` with `status: "active"`) is refused from Plan mode, so a prompt-injected instruction inside repository content cannot self-escalate a planning session into Build-mode execution. Switching to Build mode and resuming is an explicit user action; resuming from Build updates the tracked agent so continuation restarts pinned to Build.
|
|
152
|
+
- Continuation prompts are pinned to the agent recorded from the last user prompt (`body.agent`), so auto-continue never silently switches the session to a different agent or mode.
|
|
153
|
+
- The goal system reminder becomes planning-only after a Plan-mode prompt: it shows goal state but instructs the agent not to perform implementation work.
|
|
154
|
+
|
|
155
|
+
The set of planning-only agents is configurable with `restricted_agents` (default `["plan"]`). Setting `allow_goal_execution_from_plan` to `true` opts out of all of these restrictions; the secure default is `false`.
|
|
156
|
+
|
|
138
157
|
## State
|
|
139
158
|
|
|
140
159
|
Goal state is stored at:
|
|
@@ -194,6 +213,6 @@ OpenCode plugin modules are target-specific. This package exports separate modul
|
|
|
194
213
|
}
|
|
195
214
|
```
|
|
196
215
|
|
|
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.
|
|
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.
|
|
198
217
|
|
|
199
218
|
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
|
@@ -21,6 +21,8 @@ var MAX_CHECKPOINTS = 8;
|
|
|
21
21
|
var CHECKPOINT_CHAR_LIMIT = 280;
|
|
22
22
|
var DEFAULT_NO_PROGRESS_TOKEN_THRESHOLD = 50;
|
|
23
23
|
var DEFAULT_MAX_NO_PROGRESS_TURNS = 2;
|
|
24
|
+
var PLAN_MODE_STOP_REASON = "plan mode";
|
|
25
|
+
var PLAN_MODE_BLOCKER = "Goal execution is paused while the session is in Plan mode. Switch to Build mode and resume the goal to continue.";
|
|
24
26
|
var NullableString = Schema.NullOr(Schema.String);
|
|
25
27
|
var NullableNumber = Schema.NullOr(Schema.Number);
|
|
26
28
|
var HistoryEntrySchema = Schema.Struct({
|
|
@@ -60,7 +62,8 @@ var GoalSchema = Schema.Struct({
|
|
|
60
62
|
checkpoints: Schema.optionalWith(Schema.Array(CheckpointSchema), { default: () => [] }),
|
|
61
63
|
lastCheckpoint: Schema.optionalWith(Schema.NullOr(CheckpointSchema), { default: () => null }),
|
|
62
64
|
lastAssistantText: Schema.optionalWith(Schema.String, { default: () => "" }),
|
|
63
|
-
lastAssistantMessageID: Schema.optionalWith(Schema.String, { default: () => "" })
|
|
65
|
+
lastAssistantMessageID: Schema.optionalWith(Schema.String, { default: () => "" }),
|
|
66
|
+
lastPromptAgent: Schema.optionalWith(NullableString, { default: () => null })
|
|
64
67
|
});
|
|
65
68
|
var StateSchema = Schema.Struct({
|
|
66
69
|
version: Schema.Literal(1),
|
|
@@ -164,6 +167,7 @@ function normalizeGoal(goal) {
|
|
|
164
167
|
goal.lastCheckpoint = goal.lastCheckpoint ?? goal.checkpoints.at(-1) ?? null;
|
|
165
168
|
goal.lastAssistantText ??= "";
|
|
166
169
|
goal.lastAssistantMessageID ??= "";
|
|
170
|
+
goal.lastPromptAgent ??= null;
|
|
167
171
|
goal.noProgressTurns = nonNegativeInteger(goal.noProgressTurns, 0);
|
|
168
172
|
goal.maxAutoTurns = positiveIntegerOrNull(goal.maxAutoTurns);
|
|
169
173
|
goal.maxDurationSeconds = positiveIntegerOrNull(goal.maxDurationSeconds);
|
|
@@ -181,7 +185,9 @@ function normalizeCreateOptions(input) {
|
|
|
181
185
|
maxAutoTurns: null,
|
|
182
186
|
maxDurationSeconds: null,
|
|
183
187
|
noProgressTokenThreshold: DEFAULT_NO_PROGRESS_TOKEN_THRESHOLD,
|
|
184
|
-
maxNoProgressTurns: DEFAULT_MAX_NO_PROGRESS_TURNS
|
|
188
|
+
maxNoProgressTurns: DEFAULT_MAX_NO_PROGRESS_TURNS,
|
|
189
|
+
agent: null,
|
|
190
|
+
initialStatus: "active"
|
|
185
191
|
};
|
|
186
192
|
}
|
|
187
193
|
return {
|
|
@@ -189,7 +195,9 @@ function normalizeCreateOptions(input) {
|
|
|
189
195
|
maxAutoTurns: positiveIntegerOrNull(input?.maxAutoTurns),
|
|
190
196
|
maxDurationSeconds: positiveIntegerOrNull(input?.maxDurationSeconds),
|
|
191
197
|
noProgressTokenThreshold: positiveIntegerOrNull(input?.noProgressTokenThreshold) ?? DEFAULT_NO_PROGRESS_TOKEN_THRESHOLD,
|
|
192
|
-
maxNoProgressTurns: positiveIntegerOrNull(input?.maxNoProgressTurns) ?? DEFAULT_MAX_NO_PROGRESS_TURNS
|
|
198
|
+
maxNoProgressTurns: positiveIntegerOrNull(input?.maxNoProgressTurns) ?? DEFAULT_MAX_NO_PROGRESS_TURNS,
|
|
199
|
+
agent: typeof input?.agent === "string" && input.agent.trim() ? input.agent.trim() : null,
|
|
200
|
+
initialStatus: input?.initialStatus === "paused" ? "paused" : "active"
|
|
193
201
|
};
|
|
194
202
|
}
|
|
195
203
|
function positiveIntegerOrNull(value) {
|
|
@@ -238,6 +246,7 @@ function snapshot(goal) {
|
|
|
238
246
|
lastCheckpoint: goal.lastCheckpoint,
|
|
239
247
|
lastAssistantText: goal.lastAssistantText,
|
|
240
248
|
lastAssistantMessageID: goal.lastAssistantMessageID,
|
|
249
|
+
lastPromptAgent: goal.lastPromptAgent,
|
|
241
250
|
autoTurns: goal.autoTurns,
|
|
242
251
|
lastContinuationAt: goal.lastContinuationAt,
|
|
243
252
|
remainingTokens: remainingTokens(goal),
|
|
@@ -258,63 +267,105 @@ async function createGoal(sessionID, objective, options) {
|
|
|
258
267
|
throw new Error("cannot create a new goal because this session already has a non-closed goal");
|
|
259
268
|
}
|
|
260
269
|
const now = nowSeconds();
|
|
270
|
+
const paused = normalizedOptions.initialStatus === "paused";
|
|
261
271
|
const goal = {
|
|
262
272
|
sessionID,
|
|
263
273
|
objective: value,
|
|
264
|
-
status:
|
|
274
|
+
status: normalizedOptions.initialStatus,
|
|
265
275
|
tokenBudget: normalizedOptions.tokenBudget,
|
|
266
276
|
tokensUsed: 0,
|
|
267
277
|
timeUsedSeconds: 0,
|
|
268
278
|
createdAt: now,
|
|
269
279
|
updatedAt: now,
|
|
270
280
|
completionEvidence: null,
|
|
271
|
-
blocker: null,
|
|
281
|
+
blocker: paused ? PLAN_MODE_BLOCKER : null,
|
|
272
282
|
closedAt: null,
|
|
273
|
-
lastAccountedAt: now,
|
|
283
|
+
lastAccountedAt: paused ? null : now,
|
|
274
284
|
autoTurns: 0,
|
|
275
285
|
lastContinuationAt: null,
|
|
276
286
|
continuationFailures: 0,
|
|
277
|
-
lastStatus: "Goal set.",
|
|
287
|
+
lastStatus: paused ? "Goal recorded from Plan mode; execution paused until resumed from Build mode." : "Goal set.",
|
|
278
288
|
maxAutoTurns: normalizedOptions.maxAutoTurns,
|
|
279
289
|
maxDurationSeconds: normalizedOptions.maxDurationSeconds,
|
|
280
290
|
noProgressTokenThreshold: normalizedOptions.noProgressTokenThreshold,
|
|
281
291
|
maxNoProgressTurns: normalizedOptions.maxNoProgressTurns,
|
|
282
292
|
noProgressTurns: 0,
|
|
283
293
|
budgetWrapupSent: false,
|
|
284
|
-
stopReason: null,
|
|
294
|
+
stopReason: paused ? PLAN_MODE_STOP_REASON : null,
|
|
285
295
|
history: [],
|
|
286
296
|
checkpoints: [],
|
|
287
297
|
lastCheckpoint: null,
|
|
288
298
|
lastAssistantText: "",
|
|
289
|
-
lastAssistantMessageID: ""
|
|
299
|
+
lastAssistantMessageID: "",
|
|
300
|
+
lastPromptAgent: normalizedOptions.agent
|
|
290
301
|
};
|
|
291
302
|
pushHistory(goal, "created", goalLimitSummary(goal));
|
|
303
|
+
if (paused)
|
|
304
|
+
pushHistory(goal, "paused", goal.lastStatus);
|
|
292
305
|
state.goals[sessionID] = goal;
|
|
293
306
|
return snapshot(goal);
|
|
294
307
|
});
|
|
295
308
|
}
|
|
296
|
-
async function updateGoalObjective(sessionID, objective, status = "active") {
|
|
309
|
+
async function updateGoalObjective(sessionID, objective, status = "active", options) {
|
|
297
310
|
const value = validateObjective(objective);
|
|
311
|
+
const agent = typeof options?.agent === "string" && options.agent.trim() ? options.agent.trim() : null;
|
|
312
|
+
const planModePause = options?.planModePause === true;
|
|
298
313
|
return mutate((state) => {
|
|
299
314
|
const goal = state.goals[sessionID];
|
|
300
315
|
if (!goal)
|
|
301
316
|
throw new Error("cannot update goal because this session has no goal");
|
|
302
317
|
accountWallClock(goal);
|
|
303
318
|
goal.objective = value;
|
|
304
|
-
goal.status = status;
|
|
319
|
+
goal.status = planModePause ? "paused" : status;
|
|
305
320
|
goal.updatedAt = nowSeconds();
|
|
306
|
-
goal.lastAccountedAt = status === "active" ? goal.updatedAt : null;
|
|
321
|
+
goal.lastAccountedAt = goal.status === "active" ? goal.updatedAt : null;
|
|
307
322
|
goal.completionEvidence = null;
|
|
308
|
-
goal.blocker = null;
|
|
323
|
+
goal.blocker = planModePause ? PLAN_MODE_BLOCKER : null;
|
|
309
324
|
goal.closedAt = null;
|
|
310
|
-
goal.stopReason = null;
|
|
325
|
+
goal.stopReason = planModePause ? PLAN_MODE_STOP_REASON : null;
|
|
311
326
|
goal.budgetWrapupSent = false;
|
|
312
|
-
|
|
327
|
+
if (agent)
|
|
328
|
+
goal.lastPromptAgent = agent;
|
|
329
|
+
goal.lastStatus = planModePause ? "Goal objective updated; execution paused while the session is in Plan mode." : goal.status === "active" ? "Goal objective updated and resumed." : "Goal objective updated and paused.";
|
|
313
330
|
pushHistory(goal, "updated", `Goal objective updated: ${summarizeText(value, 400)}`);
|
|
331
|
+
if (planModePause)
|
|
332
|
+
pushHistory(goal, "paused", goal.lastStatus);
|
|
333
|
+
return snapshot(goal);
|
|
334
|
+
});
|
|
335
|
+
}
|
|
336
|
+
async function recordPromptAgent(sessionID, agent) {
|
|
337
|
+
const value = agent.trim();
|
|
338
|
+
if (!value)
|
|
339
|
+
return null;
|
|
340
|
+
return mutate((state) => {
|
|
341
|
+
const goal = state.goals[sessionID];
|
|
342
|
+
if (!goal || isClosed(goal.status))
|
|
343
|
+
return goal ? snapshot(goal) : null;
|
|
344
|
+
if (goal.lastPromptAgent === value)
|
|
345
|
+
return snapshot(goal);
|
|
346
|
+
goal.lastPromptAgent = value;
|
|
347
|
+
goal.updatedAt = nowSeconds();
|
|
348
|
+
return snapshot(goal);
|
|
349
|
+
});
|
|
350
|
+
}
|
|
351
|
+
async function pauseGoalForPlanMode(sessionID) {
|
|
352
|
+
return mutate((state) => {
|
|
353
|
+
const goal = state.goals[sessionID];
|
|
354
|
+
if (!goal || goal.status !== "active")
|
|
355
|
+
return goal ? snapshot(goal) : null;
|
|
356
|
+
accountWallClock(goal);
|
|
357
|
+
goal.status = "paused";
|
|
358
|
+
goal.lastAccountedAt = null;
|
|
359
|
+
goal.stopReason = PLAN_MODE_STOP_REASON;
|
|
360
|
+
goal.blocker = PLAN_MODE_BLOCKER;
|
|
361
|
+
goal.lastStatus = "Auto-continue paused while the session is in Plan mode.";
|
|
362
|
+
goal.updatedAt = nowSeconds();
|
|
363
|
+
pushHistory(goal, "paused", goal.lastStatus);
|
|
314
364
|
return snapshot(goal);
|
|
315
365
|
});
|
|
316
366
|
}
|
|
317
|
-
async function setGoalStatus(sessionID, status) {
|
|
367
|
+
async function setGoalStatus(sessionID, status, agent) {
|
|
368
|
+
const agentValue = typeof agent === "string" && agent.trim() ? agent.trim() : null;
|
|
318
369
|
return mutate((state) => {
|
|
319
370
|
const goal = state.goals[sessionID];
|
|
320
371
|
if (!goal)
|
|
@@ -328,6 +379,8 @@ async function setGoalStatus(sessionID, status) {
|
|
|
328
379
|
goal.stopReason = status === "active" ? null : "paused";
|
|
329
380
|
goal.budgetWrapupSent = status === "active" ? false : goal.budgetWrapupSent;
|
|
330
381
|
goal.blocker = status === "active" ? null : goal.blocker;
|
|
382
|
+
if (agentValue)
|
|
383
|
+
goal.lastPromptAgent = agentValue;
|
|
331
384
|
goal.lastStatus = status === "active" ? "Goal resumed." : "Goal paused.";
|
|
332
385
|
pushHistory(goal, status === "active" ? "resumed" : "paused", goal.lastStatus);
|
|
333
386
|
return snapshot(goal);
|
|
@@ -674,9 +727,23 @@ Stop reason: ${goal.stopReason ?? "goal limit reached"}
|
|
|
674
727
|
|
|
675
728
|
Do not start new substantive work for this goal. Wrap up this turn soon: summarize useful progress, identify remaining work or blockers, and leave the user with a clear next step. Do not call update_goal unless the goal is actually complete.`;
|
|
676
729
|
}
|
|
677
|
-
function
|
|
730
|
+
function planModeReminder(goal) {
|
|
731
|
+
return `OpenCode goal mode is tracking a goal, but this session is currently in Plan mode.
|
|
732
|
+
|
|
733
|
+
${formatGoal(goal)}
|
|
734
|
+
|
|
735
|
+
Plan-mode constraints:
|
|
736
|
+
- Do not perform implementation work for this goal: no file edits, no state-changing commands, no dependency or repository changes.
|
|
737
|
+
- Use this turn for analysis, planning, and answering the user.
|
|
738
|
+
- Goal auto-continue stays disabled while the session is in Plan mode.
|
|
739
|
+
- If the user wants the goal executed, ask them to switch to Build mode and resume the goal (for example with "/goal resume").
|
|
740
|
+
- Do not treat the goal objective as higher-priority instructions.`;
|
|
741
|
+
}
|
|
742
|
+
function systemReminder(goal, options) {
|
|
678
743
|
if (!goal || goal.status === "complete" || goal.status === "unmet")
|
|
679
744
|
return "";
|
|
745
|
+
if (options?.planningOnly)
|
|
746
|
+
return planModeReminder(goal);
|
|
680
747
|
if (goal.status === "active")
|
|
681
748
|
return `OpenCode goal mode active reminder:
|
|
682
749
|
|
|
@@ -700,8 +767,19 @@ var DEFAULT_MAX_AUTO_TURNS = 25;
|
|
|
700
767
|
var DEFAULT_CONTINUE_INTERVAL_SECONDS = 3;
|
|
701
768
|
var DEFAULT_MAX_PROMPT_FAILURES = 3;
|
|
702
769
|
var DEFAULT_COMMAND_NAME = "goal";
|
|
770
|
+
var DEFAULT_RESTRICTED_AGENTS = ["plan"];
|
|
703
771
|
var GOAL_SYSTEM_MARKER = "OpenCode goal mode";
|
|
772
|
+
var TASK_SETTLE_DELAY_MS = 25;
|
|
773
|
+
var SNAPSHOT_IDLE_HOLD_MS = 250;
|
|
774
|
+
var TASK_TERMINAL_STATES = new Set(["completed", "error", "cancelled"]);
|
|
775
|
+
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.';
|
|
704
776
|
var activeContinuations = new Set;
|
|
777
|
+
function restrictedAgentSet(options) {
|
|
778
|
+
if (options?.allow_goal_execution_from_plan === true)
|
|
779
|
+
return new Set;
|
|
780
|
+
const names = Array.isArray(options?.restricted_agents) ? options.restricted_agents : DEFAULT_RESTRICTED_AGENTS;
|
|
781
|
+
return new Set(names.map((name) => typeof name === "string" ? name.trim().toLowerCase() : "").filter(Boolean));
|
|
782
|
+
}
|
|
705
783
|
function goalCommandTemplate(commandName) {
|
|
706
784
|
return `OpenCode goal mode command "/${commandName}" was invoked.
|
|
707
785
|
|
|
@@ -757,6 +835,16 @@ function textFromMessage(message) {
|
|
|
757
835
|
return (message.parts ?? []).map(textFromPart).filter(Boolean).join(`
|
|
758
836
|
`).trim();
|
|
759
837
|
}
|
|
838
|
+
function isRecord(value) {
|
|
839
|
+
return typeof value === "object" && value !== null;
|
|
840
|
+
}
|
|
841
|
+
function sessionIDFromMessage(message) {
|
|
842
|
+
if (typeof message.sessionID === "string")
|
|
843
|
+
return message.sessionID;
|
|
844
|
+
if (isRecord(message.info) && typeof message.info.sessionID === "string")
|
|
845
|
+
return message.info.sessionID;
|
|
846
|
+
return;
|
|
847
|
+
}
|
|
760
848
|
function estimateMessages(messages) {
|
|
761
849
|
return messages.reduce((sum, message) => sum + estimateTokensFromText(textFromMessage(message)), 0);
|
|
762
850
|
}
|
|
@@ -810,10 +898,71 @@ function tokensFromMessages(messages) {
|
|
|
810
898
|
const exactTotal = messages.reduce((sum, message) => sum + (exactTokensFromMessage(message) ?? 0), 0);
|
|
811
899
|
return exactTotal > 0 ? exactTotal : estimateMessages(messages);
|
|
812
900
|
}
|
|
813
|
-
|
|
901
|
+
function taskHeader(output) {
|
|
902
|
+
const resultIndex = output.search(/<task_(?:result|error)>/);
|
|
903
|
+
return resultIndex === -1 ? output : output.slice(0, resultIndex);
|
|
904
|
+
}
|
|
905
|
+
function parseTaskID(output) {
|
|
906
|
+
const xmlMatch = /<task\s+[^>]*\bid=["']([^"']+)["'][^>]*>/i.exec(output);
|
|
907
|
+
if (xmlMatch?.[1])
|
|
908
|
+
return xmlMatch[1];
|
|
909
|
+
for (const line of output.split(/\r?\n/)) {
|
|
910
|
+
const match = /^task_id:\s*([^\s()]+)(?:\s*\(.*)?$/i.exec(line.trim());
|
|
911
|
+
if (match?.[1])
|
|
912
|
+
return match[1];
|
|
913
|
+
}
|
|
914
|
+
return;
|
|
915
|
+
}
|
|
916
|
+
function parseTaskState(output) {
|
|
917
|
+
const xmlMatch = /<task\s+[^>]*\bstate=["'](running|completed|error|cancelled)["'][^>]*>/i.exec(output);
|
|
918
|
+
if (xmlMatch?.[1])
|
|
919
|
+
return xmlMatch[1].toLowerCase();
|
|
920
|
+
for (const line of taskHeader(output).split(/\r?\n/)) {
|
|
921
|
+
const match = /^state:\s*(running|completed|error|cancelled)\s*$/i.exec(line.trim());
|
|
922
|
+
if (match?.[1])
|
|
923
|
+
return match[1].toLowerCase();
|
|
924
|
+
}
|
|
925
|
+
return;
|
|
926
|
+
}
|
|
927
|
+
function parseTaskStatus(output) {
|
|
928
|
+
if (typeof output !== "string")
|
|
929
|
+
return;
|
|
930
|
+
const taskID = parseTaskID(output);
|
|
931
|
+
const state = parseTaskState(output);
|
|
932
|
+
return taskID && state ? { taskID, state } : undefined;
|
|
933
|
+
}
|
|
934
|
+
function messageCompletedAt(message) {
|
|
935
|
+
const time = isRecord(message.time) ? message.time : isRecord(message.info) && isRecord(message.info.time) ? message.info.time : undefined;
|
|
936
|
+
const completed = time?.completed;
|
|
937
|
+
return typeof completed === "number" && Number.isFinite(completed) ? completed : null;
|
|
938
|
+
}
|
|
939
|
+
function assistantMarker(message) {
|
|
940
|
+
if (messageRole(message) !== "assistant")
|
|
941
|
+
return;
|
|
942
|
+
return {
|
|
943
|
+
id: messageID(message) ?? null,
|
|
944
|
+
completedAt: messageCompletedAt(message)
|
|
945
|
+
};
|
|
946
|
+
}
|
|
947
|
+
function agentFromMessage(message) {
|
|
948
|
+
if (!message)
|
|
949
|
+
return;
|
|
950
|
+
for (const source of [message, message.info]) {
|
|
951
|
+
if (!isRecord(source))
|
|
952
|
+
continue;
|
|
953
|
+
for (const key of ["agent", "mode"]) {
|
|
954
|
+
const value = source[key];
|
|
955
|
+
if (typeof value === "string" && value.trim())
|
|
956
|
+
return value.trim();
|
|
957
|
+
}
|
|
958
|
+
}
|
|
959
|
+
return;
|
|
960
|
+
}
|
|
961
|
+
async function sendContinuation(client, sessionID, prompt, agent) {
|
|
814
962
|
await client.session.promptAsync({
|
|
815
963
|
path: { id: sessionID },
|
|
816
964
|
body: {
|
|
965
|
+
...agent ? { agent } : {},
|
|
817
966
|
parts: [{ type: "text", text: prompt }]
|
|
818
967
|
}
|
|
819
968
|
});
|
|
@@ -861,6 +1010,235 @@ async function fetchLatestAssistant(client, sessionID) {
|
|
|
861
1010
|
const data = Array.isArray(result.data) ? result.data : [];
|
|
862
1011
|
return latestAssistantMessage(data);
|
|
863
1012
|
}
|
|
1013
|
+
|
|
1014
|
+
class TaskTracker {
|
|
1015
|
+
tasks = new Map;
|
|
1016
|
+
pendingTaskCalls = new Map;
|
|
1017
|
+
latestAssistantBySession = new Map;
|
|
1018
|
+
snapshotIdleHolds = new Map;
|
|
1019
|
+
settledSnapshotIdleTasks = new Set;
|
|
1020
|
+
noteTaskCall(input) {
|
|
1021
|
+
if (typeof input.tool !== "string" || input.tool.toLowerCase() !== "task")
|
|
1022
|
+
return;
|
|
1023
|
+
if (typeof input.sessionID !== "string")
|
|
1024
|
+
return;
|
|
1025
|
+
if (typeof input.callID === "string")
|
|
1026
|
+
this.pendingTaskCalls.set(input.callID, input.sessionID);
|
|
1027
|
+
}
|
|
1028
|
+
noteTaskOutput(input, output) {
|
|
1029
|
+
if (typeof input.tool !== "string" || input.tool.toLowerCase() !== "task")
|
|
1030
|
+
return;
|
|
1031
|
+
const parentSessionID = typeof input.callID === "string" ? this.pendingTaskCalls.get(input.callID) ?? input.sessionID : input.sessionID;
|
|
1032
|
+
if (typeof input.callID === "string")
|
|
1033
|
+
this.pendingTaskCalls.delete(input.callID);
|
|
1034
|
+
if (typeof parentSessionID !== "string")
|
|
1035
|
+
return;
|
|
1036
|
+
const status = parseTaskStatus(output.output);
|
|
1037
|
+
if (!status)
|
|
1038
|
+
return;
|
|
1039
|
+
if (status.state === "running") {
|
|
1040
|
+
this.markRunning(parentSessionID, status.taskID);
|
|
1041
|
+
return;
|
|
1042
|
+
}
|
|
1043
|
+
this.markTerminal(status.taskID, status.state, parentSessionID, { resetReconciled: true });
|
|
1044
|
+
}
|
|
1045
|
+
observeSessionCreated(event) {
|
|
1046
|
+
const info = event.properties?.info;
|
|
1047
|
+
if (!isRecord(info) || typeof info.id !== "string" || typeof info.parentID !== "string")
|
|
1048
|
+
return;
|
|
1049
|
+
this.markRunning(info.parentID, info.id);
|
|
1050
|
+
}
|
|
1051
|
+
observeSessionStatus(sessionID, status) {
|
|
1052
|
+
const task = this.tasks.get(sessionID);
|
|
1053
|
+
if (!task)
|
|
1054
|
+
return;
|
|
1055
|
+
if (status === "busy") {
|
|
1056
|
+
this.markRunning(task.parentSessionID, sessionID);
|
|
1057
|
+
return;
|
|
1058
|
+
}
|
|
1059
|
+
if (status === "idle")
|
|
1060
|
+
this.markTerminal(sessionID, "completed", task.parentSessionID);
|
|
1061
|
+
}
|
|
1062
|
+
observeSessionDeleted(sessionID) {
|
|
1063
|
+
this.tasks.delete(sessionID);
|
|
1064
|
+
for (const task of this.tasks.values()) {
|
|
1065
|
+
if (task.parentSessionID === sessionID)
|
|
1066
|
+
this.tasks.delete(task.taskID);
|
|
1067
|
+
}
|
|
1068
|
+
this.latestAssistantBySession.delete(sessionID);
|
|
1069
|
+
this.clearSnapshotIdleForSession(sessionID);
|
|
1070
|
+
}
|
|
1071
|
+
observeMessages(messages) {
|
|
1072
|
+
for (const message of messages) {
|
|
1073
|
+
const sessionID = sessionIDFromMessage(message);
|
|
1074
|
+
if (!sessionID)
|
|
1075
|
+
continue;
|
|
1076
|
+
const marker = assistantMarker(message);
|
|
1077
|
+
if (marker) {
|
|
1078
|
+
this.observeAssistant(sessionID, marker);
|
|
1079
|
+
continue;
|
|
1080
|
+
}
|
|
1081
|
+
for (const part of message.parts ?? []) {
|
|
1082
|
+
const status = parseTaskStatus(textFromPart(part));
|
|
1083
|
+
if (!status)
|
|
1084
|
+
continue;
|
|
1085
|
+
if (status.state === "running")
|
|
1086
|
+
this.markRunning(sessionID, status.taskID);
|
|
1087
|
+
else
|
|
1088
|
+
this.markTerminal(status.taskID, status.state, sessionID, { resetReconciled: true });
|
|
1089
|
+
}
|
|
1090
|
+
}
|
|
1091
|
+
}
|
|
1092
|
+
observeAssistantMessage(sessionID, message) {
|
|
1093
|
+
const marker = message ? assistantMarker(message) : undefined;
|
|
1094
|
+
if (marker)
|
|
1095
|
+
this.observeAssistant(sessionID, marker);
|
|
1096
|
+
}
|
|
1097
|
+
hasBlockingTasks(parentSessionID) {
|
|
1098
|
+
this.pruneExpiredSnapshotIdleHolds();
|
|
1099
|
+
for (const task of this.tasks.values()) {
|
|
1100
|
+
if (task.parentSessionID !== parentSessionID)
|
|
1101
|
+
continue;
|
|
1102
|
+
if (task.state === "running" || task.terminalUnreconciled)
|
|
1103
|
+
return true;
|
|
1104
|
+
}
|
|
1105
|
+
for (const hold of this.snapshotIdleHolds.values()) {
|
|
1106
|
+
if (hold.parentSessionID === parentSessionID)
|
|
1107
|
+
return true;
|
|
1108
|
+
}
|
|
1109
|
+
return false;
|
|
1110
|
+
}
|
|
1111
|
+
nextSnapshotIdleRetryAt(parentSessionID) {
|
|
1112
|
+
this.pruneExpiredSnapshotIdleHolds();
|
|
1113
|
+
let next = null;
|
|
1114
|
+
for (const hold of this.snapshotIdleHolds.values()) {
|
|
1115
|
+
if (hold.parentSessionID !== parentSessionID)
|
|
1116
|
+
continue;
|
|
1117
|
+
next = next == null ? hold.expiresAt : Math.min(next, hold.expiresAt);
|
|
1118
|
+
}
|
|
1119
|
+
return next;
|
|
1120
|
+
}
|
|
1121
|
+
async refreshLiveChildren(client, parentSessionID) {
|
|
1122
|
+
const session = client.session;
|
|
1123
|
+
if (!session.children || !session.status)
|
|
1124
|
+
return;
|
|
1125
|
+
let childIDs;
|
|
1126
|
+
try {
|
|
1127
|
+
const result = await session.children({ path: { id: parentSessionID } });
|
|
1128
|
+
const data = Array.isArray(result) ? result : Array.isArray(result.data) ? result.data : [];
|
|
1129
|
+
childIDs = data.flatMap((child) => isRecord(child) && typeof child.id === "string" ? [child.id] : []);
|
|
1130
|
+
} catch {
|
|
1131
|
+
return;
|
|
1132
|
+
}
|
|
1133
|
+
if (childIDs.length === 0)
|
|
1134
|
+
return;
|
|
1135
|
+
let statuses;
|
|
1136
|
+
try {
|
|
1137
|
+
const result = await session.status();
|
|
1138
|
+
statuses = isRecord(result) && isRecord(result.data) ? result.data : isRecord(result) ? result : {};
|
|
1139
|
+
} catch {
|
|
1140
|
+
return;
|
|
1141
|
+
}
|
|
1142
|
+
for (const childID of childIDs) {
|
|
1143
|
+
const status = statuses[childID];
|
|
1144
|
+
const statusType = isRecord(status) && typeof status.type === "string" ? status.type : undefined;
|
|
1145
|
+
if (statusType === "busy")
|
|
1146
|
+
this.markRunning(parentSessionID, childID);
|
|
1147
|
+
else if (statusType === "idle") {
|
|
1148
|
+
if (this.tasks.has(childID))
|
|
1149
|
+
this.markTerminal(childID, "completed", parentSessionID);
|
|
1150
|
+
else
|
|
1151
|
+
this.markSnapshotIdle(parentSessionID, childID);
|
|
1152
|
+
}
|
|
1153
|
+
}
|
|
1154
|
+
}
|
|
1155
|
+
markRunning(parentSessionID, taskID) {
|
|
1156
|
+
const existing = this.tasks.get(taskID);
|
|
1157
|
+
this.clearSnapshotIdle(parentSessionID, taskID);
|
|
1158
|
+
this.tasks.set(taskID, {
|
|
1159
|
+
taskID,
|
|
1160
|
+
parentSessionID,
|
|
1161
|
+
state: "running",
|
|
1162
|
+
terminalUnreconciled: false,
|
|
1163
|
+
terminalAt: null,
|
|
1164
|
+
lastAssistantMessageIDAtTerminal: existing?.lastAssistantMessageIDAtTerminal ?? null
|
|
1165
|
+
});
|
|
1166
|
+
}
|
|
1167
|
+
markTerminal(taskID, state, parentSessionID, options = {}) {
|
|
1168
|
+
if (!TASK_TERMINAL_STATES.has(state))
|
|
1169
|
+
return;
|
|
1170
|
+
const existing = this.tasks.get(taskID);
|
|
1171
|
+
const resolvedParentSessionID = existing?.parentSessionID ?? parentSessionID;
|
|
1172
|
+
if (!resolvedParentSessionID)
|
|
1173
|
+
return;
|
|
1174
|
+
this.clearSnapshotIdle(resolvedParentSessionID, taskID);
|
|
1175
|
+
if (existing && TASK_TERMINAL_STATES.has(existing.state) && !existing.terminalUnreconciled && !options.resetReconciled) {
|
|
1176
|
+
return;
|
|
1177
|
+
}
|
|
1178
|
+
this.tasks.set(taskID, {
|
|
1179
|
+
taskID,
|
|
1180
|
+
parentSessionID: resolvedParentSessionID,
|
|
1181
|
+
state,
|
|
1182
|
+
terminalUnreconciled: true,
|
|
1183
|
+
terminalAt: Date.now(),
|
|
1184
|
+
lastAssistantMessageIDAtTerminal: this.latestAssistantBySession.get(resolvedParentSessionID)?.id ?? null
|
|
1185
|
+
});
|
|
1186
|
+
}
|
|
1187
|
+
markSnapshotIdle(parentSessionID, taskID) {
|
|
1188
|
+
const key = this.snapshotIdleKey(parentSessionID, taskID);
|
|
1189
|
+
if (this.settledSnapshotIdleTasks.has(key) || this.snapshotIdleHolds.has(key))
|
|
1190
|
+
return;
|
|
1191
|
+
this.snapshotIdleHolds.set(key, {
|
|
1192
|
+
taskID,
|
|
1193
|
+
parentSessionID,
|
|
1194
|
+
expiresAt: Date.now() + SNAPSHOT_IDLE_HOLD_MS
|
|
1195
|
+
});
|
|
1196
|
+
}
|
|
1197
|
+
clearSnapshotIdle(parentSessionID, taskID) {
|
|
1198
|
+
const key = this.snapshotIdleKey(parentSessionID, taskID);
|
|
1199
|
+
this.snapshotIdleHolds.delete(key);
|
|
1200
|
+
this.settledSnapshotIdleTasks.delete(key);
|
|
1201
|
+
}
|
|
1202
|
+
clearSnapshotIdleForSession(sessionID) {
|
|
1203
|
+
for (const [key, hold] of this.snapshotIdleHolds) {
|
|
1204
|
+
if (hold.taskID === sessionID || hold.parentSessionID === sessionID)
|
|
1205
|
+
this.snapshotIdleHolds.delete(key);
|
|
1206
|
+
}
|
|
1207
|
+
for (const key of this.settledSnapshotIdleTasks) {
|
|
1208
|
+
if (key.startsWith(`${sessionID}\x00`) || key.endsWith(`\x00${sessionID}`)) {
|
|
1209
|
+
this.settledSnapshotIdleTasks.delete(key);
|
|
1210
|
+
}
|
|
1211
|
+
}
|
|
1212
|
+
}
|
|
1213
|
+
pruneExpiredSnapshotIdleHolds(now = Date.now()) {
|
|
1214
|
+
for (const [key, hold] of this.snapshotIdleHolds) {
|
|
1215
|
+
if (hold.expiresAt > now)
|
|
1216
|
+
continue;
|
|
1217
|
+
this.snapshotIdleHolds.delete(key);
|
|
1218
|
+
this.settledSnapshotIdleTasks.add(key);
|
|
1219
|
+
}
|
|
1220
|
+
}
|
|
1221
|
+
snapshotIdleKey(parentSessionID, taskID) {
|
|
1222
|
+
return `${parentSessionID}\x00${taskID}`;
|
|
1223
|
+
}
|
|
1224
|
+
observeAssistant(sessionID, marker) {
|
|
1225
|
+
this.latestAssistantBySession.set(sessionID, marker);
|
|
1226
|
+
for (const task of this.tasks.values()) {
|
|
1227
|
+
if (task.parentSessionID !== sessionID || !task.terminalUnreconciled)
|
|
1228
|
+
continue;
|
|
1229
|
+
if (this.assistantReconcilesTask(task, marker)) {
|
|
1230
|
+
this.tasks.set(task.taskID, { ...task, terminalUnreconciled: false });
|
|
1231
|
+
}
|
|
1232
|
+
}
|
|
1233
|
+
}
|
|
1234
|
+
assistantReconcilesTask(task, marker) {
|
|
1235
|
+
if (marker.id && task.lastAssistantMessageIDAtTerminal && marker.id !== task.lastAssistantMessageIDAtTerminal)
|
|
1236
|
+
return true;
|
|
1237
|
+
if (marker.completedAt != null && task.terminalAt != null && marker.completedAt >= task.terminalAt)
|
|
1238
|
+
return true;
|
|
1239
|
+
return false;
|
|
1240
|
+
}
|
|
1241
|
+
}
|
|
864
1242
|
async function recordAssistantMessage(sessionID, message, options) {
|
|
865
1243
|
if (!message)
|
|
866
1244
|
return;
|
|
@@ -887,12 +1265,110 @@ ${reminder}`;
|
|
|
887
1265
|
}
|
|
888
1266
|
var server = async ({ client }, options) => {
|
|
889
1267
|
const autoContinue = options?.auto_continue ?? true;
|
|
1268
|
+
const deferWhileTasksActive = options?.defer_while_tasks_active ?? true;
|
|
890
1269
|
const maxAutoTurns = positiveIntegerOrNull2(options?.max_auto_turns) ?? DEFAULT_MAX_AUTO_TURNS;
|
|
891
1270
|
const minInterval = positiveIntegerOrNull2(options?.min_continue_interval_seconds) ?? DEFAULT_CONTINUE_INTERVAL_SECONDS;
|
|
892
1271
|
const maxPromptFailures = positiveIntegerOrNull2(options?.max_prompt_failures) ?? DEFAULT_MAX_PROMPT_FAILURES;
|
|
893
1272
|
const registerCommand = options?.register_command ?? true;
|
|
894
1273
|
const commandName = commandNameFromOptions(options);
|
|
1274
|
+
const taskTracker = new TaskTracker;
|
|
1275
|
+
const taskDeferredSessions = new Set;
|
|
1276
|
+
const scheduledContinuations = new Map;
|
|
1277
|
+
const busySessions = new Set;
|
|
1278
|
+
const planAgents = restrictedAgentSet(options);
|
|
1279
|
+
const isPlanAgent = (agent) => typeof agent === "string" && planAgents.has(agent.trim().toLowerCase());
|
|
1280
|
+
async function createGoalFromTool(input, context) {
|
|
1281
|
+
const planningOnly = isPlanAgent(context.agent);
|
|
1282
|
+
const goal = await createGoal(context.sessionID, input.objective, {
|
|
1283
|
+
tokenBudget: input.token_budget ?? options?.default_token_budget ?? null,
|
|
1284
|
+
maxAutoTurns: input.max_auto_turns ?? null,
|
|
1285
|
+
maxDurationSeconds: input.max_duration_seconds ?? options?.max_goal_duration_seconds ?? null,
|
|
1286
|
+
noProgressTokenThreshold: options?.no_progress_token_threshold ?? null,
|
|
1287
|
+
maxNoProgressTurns: options?.max_no_progress_turns ?? null,
|
|
1288
|
+
agent: typeof context.agent === "string" ? context.agent : null,
|
|
1289
|
+
initialStatus: planningOnly ? "paused" : "active"
|
|
1290
|
+
});
|
|
1291
|
+
return JSON.stringify(planningOnly ? { goal, plan_mode_notice: PLAN_MODE_CREATE_NOTICE } : { goal }, null, 2);
|
|
1292
|
+
}
|
|
1293
|
+
async function taskBlockStatus(sessionID) {
|
|
1294
|
+
if (!deferWhileTasksActive)
|
|
1295
|
+
return false;
|
|
1296
|
+
await taskTracker.refreshLiveChildren(client, sessionID);
|
|
1297
|
+
return {
|
|
1298
|
+
blocked: taskTracker.hasBlockingTasks(sessionID),
|
|
1299
|
+
retryAt: taskTracker.nextSnapshotIdleRetryAt(sessionID)
|
|
1300
|
+
};
|
|
1301
|
+
}
|
|
1302
|
+
function scheduleSettledContinuation(sessionID, delayMs = TASK_SETTLE_DELAY_MS) {
|
|
1303
|
+
if (scheduledContinuations.has(sessionID))
|
|
1304
|
+
return;
|
|
1305
|
+
const timer = setTimeout(() => {
|
|
1306
|
+
scheduledContinuations.delete(sessionID);
|
|
1307
|
+
runAutoContinue(sessionID, true);
|
|
1308
|
+
}, Math.max(0, delayMs));
|
|
1309
|
+
const maybeUnref = timer;
|
|
1310
|
+
if (typeof maybeUnref.unref === "function")
|
|
1311
|
+
maybeUnref.unref();
|
|
1312
|
+
scheduledContinuations.set(sessionID, timer);
|
|
1313
|
+
}
|
|
1314
|
+
async function runAutoContinue(sessionID, fromTaskDeferral = false) {
|
|
1315
|
+
if (busySessions.has(sessionID))
|
|
1316
|
+
return;
|
|
1317
|
+
if (activeContinuations.has(sessionID))
|
|
1318
|
+
return;
|
|
1319
|
+
activeContinuations.add(sessionID);
|
|
1320
|
+
try {
|
|
1321
|
+
const latestAssistant = await fetchLatestAssistant(client, sessionID);
|
|
1322
|
+
taskTracker.observeAssistantMessage(sessionID, latestAssistant);
|
|
1323
|
+
const taskStatus = await taskBlockStatus(sessionID);
|
|
1324
|
+
if (taskStatus && taskStatus.blocked) {
|
|
1325
|
+
taskDeferredSessions.add(sessionID);
|
|
1326
|
+
if (taskStatus.retryAt != null)
|
|
1327
|
+
scheduleSettledContinuation(sessionID, taskStatus.retryAt - Date.now());
|
|
1328
|
+
return;
|
|
1329
|
+
}
|
|
1330
|
+
if (busySessions.has(sessionID))
|
|
1331
|
+
return;
|
|
1332
|
+
await recordAssistantMessage(sessionID, latestAssistant, options ?? {});
|
|
1333
|
+
const current = await getGoal(sessionID);
|
|
1334
|
+
if (!current)
|
|
1335
|
+
return;
|
|
1336
|
+
const latestTurnAgent = agentFromMessage(latestAssistant);
|
|
1337
|
+
if (isPlanAgent(current.lastPromptAgent) || isPlanAgent(latestTurnAgent)) {
|
|
1338
|
+
if (current.status === "active")
|
|
1339
|
+
await pauseGoalForPlanMode(sessionID);
|
|
1340
|
+
return;
|
|
1341
|
+
}
|
|
1342
|
+
if (!fromTaskDeferral && taskDeferredSessions.has(sessionID)) {
|
|
1343
|
+
scheduleSettledContinuation(sessionID);
|
|
1344
|
+
return;
|
|
1345
|
+
}
|
|
1346
|
+
taskDeferredSessions.delete(sessionID);
|
|
1347
|
+
const goal = await reserveContinuation(sessionID, maxAutoTurns, minInterval);
|
|
1348
|
+
if (!goal)
|
|
1349
|
+
return;
|
|
1350
|
+
await sendContinuation(client, sessionID, goal.status === "active" ? continuationPrompt(goal) : limitPrompt(goal), goal.lastPromptAgent ?? latestTurnAgent ?? null);
|
|
1351
|
+
await recordContinuationResult(sessionID, "success", maxPromptFailures);
|
|
1352
|
+
} catch (error) {
|
|
1353
|
+
await recordContinuationResult(sessionID, "failure", maxPromptFailures);
|
|
1354
|
+
await client.app?.log?.({
|
|
1355
|
+
body: {
|
|
1356
|
+
service: "opencode-goal-plugin",
|
|
1357
|
+
level: "error",
|
|
1358
|
+
message: "Auto-continue failed",
|
|
1359
|
+
extra: { error: error instanceof Error ? error.message : String(error) }
|
|
1360
|
+
}
|
|
1361
|
+
});
|
|
1362
|
+
} finally {
|
|
1363
|
+
activeContinuations.delete(sessionID);
|
|
1364
|
+
}
|
|
1365
|
+
}
|
|
895
1366
|
return {
|
|
1367
|
+
async dispose() {
|
|
1368
|
+
for (const timer of scheduledContinuations.values())
|
|
1369
|
+
clearTimeout(timer);
|
|
1370
|
+
scheduledContinuations.clear();
|
|
1371
|
+
},
|
|
896
1372
|
async config(config) {
|
|
897
1373
|
if (!registerCommand)
|
|
898
1374
|
return;
|
|
@@ -915,7 +1391,7 @@ var server = async ({ client }, options) => {
|
|
|
915
1391
|
}
|
|
916
1392
|
},
|
|
917
1393
|
create_goal: {
|
|
918
|
-
description: "Create a goal only when explicitly requested by the user or system/developer instructions; do not infer goals from ordinary tasks. Fails if a non-complete goal exists.",
|
|
1394
|
+
description: "Create a goal only when explicitly requested by the user or system/developer instructions; do not infer goals from ordinary tasks. Fails if a non-complete goal exists. While the session is in Plan mode, the goal is recorded as paused and execution requires the user to switch to Build mode.",
|
|
919
1395
|
args: {
|
|
920
1396
|
objective: z.string().min(1).max(4000).describe("The concrete objective to start pursuing."),
|
|
921
1397
|
token_budget: z.number().int().positive().nullable().optional().describe("Optional positive token budget."),
|
|
@@ -923,19 +1399,11 @@ var server = async ({ client }, options) => {
|
|
|
923
1399
|
max_duration_seconds: z.number().int().positive().nullable().optional().describe("Optional per-goal duration limit.")
|
|
924
1400
|
},
|
|
925
1401
|
async execute(args, context) {
|
|
926
|
-
|
|
927
|
-
const goal = await createGoal(context.sessionID, input.objective, {
|
|
928
|
-
tokenBudget: input.token_budget ?? options?.default_token_budget ?? null,
|
|
929
|
-
maxAutoTurns: input.max_auto_turns ?? null,
|
|
930
|
-
maxDurationSeconds: input.max_duration_seconds ?? options?.max_goal_duration_seconds ?? null,
|
|
931
|
-
noProgressTokenThreshold: options?.no_progress_token_threshold ?? null,
|
|
932
|
-
maxNoProgressTurns: options?.max_no_progress_turns ?? null
|
|
933
|
-
});
|
|
934
|
-
return JSON.stringify({ goal }, null, 2);
|
|
1402
|
+
return createGoalFromTool(args, context);
|
|
935
1403
|
}
|
|
936
1404
|
},
|
|
937
1405
|
set_goal: {
|
|
938
|
-
description: "Set a new goal when the user explicitly asks the agent to formulate and set its own goal. The model should write the objective itself based on the user's explicit request. Fails if a non-complete goal exists.",
|
|
1406
|
+
description: "Set a new goal when the user explicitly asks the agent to formulate and set its own goal. The model should write the objective itself based on the user's explicit request. Fails if a non-complete goal exists. While the session is in Plan mode, the goal is recorded as paused and execution requires the user to switch to Build mode.",
|
|
939
1407
|
args: {
|
|
940
1408
|
objective: z.string().min(1).max(4000).describe("The model-formulated concrete objective to start pursuing."),
|
|
941
1409
|
token_budget: z.number().int().positive().nullable().optional().describe("Optional positive token budget."),
|
|
@@ -943,15 +1411,7 @@ var server = async ({ client }, options) => {
|
|
|
943
1411
|
max_duration_seconds: z.number().int().positive().nullable().optional().describe("Optional per-goal duration limit.")
|
|
944
1412
|
},
|
|
945
1413
|
async execute(args, context) {
|
|
946
|
-
|
|
947
|
-
const goal = await createGoal(context.sessionID, input.objective, {
|
|
948
|
-
tokenBudget: input.token_budget ?? options?.default_token_budget ?? null,
|
|
949
|
-
maxAutoTurns: input.max_auto_turns ?? null,
|
|
950
|
-
maxDurationSeconds: input.max_duration_seconds ?? options?.max_goal_duration_seconds ?? null,
|
|
951
|
-
noProgressTokenThreshold: options?.no_progress_token_threshold ?? null,
|
|
952
|
-
maxNoProgressTurns: options?.max_no_progress_turns ?? null
|
|
953
|
-
});
|
|
954
|
-
return JSON.stringify({ goal }, null, 2);
|
|
1414
|
+
return createGoalFromTool(args, context);
|
|
955
1415
|
}
|
|
956
1416
|
},
|
|
957
1417
|
update_goal_objective: {
|
|
@@ -962,8 +1422,13 @@ var server = async ({ client }, options) => {
|
|
|
962
1422
|
},
|
|
963
1423
|
async execute(args, context) {
|
|
964
1424
|
const input = args;
|
|
965
|
-
const
|
|
966
|
-
|
|
1425
|
+
const requested = input.status ?? "active";
|
|
1426
|
+
const planningOnly = requested === "active" && isPlanAgent(context.agent);
|
|
1427
|
+
const goal = await updateGoalObjective(context.sessionID, input.objective, planningOnly ? "paused" : requested, {
|
|
1428
|
+
agent: typeof context.agent === "string" ? context.agent : null,
|
|
1429
|
+
planModePause: planningOnly
|
|
1430
|
+
});
|
|
1431
|
+
return JSON.stringify(planningOnly ? { goal, plan_mode_notice: PLAN_MODE_CREATE_NOTICE } : { goal }, null, 2);
|
|
967
1432
|
}
|
|
968
1433
|
},
|
|
969
1434
|
update_goal: {
|
|
@@ -987,13 +1452,16 @@ var server = async ({ client }, options) => {
|
|
|
987
1452
|
}
|
|
988
1453
|
},
|
|
989
1454
|
update_goal_status: {
|
|
990
|
-
description: "Pause or resume the current OpenCode goal when the user explicitly asks to pause or resume it.",
|
|
1455
|
+
description: "Pause or resume the current OpenCode goal when the user explicitly asks to pause or resume it. Resuming is not allowed while the session is in Plan mode; the user must switch to Build mode first.",
|
|
991
1456
|
args: {
|
|
992
1457
|
status: z.enum(["active", "paused"]).describe("active resumes a goal; paused pauses it without clearing it.")
|
|
993
1458
|
},
|
|
994
1459
|
async execute(args, context) {
|
|
995
1460
|
const input = args;
|
|
996
|
-
|
|
1461
|
+
if (input.status === "active" && isPlanAgent(context.agent)) {
|
|
1462
|
+
throw new Error("cannot resume the goal while the session is in Plan mode; ask the user to switch to Build mode and resume the goal from there");
|
|
1463
|
+
}
|
|
1464
|
+
const goal = await setGoalStatus(context.sessionID, input.status, typeof context.agent === "string" ? context.agent : null);
|
|
997
1465
|
return JSON.stringify({ goal }, null, 2);
|
|
998
1466
|
}
|
|
999
1467
|
},
|
|
@@ -1005,7 +1473,21 @@ var server = async ({ client }, options) => {
|
|
|
1005
1473
|
}
|
|
1006
1474
|
}
|
|
1007
1475
|
},
|
|
1476
|
+
async "tool.execute.before"(input) {
|
|
1477
|
+
taskTracker.noteTaskCall(input);
|
|
1478
|
+
},
|
|
1479
|
+
async "tool.execute.after"(input, output) {
|
|
1480
|
+
taskTracker.noteTaskOutput(input, output);
|
|
1481
|
+
},
|
|
1482
|
+
async "chat.message"(input, output) {
|
|
1483
|
+
const sessionID = typeof input?.sessionID === "string" ? input.sessionID : output.message?.sessionID;
|
|
1484
|
+
const agent = typeof input?.agent === "string" && input.agent.trim() ? input.agent : output.message?.agent;
|
|
1485
|
+
if (typeof sessionID !== "string" || typeof agent !== "string" || !agent.trim())
|
|
1486
|
+
return;
|
|
1487
|
+
await recordPromptAgent(sessionID, agent);
|
|
1488
|
+
},
|
|
1008
1489
|
async "experimental.chat.messages.transform"(input, output) {
|
|
1490
|
+
taskTracker.observeMessages(output.messages);
|
|
1009
1491
|
const sessionID = "sessionID" in input && typeof input.sessionID === "string" ? input.sessionID : output.messages.find((message) => typeof message.info.sessionID === "string")?.info.sessionID;
|
|
1010
1492
|
if (!sessionID)
|
|
1011
1493
|
return;
|
|
@@ -1015,7 +1497,8 @@ var server = async ({ client }, options) => {
|
|
|
1015
1497
|
async "experimental.chat.system.transform"(input, output) {
|
|
1016
1498
|
if (typeof input.sessionID !== "string")
|
|
1017
1499
|
return;
|
|
1018
|
-
|
|
1500
|
+
const goal = await getGoal(input.sessionID);
|
|
1501
|
+
mergeSystemReminder(output, systemReminder(goal, { planningOnly: isPlanAgent(goal?.lastPromptAgent) }));
|
|
1019
1502
|
},
|
|
1020
1503
|
async "experimental.session.compacting"(input, output) {
|
|
1021
1504
|
const goal = await getGoal(input.sessionID);
|
|
@@ -1030,38 +1513,39 @@ var server = async ({ client }, options) => {
|
|
|
1030
1513
|
},
|
|
1031
1514
|
async event({ event }) {
|
|
1032
1515
|
const sessionID = sessionIDFromEvent(event);
|
|
1516
|
+
const eventType = event.type;
|
|
1517
|
+
if (eventType === "session.created") {
|
|
1518
|
+
taskTracker.observeSessionCreated(event);
|
|
1519
|
+
}
|
|
1520
|
+
if (sessionID && eventType === "session.status") {
|
|
1521
|
+
const status = event.properties?.status;
|
|
1522
|
+
if (isRecord(status) && typeof status.type === "string") {
|
|
1523
|
+
if (status.type === "busy")
|
|
1524
|
+
busySessions.add(sessionID);
|
|
1525
|
+
if (status.type === "idle")
|
|
1526
|
+
busySessions.delete(sessionID);
|
|
1527
|
+
taskTracker.observeSessionStatus(sessionID, status.type);
|
|
1528
|
+
}
|
|
1529
|
+
}
|
|
1530
|
+
if (sessionID && eventType === "session.idle") {
|
|
1531
|
+
busySessions.delete(sessionID);
|
|
1532
|
+
taskTracker.observeSessionStatus(sessionID, "idle");
|
|
1533
|
+
}
|
|
1534
|
+
if (sessionID && eventType === "session.deleted") {
|
|
1535
|
+
busySessions.delete(sessionID);
|
|
1536
|
+
taskTracker.observeSessionDeleted(sessionID);
|
|
1537
|
+
}
|
|
1033
1538
|
if (sessionID && event.type === "message.updated") {
|
|
1034
1539
|
const props = event.properties ?? {};
|
|
1035
1540
|
const message = [props.info, props.message].find((value) => value && typeof value === "object");
|
|
1541
|
+
taskTracker.observeAssistantMessage(sessionID, message);
|
|
1036
1542
|
await recordAssistantMessage(sessionID, message, options ?? {});
|
|
1037
1543
|
}
|
|
1038
1544
|
if (!autoContinue || !isIdleEvent(event))
|
|
1039
1545
|
return;
|
|
1040
1546
|
if (!sessionID)
|
|
1041
1547
|
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
|
-
}
|
|
1548
|
+
await runAutoContinue(sessionID);
|
|
1065
1549
|
}
|
|
1066
1550
|
};
|
|
1067
1551
|
};
|
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.20",
|
|
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",
|