@prevalentware/opencode-goal-plugin 0.1.19 → 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.
Files changed (3) hide show
  1. package/README.md +18 -1
  2. package/dist/server.js +158 -44
  3. 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
  ]
@@ -109,6 +112,8 @@ Defaults:
109
112
  - `max_no_progress_turns`: `2`
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
 
@@ -137,6 +142,18 @@ The plugin also uses safety states while keeping the goal available for review o
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,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: "active",
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
- goal.lastStatus = status === "active" ? "Goal objective updated and resumed." : "Goal objective updated and paused.";
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();
314
348
  return snapshot(goal);
315
349
  });
316
350
  }
317
- async function setGoalStatus(sessionID, status) {
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);
364
+ return snapshot(goal);
365
+ });
366
+ }
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 systemReminder(goal) {
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,11 +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";
704
772
  var TASK_SETTLE_DELAY_MS = 25;
705
773
  var SNAPSHOT_IDLE_HOLD_MS = 250;
706
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.';
707
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
+ }
708
783
  function goalCommandTemplate(commandName) {
709
784
  return `OpenCode goal mode command "/${commandName}" was invoked.
710
785
 
@@ -869,10 +944,25 @@ function assistantMarker(message) {
869
944
  completedAt: messageCompletedAt(message)
870
945
  };
871
946
  }
872
- async function sendContinuation(client, sessionID, prompt) {
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) {
873
962
  await client.session.promptAsync({
874
963
  path: { id: sessionID },
875
964
  body: {
965
+ ...agent ? { agent } : {},
876
966
  parts: [{ type: "text", text: prompt }]
877
967
  }
878
968
  });
@@ -1185,6 +1275,21 @@ var server = async ({ client }, options) => {
1185
1275
  const taskDeferredSessions = new Set;
1186
1276
  const scheduledContinuations = new Map;
1187
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
+ }
1188
1293
  async function taskBlockStatus(sessionID) {
1189
1294
  if (!deferWhileTasksActive)
1190
1295
  return false;
@@ -1225,6 +1330,15 @@ var server = async ({ client }, options) => {
1225
1330
  if (busySessions.has(sessionID))
1226
1331
  return;
1227
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
+ }
1228
1342
  if (!fromTaskDeferral && taskDeferredSessions.has(sessionID)) {
1229
1343
  scheduleSettledContinuation(sessionID);
1230
1344
  return;
@@ -1233,7 +1347,7 @@ var server = async ({ client }, options) => {
1233
1347
  const goal = await reserveContinuation(sessionID, maxAutoTurns, minInterval);
1234
1348
  if (!goal)
1235
1349
  return;
1236
- await sendContinuation(client, sessionID, goal.status === "active" ? continuationPrompt(goal) : limitPrompt(goal));
1350
+ await sendContinuation(client, sessionID, goal.status === "active" ? continuationPrompt(goal) : limitPrompt(goal), goal.lastPromptAgent ?? latestTurnAgent ?? null);
1237
1351
  await recordContinuationResult(sessionID, "success", maxPromptFailures);
1238
1352
  } catch (error) {
1239
1353
  await recordContinuationResult(sessionID, "failure", maxPromptFailures);
@@ -1277,7 +1391,7 @@ var server = async ({ client }, options) => {
1277
1391
  }
1278
1392
  },
1279
1393
  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.",
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.",
1281
1395
  args: {
1282
1396
  objective: z.string().min(1).max(4000).describe("The concrete objective to start pursuing."),
1283
1397
  token_budget: z.number().int().positive().nullable().optional().describe("Optional positive token budget."),
@@ -1285,19 +1399,11 @@ var server = async ({ client }, options) => {
1285
1399
  max_duration_seconds: z.number().int().positive().nullable().optional().describe("Optional per-goal duration limit.")
1286
1400
  },
1287
1401
  async execute(args, context) {
1288
- const input = args;
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);
1402
+ return createGoalFromTool(args, context);
1297
1403
  }
1298
1404
  },
1299
1405
  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.",
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.",
1301
1407
  args: {
1302
1408
  objective: z.string().min(1).max(4000).describe("The model-formulated concrete objective to start pursuing."),
1303
1409
  token_budget: z.number().int().positive().nullable().optional().describe("Optional positive token budget."),
@@ -1305,15 +1411,7 @@ var server = async ({ client }, options) => {
1305
1411
  max_duration_seconds: z.number().int().positive().nullable().optional().describe("Optional per-goal duration limit.")
1306
1412
  },
1307
1413
  async execute(args, context) {
1308
- const input = args;
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);
1414
+ return createGoalFromTool(args, context);
1317
1415
  }
1318
1416
  },
1319
1417
  update_goal_objective: {
@@ -1324,8 +1422,13 @@ var server = async ({ client }, options) => {
1324
1422
  },
1325
1423
  async execute(args, context) {
1326
1424
  const input = args;
1327
- const goal = await updateGoalObjective(context.sessionID, input.objective, input.status ?? "active");
1328
- return JSON.stringify({ goal }, null, 2);
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);
1329
1432
  }
1330
1433
  },
1331
1434
  update_goal: {
@@ -1349,13 +1452,16 @@ var server = async ({ client }, options) => {
1349
1452
  }
1350
1453
  },
1351
1454
  update_goal_status: {
1352
- 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.",
1353
1456
  args: {
1354
1457
  status: z.enum(["active", "paused"]).describe("active resumes a goal; paused pauses it without clearing it.")
1355
1458
  },
1356
1459
  async execute(args, context) {
1357
1460
  const input = args;
1358
- const goal = await setGoalStatus(context.sessionID, input.status);
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);
1359
1465
  return JSON.stringify({ goal }, null, 2);
1360
1466
  }
1361
1467
  },
@@ -1373,6 +1479,13 @@ var server = async ({ client }, options) => {
1373
1479
  async "tool.execute.after"(input, output) {
1374
1480
  taskTracker.noteTaskOutput(input, output);
1375
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
+ },
1376
1489
  async "experimental.chat.messages.transform"(input, output) {
1377
1490
  taskTracker.observeMessages(output.messages);
1378
1491
  const sessionID = "sessionID" in input && typeof input.sessionID === "string" ? input.sessionID : output.messages.find((message) => typeof message.info.sessionID === "string")?.info.sessionID;
@@ -1384,7 +1497,8 @@ var server = async ({ client }, options) => {
1384
1497
  async "experimental.chat.system.transform"(input, output) {
1385
1498
  if (typeof input.sessionID !== "string")
1386
1499
  return;
1387
- mergeSystemReminder(output, systemReminder(await getGoal(input.sessionID)));
1500
+ const goal = await getGoal(input.sessionID);
1501
+ mergeSystemReminder(output, systemReminder(goal, { planningOnly: isPlanAgent(goal?.lastPromptAgent) }));
1388
1502
  },
1389
1503
  async "experimental.session.compacting"(input, output) {
1390
1504
  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.19",
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",