@prevalentware/opencode-goal-plugin 0.1.16 → 0.1.17

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 CHANGED
@@ -83,7 +83,8 @@ Server options can be configured in `opencode.json`:
83
83
  {
84
84
  "auto_continue": true,
85
85
  "max_auto_turns": 25,
86
- "min_continue_interval_seconds": 3
86
+ "min_continue_interval_seconds": 3,
87
+ "max_prompt_failures": 3
87
88
  }
88
89
  ]
89
90
  ]
@@ -95,6 +96,7 @@ Defaults:
95
96
  - `auto_continue`: `true`
96
97
  - `max_auto_turns`: `25`
97
98
  - `min_continue_interval_seconds`: `3`
99
+ - `max_prompt_failures`: `3`
98
100
  - `register_command`: `true`
99
101
  - `command_name`: `"goal"`
100
102
 
@@ -106,7 +108,7 @@ Use `/goal <objective>` in a fresh OpenCode chat to create a long-running goal:
106
108
  /goal review the frontend and translate visible English UI text to Spanish
107
109
  ```
108
110
 
109
- Bare `/goal` reports the current goal state. `/goal clear` clears the goal. The TUI also includes a `Goal` command-palette entry for viewing, refreshing, or clearing the current goal state without creating a new goal.
111
+ Bare `/goal` reports the current goal state. `/goal pause` pauses the goal without clearing it, and `/goal resume` resumes it. `/goal clear` clears the goal; `/goal stop`, `/goal off`, `/goal reset`, `/goal none`, and `/goal cancel` are clear aliases. The TUI also includes a `Goal` command-palette entry for viewing, refreshing, or clearing the current goal state without creating a new goal.
110
112
 
111
113
  You can also ask the agent to formulate the objective and call `set_goal` itself, for example: "set your own goal to finish this refactor safely." The tool uses the agent-written objective but still only creates a goal when explicitly requested.
112
114
 
@@ -170,4 +172,6 @@ OpenCode plugin modules are target-specific. This package exports separate modul
170
172
  }
171
173
  ```
172
174
 
173
- Codex goal mode has deeper runtime integration for thread lifecycle control. This plugin implements the same workflow using OpenCode plugin hooks. Token usage is read from OpenCode step-finish usage when available and falls back to message token metadata or text estimation when exact usage is unavailable. Continuation is driven by OpenCode's `session.idle` event.
175
+ Codex goal mode has deeper runtime integration for thread lifecycle control. This plugin implements the same workflow using OpenCode plugin hooks. Token usage is read from OpenCode step-finish usage when available and falls back to message token metadata or text estimation when exact usage is unavailable. Continuation is driven by OpenCode idle events, including `session.idle` and `session.status` idle notifications.
176
+
177
+ The goal sidebar shows the current status, elapsed time, auto-continue count, latest status message, and objective when a goal is active or paused. Closed goals remain visible briefly through the latest tool state as achieved or unmet.
package/dist/server.js CHANGED
@@ -32,7 +32,9 @@ var GoalSchema = Schema.Struct({
32
32
  closedAt: Schema.optionalWith(NullableNumber, { default: () => null }),
33
33
  lastAccountedAt: NullableNumber,
34
34
  autoTurns: Schema.Number,
35
- lastContinuationAt: NullableNumber
35
+ lastContinuationAt: NullableNumber,
36
+ continuationFailures: Schema.optionalWith(Schema.Number, { default: () => 0 }),
37
+ lastStatus: Schema.optionalWith(NullableString, { default: () => null })
36
38
  });
37
39
  var StateSchema = Schema.Struct({
38
40
  version: Schema.Literal(1),
@@ -145,6 +147,10 @@ function snapshot(goal) {
145
147
  completionEvidence: goal.completionEvidence ?? null,
146
148
  blocker: goal.blocker ?? null,
147
149
  closedAt: goal.closedAt ?? null,
150
+ continuationFailures: goal.continuationFailures,
151
+ lastStatus: goal.lastStatus,
152
+ autoTurns: goal.autoTurns,
153
+ lastContinuationAt: goal.lastContinuationAt,
148
154
  remainingTokens: null,
149
155
  sampledAt
150
156
  };
@@ -176,12 +182,28 @@ async function createGoal(sessionID, objective, _tokenBudget) {
176
182
  closedAt: null,
177
183
  lastAccountedAt: now,
178
184
  autoTurns: 0,
179
- lastContinuationAt: null
185
+ lastContinuationAt: null,
186
+ continuationFailures: 0,
187
+ lastStatus: "Goal set."
180
188
  };
181
189
  state.goals[sessionID] = goal;
182
190
  return snapshot(goal);
183
191
  });
