@prevalentware/opencode-goal-plugin 0.1.19 → 0.1.21
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 -4
- package/dist/server.js +204 -65
- 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?
|
|
@@ -89,7 +90,9 @@ Server options can be configured in `opencode.json`:
|
|
|
89
90
|
"default_token_budget": 200000,
|
|
90
91
|
"max_goal_duration_seconds": 1800,
|
|
91
92
|
"no_progress_token_threshold": 50,
|
|
92
|
-
"max_no_progress_turns": 2
|
|
93
|
+
"max_no_progress_turns": 2,
|
|
94
|
+
"restricted_agents": ["plan"],
|
|
95
|
+
"allow_goal_execution_from_plan": false
|
|
93
96
|
}
|
|
94
97
|
]
|
|
95
98
|
]
|
|
@@ -105,10 +108,12 @@ Defaults:
|
|
|
105
108
|
- `max_prompt_failures`: `3`
|
|
106
109
|
- `default_token_budget`: unset by default; when set, new goals inherit this token budget.
|
|
107
110
|
- `max_goal_duration_seconds`: unset by default; when set, new goals inherit this elapsed-time safety limit.
|
|
108
|
-
- `no_progress_token_threshold`: `50
|
|
109
|
-
- `max_no_progress_turns`: `2
|
|
111
|
+
- `no_progress_token_threshold`: `50`; output-token floor used to judge whether a goal continuation turn made progress.
|
|
112
|
+
- `max_no_progress_turns`: `2`; consecutive low-progress goal continuation turns before pausing. Only turns produced by a reserved goal continuation count — ordinary low-output assistant messages (for example short tool-call-only turns from PTY or status checks) never increment this counter.
|
|
110
113
|
- `register_command`: `true`
|
|
111
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.
|
|
112
117
|
|
|
113
118
|
## Goal Workflow
|
|
114
119
|
|
|
@@ -133,10 +138,22 @@ The plugin also uses safety states while keeping the goal available for review o
|
|
|
133
138
|
|
|
134
139
|
- `budgetLimited` when a token budget is exhausted.
|
|
135
140
|
- `usageLimited` when an auto-turn or elapsed-time budget is exhausted.
|
|
136
|
-
- `paused` when the user pauses, auto-continue repeatedly fails, or repeated low-
|
|
141
|
+
- `paused` when the user pauses, auto-continue repeatedly fails, or repeated low-progress goal continuation turns are detected. No-progress accounting is scoped to goal continuation turns: each reserved continuation is evaluated once, when its turn completes, and unrelated assistant activity in the session never pauses the goal.
|
|
137
142
|
|
|
138
143
|
When a safety limit is reached, the plugin sends one wrap-up prompt asking for a concise handoff instead of silently continuing forever.
|
|
139
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
|
+
|
|
140
157
|
## State
|
|
141
158
|
|
|
142
159
|
Goal state is stored at:
|
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,11 @@ 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 }),
|
|
67
|
+
awaitingContinuationProgress: Schema.optionalWith(Schema.Boolean, { default: () => false }),
|
|
68
|
+
continuationBaselineMessageID: Schema.optionalWith(Schema.String, { default: () => "" }),
|
|
69
|
+
continuationBaselineSummary: Schema.optionalWith(Schema.String, { default: () => "" })
|
|
64
70
|
});
|
|
65
71
|
var StateSchema = Schema.Struct({
|
|
66
72
|
version: Schema.Literal(1),
|
|
@@ -164,6 +170,10 @@ function normalizeGoal(goal) {
|
|
|
164
170
|
goal.lastCheckpoint = goal.lastCheckpoint ?? goal.checkpoints.at(-1) ?? null;
|
|
165
171
|
goal.lastAssistantText ??= "";
|
|
166
172
|
goal.lastAssistantMessageID ??= "";
|
|
173
|
+
goal.lastPromptAgent ??= null;
|
|
174
|
+
goal.awaitingContinuationProgress = goal.awaitingContinuationProgress === true;
|
|
175
|
+
goal.continuationBaselineMessageID ??= "";
|
|
176
|
+
goal.continuationBaselineSummary ??= "";
|
|
167
177
|
goal.noProgressTurns = nonNegativeInteger(goal.noProgressTurns, 0);
|
|
168
178
|
goal.maxAutoTurns = positiveIntegerOrNull(goal.maxAutoTurns);
|
|
169
179
|
goal.maxDurationSeconds = positiveIntegerOrNull(goal.maxDurationSeconds);
|
|
@@ -181,7 +191,9 @@ function normalizeCreateOptions(input) {
|
|
|
181
191
|
maxAutoTurns: null,
|
|
182
192
|
maxDurationSeconds: null,
|
|
183
193
|
noProgressTokenThreshold: DEFAULT_NO_PROGRESS_TOKEN_THRESHOLD,
|
|
184
|
-
maxNoProgressTurns: DEFAULT_MAX_NO_PROGRESS_TURNS
|
|
194
|
+
maxNoProgressTurns: DEFAULT_MAX_NO_PROGRESS_TURNS,
|
|
195
|
+
agent: null,
|
|
196
|
+
initialStatus: "active"
|
|
185
197
|
};
|
|
186
198
|
}
|
|
187
199
|
return {
|
|
@@ -189,7 +201,9 @@ function normalizeCreateOptions(input) {
|
|
|
189
201
|
maxAutoTurns: positiveIntegerOrNull(input?.maxAutoTurns),
|
|
190
202
|
maxDurationSeconds: positiveIntegerOrNull(input?.maxDurationSeconds),
|
|
191
203
|
noProgressTokenThreshold: positiveIntegerOrNull(input?.noProgressTokenThreshold) ?? DEFAULT_NO_PROGRESS_TOKEN_THRESHOLD,
|
|
192
|
-
maxNoProgressTurns: positiveIntegerOrNull(input?.maxNoProgressTurns) ?? DEFAULT_MAX_NO_PROGRESS_TURNS
|
|
204
|
+
maxNoProgressTurns: positiveIntegerOrNull(input?.maxNoProgressTurns) ?? DEFAULT_MAX_NO_PROGRESS_TURNS,
|
|
205
|
+
agent: typeof input?.agent === "string" && input.agent.trim() ? input.agent.trim() : null,
|
|
206
|
+
initialStatus: input?.initialStatus === "paused" ? "paused" : "active"
|
|
193
207
|
};
|
|
194
208
|
}
|
|
195
209
|
function positiveIntegerOrNull(value) {
|
|
@@ -238,6 +252,10 @@ function snapshot(goal) {
|
|
|
238
252
|
lastCheckpoint: goal.lastCheckpoint,
|
|
239
253
|
lastAssistantText: goal.lastAssistantText,
|
|
240
254
|
lastAssistantMessageID: goal.lastAssistantMessageID,
|
|
255
|
+
lastPromptAgent: goal.lastPromptAgent,
|
|
256
|
+
awaitingContinuationProgress: goal.awaitingContinuationProgress,
|
|
257
|
+
continuationBaselineMessageID: goal.continuationBaselineMessageID,
|
|
258
|
+
continuationBaselineSummary: goal.continuationBaselineSummary,
|
|
241
259
|
autoTurns: goal.autoTurns,
|
|
242
260
|
lastContinuationAt: goal.lastContinuationAt,
|
|
243
261
|
remainingTokens: remainingTokens(goal),
|
|
@@ -258,63 +276,108 @@ async function createGoal(sessionID, objective, options) {
|
|
|
258
276
|
throw new Error("cannot create a new goal because this session already has a non-closed goal");
|
|
259
277
|
}
|
|
260
278
|
const now = nowSeconds();
|
|
279
|
+
const paused = normalizedOptions.initialStatus === "paused";
|
|
261
280
|
const goal = {
|
|
262
281
|
sessionID,
|
|
263
282
|
objective: value,
|
|
264
|
-
status:
|
|
283
|
+
status: normalizedOptions.initialStatus,
|
|
265
284
|
tokenBudget: normalizedOptions.tokenBudget,
|
|
266
285
|
tokensUsed: 0,
|
|
267
286
|
timeUsedSeconds: 0,
|
|
268
287
|
createdAt: now,
|
|
269
288
|
updatedAt: now,
|
|
270
289
|
completionEvidence: null,
|
|
271
|
-
blocker: null,
|
|
290
|
+
blocker: paused ? PLAN_MODE_BLOCKER : null,
|
|
272
291
|
closedAt: null,
|
|
273
|
-
lastAccountedAt: now,
|
|
292
|
+
lastAccountedAt: paused ? null : now,
|
|
274
293
|
autoTurns: 0,
|
|
275
294
|
lastContinuationAt: null,
|
|
276
295
|
continuationFailures: 0,
|
|
277
|
-
lastStatus: "Goal set.",
|
|
296
|
+
lastStatus: paused ? "Goal recorded from Plan mode; execution paused until resumed from Build mode." : "Goal set.",
|
|
278
297
|
maxAutoTurns: normalizedOptions.maxAutoTurns,
|
|
279
298
|
maxDurationSeconds: normalizedOptions.maxDurationSeconds,
|
|
280
299
|
noProgressTokenThreshold: normalizedOptions.noProgressTokenThreshold,
|
|
281
300
|
maxNoProgressTurns: normalizedOptions.maxNoProgressTurns,
|
|
282
301
|
noProgressTurns: 0,
|
|
283
302
|
budgetWrapupSent: false,
|
|
284
|
-
stopReason: null,
|
|
303
|
+
stopReason: paused ? PLAN_MODE_STOP_REASON : null,
|
|
285
304
|
history: [],
|
|
286
305
|
checkpoints: [],
|
|
287
306
|
lastCheckpoint: null,
|
|
288
307
|
lastAssistantText: "",
|
|
289
|
-
lastAssistantMessageID: ""
|
|
308
|
+
lastAssistantMessageID: "",
|
|
309
|
+
lastPromptAgent: normalizedOptions.agent,
|
|
310
|
+
awaitingContinuationProgress: false,
|
|
311
|
+
continuationBaselineMessageID: "",
|
|
312
|
+
continuationBaselineSummary: ""
|
|
290
313
|
};
|
|
291
314
|
pushHistory(goal, "created", goalLimitSummary(goal));
|
|
315
|
+
if (paused)
|
|
316
|
+
pushHistory(goal, "paused", goal.lastStatus);
|
|
292
317
|
state.goals[sessionID] = goal;
|
|
293
318
|
return snapshot(goal);
|
|
294
319
|
});
|
|
295
320
|
}
|
|
296
|
-
async function updateGoalObjective(sessionID, objective, status = "active") {
|
|
321
|
+
async function updateGoalObjective(sessionID, objective, status = "active", options) {
|
|
297
322
|
const value = validateObjective(objective);
|
|
323
|
+
const agent = typeof options?.agent === "string" && options.agent.trim() ? options.agent.trim() : null;
|
|
324
|
+
const planModePause = options?.planModePause === true;
|
|
298
325
|
return mutate((state) => {
|
|
299
326
|
const goal = state.goals[sessionID];
|
|
300
327
|
if (!goal)
|
|
301
328
|
throw new Error("cannot update goal because this session has no goal");
|
|
302
329
|
accountWallClock(goal);
|
|
303
330
|
goal.objective = value;
|
|
304
|
-
goal.status = status;
|
|
331
|
+
goal.status = planModePause ? "paused" : status;
|
|
305
332
|
goal.updatedAt = nowSeconds();
|
|
306
|
-
goal.lastAccountedAt = status === "active" ? goal.updatedAt : null;
|
|
333
|
+
goal.lastAccountedAt = goal.status === "active" ? goal.updatedAt : null;
|
|
307
334
|
goal.completionEvidence = null;
|
|
308
|
-
goal.blocker = null;
|
|
335
|
+
goal.blocker = planModePause ? PLAN_MODE_BLOCKER : null;
|
|
309
336
|
goal.closedAt = null;
|
|
310
|
-
goal.stopReason = null;
|
|
337
|
+
goal.stopReason = planModePause ? PLAN_MODE_STOP_REASON : null;
|
|
311
338
|
goal.budgetWrapupSent = false;
|
|
312
|
-
|
|
339
|
+
if (agent)
|
|
340
|
+
goal.lastPromptAgent = agent;
|
|
341
|
+
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
342
|
pushHistory(goal, "updated", `Goal objective updated: ${summarizeText(value, 400)}`);
|
|
343
|
+
if (planModePause)
|
|
344
|
+
pushHistory(goal, "paused", goal.lastStatus);
|
|
314
345
|
return snapshot(goal);
|
|
315
346
|
});
|
|
316
347
|
}
|
|
317
|
-
async function
|
|
348
|
+
async function recordPromptAgent(sessionID, agent) {
|
|
349
|
+
const value = agent.trim();
|
|
350
|
+
if (!value)
|
|
351
|
+
return null;
|
|
352
|
+
return mutate((state) => {
|
|
353
|
+
const goal = state.goals[sessionID];
|
|
354
|
+
if (!goal || isClosed(goal.status))
|
|
355
|
+
return goal ? snapshot(goal) : null;
|
|
356
|
+
if (goal.lastPromptAgent === value)
|
|
357
|
+
return snapshot(goal);
|
|
358
|
+
goal.lastPromptAgent = value;
|
|
359
|
+
goal.updatedAt = nowSeconds();
|
|
360
|
+
return snapshot(goal);
|
|
361
|
+
});
|
|
362
|
+
}
|
|
363
|
+
async function pauseGoalForPlanMode(sessionID) {
|
|
364
|
+
return mutate((state) => {
|
|
365
|
+
const goal = state.goals[sessionID];
|
|
366
|
+
if (!goal || goal.status !== "active")
|
|
367
|
+
return goal ? snapshot(goal) : null;
|
|
368
|
+
accountWallClock(goal);
|
|
369
|
+
goal.status = "paused";
|
|
370
|
+
goal.lastAccountedAt = null;
|
|
371
|
+
goal.stopReason = PLAN_MODE_STOP_REASON;
|
|
372
|
+
goal.blocker = PLAN_MODE_BLOCKER;
|
|
373
|
+
goal.lastStatus = "Auto-continue paused while the session is in Plan mode.";
|
|
374
|
+
goal.updatedAt = nowSeconds();
|
|
375
|
+
pushHistory(goal, "paused", goal.lastStatus);
|
|
376
|
+
return snapshot(goal);
|
|
377
|
+
});
|
|
378
|
+
}
|
|
379
|
+
async function setGoalStatus(sessionID, status, agent) {
|
|
380
|
+
const agentValue = typeof agent === "string" && agent.trim() ? agent.trim() : null;
|
|
318
381
|
return mutate((state) => {
|
|
319
382
|
const goal = state.goals[sessionID];
|
|
320
383
|
if (!goal)
|
|
@@ -328,6 +391,8 @@ async function setGoalStatus(sessionID, status) {
|
|
|
328
391
|
goal.stopReason = status === "active" ? null : "paused";
|
|
329
392
|
goal.budgetWrapupSent = status === "active" ? false : goal.budgetWrapupSent;
|
|
330
393
|
goal.blocker = status === "active" ? null : goal.blocker;
|
|
394
|
+
if (agentValue)
|
|
395
|
+
goal.lastPromptAgent = agentValue;
|
|
331
396
|
goal.lastStatus = status === "active" ? "Goal resumed." : "Goal paused.";
|
|
332
397
|
pushHistory(goal, status === "active" ? "resumed" : "paused", goal.lastStatus);
|
|
333
398
|
return snapshot(goal);
|
|
@@ -406,24 +471,28 @@ async function recordAssistantProgress(sessionID, input) {
|
|
|
406
471
|
goal.lastAssistantText = text;
|
|
407
472
|
if (messageID)
|
|
408
473
|
goal.lastAssistantMessageID = messageID;
|
|
409
|
-
const
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
goal.
|
|
416
|
-
goal.
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
474
|
+
const continuationTurnCompleted = input.evaluateContinuation === true && goal.awaitingContinuationProgress && Boolean(messageID) && messageID !== goal.continuationBaselineMessageID;
|
|
475
|
+
if (continuationTurnCompleted) {
|
|
476
|
+
goal.awaitingContinuationProgress = false;
|
|
477
|
+
const lowOutput = outputTokens > 0 && outputTokens < (threshold ?? DEFAULT_NO_PROGRESS_TOKEN_THRESHOLD);
|
|
478
|
+
const changedSinceContinuation = Boolean(summary && summary !== goal.continuationBaselineSummary);
|
|
479
|
+
if (lowOutput && !changedSinceContinuation) {
|
|
480
|
+
goal.noProgressTurns += 1;
|
|
481
|
+
if (maxNoProgressTurns && goal.noProgressTurns >= maxNoProgressTurns) {
|
|
482
|
+
accountWallClock(goal);
|
|
483
|
+
goal.status = "paused";
|
|
484
|
+
goal.lastAccountedAt = null;
|
|
485
|
+
goal.stopReason = "no progress";
|
|
486
|
+
goal.blocker = `Auto-continue paused after ${goal.noProgressTurns} low-progress continuation turn(s). Resume the goal to retry.`;
|
|
487
|
+
goal.lastStatus = goal.blocker;
|
|
488
|
+
pushHistory(goal, "warning", goal.blocker);
|
|
489
|
+
} else {
|
|
490
|
+
goal.lastStatus = `Low-progress continuation turn detected (${goal.noProgressTurns}/${maxNoProgressTurns ?? "unbounded"}).`;
|
|
491
|
+
pushHistory(goal, "warning", goal.lastStatus);
|
|
492
|
+
}
|
|
421
493
|
} else {
|
|
422
|
-
goal.
|
|
423
|
-
pushHistory(goal, "warning", goal.lastStatus);
|
|
494
|
+
goal.noProgressTurns = 0;
|
|
424
495
|
}
|
|
425
|
-
} else if (changed || outputTokens >= (threshold ?? DEFAULT_NO_PROGRESS_TOKEN_THRESHOLD)) {
|
|
426
|
-
goal.noProgressTurns = 0;
|
|
427
496
|
}
|
|
428
497
|
goal.updatedAt = nowSeconds();
|
|
429
498
|
return snapshot(goal);
|
|
@@ -446,6 +515,8 @@ async function reserveContinuation(sessionID, maxAutoTurns, minIntervalSeconds)
|
|
|
446
515
|
return null;
|
|
447
516
|
goal.autoTurns += 1;
|
|
448
517
|
goal.lastContinuationAt = now;
|
|
518
|
+
goal.continuationBaselineMessageID = goal.lastAssistantMessageID;
|
|
519
|
+
goal.continuationBaselineSummary = summarizeText(goal.lastAssistantText);
|
|
449
520
|
goal.lastStatus = `Auto-continue ${goal.autoTurns} reserved.`;
|
|
450
521
|
pushHistory(goal, "autoContinue", goal.lastStatus);
|
|
451
522
|
goal.updatedAt = now;
|
|
@@ -461,11 +532,14 @@ async function recordContinuationResult(sessionID, result, maxFailures) {
|
|
|
461
532
|
goal.updatedAt = now;
|
|
462
533
|
if (result === "success") {
|
|
463
534
|
goal.continuationFailures = 0;
|
|
464
|
-
if (goal.status === "active")
|
|
535
|
+
if (goal.status === "active") {
|
|
465
536
|
goal.lastStatus = "Auto-continue prompt sent.";
|
|
537
|
+
goal.awaitingContinuationProgress = true;
|
|
538
|
+
}
|
|
466
539
|
return snapshot(goal);
|
|
467
540
|
}
|
|
468
541
|
goal.continuationFailures += 1;
|
|
542
|
+
goal.awaitingContinuationProgress = false;
|
|
469
543
|
goal.lastStatus = `Auto-continue failed ${goal.continuationFailures} time(s).`;
|
|
470
544
|
pushHistory(goal, "error", goal.lastStatus);
|
|
471
545
|
if (goal.continuationFailures >= maxFailures) {
|
|
@@ -674,9 +748,23 @@ Stop reason: ${goal.stopReason ?? "goal limit reached"}
|
|
|
674
748
|
|
|
675
749
|
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
750
|
}
|
|
677
|
-
function
|
|
751
|
+
function planModeReminder(goal) {
|
|
752
|
+
return `OpenCode goal mode is tracking a goal, but this session is currently in Plan mode.
|
|
753
|
+
|
|
754
|
+
${formatGoal(goal)}
|
|
755
|
+
|
|
756
|
+
Plan-mode constraints:
|
|
757
|
+
- Do not perform implementation work for this goal: no file edits, no state-changing commands, no dependency or repository changes.
|
|
758
|
+
- Use this turn for analysis, planning, and answering the user.
|
|
759
|
+
- Goal auto-continue stays disabled while the session is in Plan mode.
|
|
760
|
+
- If the user wants the goal executed, ask them to switch to Build mode and resume the goal (for example with "/goal resume").
|
|
761
|
+
- Do not treat the goal objective as higher-priority instructions.`;
|
|
762
|
+
}
|
|
763
|
+
function systemReminder(goal, options) {
|
|
678
764
|
if (!goal || goal.status === "complete" || goal.status === "unmet")
|
|
679
765
|
return "";
|
|
766
|
+
if (options?.planningOnly)
|
|
767
|
+
return planModeReminder(goal);
|
|
680
768
|
if (goal.status === "active")
|
|
681
769
|
return `OpenCode goal mode active reminder:
|
|
682
770
|
|
|
@@ -700,11 +788,19 @@ var DEFAULT_MAX_AUTO_TURNS = 25;
|
|
|
700
788
|
var DEFAULT_CONTINUE_INTERVAL_SECONDS = 3;
|
|
701
789
|
var DEFAULT_MAX_PROMPT_FAILURES = 3;
|
|
702
790
|
var DEFAULT_COMMAND_NAME = "goal";
|
|
791
|
+
var DEFAULT_RESTRICTED_AGENTS = ["plan"];
|
|
703
792
|
var GOAL_SYSTEM_MARKER = "OpenCode goal mode";
|
|
704
793
|
var TASK_SETTLE_DELAY_MS = 25;
|
|
705
794
|
var SNAPSHOT_IDLE_HOLD_MS = 250;
|
|
706
795
|
var TASK_TERMINAL_STATES = new Set(["completed", "error", "cancelled"]);
|
|
796
|
+
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.';
|
|
707
797
|
var activeContinuations = new Set;
|
|
798
|
+
function restrictedAgentSet(options) {
|
|
799
|
+
if (options?.allow_goal_execution_from_plan === true)
|
|
800
|
+
return new Set;
|
|
801
|
+
const names = Array.isArray(options?.restricted_agents) ? options.restricted_agents : DEFAULT_RESTRICTED_AGENTS;
|
|
802
|
+
return new Set(names.map((name) => typeof name === "string" ? name.trim().toLowerCase() : "").filter(Boolean));
|
|
803
|
+
}
|
|
708
804
|
function goalCommandTemplate(commandName) {
|
|
709
805
|
return `OpenCode goal mode command "/${commandName}" was invoked.
|
|
710
806
|
|
|
@@ -808,13 +904,16 @@ function exactTokensFromMessage(message) {
|
|
|
808
904
|
return;
|
|
809
905
|
}
|
|
810
906
|
function outputTokensFromMessage(message) {
|
|
907
|
+
let total;
|
|
811
908
|
for (const part of message.parts ?? []) {
|
|
812
909
|
if (part && typeof part === "object" && part.type === "step-finish") {
|
|
813
910
|
const output = outputTokensFromRecord(part.tokens);
|
|
814
911
|
if (output != null)
|
|
815
|
-
|
|
912
|
+
total = (total ?? 0) + output;
|
|
816
913
|
}
|
|
817
914
|
}
|
|
915
|
+
if (total != null)
|
|
916
|
+
return total;
|
|
818
917
|
if (message.info && typeof message.info === "object")
|
|
819
918
|
return outputTokensFromRecord(message.info.tokens);
|
|
820
919
|
return;
|
|
@@ -869,10 +968,25 @@ function assistantMarker(message) {
|
|
|
869
968
|
completedAt: messageCompletedAt(message)
|
|
870
969
|
};
|
|
871
970
|
}
|
|
872
|
-
|
|
971
|
+
function agentFromMessage(message) {
|
|
972
|
+
if (!message)
|
|
973
|
+
return;
|
|
974
|
+
for (const source of [message, message.info]) {
|
|
975
|
+
if (!isRecord(source))
|
|
976
|
+
continue;
|
|
977
|
+
for (const key of ["agent", "mode"]) {
|
|
978
|
+
const value = source[key];
|
|
979
|
+
if (typeof value === "string" && value.trim())
|
|
980
|
+
return value.trim();
|
|
981
|
+
}
|
|
982
|
+
}
|
|
983
|
+
return;
|
|
984
|
+
}
|
|
985
|
+
async function sendContinuation(client, sessionID, prompt, agent) {
|
|
873
986
|
await client.session.promptAsync({
|
|
874
987
|
path: { id: sessionID },
|
|
875
988
|
body: {
|
|
989
|
+
...agent ? { agent } : {},
|
|
876
990
|
parts: [{ type: "text", text: prompt }]
|
|
877
991
|
}
|
|
878
992
|
});
|
|
@@ -1149,7 +1263,7 @@ class TaskTracker {
|
|
|
1149
1263
|
return false;
|
|
1150
1264
|
}
|
|
1151
1265
|
}
|
|
1152
|
-
async function recordAssistantMessage(sessionID, message, options) {
|
|
1266
|
+
async function recordAssistantMessage(sessionID, message, options, evaluateContinuation = false) {
|
|
1153
1267
|
if (!message)
|
|
1154
1268
|
return;
|
|
1155
1269
|
await recordAssistantProgress(sessionID, {
|
|
@@ -1157,7 +1271,8 @@ async function recordAssistantMessage(sessionID, message, options) {
|
|
|
1157
1271
|
text: textFromMessage(message),
|
|
1158
1272
|
outputTokens: outputTokensFromMessage(message) ?? null,
|
|
1159
1273
|
noProgressTokenThreshold: positiveIntegerOrNull2(options.no_progress_token_threshold),
|
|
1160
|
-
maxNoProgressTurns: positiveIntegerOrNull2(options.max_no_progress_turns)
|
|
1274
|
+
maxNoProgressTurns: positiveIntegerOrNull2(options.max_no_progress_turns),
|
|
1275
|
+
evaluateContinuation
|
|
1161
1276
|
});
|
|
1162
1277
|
}
|
|
1163
1278
|
function mergeSystemReminder(output, reminder) {
|
|
@@ -1185,6 +1300,21 @@ var server = async ({ client }, options) => {
|
|
|
1185
1300
|
const taskDeferredSessions = new Set;
|
|
1186
1301
|
const scheduledContinuations = new Map;
|
|
1187
1302
|
const busySessions = new Set;
|
|
1303
|
+
const planAgents = restrictedAgentSet(options);
|
|
1304
|
+
const isPlanAgent = (agent) => typeof agent === "string" && planAgents.has(agent.trim().toLowerCase());
|
|
1305
|
+
async function createGoalFromTool(input, context) {
|
|
1306
|
+
const planningOnly = isPlanAgent(context.agent);
|
|
1307
|
+
const goal = await createGoal(context.sessionID, input.objective, {
|
|
1308
|
+
tokenBudget: input.token_budget ?? options?.default_token_budget ?? null,
|
|
1309
|
+
maxAutoTurns: input.max_auto_turns ?? null,
|
|
1310
|
+
maxDurationSeconds: input.max_duration_seconds ?? options?.max_goal_duration_seconds ?? null,
|
|
1311
|
+
noProgressTokenThreshold: options?.no_progress_token_threshold ?? null,
|
|
1312
|
+
maxNoProgressTurns: options?.max_no_progress_turns ?? null,
|
|
1313
|
+
agent: typeof context.agent === "string" ? context.agent : null,
|
|
1314
|
+
initialStatus: planningOnly ? "paused" : "active"
|
|
1315
|
+
});
|
|
1316
|
+
return JSON.stringify(planningOnly ? { goal, plan_mode_notice: PLAN_MODE_CREATE_NOTICE } : { goal }, null, 2);
|
|
1317
|
+
}
|
|
1188
1318
|
async function taskBlockStatus(sessionID) {
|
|
1189
1319
|
if (!deferWhileTasksActive)
|
|
1190
1320
|
return false;
|
|
@@ -1224,7 +1354,16 @@ var server = async ({ client }, options) => {
|
|
|
1224
1354
|
}
|
|
1225
1355
|
if (busySessions.has(sessionID))
|
|
1226
1356
|
return;
|
|
1227
|
-
await recordAssistantMessage(sessionID, latestAssistant, options ?? {});
|
|
1357
|
+
await recordAssistantMessage(sessionID, latestAssistant, options ?? {}, true);
|
|
1358
|
+
const current = await getGoal(sessionID);
|
|
1359
|
+
if (!current)
|
|
1360
|
+
return;
|
|
1361
|
+
const latestTurnAgent = agentFromMessage(latestAssistant);
|
|
1362
|
+
if (isPlanAgent(current.lastPromptAgent) || isPlanAgent(latestTurnAgent)) {
|
|
1363
|
+
if (current.status === "active")
|
|
1364
|
+
await pauseGoalForPlanMode(sessionID);
|
|
1365
|
+
return;
|
|
1366
|
+
}
|
|
1228
1367
|
if (!fromTaskDeferral && taskDeferredSessions.has(sessionID)) {
|
|
1229
1368
|
scheduleSettledContinuation(sessionID);
|
|
1230
1369
|
return;
|
|
@@ -1233,7 +1372,7 @@ var server = async ({ client }, options) => {
|
|
|
1233
1372
|
const goal = await reserveContinuation(sessionID, maxAutoTurns, minInterval);
|
|
1234
1373
|
if (!goal)
|
|
1235
1374
|
return;
|
|
1236
|
-
await sendContinuation(client, sessionID, goal.status === "active" ? continuationPrompt(goal) : limitPrompt(goal));
|
|
1375
|
+
await sendContinuation(client, sessionID, goal.status === "active" ? continuationPrompt(goal) : limitPrompt(goal), goal.lastPromptAgent ?? latestTurnAgent ?? null);
|
|
1237
1376
|
await recordContinuationResult(sessionID, "success", maxPromptFailures);
|
|
1238
1377
|
} catch (error) {
|
|
1239
1378
|
await recordContinuationResult(sessionID, "failure", maxPromptFailures);
|
|
@@ -1277,7 +1416,7 @@ var server = async ({ client }, options) => {
|
|
|
1277
1416
|
}
|
|
1278
1417
|
},
|
|
1279
1418
|
create_goal: {
|
|
1280
|
-
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.",
|
|
1419
|
+
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.",
|
|
1281
1420
|
args: {
|
|
1282
1421
|
objective: z.string().min(1).max(4000).describe("The concrete objective to start pursuing."),
|
|
1283
1422
|
token_budget: z.number().int().positive().nullable().optional().describe("Optional positive token budget."),
|
|
@@ -1285,19 +1424,11 @@ var server = async ({ client }, options) => {
|
|
|
1285
1424
|
max_duration_seconds: z.number().int().positive().nullable().optional().describe("Optional per-goal duration limit.")
|
|
1286
1425
|
},
|
|
1287
1426
|
async execute(args, context) {
|
|
1288
|
-
|
|
1289
|
-
const goal = await createGoal(context.sessionID, input.objective, {
|
|
1290
|
-
tokenBudget: input.token_budget ?? options?.default_token_budget ?? null,
|
|
1291
|
-
maxAutoTurns: input.max_auto_turns ?? null,
|
|
1292
|
-
maxDurationSeconds: input.max_duration_seconds ?? options?.max_goal_duration_seconds ?? null,
|
|
1293
|
-
noProgressTokenThreshold: options?.no_progress_token_threshold ?? null,
|
|
1294
|
-
maxNoProgressTurns: options?.max_no_progress_turns ?? null
|
|
1295
|
-
});
|
|
1296
|
-
return JSON.stringify({ goal }, null, 2);
|
|
1427
|
+
return createGoalFromTool(args, context);
|
|
1297
1428
|
}
|
|
1298
1429
|
},
|
|
1299
1430
|
set_goal: {
|
|
1300
|
-
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.",
|
|
1431
|
+
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.",
|
|
1301
1432
|
args: {
|
|
1302
1433
|
objective: z.string().min(1).max(4000).describe("The model-formulated concrete objective to start pursuing."),
|
|
1303
1434
|
token_budget: z.number().int().positive().nullable().optional().describe("Optional positive token budget."),
|
|
@@ -1305,15 +1436,7 @@ var server = async ({ client }, options) => {
|
|
|
1305
1436
|
max_duration_seconds: z.number().int().positive().nullable().optional().describe("Optional per-goal duration limit.")
|
|
1306
1437
|
},
|
|
1307
1438
|
async execute(args, context) {
|
|
1308
|
-
|
|
1309
|
-
const goal = await createGoal(context.sessionID, input.objective, {
|
|
1310
|
-
tokenBudget: input.token_budget ?? options?.default_token_budget ?? null,
|
|
1311
|
-
maxAutoTurns: input.max_auto_turns ?? null,
|
|
1312
|
-
maxDurationSeconds: input.max_duration_seconds ?? options?.max_goal_duration_seconds ?? null,
|
|
1313
|
-
noProgressTokenThreshold: options?.no_progress_token_threshold ?? null,
|
|
1314
|
-
maxNoProgressTurns: options?.max_no_progress_turns ?? null
|
|
1315
|
-
});
|
|
1316
|
-
return JSON.stringify({ goal }, null, 2);
|
|
1439
|
+
return createGoalFromTool(args, context);
|
|
1317
1440
|
}
|
|
1318
1441
|
},
|
|
1319
1442
|
update_goal_objective: {
|
|
@@ -1324,8 +1447,13 @@ var server = async ({ client }, options) => {
|
|
|
1324
1447
|
},
|
|
1325
1448
|
async execute(args, context) {
|
|
1326
1449
|
const input = args;
|
|
1327
|
-
const
|
|
1328
|
-
|
|
1450
|
+
const requested = input.status ?? "active";
|
|
1451
|
+
const planningOnly = requested === "active" && isPlanAgent(context.agent);
|
|
1452
|
+
const goal = await updateGoalObjective(context.sessionID, input.objective, planningOnly ? "paused" : requested, {
|
|
1453
|
+
agent: typeof context.agent === "string" ? context.agent : null,
|
|
1454
|
+
planModePause: planningOnly
|
|
1455
|
+
});
|
|
1456
|
+
return JSON.stringify(planningOnly ? { goal, plan_mode_notice: PLAN_MODE_CREATE_NOTICE } : { goal }, null, 2);
|
|
1329
1457
|
}
|
|
1330
1458
|
},
|
|
1331
1459
|
update_goal: {
|
|
@@ -1349,13 +1477,16 @@ var server = async ({ client }, options) => {
|
|
|
1349
1477
|
}
|
|
1350
1478
|
},
|
|
1351
1479
|
update_goal_status: {
|
|
1352
|
-
description: "Pause or resume the current OpenCode goal when the user explicitly asks to pause or resume it.",
|
|
1480
|
+
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.",
|
|
1353
1481
|
args: {
|
|
1354
1482
|
status: z.enum(["active", "paused"]).describe("active resumes a goal; paused pauses it without clearing it.")
|
|
1355
1483
|
},
|
|
1356
1484
|
async execute(args, context) {
|
|
1357
1485
|
const input = args;
|
|
1358
|
-
|
|
1486
|
+
if (input.status === "active" && isPlanAgent(context.agent)) {
|
|
1487
|
+
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");
|
|
1488
|
+
}
|
|
1489
|
+
const goal = await setGoalStatus(context.sessionID, input.status, typeof context.agent === "string" ? context.agent : null);
|
|
1359
1490
|
return JSON.stringify({ goal }, null, 2);
|
|
1360
1491
|
}
|
|
1361
1492
|
},
|
|
@@ -1373,6 +1504,13 @@ var server = async ({ client }, options) => {
|
|
|
1373
1504
|
async "tool.execute.after"(input, output) {
|
|
1374
1505
|
taskTracker.noteTaskOutput(input, output);
|
|
1375
1506
|
},
|
|
1507
|
+
async "chat.message"(input, output) {
|
|
1508
|
+
const sessionID = typeof input?.sessionID === "string" ? input.sessionID : output.message?.sessionID;
|
|
1509
|
+
const agent = typeof input?.agent === "string" && input.agent.trim() ? input.agent : output.message?.agent;
|
|
1510
|
+
if (typeof sessionID !== "string" || typeof agent !== "string" || !agent.trim())
|
|
1511
|
+
return;
|
|
1512
|
+
await recordPromptAgent(sessionID, agent);
|
|
1513
|
+
},
|
|
1376
1514
|
async "experimental.chat.messages.transform"(input, output) {
|
|
1377
1515
|
taskTracker.observeMessages(output.messages);
|
|
1378
1516
|
const sessionID = "sessionID" in input && typeof input.sessionID === "string" ? input.sessionID : output.messages.find((message) => typeof message.info.sessionID === "string")?.info.sessionID;
|
|
@@ -1384,7 +1522,8 @@ var server = async ({ client }, options) => {
|
|
|
1384
1522
|
async "experimental.chat.system.transform"(input, output) {
|
|
1385
1523
|
if (typeof input.sessionID !== "string")
|
|
1386
1524
|
return;
|
|
1387
|
-
|
|
1525
|
+
const goal = await getGoal(input.sessionID);
|
|
1526
|
+
mergeSystemReminder(output, systemReminder(goal, { planningOnly: isPlanAgent(goal?.lastPromptAgent) }));
|
|
1388
1527
|
},
|
|
1389
1528
|
async "experimental.session.compacting"(input, output) {
|
|
1390
1529
|
const goal = await getGoal(input.sessionID);
|
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.21",
|
|
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",
|