opencode-goal-plugin 0.1.7 → 0.1.9

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/CHANGELOG.md CHANGED
@@ -1,5 +1,19 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.9 — 2026-05-18
4
+
5
+ > This release makes the goal plugin much more reliable for real unattended use. Goals now persist across restarts, recover in a safe paused state, expose better status/history visibility, and use smarter no-progress detection to avoid premature stalls. It also hardens persistence with atomic writes, stricter file permissions, and regression tests around corrupt or missing state.
6
+
7
+ - Persist active goals and recent results to `~/.opencode-goal-plugin/state.json` by default, with recovered goals loaded in a paused state.
8
+ - Add `/goal history` plus richer `/goal status` output with recent checkpoint and suggested-next-action hints.
9
+ - Replace one-shot low-output pausing with a configurable consecutive-stall grace window via `noProgressTurnsBeforePause` / `--no-progress-turns`.
10
+ - Expand tests to cover history output, persistence recovery, repeated-stall pausing, and changing short assistant updates.
11
+
12
+ ## 0.1.8 — 2026-05-18
13
+
14
+ - Harden `--max-minutes` fallback arithmetic when mixed with millisecond duration overrides.
15
+ - Clarify plugin-default config merging and goal-text trust guidance.
16
+
3
17
  ## 0.1.7 — 2026-05-18
4
18
 
5
19
  - Accept bare final-line `goal:complete` and `goal:blocked` markers in addition to canonical bracketed markers, matching observed model output during smoke testing.
package/README.md CHANGED
@@ -41,12 +41,20 @@ Override limits for a single goal:
41
41
  /goal fix the failing tests --max-turns 20 --max-minutes 30 --max-tokens 400000
42
42
  ```
43
43
 
44
+ Flags accept either `--flag value` or `--flag=value`. If a flag is unknown, missing a value, or given a non-positive integer, the plugin rejects the command with a helpful error instead of silently folding the bad flag into the goal text.
45
+
44
46
  Check status:
45
47
 
46
48
  ```
47
49
  /goal status
48
50
  ```
49
51
 
52
+ View lifecycle history and the latest checkpoint:
53
+
54
+ ```
55
+ /goal history
56
+ ```
57
+
50
58
  Resume a paused or stopped goal:
51
59
 
52
60
  ```
@@ -95,7 +103,7 @@ Markers must appear on their own final line. The bracketed form is canonical, bu
95
103
  | Max duration | 15 minutes |
96
104
  | Tracked tokens | 200,000 |
97
105
  | Min delay between continues | 1.5 seconds |
98
- | No-progress pause | < 50 output tokens on a turn |
106
+ | No-progress pause | < 50 output tokens on a stalled turn (after a 2-turn grace window) |
99
107
  | Budget wrap-up threshold | 80% of tracked token budget |
100
108
  | Auto-continue failure pause | 3 consecutive prompt failures |
101
109
 
@@ -103,11 +111,17 @@ Markers must appear on their own final line. The bracketed form is canonical, bu
103
111
 
104
112
  **Token budget.** The plugin tracks `input + output + reasoning` tokens across all session messages. In high-context sessions (large codebases, long conversation history), input overhead per turn can be substantial and the budget may be exhausted before the turn limit is reached. Treat it as a safety brake, not precise billing accounting.
105
113
 
114
+ **No-progress heuristic.** A low-output turn does not pause immediately anymore. The plugin pauses only after `noProgressTurnsBeforePause` consecutive *stalled* low-output turns — repeated turns with very little output and no meaningful change in the latest assistant checkpoint.
115
+
106
116
  **Wrap-up vs. hard stop.** When a limit is reached, the plugin sends one final prompt asking the assistant to summarize what is done, what remains, and the next concrete step — rather than stopping silently. Use `/goal resume` to continue after any stop, including limit stops and no-progress pauses.
107
117
 
108
- Goal state is process-memory only. It is not persisted across OpenCode restarts, plugin reloads, or config reloads.
118
+ Goal state is persisted by default to `~/.opencode-goal-plugin/state.json`, but only as a local workflow checkpoint. It is not synchronized across machines or OpenCode instances.
119
+
120
+ The state directory is created with owner-only permissions, and the JSON state file is written as `0600` because it may contain goal text, assistant checkpoints, and workflow history.
109
121
 
110
- `/goal resume` continues the same in-memory objective with a fresh local budget window. This lets you continue after pause, blocker, no-progress pause, rate-limit failures, or a limit stop without retyping the objective.
122
+ Recovered active goals are loaded in a **paused** state with a recovery note, so unattended auto-continue does not resume blindly after a restart. Set `"persistState": false` to keep purely in-memory behavior.
123
+
124
+ `/goal resume` continues the same objective with a fresh local budget window. This lets you continue after pause, blocker, no-progress pause, rate-limit failures, or a limit stop without retyping the objective.
111
125
 
112
126
  ### Per-goal flags
113
127
 
@@ -121,10 +135,19 @@ Override any limit for a single goal:
121
135
  | `--max-tokens <n>` | Tracked token limit |
122
136
  | `--cooldown-ms <n>` | Minimum delay between continues |
123
137
  | `--no-progress-threshold <n>` | Output token floor before pausing |
138
+ | `--no-progress-turns <n>` | Consecutive stalled low-output turns before pausing |
139
+
140
+ Examples:
141
+
142
+ ```sh
143
+ /goal fix tests --max-turns 20 --max-tokens 400000
144
+ /goal fix tests --max-turns=20 --max-tokens=400000
145
+ /goal fix tests --no-progress-threshold 50 --no-progress-turns 2
146
+ ```
124
147
 
125
148
  ### Plugin-level defaults
126
149
 
127
- Pass options when registering the plugin to change the defaults for all goals:
150
+ Pass options when registering the plugin to change the defaults for all goals. To combine with the `goal` command, merge this plugin entry into the config shown above.
128
151
 