184
192
  }
193
+ async function setGoalStatus(sessionID, status) {
194
+ return mutate((state) => {
195
+ const goal = state.goals[sessionID];
196
+ if (!goal)
197
+ throw new Error("cannot update goal because this session has no goal");
198
+ accountWallClock(goal);
199
+ goal.status = status;
200
+ goal.updatedAt = nowSeconds();
201
+ goal.lastAccountedAt = status === "active" ? goal.updatedAt : null;
202
+ goal.continuationFailures = status === "active" ? 0 : goal.continuationFailures;
203
+ goal.lastStatus = status === "active" ? "Goal resumed." : "Goal paused.";
204
+ return snapshot(goal);
205
+ });
206
+ }
185
207
  async function closeGoal(sessionID, input) {
186
208
  return mutate((state) => {
187
209
  const goal = state.goals[sessionID];
@@ -252,10 +274,35 @@ async function reserveContinuation(sessionID, maxAutoTurns, minIntervalSeconds)
252
274
  accountWallClock(goal, now);
253
275
  goal.autoTurns += 1;
254
276
  goal.lastContinuationAt = now;
277
+ goal.lastStatus = `Auto-continue ${goal.autoTurns} reserved.`;
255
278
  goal.updatedAt = now;
256
279
  return snapshot(goal);
257
280
  });
258
281
  }
