@prevalentware/opencode-goal-plugin 0.1.44 → 0.1.46
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 +19 -2
- package/dist/server.js +135 -62
- package/package.json +2 -1
package/README.md
CHANGED
|
@@ -128,7 +128,8 @@ In OpenCode 1, server options use the package-and-options tuple in `opencode.jso
|
|
|
128
128
|
"no_progress_token_threshold": 50,
|
|
129
129
|
"max_no_progress_turns": 2,
|
|
130
130
|
"restricted_agents": ["plan"],
|
|
131
|
-
"allow_goal_execution_from_plan": false
|
|
131
|
+
"allow_goal_execution_from_plan": false,
|
|
132
|
+
"max_objective_chars": 100000
|
|
132
133
|
}
|
|
133
134
|
]
|
|
134
135
|
]
|
|
@@ -159,6 +160,7 @@ Defaults:
|
|
|
159
160
|
- `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.
|
|
160
161
|
- `max_auto_turns`: `25`
|
|
161
162
|
- `min_continue_interval_seconds`: `3`
|
|
163
|
+
- Fast V2 executions that finish inside this interval schedule a delayed continuation; they do not require another user message to wake up.
|
|
162
164
|
- `max_turn_time`: unset by default; set a positive number of seconds to retry one active-goal continuation prompt when a model turn remains busy for that long. Each new busy event resets the watchdog. Idle, built-in retry, session deletion, active Task children, and restricted agents suppress the retry. Watchdog retries are independent of `min_continue_interval_seconds` and never consume auto-turn or no-progress budgets, but recognized transport failures still count toward the `max_prompt_failures` ceiling.
|
|
163
165
|
- `max_prompt_failures`: `3`; consecutive transport or no-response continuation failures pause the goal at this ceiling. Prompt delivery alone does not reset the count; substantive assistant or tool progress, a new goal, or an explicit resume does.
|
|
164
166
|
- `default_token_budget`: unset by default; when set, new goals inherit this token budget.
|
|
@@ -169,6 +171,10 @@ Defaults:
|
|
|
169
171
|
- `command_name`: `"goal"`; renames the main goal command only. The reserved names `pause_goal` and `resume_goal` fall back to `goal` so the standalone controls remain available.
|
|
170
172
|
- `restricted_agents`: `["plan"]`; agents (matched case-insensitively) treated as planning-only for goal execution.
|
|
171
173
|
- `allow_goal_execution_from_plan`: `false`; when `true`, disables Plan-mode goal restrictions entirely.
|
|
174
|
+
- `max_objective_chars`: `100000`; maximum Unicode code-point length of the submitted goal objective, completion evidence,
|
|
175
|
+
and blocker text. The previous 4000-character cap was a defect, not a compatibility constraint. The same limit is
|
|
176
|
+
advertised on V1 and V2 tool schemas and enforced at runtime, independently per plugin instance. Accepted values are
|
|
177
|
+
trimmed before persistence. Large objectives are echoed into continuation and compaction prompts.
|
|
172
178
|
|
|
173
179
|
## Goal Workflow
|
|
174
180
|
|
|
@@ -248,6 +254,17 @@ bun run build
|
|
|
248
254
|
npm pack --dry-run
|
|
249
255
|
```
|
|
250
256
|
|
|
257
|
+
With `opencode2` installed, run `bun run build && bun run smoke:v2` to exercise the
|
|
258
|
+
native V2 lifecycle, not just mocked events. The smoke test uses a private server,
|
|
259
|
+
isolated home/config/database/goal state, and a deterministic local model with no
|
|
260
|
+
real provider credentials. It invokes `/goal`, requires **two** automatic
|
|
261
|
+
continuations with the default minimum interval, then verifies completion. A
|
|
262
|
+
second loaded location checks that server-wide events do not duplicate delivery.
|
|
263
|
+
The test prints its temporary artifact directory and shuts down its private
|
|
264
|
+
server. Use `OPENCODE_V2_BIN` to select another V2 binary, or run
|
|
265
|
+
`bun run smoke:v2 @prevalentware/opencode-goal-plugin@<version>` to install and
|
|
266
|
+
verify an exact published package in the isolated environment.
|
|
267
|
+
|
|
251
268
|
## Publishing
|
|
252
269
|
|
|
253
270
|
This package is set up for npm Trusted Publishing from GitHub Actions. On every push to `main`, CI runs typecheck, lint, and unit tests in parallel. If they all pass, the publish job computes the next patch version from the latest version on npm, builds the package, and runs `npm publish`.
|
|
@@ -274,6 +291,6 @@ OpenCode plugin modules are target-specific. This package exports separate modul
|
|
|
274
291
|
}
|
|
275
292
|
```
|
|
276
293
|
|
|
277
|
-
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
|
|
294
|
+
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 V2 `session.execution.succeeded` events and legacy `session.idle` / `session.status` idle notifications, never by intermediate model-step completion. V2 execution starts arm busy tracking; native `session.retry.scheduled` events cancel plugin recovery while OpenCode retries. Terminal execution transport failures use bounded recovery, while interruptions (user, shutdown, or superseded) cancel local timers without starting another turn or charging a prompt failure. Interrupted or non-transport-failed executions remain suppressed until the host starts a new execution; this does not change the persisted goal status. Use `/pause_goal` for a durable pause. Each V2 plugin instance handles goal events only for its own location while still observing cross-location child task lifecycles. The optional `max_turn_time` watchdog can retry one goal continuation prompt when a model turn remains busy, without consuming the goal's auto-turn or no-progress budgets; recognized transport failures do count toward the prompt-failure ceiling. 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.
|
|
278
295
|
|
|
279
296
|
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
|
@@ -370,21 +370,23 @@ async function mutate(fn) {
|
|
|
370
370
|
}));
|
|
371
371
|
});
|
|
372
372
|
}
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
const value = evidence?.trim();
|
|
383
|
-
if (!value)
|
|
373
|
+
var DEFAULT_MAX_OBJECTIVE_CHARS = 1e5;
|
|
374
|
+
function resolveMaxObjectiveChars(value) {
|
|
375
|
+
return typeof value === "number" && Number.isSafeInteger(value) && value > 0 ? value : DEFAULT_MAX_OBJECTIVE_CHARS;
|
|
376
|
+
}
|
|
377
|
+
function boundedText(value, limit, label) {
|
|
378
|
+
if ([...value].length > limit)
|
|
379
|
+
throw new Error(`${label} must be at most ${limit} characters`);
|
|
380
|
+
const trimmed = value.trim();
|
|
381
|
+
if (!trimmed)
|
|
384
382
|
throw new Error(`${label} must not be empty`);
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
383
|
+
return trimmed;
|
|
384
|
+
}
|
|
385
|
+
function validateObjective(objective, limit = DEFAULT_MAX_OBJECTIVE_CHARS) {
|
|
386
|
+
return boundedText(objective, limit, "goal objective");
|
|
387
|
+
}
|
|
388
|
+
function validateEvidence(evidence, label, limit = DEFAULT_MAX_OBJECTIVE_CHARS) {
|
|
389
|
+
return boundedText(evidence ?? "", limit, label);
|
|
388
390
|
}
|
|
389
391
|
function normalizeState(state) {
|
|
390
392
|
for (const goal of Object.values(state.goals))
|
|
@@ -460,7 +462,8 @@ function normalizeCreateOptions(input) {
|
|
|
460
462
|
noProgressTokenThreshold: DEFAULT_NO_PROGRESS_TOKEN_THRESHOLD,
|
|
461
463
|
maxNoProgressTurns: DEFAULT_MAX_NO_PROGRESS_TURNS,
|
|
462
464
|
agent: null,
|
|
463
|
-
initialStatus: "active"
|
|
465
|
+
initialStatus: "active",
|
|
466
|
+
maxObjectiveChars: DEFAULT_MAX_OBJECTIVE_CHARS
|
|
464
467
|
};
|
|
465
468
|
}
|
|
466
469
|
return {
|
|
@@ -470,7 +473,8 @@ function normalizeCreateOptions(input) {
|
|
|
470
473
|
noProgressTokenThreshold: positiveIntegerOrNull(input?.noProgressTokenThreshold) ?? DEFAULT_NO_PROGRESS_TOKEN_THRESHOLD,
|
|
471
474
|
maxNoProgressTurns: positiveIntegerOrNull(input?.maxNoProgressTurns) ?? DEFAULT_MAX_NO_PROGRESS_TURNS,
|
|
472
475
|
agent: typeof input?.agent === "string" && input.agent.trim() ? input.agent.trim() : null,
|
|
473
|
-
initialStatus: input?.initialStatus === "paused" ? "paused" : "active"
|
|
476
|
+
initialStatus: input?.initialStatus === "paused" ? "paused" : "active",
|
|
477
|
+
maxObjectiveChars: resolveMaxObjectiveChars(input?.maxObjectiveChars)
|
|
474
478
|
};
|
|
475
479
|
}
|
|
476
480
|
function positiveIntegerOrNull(value) {
|
|
@@ -570,8 +574,8 @@ async function getGoalInternal(sessionID) {
|
|
|
570
574
|
return goal ? snapshotInternal(goal) : null;
|
|
571
575
|
}
|
|
572
576
|
async function createGoal(sessionID, objective, options) {
|
|
573
|
-
const value = validateObjective(objective);
|
|
574
577
|
const normalizedOptions = normalizeCreateOptions(options);
|
|
578
|
+
const value = validateObjective(objective, resolveMaxObjectiveChars(normalizedOptions.maxObjectiveChars));
|
|
575
579
|
return mutate((state) => {
|
|
576
580
|
const existing = state.goals[sessionID];
|
|
577
581
|
if (existing && !isClosed(existing.status)) {
|
|
@@ -623,7 +627,7 @@ async function createGoal(sessionID, objective, options) {
|
|
|
623
627
|
});
|
|
624
628
|
}
|
|
625
629
|
async function updateGoalObjective(sessionID, objective, status = "active", options) {
|
|
626
|
-
const value = validateObjective(objective);
|
|
630
|
+
const value = validateObjective(objective, resolveMaxObjectiveChars(options?.maxObjectiveChars));
|
|
627
631
|
const agent = typeof options?.agent === "string" && options.agent.trim() ? options.agent.trim() : null;
|
|
628
632
|
const planModePause = options?.planModePause === true;
|
|
629
633
|
return mutate((state) => {
|
|
@@ -714,7 +718,8 @@ async function setGoalStatus(sessionID, status, agent) {
|
|
|
714
718
|
return snapshot(goal);
|
|
715
719
|
});
|
|
716
720
|
}
|
|
717
|
-
async function closeGoal(sessionID, input) {
|
|
721
|
+
async function closeGoal(sessionID, input, maxObjectiveChars = DEFAULT_MAX_OBJECTIVE_CHARS) {
|
|
722
|
+
const limit = resolveMaxObjectiveChars(maxObjectiveChars);
|
|
718
723
|
return mutate((state) => {
|
|
719
724
|
const goal = state.goals[sessionID];
|
|
720
725
|
if (!goal)
|
|
@@ -727,12 +732,12 @@ async function closeGoal(sessionID, input) {
|
|
|
727
732
|
goal.lastAccountedAt = null;
|
|
728
733
|
goal.stopReason = input.status === "complete" ? null : "blocked";
|
|
729
734
|
if (input.status === "complete") {
|
|
730
|
-
goal.completionEvidence = validateEvidence(input.evidence, "completion evidence");
|
|
735
|
+
goal.completionEvidence = validateEvidence(input.evidence, "completion evidence", limit);
|
|
731
736
|
goal.blocker = null;
|
|
732
737
|
goal.lastStatus = "Goal completed.";
|
|
733
738
|
pushHistory(goal, "completed", goal.completionEvidence);
|
|
734
739
|
} else {
|
|
735
|
-
goal.blocker = validateEvidence(input.blocker, "blocker");
|
|
740
|
+
goal.blocker = validateEvidence(input.blocker, "blocker", limit);
|
|
736
741
|
goal.completionEvidence = null;
|
|
737
742
|
goal.lastStatus = "Goal marked unmet.";
|
|
738
743
|
pushHistory(goal, "unmet", goal.blocker);
|
|
@@ -740,11 +745,11 @@ async function closeGoal(sessionID, input) {
|
|
|
740
745
|
return snapshot(goal);
|
|
741
746
|
});
|
|
742
747
|
}
|
|
743
|
-
async function completeGoal(sessionID, evidence) {
|
|
744
|
-
return closeGoal(sessionID, { status: "complete", evidence });
|
|
748
|
+
async function completeGoal(sessionID, evidence, maxObjectiveChars = DEFAULT_MAX_OBJECTIVE_CHARS) {
|
|
749
|
+
return closeGoal(sessionID, { status: "complete", evidence }, maxObjectiveChars);
|
|
745
750
|
}
|
|
746
|
-
async function markGoalUnmet(sessionID, blocker) {
|
|
747
|
-
return closeGoal(sessionID, { status: "unmet", blocker });
|
|
751
|
+
async function markGoalUnmet(sessionID, blocker, maxObjectiveChars = DEFAULT_MAX_OBJECTIVE_CHARS) {
|
|
752
|
+
return closeGoal(sessionID, { status: "unmet", blocker }, maxObjectiveChars);
|
|
748
753
|
}
|
|
749
754
|
async function clearGoal(sessionID) {
|
|
750
755
|
return mutate((state) => {
|
|
@@ -1269,6 +1274,15 @@ function restrictedAgentSet(options) {
|
|
|
1269
1274
|
return new Set(names.map((name) => typeof name === "string" ? name.trim().toLowerCase() : "").filter(Boolean));
|
|
1270
1275
|
}
|
|
1271
1276
|
function goalCommandTemplate(commandName) {
|
|
1277
|
+
const createGuidance = [
|
|
1278
|
+
"Otherwise, call get_goal first.",
|
|
1279
|
+
"If it returns a non-closed goal with the same objective, do not create it again; " + "continue working from the returned state.",
|
|
1280
|
+
"If it returns a different non-closed goal, report that conflict instead of replacing it.",
|
|
1281
|
+
"Only when there is no non-closed goal, call create_goal once.",
|
|
1282
|
+
"Build the objective as a complete, faithful representation of the arguments: keep every requirement, constraint, " + "scope boundary, and success criterion with no omissions or loss of meaning.",
|
|
1283
|
+
"You may restructure and rephrase for clarity and coherence, but do NOT compress, truncate, or drop any content, " + "and do NOT substitute the content with references or pointers to external files.",
|
|
1284
|
+
"If the user includes explicit budget instructions, pass token_budget, max_auto_turns, or max_duration_seconds to " + "create_goal rather than leaving those words in the objective."
|
|
1285
|
+
].join(" ");
|
|
1272
1286
|
return `OpenCode goal mode command "/${commandName}" was invoked.
|
|
1273
1287
|
|
|
1274
1288
|
Arguments:
|
|
@@ -1287,7 +1301,7 @@ Use the goal tools to handle this command:
|
|
|
1287
1301
|
- If the arguments start with "edit ", update the current goal objective by calling update_goal_objective with the remaining text.
|
|
1288
1302
|
- If the arguments start with "complete " or "done ", perform a completion audit against real artifacts and command output. Call update_goal with status "complete" only if the goal is achieved, using concise evidence from the audit.
|
|
1289
1303
|
- If the arguments start with "unmet ", "blocked ", or "blocker ", call update_goal with status "unmet" only when the goal cannot be achieved or needs external input, using the remaining arguments as the blocker.
|
|
1290
|
-
-
|
|
1304
|
+
- ${createGuidance}
|
|
1291
1305
|
|
|
1292
1306
|
Create a goal only from these explicit command arguments. Do not infer a goal from unrelated session context. After create_goal succeeds or returns an existing matching goal, never call it again for this command; continue working from the returned goal state.`;
|
|
1293
1307
|
}
|
|
@@ -1949,9 +1963,24 @@ function getGoalToolResult(goal) {
|
|
|
1949
1963
|
}
|
|
1950
1964
|
return JSON.stringify(result, null, 2);
|
|
1951
1965
|
}
|
|
1966
|
+
function boundedGoalTextSchema(limit, description, validate) {
|
|
1967
|
+
return z.string().superRefine((value, ctx) => {
|
|
1968
|
+
try {
|
|
1969
|
+
validate(value);
|
|
1970
|
+
} catch (error) {
|
|
1971
|
+
ctx.addIssue({
|
|
1972
|
+
code: "custom",
|
|
1973
|
+
message: error instanceof Error ? error.message : String(error)
|
|
1974
|
+
});
|
|
1975
|
+
}
|
|
1976
|
+
}).meta({ minLength: 1, maxLength: limit, pattern: "\\S", description });
|
|
1977
|
+
}
|
|
1978
|
+
function v2GoalTextSchema(limit, description) {
|
|
1979
|
+
return { type: "string", minLength: 1, maxLength: limit, pattern: "\\S", description };
|
|
1980
|
+
}
|
|
1952
1981
|
async function createGoalFromTool(input, context, services) {
|
|
1953
1982
|
const planningOnly = services.isPlanAgent(context.agent);
|
|
1954
|
-
const objective = validateObjective(input.objective);
|
|
1983
|
+
const objective = validateObjective(input.objective, services.maxObjectiveChars);
|
|
1955
1984
|
const existing = await getGoal(context.sessionID);
|
|
1956
1985
|
if (existing && !isClosedGoal(existing))
|
|
1957
1986
|
return existingGoalResult(existing, objective, planningOnly);
|
|
@@ -1964,7 +1993,8 @@ async function createGoalFromTool(input, context, services) {
|
|
|
1964
1993
|
noProgressTokenThreshold: services.options.no_progress_token_threshold ?? null,
|
|
1965
1994
|
maxNoProgressTurns: services.options.max_no_progress_turns ?? null,
|
|
1966
1995
|
agent: typeof context.agent === "string" ? context.agent : null,
|
|
1967
|
-
initialStatus: planningOnly ? "paused" : "active"
|
|
1996
|
+
initialStatus: planningOnly ? "paused" : "active",
|
|
1997
|
+
maxObjectiveChars: services.maxObjectiveChars
|
|
1968
1998
|
});
|
|
1969
1999
|
} catch (error) {
|
|
1970
2000
|
if (!(error instanceof Error) || !error.message.includes("non-closed goal"))
|
|
@@ -1994,18 +2024,19 @@ async function updateGoalObjectiveFromTool(input, context, services) {
|
|
|
1994
2024
|
const planningOnly = requested === "active" && services.isPlanAgent(context.agent);
|
|
1995
2025
|
const goal = await updateGoalObjective(context.sessionID, input.objective, planningOnly ? "paused" : requested, {
|
|
1996
2026
|
agent: typeof context.agent === "string" ? context.agent : null,
|
|
1997
|
-
planModePause: planningOnly
|
|
2027
|
+
planModePause: planningOnly,
|
|
2028
|
+
maxObjectiveChars: services.maxObjectiveChars
|
|
1998
2029
|
});
|
|
1999
2030
|
return JSON.stringify(planningOnly ? { goal, plan_mode_notice: PLAN_MODE_CREATE_NOTICE } : { goal }, null, 2);
|
|
2000
2031
|
}
|
|
2001
|
-
async function closeGoalFromTool(input, context) {
|
|
2032
|
+
async function closeGoalFromTool(input, context, services) {
|
|
2002
2033
|
if (input.status === "complete") {
|
|
2003
|
-
const goal = await completeGoal(context.sessionID, input.evidence ?? "");
|
|
2034
|
+
const goal = await completeGoal(context.sessionID, input.evidence ?? "", services.maxObjectiveChars);
|
|
2004
2035
|
const budget = goal.tokenBudget == null ? "" : ` Token usage: ${goal.tokensUsed}/${goal.tokenBudget}.`;
|
|
2005
2036
|
const report = `Goal achieved. Time used: ${goal.timeUsedSeconds} seconds.${budget} Evidence: ${goal.completionEvidence}.`;
|
|
2006
2037
|
return JSON.stringify({ goal, completion_report: report }, null, 2);
|
|
2007
2038
|
}
|
|
2008
|
-
const goal = await markGoalUnmet(context.sessionID, input.blocker ?? "");
|
|
2039
|
+
const goal = await markGoalUnmet(context.sessionID, input.blocker ?? "", services.maxObjectiveChars);
|
|
2009
2040
|
const report = `Goal unmet. Time used: ${goal.timeUsedSeconds} seconds. Blocker: ${goal.blocker}.`;
|
|
2010
2041
|
return JSON.stringify({ goal, unmet_report: report }, null, 2);
|
|
2011
2042
|
}
|
|
@@ -2069,6 +2100,7 @@ var server = async ({ client }, options) => {
|
|
|
2069
2100
|
const maxPromptFailures = positiveIntegerOrNull2(options?.max_prompt_failures) ?? DEFAULT_MAX_PROMPT_FAILURES;
|
|
2070
2101
|
const registerCommand = options?.register_command ?? true;
|
|
2071
2102
|
const commandName = commandNameFromOptions(options);
|
|
2103
|
+
const objectiveChars = resolveMaxObjectiveChars(options?.max_objective_chars);
|
|
2072
2104
|
const taskTracker = new TaskTracker;
|
|
2073
2105
|
const taskDeferredSessions = new Set;
|
|
2074
2106
|
const scheduledContinuations = new Map;
|
|
@@ -2080,7 +2112,7 @@ var server = async ({ client }, options) => {
|
|
|
2080
2112
|
const watchdogRescuedSessions = new Set;
|
|
2081
2113
|
const planAgents = restrictedAgentSet(options);
|
|
2082
2114
|
const isPlanAgent = (agent) => typeof agent === "string" && planAgents.has(agent.trim().toLowerCase());
|
|
2083
|
-
const goalServices = { options: options ?? {}, isPlanAgent };
|
|
2115
|
+
const goalServices = { options: options ?? {}, isPlanAgent, maxObjectiveChars: objectiveChars };
|
|
2084
2116
|
const stopStateRecoveryReporting = onStateRecovery(statePath(), async ({ stateFile, quarantineFile, outcome, error }) => {
|
|
2085
2117
|
await client.app?.log?.({
|
|
2086
2118
|
body: {
|
|
@@ -2382,7 +2414,7 @@ var server = async ({ client }, options) => {
|
|
|
2382
2414
|
create_goal: {
|
|
2383
2415
|
description: "Create a goal only when explicitly requested by the user or system/developer instructions; do not infer goals from ordinary tasks. If any non-closed goal exists, this returns the existing goal as either reused or conflicting and must not be retried. While the session is in Plan mode, the goal is recorded as paused and execution requires the user to switch to Build mode.",
|
|
2384
2416
|
args: {
|
|
2385
|
-
objective:
|
|
2417
|
+
objective: boundedGoalTextSchema(objectiveChars, "The concrete objective to start pursuing.", (value) => validateObjective(value, objectiveChars)),
|
|
2386
2418
|
token_budget: z.number().int().positive().nullable().optional().describe("Optional positive token budget."),
|
|
2387
2419
|
max_auto_turns: z.number().int().positive().nullable().optional().describe("Optional per-goal auto-continue limit."),
|
|
2388
2420
|
max_duration_seconds: z.number().int().positive().nullable().optional().describe("Optional per-goal duration limit.")
|
|
@@ -2394,7 +2426,7 @@ var server = async ({ client }, options) => {
|
|
|
2394
2426
|
set_goal: {
|
|
2395
2427
|
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. If any non-closed goal exists, this returns the existing goal as either reused or conflicting and must not be retried. While the session is in Plan mode, the goal is recorded as paused and execution requires the user to switch to Build mode.",
|
|
2396
2428
|
args: {
|
|
2397
|
-
objective:
|
|
2429
|
+
objective: boundedGoalTextSchema(objectiveChars, "The model-formulated concrete objective to start pursuing.", (value) => validateObjective(value, objectiveChars)),
|
|
2398
2430
|
token_budget: z.number().int().positive().nullable().optional().describe("Optional positive token budget."),
|
|
2399
2431
|
max_auto_turns: z.number().int().positive().nullable().optional().describe("Optional per-goal auto-continue limit."),
|
|
2400
2432
|
max_duration_seconds: z.number().int().positive().nullable().optional().describe("Optional per-goal duration limit.")
|
|
@@ -2406,7 +2438,7 @@ var server = async ({ client }, options) => {
|
|
|
2406
2438
|
update_goal_objective: {
|
|
2407
2439
|
description: "Edit the current OpenCode goal objective when the user explicitly asks to edit or replace it.",
|
|
2408
2440
|
args: {
|
|
2409
|
-
objective:
|
|
2441
|
+
objective: boundedGoalTextSchema(objectiveChars, "The updated concrete objective.", (value) => validateObjective(value, objectiveChars)),
|
|
2410
2442
|
status: z.enum(["active", "paused"]).optional().describe("Whether the edited goal should be active or paused.")
|
|
2411
2443
|
},
|
|
2412
2444
|
async execute(args, context) {
|
|
@@ -2417,11 +2449,11 @@ var server = async ({ client }, options) => {
|
|
|
2417
2449
|
description: "Close the existing goal only after an audit against real evidence. Use status complete only when the objective is achieved and no required work remains, and include evidence. Use status unmet only when the objective cannot be achieved or is blocked, and include the blocker. Do not close a goal merely because work is stopping.",
|
|
2418
2450
|
args: {
|
|
2419
2451
|
status: z.enum(["complete", "unmet"]).describe("Required. complete means achieved; unmet means blocked or impossible."),
|
|
2420
|
-
evidence:
|
|
2421
|
-
blocker:
|
|
2452
|
+
evidence: boundedGoalTextSchema(objectiveChars, "Required when status is complete. Summarize the concrete evidence verified.", (value) => validateEvidence(value, "completion evidence", objectiveChars)).optional(),
|
|
2453
|
+
blocker: boundedGoalTextSchema(objectiveChars, "Required when status is unmet. Explain the concrete blocker or impossibility.", (value) => validateEvidence(value, "blocker", objectiveChars)).optional()
|
|
2422
2454
|
},
|
|
2423
2455
|
async execute(args, context) {
|
|
2424
|
-
return closeGoalFromTool(args, context);
|
|
2456
|
+
return closeGoalFromTool(args, context, goalServices);
|
|
2425
2457
|
}
|
|
2426
2458
|
},
|
|
2427
2459
|
update_goal_status: {
|
|
@@ -2642,6 +2674,7 @@ async function setupV2(context) {
|
|
|
2642
2674
|
const maxPromptFailures = positiveIntegerOrNull2(options.max_prompt_failures) ?? DEFAULT_MAX_PROMPT_FAILURES;
|
|
2643
2675
|
const registerCommand = options.register_command ?? true;
|
|
2644
2676
|
const commandName = commandNameFromOptions(options);
|
|
2677
|
+
const objectiveChars = resolveMaxObjectiveChars(options.max_objective_chars);
|
|
2645
2678
|
const taskTracker = new TaskTracker;
|
|
2646
2679
|
const taskDeferredSessions = new Set;
|
|
2647
2680
|
const scheduledContinuations = new Map;
|
|
@@ -2654,11 +2687,13 @@ async function setupV2(context) {
|
|
|
2654
2687
|
const planAgents = restrictedAgentSet(options);
|
|
2655
2688
|
const isPlanAgent = (agent) => typeof agent === "string" && planAgents.has(agent.trim().toLowerCase());
|
|
2656
2689
|
const activeContinuationsV2 = new Set;
|
|
2690
|
+
const stoppedExecutions = new Set;
|
|
2657
2691
|
const latestStepBySession = new Map;
|
|
2658
2692
|
const stepTextBuffers = new Map;
|
|
2659
2693
|
const stepTokenSums = new Map;
|
|
2660
2694
|
const goalServices = {
|
|
2661
2695
|
options,
|
|
2696
|
+
maxObjectiveChars: objectiveChars,
|
|
2662
2697
|
isPlanAgent,
|
|
2663
2698
|
initializeUsage: async (sessionID) => {
|
|
2664
2699
|
try {
|
|
@@ -2799,6 +2834,8 @@ async function setupV2(context) {
|
|
|
2799
2834
|
async function runAutoContinue(sessionID, fromTaskDeferral = false, scheduled) {
|
|
2800
2835
|
if (disposed)
|
|
2801
2836
|
return;
|
|
2837
|
+
if (stoppedExecutions.has(sessionID))
|
|
2838
|
+
return;
|
|
2802
2839
|
if (busySessions.has(sessionID))
|
|
2803
2840
|
return;
|
|
2804
2841
|
if (activeContinuationsV2.has(sessionID))
|
|
@@ -2879,8 +2916,13 @@ async function setupV2(context) {
|
|
|
2879
2916
|
if (nativeRetrySessions.has(sessionID))
|
|
2880
2917
|
return;
|
|
2881
2918
|
const goal = await reserveContinuation(sessionID, maxAutoTurns, minInterval);
|
|
2882
|
-
if (!goal)
|
|
2919
|
+
if (!goal) {
|
|
2920
|
+
const waiting = await getGoalInternal(sessionID);
|
|
2921
|
+
if (waiting?.status === "active" && waiting.pendingAttempt == null && waiting.lastContinuationAt != null && minInterval > 0) {
|
|
2922
|
+
scheduleSettledContinuation(sessionID, continuationDelayFromSnapshot(minInterval, waiting.lastContinuationAt), scheduled != null);
|
|
2923
|
+
}
|
|
2883
2924
|
return;
|
|
2925
|
+
}
|
|
2884
2926
|
attemptReservedAt = goal.pendingAttempt?.reservedAt ?? Date.now();
|
|
2885
2927
|
if (nativeRetrySessions.has(sessionID)) {
|
|
2886
2928
|
await rollbackContinuationAttempt(sessionID);
|
|
@@ -2921,6 +2963,23 @@ async function setupV2(context) {
|
|
|
2921
2963
|
async function handleV2Event(event) {
|
|
2922
2964
|
const data = event.data;
|
|
2923
2965
|
const sessionID = typeof data.sessionID === "string" ? data.sessionID : undefined;
|
|
2966
|
+
if (context.location && event.location && (event.location.directory !== context.location.directory || event.location.workspaceID !== context.location.workspaceID)) {
|
|
2967
|
+
if (event.type === "session.created" && sessionID && typeof data.parentID === "string") {
|
|
2968
|
+
taskTracker.observeSessionCreated({ properties: { info: { id: sessionID, parentID: data.parentID } } });
|
|
2969
|
+
} else if (sessionID) {
|
|
2970
|
+
if (event.type === "session.execution.started")
|
|
2971
|
+
taskTracker.observeSessionStatus(sessionID, "busy");
|
|
2972
|
+
if (["session.execution.succeeded", "session.execution.failed", "session.execution.interrupted", "session.idle"].includes(event.type)) {
|
|
2973
|
+
taskTracker.observeSessionStatus(sessionID, "idle");
|
|
2974
|
+
}
|
|
2975
|
+
if (event.type === "session.status" && isRecord(data.status) && typeof data.status.type === "string") {
|
|
2976
|
+
taskTracker.observeSessionStatus(sessionID, data.status.type);
|
|
2977
|
+
}
|
|
2978
|
+
if (event.type === "session.deleted")
|
|
2979
|
+
taskTracker.observeSessionDeleted(sessionID);
|
|
2980
|
+
}
|
|
2981
|
+
return;
|
|
2982
|
+
}
|
|
2924
2983
|
switch (event.type) {
|
|
2925
2984
|
case "session.created": {
|
|
2926
2985
|
const parentID = data.parentID;
|
|
@@ -2929,10 +2988,13 @@ async function setupV2(context) {
|
|
|
2929
2988
|
}
|
|
2930
2989
|
return;
|
|
2931
2990
|
}
|
|
2991
|
+
case "session.execution.started":
|
|
2992
|
+
case "session.retry.scheduled":
|
|
2932
2993
|
case "session.status": {
|
|
2933
|
-
const status = data.status;
|
|
2994
|
+
const status = event.type === "session.execution.started" ? { type: "busy" } : event.type === "session.retry.scheduled" ? { type: "retry" } : data.status;
|
|
2934
2995
|
if (sessionID && isRecord(status) && typeof status.type === "string") {
|
|
2935
2996
|
if (status.type === "busy") {
|
|
2997
|
+
stoppedExecutions.delete(sessionID);
|
|
2936
2998
|
busySessions.add(sessionID);
|
|
2937
2999
|
nativeRetrySessions.delete(sessionID);
|
|
2938
3000
|
armTurnWatchdog(sessionID);
|
|
@@ -2958,6 +3020,7 @@ async function setupV2(context) {
|
|
|
2958
3020
|
}
|
|
2959
3021
|
return;
|
|
2960
3022
|
}
|
|
3023
|
+
case "session.execution.succeeded":
|
|
2961
3024
|
case "session.idle": {
|
|
2962
3025
|
if (sessionID) {
|
|
2963
3026
|
busySessions.delete(sessionID);
|
|
@@ -2973,9 +3036,25 @@ async function setupV2(context) {
|
|
|
2973
3036
|
}
|
|
2974
3037
|
return;
|
|
2975
3038
|
}
|
|
3039
|
+
case "session.execution.interrupted": {
|
|
3040
|
+
if (!sessionID)
|
|
3041
|
+
return;
|
|
3042
|
+
stoppedExecutions.add(sessionID);
|
|
3043
|
+
busySessions.delete(sessionID);
|
|
3044
|
+
nativeRetrySessions.delete(sessionID);
|
|
3045
|
+
clearTurnWatchdog(sessionID);
|
|
3046
|
+
watchdogRescuedSessions.delete(sessionID);
|
|
3047
|
+
cancelScheduledContinuation(sessionID);
|
|
3048
|
+
taskDeferredSessions.delete(sessionID);
|
|
3049
|
+
taskTracker.observeSessionStatus(sessionID, "idle");
|
|
3050
|
+
return;
|
|
3051
|
+
}
|
|
3052
|
+
case "session.execution.failed":
|
|
2976
3053
|
case "session.error": {
|
|
2977
3054
|
if (!sessionID)
|
|
2978
3055
|
return;
|
|
3056
|
+
if (event.type === "session.execution.failed")
|
|
3057
|
+
nativeRetrySessions.delete(sessionID);
|
|
2979
3058
|
const inNativeRetry = nativeRetrySessions.has(sessionID);
|
|
2980
3059
|
busySessions.delete(sessionID);
|
|
2981
3060
|
clearTurnWatchdog(sessionID);
|
|
@@ -2984,6 +3063,13 @@ async function setupV2(context) {
|
|
|
2984
3063
|
nativeRetrySessions.delete(sessionID);
|
|
2985
3064
|
watchdogRescuedSessions.delete(sessionID);
|
|
2986
3065
|
const errorMessage = transportErrorMessageFromEvent(data);
|
|
3066
|
+
if (event.type === "session.execution.failed" && !isTransportError(errorMessage)) {
|
|
3067
|
+
stoppedExecutions.add(sessionID);
|
|
3068
|
+
cancelScheduledContinuation(sessionID);
|
|
3069
|
+
taskDeferredSessions.delete(sessionID);
|
|
3070
|
+
}
|
|
3071
|
+
if (event.type === "session.execution.failed")
|
|
3072
|
+
taskTracker.observeSessionStatus(sessionID, "idle");
|
|
2987
3073
|
if (errorMessage && isTransportError(errorMessage)) {
|
|
2988
3074
|
const goal = await getGoalInternal(sessionID);
|
|
2989
3075
|
if (goal?.status === "active") {
|
|
@@ -3007,6 +3093,7 @@ async function setupV2(context) {
|
|
|
3007
3093
|
case "session.deleted": {
|
|
3008
3094
|
if (!sessionID)
|
|
3009
3095
|
return;
|
|
3096
|
+
stoppedExecutions.delete(sessionID);
|
|
3010
3097
|
busySessions.delete(sessionID);
|
|
3011
3098
|
clearTurnWatchdog(sessionID);
|
|
3012
3099
|
watchdogRescuedSessions.delete(sessionID);
|
|
@@ -3283,6 +3370,7 @@ async function setupV2(context) {
|
|
|
3283
3370
|
clearTimeout(watchdog.timer);
|
|
3284
3371
|
turnWatchdogs.clear();
|
|
3285
3372
|
activeContinuationsV2.clear();
|
|
3373
|
+
stoppedExecutions.clear();
|
|
3286
3374
|
nativeRetrySessions.clear();
|
|
3287
3375
|
locallyDeliveredPendingSessions.clear();
|
|
3288
3376
|
watchdogRescuedSessions.clear();
|
|
@@ -3327,7 +3415,7 @@ function goalToolsV2(services) {
|
|
|
3327
3415
|
name: "create_goal",
|
|
3328
3416
|
description: "Create a goal only when explicitly requested by the user or system/developer instructions; do not infer goals from ordinary tasks. If any non-closed goal exists, this returns the existing goal as either reused or conflicting and must not be retried. While the session is in Plan mode, the goal is recorded as paused and execution requires the user to switch to Build mode.",
|
|
3329
3417
|
input: v2ObjectSchema({
|
|
3330
|
-
objective:
|
|
3418
|
+
objective: v2GoalTextSchema(services.maxObjectiveChars, "The concrete objective to start pursuing."),
|
|
3331
3419
|
token_budget: { type: ["integer", "null"], minimum: 1, description: "Optional positive token budget." },
|
|
3332
3420
|
max_auto_turns: { type: ["integer", "null"], minimum: 1, description: "Optional per-goal auto-continue limit." },
|
|
3333
3421
|
max_duration_seconds: { type: ["integer", "null"], minimum: 1, description: "Optional per-goal duration limit." }
|
|
@@ -3341,12 +3429,7 @@ function goalToolsV2(services) {
|
|
|
3341
3429
|
name: "set_goal",
|
|
3342
3430
|
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. If any non-closed goal exists, this returns the existing goal as either reused or conflicting and must not be retried. While the session is in Plan mode, the goal is recorded as paused and execution requires the user to switch to Build mode.",
|
|
3343
3431
|
input: v2ObjectSchema({
|
|
3344
|
-
objective:
|
|
3345
|
-
type: "string",
|
|
3346
|
-
minLength: 1,
|
|
3347
|
-
maxLength: 4000,
|
|
3348
|
-
description: "The model-formulated concrete objective to start pursuing."
|
|
3349
|
-
},
|
|
3432
|
+
objective: v2GoalTextSchema(services.maxObjectiveChars, "The model-formulated concrete objective to start pursuing."),
|
|
3350
3433
|
token_budget: { type: ["integer", "null"], minimum: 1, description: "Optional positive token budget." },
|
|
3351
3434
|
max_auto_turns: { type: ["integer", "null"], minimum: 1, description: "Optional per-goal auto-continue limit." },
|
|
3352
3435
|
max_duration_seconds: { type: ["integer", "null"], minimum: 1, description: "Optional per-goal duration limit." }
|
|
@@ -3360,7 +3443,7 @@ function goalToolsV2(services) {
|
|
|
3360
3443
|
name: "update_goal_objective",
|
|
3361
3444
|
description: "Edit the current OpenCode goal objective when the user explicitly asks to edit or replace it.",
|
|
3362
3445
|
input: v2ObjectSchema({
|
|
3363
|
-
objective:
|
|
3446
|
+
objective: v2GoalTextSchema(services.maxObjectiveChars, "The updated concrete objective."),
|
|
3364
3447
|
status: { type: "string", enum: ["active", "paused"], description: "Whether the edited goal should be active or paused." }
|
|
3365
3448
|
}, ["objective"]),
|
|
3366
3449
|
options: { codemode: false },
|
|
@@ -3377,22 +3460,12 @@ function goalToolsV2(services) {
|
|
|
3377
3460
|
enum: ["complete", "unmet"],
|
|
3378
3461
|
description: "Required. complete means achieved; unmet means blocked or impossible."
|
|
3379
3462
|
},
|
|
3380
|
-
evidence:
|
|
3381
|
-
|
|
3382
|
-
minLength: 1,
|
|
3383
|
-
maxLength: 4000,
|
|
3384
|
-
description: "Required when status is complete. Summarize the concrete evidence verified."
|
|
3385
|
-
},
|
|
3386
|
-
blocker: {
|
|
3387
|
-
type: "string",
|
|
3388
|
-
minLength: 1,
|
|
3389
|
-
maxLength: 4000,
|
|
3390
|
-
description: "Required when status is unmet. Explain the concrete blocker or impossibility."
|
|
3391
|
-
}
|
|
3463
|
+
evidence: v2GoalTextSchema(services.maxObjectiveChars, "Required when status is complete. Summarize the concrete evidence verified."),
|
|
3464
|
+
blocker: v2GoalTextSchema(services.maxObjectiveChars, "Required when status is unmet. Explain the concrete blocker or impossibility.")
|
|
3392
3465
|
}, ["status"]),
|
|
3393
3466
|
options: { codemode: false },
|
|
3394
3467
|
execute: async (args, toolContext) => ({
|
|
3395
|
-
content: await closeGoalFromTool(args, toolContext)
|
|
3468
|
+
content: await closeGoalFromTool(args, toolContext, services)
|
|
3396
3469
|
})
|
|
3397
3470
|
},
|
|
3398
3471
|
{
|
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.46",
|
|
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",
|
|
@@ -49,6 +49,7 @@
|
|
|
49
49
|
"lint": "eslint .",
|
|
50
50
|
"pack:dry-run": "npm pack --dry-run",
|
|
51
51
|
"test": "bun test",
|
|
52
|
+
"smoke:v2": "bun scripts/smoke-v2-lifecycle.ts",
|
|
52
53
|
"test:coverage": "bun test --coverage",
|
|
53
54
|
"typecheck": "tsc --noEmit",
|
|
54
55
|
"prepublishOnly": "bun run test && bun run build"
|