129
152
  ```json
130
153
  {
@@ -136,15 +159,30 @@ Pass options when registering the plugin to change the defaults for all goals:
136
159
  "maxDurationMs": 900000,
137
160
  "maxTokens": 200000,
138
161
  "minDelayMs": 1500,
162
+ "maxRecentMessages": 50,
139
163
  "noProgressTokenThreshold": 50,
164
+ "noProgressTurnsBeforePause": 2,
140
165
  "budgetWrapupRatio": 0.8,
141
- "maxPromptFailures": 3
166
+ "maxPromptFailures": 3,
167
+ "persistState": true,
168
+ "stateFilePath": "/home/you/.opencode-goal-plugin/state.json",
169
+ "resultRetentionMs": 604800000,
170
+ "maxStoredResults": 200
142
171
  }
143
172
  ]
144
173
  ]
145
174
  }
146
175
  ```
147
176
 
177
+ Additional plugin-level options:
178
+
179
+ - `maxRecentMessages` — how many recent session messages to scan when looking for the latest assistant turn before auto-continuing. Higher values make long, tool-heavy sessions less likely to lose the most recent assistant response.
180
+ - `noProgressTurnsBeforePause` — grace window for low-output stalls. The plugin pauses only after this many consecutive stalled low-output turns rather than on the first one.
181
+ - `persistState` — whether to persist active goals and recent goal results to disk.
182
+ - `stateFilePath` — where the persisted state JSON is written. Useful if you want per-project or ephemeral storage.
183
+ - `resultRetentionMs` — how long a completed goal summary remains available through `/goal status` after the goal leaves active memory.
184
+ - `maxStoredResults` — maximum number of completed-goal summaries retained in process memory before the oldest ones are evicted.
185
+
148
186
  ## Prompt safety
149
187
 
150
188
  The goal text is wrapped in `<goal_objective>` tags and labeled as user-provided task data. The assistant is told to treat it as a task description, not as elevated instructions that can override system, developer, tool, or repository policies.
@@ -180,8 +218,9 @@ Keep test files outside OpenCode's auto-loaded plugin directory — OpenCode wil
180
218
  ## Development
181
219
 
182
220
  ```sh
183
- npm test # run the test suite
184
- npm run check # syntax check + tests
221
+ npm test # run the test suite
222
+ npm run test:coverage # run tests with coverage
223
+ npm run check # syntax check + tests
185
224
  npm run pack:check # verify package contents before publishing
186
225
  ```
187
226
 
package/SECURITY.md CHANGED
@@ -21,4 +21,4 @@ Relevant security-sensitive areas include:
21
21
  - incorrect command or hook handling across OpenCode versions
22
22
  - leakage of goal text through logs or status output
23
23
 
24
- The goal text is wrapped in `<goal_objective>` tags and the closing tag is escaped before insertion. Other structural tags used in continuation prompts (`<goal_continuation>`, `<progress_budget>`, etc.) are not escaped. Crafted goal text containing those literal strings would close the tag early in the plaintext prompt; the model treats it as text rather than structure, so the practical risk for a local single-user tool is negligible.
24
+ The goal text is wrapped in `<goal_objective>` tags and the closing tag is escaped before insertion. Other structural tags used in continuation prompts (`<goal_continuation>`, `<progress_budget>`, etc.) are not escaped. Crafted goal text containing those literal strings would close the tag early in the plaintext prompt; the model treats it as text rather than structure, so the practical risk for a local single-user tool is negligible. Do not paste untrusted third-party text into a goal; treat goal text as if you typed it directly into the assistant.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-goal-plugin",
3
- "version": "0.1.7",
3
+ "version": "0.1.9",
4
4
  "description": "Session-scoped /goal workflow for OpenCode.",
5
5
  "type": "module",
6
6
  "main": "./src/goal-plugin.js",
@@ -19,6 +19,7 @@
19
19
  ],
20
20
  "scripts": {
21
21
  "test": "node --test",
22
+ "test:coverage": "node --test --experimental-test-coverage",
22
23
  "check": "node -c src/goal-plugin.js && npm test",
23
24
  "pack:check": "npm pack --dry-run"
24
25
  },
@@ -1,16 +1,29 @@
1
1
  import { randomUUID } from "node:crypto"
2
+ import { promises as fs } from "node:fs"
3
+ import { homedir } from "node:os"
4
+ import { dirname, join } from "node:path"
5
+
6
+ const STATE_FILE_VERSION = 1
7
+ const DEFAULT_STATE_FILE_PATH = join(homedir(), ".opencode-goal-plugin", "state.json")
8
+ const MAX_HISTORY_ENTRIES = 20
9
+ const MAX_CHECKPOINTS = 5
10
+ const CHECKPOINT_CHAR_LIMIT = 280
2
11
 
3
12
  const DEFAULT_OPTIONS = {
4
13
  maxTurns: 10,
5
14
  maxDurationMs: 15 * 60 * 1000,
6
15
  maxTokens: 200000,
7
16
  minDelayMs: 1500,
17
+ maxRecentMessages: 50,
8
18
  noProgressTokenThreshold: 50,
19
+ noProgressTurnsBeforePause: 2,
9
20
  budgetWrapupRatio: 0.8,
10
21
  warnTurnsRemaining: 3,
11
22
  warnDurationMsRemaining: 60 * 1000,
12
23
  warnTokensRemaining: 25000,
13
24
  maxPromptFailures: 3,
25
+ resultRetentionMs: 7 * 24 * 60 * 60 * 1000,
26
+ maxStoredResults: 200,
14
27
  }
15
28
 
16
29
  const goalStates = new Map()
@@ -20,6 +33,39 @@ const seenOutputTokens = new Map()
20
33
  const activeContinues = new Set()
21
34
  const CLEAR_COMMANDS = new Set(["clear", "stop", "off", "reset", "none", "cancel"])
22
35
  const PAUSE_COMMANDS = new Set(["pause"])