282
+ async function recordContinuationResult(sessionID, result, maxFailures) {
283
+ return mutate((state) => {
284
+ const goal = state.goals[sessionID];
285
+ if (!goal || goal.status !== "active")
286
+ return goal ? snapshot(goal) : null;
287
+ const now = nowSeconds();
288
+ goal.updatedAt = now;
289
+ if (result === "success") {
290
+ goal.continuationFailures = 0;
291
+ goal.lastStatus = "Auto-continue prompt sent.";
292
+ return snapshot(goal);
293
+ }
294
+ goal.continuationFailures += 1;
295
+ goal.lastStatus = `Auto-continue failed ${goal.continuationFailures} time(s).`;
296
+ if (goal.continuationFailures >= maxFailures) {
297
+ accountWallClock(goal, now);
298
+ goal.status = "paused";
299
+ goal.lastAccountedAt = null;
300
+ goal.lastStatus = `Paused after ${goal.continuationFailures} auto-continue failure(s).`;
301
+ goal.blocker = "Auto-continue prompt failed repeatedly. Resume the goal to retry.";
302
+ }
303
+ return snapshot(goal);
304
+ });
305
+ }
259
306
  function accountWallClock(goal, now = nowSeconds()) {
260
307
  if (goal.status !== "active")
261
308
  return;
@@ -275,8 +322,11 @@ function formatGoal(goal) {
275
322
  const lines = [
276
323
  `Objective: ${goal.objective}`,
277
324
  `Status: ${goal.status}`,
278
- `Time used: ${goal.timeUsedSeconds}s`
325
+ `Time used: ${goal.timeUsedSeconds}s`,
326
+ `Auto-continues: ${goal.autoTurns}`
279
327
  ];
328
+ if (goal.lastStatus)
329
+ lines.push(`Last status: ${goal.lastStatus}`);
280
330
  if (goal.completionEvidence)
281
331
  lines.push(`Completion evidence: ${goal.completionEvidence}`);
282
332
  if (goal.blocker)
@@ -335,7 +385,9 @@ Preserve the goal objective, status, elapsed time, and any completion evidence o
335
385
  // src/server.ts
336
386
  var DEFAULT_MAX_AUTO_TURNS = 25;
337
387
  var DEFAULT_CONTINUE_INTERVAL_SECONDS = 3;
388
+ var DEFAULT_MAX_PROMPT_FAILURES = 3;
338
389
  var DEFAULT_COMMAND_NAME = "goal";
390
+ var activeContinuations = new Set;
339
391
  function goalCommandTemplate(commandName) {
340
392
  return `OpenCode goal mode command "/${commandName}" was invoked.
341
393
 
@@ -348,7 +400,9 @@ Use the goal tools to handle this command:
348
400
 
349
401
  - If the arguments are empty, call get_goal and briefly report the current goal state.
350
402
  - If the arguments are "status", "show", or "current", call get_goal and briefly report the current goal state.
351
- - If the arguments are "clear", call clear_goal and report whether a goal was cleared.
403
+ - If the arguments are "clear", "stop", "off", "reset", "none", or "cancel", call clear_goal and report whether a goal was cleared.
404
+ - If the arguments are "pause", pause the current goal by calling update_goal_status with status "paused" and report the result.
405
+ - If the arguments are "resume", resume the current goal by calling update_goal_status with status "active" and continue working toward it.
352
406
  - 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.
353
407
  - 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.
354
408
  - Otherwise, create a new goal with create_goal. Use the full arguments as the objective.
@@ -426,10 +480,27 @@ async function sendContinuation(client, sessionID, prompt) {
426
480
  }
427
481
  });
428
482
  }
483
+ function isIdleEvent(event) {
484
+ if (event.type === "session.idle")
485
+ return true;
486
+ const status = event.properties?.status;
487
+ return event.type === "session.status" && typeof status === "object" && status !== null && status.type === "idle";
488
+ }
489
+ function sessionIDFromEvent(event) {
490
+ const direct = event.properties?.sessionID;
491
+ if (typeof direct === "string")
492
+ return direct;
493
+ const info = event.properties?.info;
494
+ if (typeof info === "object" && info !== null && typeof info.sessionID === "string") {
495
+ return info.sessionID;
496
+ }
497
+ return;
498
+ }
429
499
  var server = async ({ client }, options) => {
430
500
  const autoContinue = options?.auto_continue ?? true;
431
501
  const maxAutoTurns = options?.max_auto_turns ?? DEFAULT_MAX_AUTO_TURNS;
432
502
  const minInterval = options?.min_continue_interval_seconds ?? DEFAULT_CONTINUE_INTERVAL_SECONDS;
503
+ const maxPromptFailures = options?.max_prompt_failures ?? DEFAULT_MAX_PROMPT_FAILURES;
433
504
  const registerCommand = options?.register_command ?? true;
434
505
  const commandName = commandNameFromOptions(options);
435
506
  return {
@@ -487,6 +558,17 @@ var server = async ({ client }, options) => {
487
558
  return JSON.stringify({ goal, unmet_report: report }, null, 2);
488
559
  }
489
560
  },
561
+ update_goal_status: {
562
+ description: "Pause or resume the current OpenCode goal when the user explicitly asks to pause or resume it.",
563
+ args: {
564
+ status: z.enum(["active", "paused"]).describe("active resumes a goal; paused pauses it without clearing it.")
565
+ },
566
+ async execute(args, context) {
567
+ const input = args;
568
+ const goal = await setGoalStatus(context.sessionID, input.status);
569
+ return JSON.stringify({ goal }, null, 2);
570
+ }
571
+ },
490
572
  clear_goal: {
491
573
  description: "Clear the current OpenCode goal for this session when the user explicitly asks to clear it.",
492
574
  args: {},
@@ -513,13 +595,33 @@ var server = async ({ client }, options) => {
513
595
  output.context.push(compactionContext(goal));
514
596
  },
515
597
  async event({ event }) {
516
- if (!autoContinue || event.type !== "session.idle")
598
+ if (!autoContinue || !isIdleEvent(event))
517
599
  return;
518
- const sessionID = event.properties.sessionID;
519
- const goal = await reserveContinuation(sessionID, maxAutoTurns, minInterval);
520
- if (!goal)
600
+ const sessionID = sessionIDFromEvent(event);
601
+ if (!sessionID)
602
+ return;
603
+ if (activeContinuations.has(sessionID))
521
604
  return;
522
- await sendContinuation(client, sessionID, continuationPrompt(goal));
605
+ activeContinuations.add(sessionID);
606
+ try {
607
+ const goal = await reserveContinuation(sessionID, maxAutoTurns, minInterval);
608
+ if (!goal)
609
+ return;
610
+ await sendContinuation(client, sessionID, continuationPrompt(goal));
611
+ await recordContinuationResult(sessionID, "success", maxPromptFailures);
612
+ } catch (error) {
613
+ await recordContinuationResult(sessionID, "failure", maxPromptFailures);
614
+ await client.app?.log?.({
615
+ body: {
616
+ service: "opencode-goal-plugin",
617
+ level: "error",
618
+ message: "Auto-continue failed",
619
+ extra: { error: error instanceof Error ? error.message : String(error) }
620
+ }
621
+ });
622
+ } finally {
623
+ activeContinuations.delete(sessionID);
624
+ }
523
625
  }
524
626
  };
525
627
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@prevalentware/opencode-goal-plugin",
3
- "version": "0.1.16",
3
+ "version": "0.1.17",
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",
package/src/tui.tsx CHANGED
@@ -14,6 +14,10 @@ type GoalSnapshot = {
14
14
  completionEvidence?: string | null
15
15
  blocker?: string | null
16
16
  closedAt?: number | null
17
+ continuationFailures: number
18
+ lastStatus: string | null
19
+ autoTurns: number
20
+ lastContinuationAt: number | null
17
21
  remainingTokens: number | null
18
22
  sampledAt?: number
19
23
  }
@@ -155,6 +159,10 @@ function isGoalSnapshot(value: unknown): value is GoalSnapshot {
155
159
  if (value.completionEvidence != null && typeof value.completionEvidence !== "string") return false
156
160
  if (value.blocker != null && typeof value.blocker !== "string") return false
157
161
  if (value.closedAt != null && typeof value.closedAt !== "number") return false
162
+ if (typeof value.continuationFailures !== "number") return false
163
+ if (value.lastStatus != null && typeof value.lastStatus !== "string") return false
164
+ if (typeof value.autoTurns !== "number") return false
165
+ if (value.lastContinuationAt != null && typeof value.lastContinuationAt !== "number") return false
158
166
  if (value.remainingTokens !== null && typeof value.remainingTokens !== "number") return false
159
167
  if (value.sampledAt != null && typeof value.sampledAt !== "number") return false
160
168
  return true
@@ -162,7 +170,7 @@ function isGoalSnapshot(value: unknown): value is GoalSnapshot {
162
170
 
163
171
  function parseGoalToolOutput(part: GoalToolPart): GoalSnapshot | null | undefined {
164
172
  if (part.type !== "tool") return undefined
165
- if (!["get_goal", "create_goal", "update_goal", "clear_goal"].includes(part.tool ?? "")) return undefined
173
+ if (!["get_goal", "create_goal", "update_goal", "update_goal_status", "clear_goal"].includes(part.tool ?? "")) return undefined
166
174
  if (part.state?.status !== "completed") return undefined
167
175
  if (part.tool === "clear_goal") return null
168
176
  if (typeof part.state.output !== "string") return undefined
@@ -209,7 +217,9 @@ function formatGoal(goal: GoalSnapshot | null) {
209
217
  `Objective: ${goal.objective}`,
210
218
  `Status: ${visibleStatus(goal.status)}`,
211
219
  `Time used: ${formatDuration(goal.timeUsedSeconds)}`,
220
+ `Auto-continues: ${goal.autoTurns}`,
212
221
  ]
222
+ if (goal.lastStatus) lines.push(`Last status: ${goal.lastStatus}`)
213
223
  if (goal.completionEvidence) lines.push(`Completion evidence: ${goal.completionEvidence}`)
214
224
  if (goal.blocker) lines.push(`Blocker: ${goal.blocker}`)
215
225
  return lines.join("\n")
@@ -243,6 +253,10 @@ function GoalSidebar(props: { api: TuiPluginApi; sessionID: string }) {
243
253
  </text>
244
254
  <text fg={theme().textMuted}>Status: {visibleStatus(value().status)}</text>
245
255
  <text fg={theme().textMuted}>Time: {formatDuration(elapsed())}</text>
256
+ <text fg={theme().textMuted}>Auto-continues: {value().autoTurns}</text>
257
+ <Show when={value().lastStatus}>
258
+ {(status: () => string) => <text fg={theme().textMuted}>{status()}</text>}
259
+ </Show>
246
260
  <text fg={theme().textMuted}>{objective()}</text>
247
261
  </box>
248
262
  }