36
+ const GOAL_FLAG_SPECS = {
37
+ "--max-turns": {
38
+ optionKey: "maxTurns",
39
+ parse: (value, options) => toPositiveInteger(value, options.maxTurns),
40
+ },
41
+ "--max-duration-ms": {
42
+ optionKey: "maxDurationMs",
43
+ parse: (value, options) => toPositiveInteger(value, options.maxDurationMs),
44
+ },
45
+ "--max-minutes": {
46
+ optionKey: "maxDurationMs",
47
+ parse: (value, options) =>
48
+ toPositiveInteger(value, Math.ceil(options.maxDurationMs / 60000)) * 60000,
49
+ },
50
+ "--max-tokens": {
51
+ optionKey: "maxTokens",
52
+ parse: (value, options) => toPositiveInteger(value, options.maxTokens),
53
+ },
54
+ "--cooldown-ms": {
55
+ optionKey: "minDelayMs",
56
+ parse: (value, options) => toPositiveInteger(value, options.minDelayMs),
57
+ },
58
+ "--no-progress-threshold": {
59
+ optionKey: "noProgressTokenThreshold",
60
+ parse: (value, options) =>
61
+ toPositiveInteger(value, options.noProgressTokenThreshold),
62
+ },
63
+ "--no-progress-turns": {
64
+ optionKey: "noProgressTurnsBeforePause",
65
+ parse: (value, options) =>
66
+ toPositiveInteger(value, options.noProgressTurnsBeforePause),
67
+ },
68
+ }
23
69
 
24
70
  function getText(parts) {
25
71
  return (parts || [])
@@ -44,12 +90,55 @@ function isIdleEvent(event) {
44
90
  )
45
91
  }
46
92
 
93
+ function summarizeText(text, limit = CHECKPOINT_CHAR_LIMIT) {
94
+ const normalized = String(text || "").replace(/\s+/g, " ").trim()
95
+ if (!normalized) return ""
96
+ return normalized.length > limit ? `${normalized.slice(0, limit - 1)}…` : normalized
97
+ }
98
+
99
+ function formatTimestamp(timestamp) {
100
+ if (!timestamp) return "unknown"
101
+ return new Date(timestamp).toISOString()
102
+ }
103
+
104
+ function formatAge(timestamp) {
105
+ if (!timestamp) return "unknown"
106
+ return `${Math.round((Date.now() - timestamp) / 1000)}s ago`
107
+ }
108
+
109
+ function makeHistoryEntry(type, detail, timestamp = Date.now()) {
110
+ return {
111
+ type,
112
+ detail: summarizeText(detail, 400),
113
+ timestamp,
114
+ }
115
+ }
116
+
117
+ function pushHistory(goal, type, detail, timestamp = Date.now()) {
118
+ goal.history = [...(goal.history || []), makeHistoryEntry(type, detail, timestamp)].slice(
119
+ -MAX_HISTORY_ENTRIES,
120
+ )
121
+ }
122
+
123
+ function recordCheckpoint(goal, text, timestamp = Date.now()) {
124
+ const summary = summarizeText(text)
125
+ if (!summary) return
126
+ if (goal.lastCheckpoint?.summary === summary) return
127
+
128
+ const checkpoint = { summary, timestamp }
129
+ goal.lastCheckpoint = checkpoint
130
+ goal.checkpoints = [...(goal.checkpoints || []), checkpoint].slice(-MAX_CHECKPOINTS)
131
+ }
132
+
47
133
  function formatStatus(goal) {
48
134
  const elapsed = Math.round((Date.now() - goal.startedAt) / 1000)
49
135
  const lastProgress =
50
136
  goal.lastProgressAt > 0
51
137
  ? `${Math.round((Date.now() - goal.lastProgressAt) / 1000)}s ago`
52
138
  : "none yet"
139
+ const lastCheckpoint = goal.lastCheckpoint
140
+ ? `${goal.lastCheckpoint.summary} (${formatAge(goal.lastCheckpoint.timestamp)})`
141
+ : "none yet"
53
142
  const lines = [
54
143
  `Active goal: ${goal.condition}`,
55
144
  `Auto-continues sent: ${goal.turnCount}/${goal.options.maxTurns}`,
@@ -57,21 +146,31 @@ function formatStatus(goal) {
57
146
  `Elapsed: ${elapsed}s/${Math.round(goal.options.maxDurationMs / 1000)}s`,
58
147
  `Last progress: ${lastProgress}`,
59
148
  `No-progress turns: ${goal.noProgressTurns}`,
149
+ `Recent checkpoint: ${lastCheckpoint}`,
60
150
  `Last status: ${goal.lastStatus || "No assistant turn recorded yet."}`,
61
151
  ]
62
152
  if (goal.stopped) lines.push(`Stopped: ${goal.stopReason || "unknown"}`)
63
153
  if (goal.blockedReason) lines.push(`Blocked reason: ${goal.blockedReason}`)
154
+ if (goal.stopped) {
155
+ lines.push(
156
+ `Suggested action: ${goal.stopReason === "blocked" ? "address the blocker, then run /goal resume" : "run /goal resume to continue, or /goal clear to discard"}`,
157
+ )
158
+ }
64
159
  return lines.join("\n")
65
160
  }
66
161
 
67
162
  function formatGoalResult(result) {
68
163
  const elapsed = Math.round((result.finishedAt - result.startedAt) / 1000)
164
+ const lastCheckpoint = result.lastCheckpoint
165
+ ? `${result.lastCheckpoint.summary} (${formatTimestamp(result.lastCheckpoint.timestamp)})`
166
+ : "none recorded"
69
167
  const lines = [
70
168
  `Last goal: ${result.condition}`,
71
169
  `State: ${result.state}`,
72
170
  `Auto-continues sent: ${result.turnCount}`,
73
171
  `Tokens: ${result.totalTokens.toLocaleString()}`,
74
172
  `Elapsed: ${elapsed}s`,
173
+ `Last checkpoint: ${lastCheckpoint}`,
75
174
  `Last status: ${result.lastStatus || "No status recorded."}`,
76
175
  ]
77
176
  if (result.reason) lines.push(`Reason: ${result.reason}`)
@@ -79,6 +178,13 @@ function formatGoalResult(result) {
79
178
  return lines.join("\n")
80
179
  }
81
180
 
181
+ function formatHistory(history = []) {
182
+ if (!history.length) return "No goal history recorded yet."
183
+ return history
184
+ .map((entry) => `- [${formatTimestamp(entry.timestamp)}] ${entry.type}: ${entry.detail}`)
185
+ .join("\n")
186
+ }
187
+
82
188
  function goalIsComplete(text) {
83
189
  return /(^|\n)\s*(?:\[goal:complete\]|goal:complete)\s*$/i.test(text.trimEnd())
84
190
  }
@@ -108,7 +214,34 @@ function cleanupGoal(sessionID) {
108
214
  activeContinues.delete(sessionID)
109
215
  }
110
216
 
217
+ function clearRuntimeState() {
218
+ goalStates.clear()
219
+ lastGoalResults.clear()
220
+ seenTokens.clear()
221
+ seenOutputTokens.clear()
222
+ activeContinues.clear()
223
+ }
224
+
225
+ function pruneGoalResults(options) {
226
+ const retentionMs = options?.resultRetentionMs ?? DEFAULT_OPTIONS.resultRetentionMs
227
+ const maxStoredResults = options?.maxStoredResults ?? DEFAULT_OPTIONS.maxStoredResults
228
+ const now = Date.now()
229
+
230
+ for (const [sessionID, result] of lastGoalResults.entries()) {
231
+ if (!result?.finishedAt || now - result.finishedAt > retentionMs) {
232
+ lastGoalResults.delete(sessionID)
233
+ }
234
+ }
235
+
236
+ while (lastGoalResults.size > maxStoredResults) {
237
+ const oldestSessionID = lastGoalResults.keys().next().value
238
+ if (oldestSessionID === undefined) break
239
+ lastGoalResults.delete(oldestSessionID)
240
+ }
241
+ }
242
+
111
243
  function rememberGoalResult(sessionID, goal, state, reason = "") {
244
+ lastGoalResults.delete(sessionID)
112
245
  lastGoalResults.set(sessionID, {
113
246
  condition: goal.condition,
114
247
  state,
@@ -119,7 +252,11 @@ function rememberGoalResult(sessionID, goal, state, reason = "") {
119
252
  startedAt: goal.startedAt,
120
253
  finishedAt: Date.now(),
121
254
  lastStatus: goal.lastStatus,
255
+ lastCheckpoint: goal.lastCheckpoint || null,
256
+ checkpoints: [...(goal.checkpoints || [])],
257
+ history: [...(goal.history || [])],
122
258
  })
259
+ pruneGoalResults(goal.options)
123
260
  }
124
261
 
125
262
  function resetGoalBudget(goal) {
@@ -137,6 +274,8 @@ function resetGoalBudget(goal) {
137
274
  goal.budgetWrapupSent = false
138
275
  goal.messageIDs = new Set()
139
276
  goal.promptFailures = 0
277
+ goal.lastAssistantMessageID = ""
278
+ goal.history = [...(goal.history || [])].slice(-MAX_HISTORY_ENTRIES)
140
279
  }
141
280
 
142
281
  function currentGoal(sessionID, goalID) {
@@ -151,16 +290,33 @@ function toPositiveInteger(value, fallback) {
151
290
  return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : fallback
152
291
  }
153
292
 
293
+ function parsePositiveIntegerStrict(value) {
294
+ const parsed = Number(value)
295
+ return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : null
296
+ }
297
+
298
+ function stripWrappingQuotes(value) {
299
+ return value.replace(/^["']|["']$/g, "")
300
+ }
301
+
154
302
  function normalizeOptions(options = {}) {
155
303
  return {
156
304
  maxTurns: toPositiveInteger(options.maxTurns, DEFAULT_OPTIONS.maxTurns),
157
305
  maxDurationMs: toPositiveInteger(options.maxDurationMs, DEFAULT_OPTIONS.maxDurationMs),
158
306
  maxTokens: toPositiveInteger(options.maxTokens, DEFAULT_OPTIONS.maxTokens),
159
307
  minDelayMs: toPositiveInteger(options.minDelayMs, DEFAULT_OPTIONS.minDelayMs),
308
+ maxRecentMessages: toPositiveInteger(
309
+ options.maxRecentMessages,
310
+ DEFAULT_OPTIONS.maxRecentMessages,
311
+ ),
160
312
  noProgressTokenThreshold: toPositiveInteger(
161
313
  options.noProgressTokenThreshold,
162
314
  DEFAULT_OPTIONS.noProgressTokenThreshold,
163
315
  ),
316
+ noProgressTurnsBeforePause: toPositiveInteger(
317
+ options.noProgressTurnsBeforePause,
318
+ DEFAULT_OPTIONS.noProgressTurnsBeforePause,
319
+ ),
164
320
  budgetWrapupRatio:
165
321
  Number(options.budgetWrapupRatio) > 0 && Number(options.budgetWrapupRatio) < 1
166
322
  ? Number(options.budgetWrapupRatio)
@@ -181,6 +337,121 @@ function normalizeOptions(options = {}) {
181
337
  options.maxPromptFailures,
182
338
  DEFAULT_OPTIONS.maxPromptFailures,
183
339
  ),
340
+ resultRetentionMs: toPositiveInteger(
341
+ options.resultRetentionMs,
342
+ DEFAULT_OPTIONS.resultRetentionMs,
343
+ ),
344
+ maxStoredResults: toPositiveInteger(
345
+ options.maxStoredResults,
346
+ DEFAULT_OPTIONS.maxStoredResults,
347
+ ),
348
+ }
349
+ }
350
+
351
+ function normalizePersistenceOptions(options = {}) {
352
+ return {
353
+ persistState: options.persistState !== false,
354
+ stateFilePath:
355
+ typeof options.stateFilePath === "string" && options.stateFilePath.trim()
356
+ ? options.stateFilePath.trim()
357
+ : DEFAULT_STATE_FILE_PATH,
358
+ }
359
+ }
360
+
361
+ function serializeGoal(goal) {
362
+ return {
363
+ ...goal,
364
+ messageIDs: [...(goal.messageIDs || [])],
365
+ history: [...(goal.history || [])],
366
+ checkpoints: [...(goal.checkpoints || [])],
367
+ lastCheckpoint: goal.lastCheckpoint || null,
368
+ }
369
+ }
370
+
371
+ function deserializeGoal(goal) {
372
+ const hydrated = {
373
+ ...goal,
374
+ messageIDs: new Set(goal?.messageIDs || []),
375
+ history: Array.isArray(goal?.history) ? goal.history : [],
376
+ checkpoints: Array.isArray(goal?.checkpoints) ? goal.checkpoints : [],
377
+ lastCheckpoint: goal?.lastCheckpoint || null,
378
+ }
379
+
380
+ if (!hydrated.stopped) {
381
+ hydrated.stopped = true
382
+ hydrated.stopReason = "recovered after restart"
383
+ hydrated.lastStatus = "Recovered persisted goal state. Review /goal status and run /goal resume when ready."
384
+ pushHistory(
385
+ hydrated,
386
+ "recovered",
387
+ "Recovered persisted goal state after plugin restart; auto-continue remains paused until you resume.",
388
+ )
389
+ }
390
+
391
+ return hydrated
392
+ }
393
+
394
+ async function loadPersistedState(persistenceOptions, client) {
395
+ if (!persistenceOptions.persistState) return "disabled"
396
+
397
+ try {
398
+ const raw = await fs.readFile(persistenceOptions.stateFilePath, "utf8")
399
+ const parsed = JSON.parse(raw)
400
+ if (parsed?.version !== STATE_FILE_VERSION) {
401
+ await logPluginError(
402
+ client,
403
+ `Skipped persisted goal state: unsupported version ${parsed?.version ?? "unknown"}.`,
404
+ )
405
+ return "invalid"
406
+ }
407
+
408
+ clearRuntimeState()
409
+
410
+ for (const goal of parsed.goals || []) {
411
+ goalStates.set(goal.sessionID, deserializeGoal(goal))
412
+ }
413
+
414
+ for (const result of parsed.results || []) {
415
+ lastGoalResults.set(result.sessionID, result)
416
+ }
417
+
418
+ return "loaded"
419
+ } catch (error) {
420
+ if (error?.code === "ENOENT") return "missing"
421
+ await logPluginError(client, "Failed to load persisted goal state", error)
422
+ return "invalid"
423
+ }
424
+ }
425
+
426
+ async function persistState(persistenceOptions, client) {
427
+ if (!persistenceOptions.persistState) return
428
+
429
+ try {
430
+ await fs.mkdir(dirname(persistenceOptions.stateFilePath), { recursive: true, mode: 0o700 })
431
+ const tmpPath = `${persistenceOptions.stateFilePath}.${process.pid}.${randomUUID()}.tmp`
432
+ await fs.writeFile(
433
+ tmpPath,
434
+ JSON.stringify(
435
+ {
436
+ version: STATE_FILE_VERSION,
437
+ goals: [...goalStates.values()].map(serializeGoal),
438
+ results: [...lastGoalResults.entries()].map(([sessionID, result]) => ({
439
+ ...result,
440
+ sessionID,
441
+ history: [...(result.history || [])],
442
+ checkpoints: [...(result.checkpoints || [])],
443
+ lastCheckpoint: result.lastCheckpoint || null,
444
+ })),
445
+ },
446
+ null,
447
+ 2,
448
+ ),
449
+ { encoding: "utf8", mode: 0o600 },
450
+ )
451
+ await fs.rename(tmpPath, persistenceOptions.stateFilePath)
452
+ await fs.chmod(persistenceOptions.stateFilePath, 0o600)
453
+ } catch (error) {
454
+ await logPluginError(client, "Failed to persist goal state", error)
184
455
  }
185
456
  }
186
457
 
@@ -204,43 +475,48 @@ function parseGoalArguments(args, defaults) {
204
475
  const parts = args.match(/"[^"]*"|'[^']*'|\S+/g) || []
205
476
  const condition = []
206
477
  const options = { ...defaults }
478
+ const errors = []
207
479
 
208
480
  for (let i = 0; i < parts.length; i += 1) {
209
481
  const part = parts[i]
210
- const next = parts[i + 1]
211
- const nextIsValue = next !== undefined && !next.startsWith("--")
212
482
 
213
- if (part === "--max-turns") {
214
- if (nextIsValue) { options.maxTurns = toPositiveInteger(next, options.maxTurns); i += 1 }
215
- continue
216
- }
217
- if (part === "--max-duration-ms") {
218
- if (nextIsValue) { options.maxDurationMs = toPositiveInteger(next, options.maxDurationMs); i += 1 }
219
- continue
220
- }
221
- if (part === "--max-minutes") {
222
- if (nextIsValue) { options.maxDurationMs = toPositiveInteger(next, options.maxDurationMs / 60000) * 60000; i += 1 }
223
- continue
224
- }
225
- if (part === "--max-tokens") {
226
- if (nextIsValue) { options.maxTokens = toPositiveInteger(next, options.maxTokens); i += 1 }
227
- continue
228
- }
229
- if (part === "--cooldown-ms") {
230
- if (nextIsValue) { options.minDelayMs = toPositiveInteger(next, options.minDelayMs); i += 1 }
231
- continue
232
- }
233
- if (part === "--no-progress-threshold") {
234
- if (nextIsValue) { options.noProgressTokenThreshold = toPositiveInteger(next, options.noProgressTokenThreshold); i += 1 }
483
+ if (part.startsWith("--")) {
484
+ const [flagName, inlineValue] = part.split(/=(.*)/s, 2)
485
+ const flagSpec = GOAL_FLAG_SPECS[flagName]
486
+
487
+ if (!flagSpec) {
488
+ const next = parts[i + 1]
489
+ if (inlineValue === undefined && next !== undefined && !next.startsWith("--")) i += 1
490
+ errors.push(`Unsupported flag: ${flagName}`)
491
+ continue
492
+ }
493
+
494
+ const next = parts[i + 1]
495
+ const value = inlineValue ?? (next !== undefined && !next.startsWith("--") ? next : undefined)
496
+ if (inlineValue === undefined && value !== undefined) i += 1
497
+
498
+ if (value === undefined) {
499
+ errors.push(`Missing value for ${flagName}`)
500
+ continue
501
+ }
502
+
503
+ const parsedValue = parsePositiveIntegerStrict(stripWrappingQuotes(value))
504
+ if (parsedValue === null) {
505
+ errors.push(`Invalid positive integer for ${flagName}: ${value}`)
506
+ continue
507
+ }
508
+
509
+ options[flagSpec.optionKey] = flagSpec.parse(parsedValue, options)
235
510
  continue
236
511
  }
237
512
 
238
- condition.push(part.replace(/^["']|["']$/g, ""))
513
+ condition.push(stripWrappingQuotes(part))
239
514
  }
240
515
 
241
516
  return {
242
517
  condition: condition.join(" ").trim(),
243
518
  options,
519
+ errors,
244
520
  }
245
521
  }
246
522
 
@@ -348,6 +624,20 @@ function extractBlockedReason(text) {
348
624
  .find((line) => line.trim())?.trim() || ""
349
625
  }
350
626
 
627
+ function formatArgumentErrors(errors) {
628
+ return [
629
+ "Goal flags could not be parsed.",
630
+ ...errors.map((error) => `- ${error}`),
631
+ "",
632
+ "Supported flags: --max-turns, --max-minutes, --max-duration-ms, --max-tokens, --cooldown-ms, --no-progress-threshold, --no-progress-turns.",
633
+ "You can pass them as `--flag value` or `--flag=value`.",
634
+ ].join("\n")
635
+ }
636
+
637
+ function findLatestAssistantMessage(messages) {
638
+ return [...(messages || [])].reverse().find((message) => message.info?.role === "assistant") || null
639
+ }
640
+
351
641
  function outputTokensForMessage(message) {
352
642
  return message?.info?.tokens?.output || 0
353
643
  }
@@ -361,6 +651,15 @@ function budgetWrapupNeeded(goal) {
361
651
 
362
652
  export const GoalPlugin = async ({ client }, pluginOptions = {}) => {
363
653
  const defaultGoalOptions = normalizeOptions(pluginOptions)
654
+ const persistenceOptions = normalizePersistenceOptions(pluginOptions)
655
+ const persist = async () => persistState(persistenceOptions, client)
656
+
657
+ clearRuntimeState()
658
+ const persistedStateStatus = await loadPersistedState(persistenceOptions, client)
659
+ pruneGoalResults(defaultGoalOptions)
660
+ if (persistedStateStatus === "loaded" || persistedStateStatus === "missing") {
661
+ await persist()
662
+ }
364
663
 
365
664
  return {
366
665
  "command.execute.before": async (input, output) => {
@@ -368,6 +667,7 @@ export const GoalPlugin = async ({ client }, pluginOptions = {}) => {
368
667
 
369
668
  const args = (input.arguments || "").trim()
370
669
  const sessionID = input.sessionID
670
+ pruneGoalResults(defaultGoalOptions)
371
671
 
372
672
  if (!args || args === "status") {
373
673
  const goal = goalStates.get(sessionID)
@@ -384,9 +684,37 @@ export const GoalPlugin = async ({ client }, pluginOptions = {}) => {
384
684
  return
385
685
  }
386
686
 
687
+ if (args === "history") {
688
+ const goal = goalStates.get(sessionID)
689
+ const lastResult = lastGoalResults.get(sessionID)
690
+ output.parts = [
691
+ makeTextPart(
692
+ goal
693
+ ? [
694
+ `Goal history for: ${goal.condition}`,
695
+ "",
696
+ `Latest checkpoint: ${goal.lastCheckpoint?.summary || "none yet"}`,
697
+ "",
698
+ formatHistory(goal.history),
699
+ ].join("\n")
700
+ : lastResult
701
+ ? [
702
+ `Last goal history for: ${lastResult.condition}`,
703
+ "",
704
+ `Latest checkpoint: ${lastResult.lastCheckpoint?.summary || "none recorded"}`,
705
+ "",
706
+ formatHistory(lastResult.history),
707
+ ].join("\n")
708
+ : "No goal history recorded yet. Set a goal with `/goal <condition>`.",
709
+ ),
710
+ ]
711
+ return
712
+ }
713
+
387
714
  if (CLEAR_COMMANDS.has(args)) {
388
715
  cleanupGoal(sessionID)
389
716
  lastGoalResults.delete(sessionID)
717
+ await persist()
390
718
  output.parts = [makeTextPart("Goal cleared.")]
391
719
  return
392
720
  }
@@ -400,6 +728,8 @@ export const GoalPlugin = async ({ client }, pluginOptions = {}) => {
400
728
  goal.stopped = true
401
729
  goal.stopReason = "paused"
402
730
  goal.lastStatus = "Goal paused."
731
+ pushHistory(goal, "paused", "User paused the active goal.")
732
+ await persist()
403
733
  output.parts = [makeTextPart(`Goal paused: ${goal.condition}`)]
404
734
  return
405
735
  }
@@ -420,11 +750,17 @@ export const GoalPlugin = async ({ client }, pluginOptions = {}) => {
420
750
  goal.stopReason = ""
421
751
  goal.blockedReason = ""
422
752
  goal.lastStatus = "Goal resumed with a fresh local budget."
753
+ pushHistory(goal, "resumed", "User resumed the goal with a fresh local budget window.")
754
+ await persist()
423
755
  output.parts = [makeTextPart(`Goal resumed with fresh limits: ${goal.condition}`)]
424
756
  return
425
757
  }
426
758
 
427
759
  const parsed = parseGoalArguments(args, defaultGoalOptions)
760
+ if (parsed.errors.length > 0) {
761
+ output.parts = [makeTextPart(formatArgumentErrors(parsed.errors))]
762
+ return
763
+ }
428
764
  if (!parsed.condition) {
429
765
  output.parts = [makeTextPart("No goal provided. Set one with `/goal <condition>`.")]
430
766
  return
@@ -440,6 +776,7 @@ export const GoalPlugin = async ({ client }, pluginOptions = {}) => {
440
776
  options: parsed.options,
441
777
  lastStatus: "Goal set.",
442
778
  lastAssistantText: "",
779
+ lastAssistantMessageID: "",
443
780
  lastContinueAt: 0,
444
781
  lastProgressAt: 0,
445
782
  noProgressTurns: 0,
@@ -449,11 +786,21 @@ export const GoalPlugin = async ({ client }, pluginOptions = {}) => {
449
786
  stopReason: "",
450
787
  promptFailures: 0,
451
788
  messageIDs: new Set(),
789
+ history: [],
790
+ checkpoints: [],
791
+ lastCheckpoint: null,
452
792
  }
453
793
 
794
+ pushHistory(
795
+ goal,
796
+ "set",
797
+ `Goal created with limits: ${goal.options.maxTurns} auto-continues, ${Math.round(goal.options.maxDurationMs / 1000)}s, ${goal.options.maxTokens.toLocaleString()} tracked tokens.`,
798
+ )
799
+
454
800
  cleanupGoal(sessionID)
455
801
  lastGoalResults.delete(sessionID)
456
802
  goalStates.set(sessionID, goal)
803
+ await persist()
457
804
  output.parts = [
458
805
  makeTextPart(
459
806
  [
@@ -462,6 +809,7 @@ export const GoalPlugin = async ({ client }, pluginOptions = {}) => {
462
809
  "Start working toward this goal now.",
463
810
  "When the goal is fully satisfied, end your response with `[goal:complete]`.",
464
811
  "If you are truly blocked and need the user, end with `[goal:blocked]`.",
812
+ "Use `/goal history` to inspect recent lifecycle events and checkpoints.",
465
813
  "",
466
814
  `Limits: ${goal.options.maxTurns} auto-continues, ${Math.round(
467
815
  goal.options.maxDurationMs / 1000,
@@ -479,6 +827,7 @@ export const GoalPlugin = async ({ client }, pluginOptions = {}) => {
479
827
  const goal = goalStates.get(message.sessionID)
480
828
  if (!goal) return
481
829
 
830
+ let changed = false
482
831
  const currentOutputTokens = message.tokens?.output || 0
483
832
  const previousOutputTokens = seenOutputTokens.get(message.id) || 0
484
833
  const currentTokens =
@@ -490,17 +839,21 @@ export const GoalPlugin = async ({ client }, pluginOptions = {}) => {
490
839
  goal.totalTokens += currentTokens - previousTokens
491
840
  seenTokens.set(message.id, currentTokens)
492
841
  goal.messageIDs.add(message.id)
842
+ changed = true
493
843
  }
494
844
 
495
845
  if (currentOutputTokens > previousOutputTokens) {
496
846
  seenOutputTokens.set(message.id, currentOutputTokens)
497
847
  goal.messageIDs.add(message.id)
848
+ changed = true
498
849
  }
499
850
 
500
851
  if (message.role === "assistant" && currentOutputTokens > previousOutputTokens) {
501
852
  goal.lastProgressAt = Date.now()
502
- goal.noProgressTurns = 0
853
+ changed = true
503
854
  }
855
+
856
+ if (changed) await persist()
504
857
  return
505
858
  }
506
859
 
@@ -515,23 +868,32 @@ export const GoalPlugin = async ({ client }, pluginOptions = {}) => {
515
868
  try {
516
869
  const messages = await client.session.messages({
517
870
  path: { id: sessionID },
518
- query: { limit: 12 },
871
+ query: { limit: goal.options.maxRecentMessages },
519
872
  })
520
873
  const activeGoalAfterMessages = currentGoal(sessionID, goalID)
521
874
  if (!activeGoalAfterMessages) return
522
875
 
523
- const latestAssistant = [...(messages.data || [])]
524
- .reverse()
525
- .find((message) => message.info?.role === "assistant")
876
+ const latestAssistant = findLatestAssistantMessage(messages.data)
877
+ const latestAssistantID = latestAssistant?.info?.id || ""
526
878
  const latestText = getText(latestAssistant?.parts)
527
- const latestOutputTokens = outputTokensForMessage(latestAssistant)
528
-
879
+ const latestOutputTokens = latestAssistant ? outputTokensForMessage(latestAssistant) : null
880
+ const previousAssistantText = activeGoalAfterMessages.lastAssistantText
881
+ const assistantChanged = summarizeText(latestText) !== summarizeText(previousAssistantText)
882
+ const assistantRepeated =
883
+ latestAssistantID && latestAssistantID === activeGoalAfterMessages.lastAssistantMessageID
884
+
885
+ if (latestText && (!assistantRepeated || assistantChanged)) {
886
+ recordCheckpoint(activeGoalAfterMessages, latestText)
887
+ }
529
888
  activeGoalAfterMessages.lastAssistantText = latestText
889
+ activeGoalAfterMessages.lastAssistantMessageID = latestAssistantID
530
890
 
531
891
  if (goalIsComplete(latestText)) {
532
892
  activeGoalAfterMessages.lastStatus = "Goal completed."
893
+ pushHistory(activeGoalAfterMessages, "completed", "Assistant marked the goal complete.")
533
894
  rememberGoalResult(sessionID, activeGoalAfterMessages, "achieved")
534
895
  cleanupGoal(sessionID)
896
+ await persist()
535
897
  return
536
898
  }
537
899
 
@@ -540,6 +902,12 @@ export const GoalPlugin = async ({ client }, pluginOptions = {}) => {
540
902
  activeGoalAfterMessages.lastStatus = "Assistant reported blocked."
541
903
  activeGoalAfterMessages.stopped = true
542
904
  activeGoalAfterMessages.stopReason = "blocked"
905
+ pushHistory(
906
+ activeGoalAfterMessages,
907
+ "blocked",
908
+ activeGoalAfterMessages.blockedReason || "Assistant reported blocked and requested user input.",
909
+ )
910
+ await persist()
543
911
  return
544
912
  }
545
913
 
@@ -550,6 +918,7 @@ export const GoalPlugin = async ({ client }, pluginOptions = {}) => {
550
918
  activeGoalAfterMessages.stopped = true
551
919
  activeGoalAfterMessages.stopReason = limitReason
552
920
  activeGoalAfterMessages.lastStatus = `${limitReason}; requested final handoff.`
921
+ pushHistory(activeGoalAfterMessages, "limit", `${limitReason}; requested a final handoff.`)
553
922
  await client.session.promptAsync({
554
923
  path: { id: sessionID },
555
924
  body: { parts: [makeTextPart(buildContinueMessage(activeGoalAfterMessages, { budgetWrapup: true }))] },
@@ -558,19 +927,44 @@ export const GoalPlugin = async ({ client }, pluginOptions = {}) => {
558
927
  activeGoalAfterMessages.stopped = true
559
928
  activeGoalAfterMessages.stopReason = limitReason
560
929
  activeGoalAfterMessages.lastStatus = limitReason
930
+ pushHistory(activeGoalAfterMessages, "limit", limitReason)
561
931
  }
932
+ await persist()
562
933
  return
563
934
  }
564
935
 
565
- if (
936
+ const lowOutputTurn =
566
937
  activeGoalAfterMessages.turnCount > 0 &&
938
+ latestOutputTokens !== null &&
567
939
  latestOutputTokens < activeGoalAfterMessages.options.noProgressTokenThreshold
568
- ) {
940
+ const lowOutputLooksStalled =
941
+ lowOutputTurn && (assistantRepeated || !latestText || !assistantChanged)
942
+ if (lowOutputLooksStalled) {
569
943
  activeGoalAfterMessages.noProgressTurns += 1
570
- activeGoalAfterMessages.stopped = true
571
- activeGoalAfterMessages.stopReason = "no progress"
572
- activeGoalAfterMessages.lastStatus = `Goal auto-continue paused: last turn produced ${latestOutputTokens} output token(s). Run /goal resume to continue.`
573
- return
944
+ if (
945
+ activeGoalAfterMessages.noProgressTurns >=
946
+ activeGoalAfterMessages.options.noProgressTurnsBeforePause
947
+ ) {
948
+ activeGoalAfterMessages.stopped = true
949
+ activeGoalAfterMessages.stopReason = "no progress"
950
+ activeGoalAfterMessages.lastStatus = `Goal auto-continue paused after ${activeGoalAfterMessages.noProgressTurns} low-progress turn(s); the latest turn produced ${latestOutputTokens} output token(s). Run /goal resume to continue.`
951
+ pushHistory(
952
+ activeGoalAfterMessages,
953
+ "paused",
954
+ `Paused after ${activeGoalAfterMessages.noProgressTurns} low-progress turn(s) below ${activeGoalAfterMessages.options.noProgressTokenThreshold} output tokens.`,
955
+ )
956
+ await persist()
957
+ return
958
+ }
959
+
960
+ activeGoalAfterMessages.lastStatus = `Low-progress turn detected (${activeGoalAfterMessages.noProgressTurns}/${activeGoalAfterMessages.options.noProgressTurnsBeforePause}); monitoring for another stalled turn before pausing.`
961
+ pushHistory(
962
+ activeGoalAfterMessages,
963
+ "warning",
964
+ `Observed a low-progress turn below ${activeGoalAfterMessages.options.noProgressTokenThreshold} output tokens; grace count ${activeGoalAfterMessages.noProgressTurns}/${activeGoalAfterMessages.options.noProgressTurnsBeforePause}.`,
965
+ )
966
+ } else if (latestOutputTokens !== null || assistantChanged) {
967
+ activeGoalAfterMessages.noProgressTurns = 0
574
968
  }
575
969
 
576
970
  const elapsedSinceLastContinue = Date.now() - activeGoalAfterMessages.lastContinueAt
@@ -615,6 +1009,7 @@ export const GoalPlugin = async ({ client }, pluginOptions = {}) => {
615
1009
  if (activeGoalAfterPrompt) {
616
1010
  activeGoalAfterPrompt.promptFailures += 1
617
1011
  activeGoalAfterPrompt.lastStatus = message
1012
+ pushHistory(activeGoalAfterPrompt, "error", message)
618
1013
  if (activeGoalAfterPrompt.promptFailures >= activeGoalAfterPrompt.options.maxPromptFailures) {
619
1014
  activeGoalAfterPrompt.stopped = true
620
1015
  activeGoalAfterPrompt.stopReason = "auto-continue failures"
@@ -624,19 +1019,31 @@ export const GoalPlugin = async ({ client }, pluginOptions = {}) => {
624
1019
  await logPluginError(client, message, response.error)
625
1020
  } else {
626
1021
  const activeGoalAfterPrompt = currentGoal(sessionID, goalID)
627
- if (activeGoalAfterPrompt) activeGoalAfterPrompt.promptFailures = 0
1022
+ if (activeGoalAfterPrompt) {
1023
+ activeGoalAfterPrompt.promptFailures = 0
1024
+ pushHistory(
1025
+ activeGoalAfterPrompt,
1026
+ budgetWrapup ? "budget-wrapup" : "auto-continue",
1027
+ budgetWrapup
1028
+ ? "Sent a final handoff request near the tracked token budget."
1029
+ : `Sent auto-continue prompt ${activeGoalAfterPrompt.turnCount}/${activeGoalAfterPrompt.options.maxTurns}.`,
1030
+ )
1031
+ }
628
1032
  }
1033
+ await persist()
629
1034
  } catch (error) {
630
1035
  const activeGoalAfterError = currentGoal(sessionID, goalID)
631
1036
  if (activeGoalAfterError) {
632
1037
  activeGoalAfterError.promptFailures += 1
633
1038
  const message = `Auto-continue failed: ${error?.message || error}`
634
1039
  activeGoalAfterError.lastStatus = message
1040
+ pushHistory(activeGoalAfterError, "error", message)
635
1041
  if (activeGoalAfterError.promptFailures >= activeGoalAfterError.options.maxPromptFailures) {
636
1042
  activeGoalAfterError.stopped = true
637
1043
  activeGoalAfterError.stopReason = "auto-continue failures"
638
1044
  activeGoalAfterError.lastStatus = `${message}; paused after ${activeGoalAfterError.promptFailures} failure(s). Run /goal resume to retry.`
639
1045
  }
1046
+ await persist()
640
1047
  }
641
1048
  await logPluginError(client, "Auto-continue failed", error)
642
1049
  } finally {
@@ -679,6 +1086,8 @@ export const testInternals = {
679
1086
  currentGoal,
680
1087
  escapeGoalText,
681
1088
  extractBlockedReason,
1089
+ findLatestAssistantMessage,
1090
+ formatArgumentErrors,
682
1091
  formatStatus,
683
1092
  getSessionID,
684
1093
  goalIsBlocked,
@@ -687,5 +1096,7 @@ export const testInternals = {
687
1096
  normalizeOptions,
688
1097
  outputTokensForMessage,
689
1098
  parseGoalArguments,
1099
+ parsePositiveIntegerStrict,
1100
+ pruneGoalResults,
690
1101
  stopReason,
691
1102
  }