opencode-goal-plugin 0.1.8 → 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,14 @@
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
+
3
12
  ## 0.1.8 — 2026-05-18
4
13
 
5
14
  - Harden `--max-minutes` fallback arithmetic when mixed with millisecond duration overrides.
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,6 +135,15 @@ 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
 
@@ -136,15 +159,30 @@ Pass options when registering the plugin to change the defaults for all goals. T
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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-goal-plugin",
3
- "version": "0.1.8",
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,47 +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) {
223
- options.maxDurationMs =
224
- toPositiveInteger(next, Math.ceil(options.maxDurationMs / 60000)) * 60000
225
- 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
226
492
  }
227
- continue
228
- }
229
- if (part === "--max-tokens") {
230
- if (nextIsValue) { options.maxTokens = toPositiveInteger(next, options.maxTokens); i += 1 }
231
- continue
232
- }
233
- if (part === "--cooldown-ms") {
234
- if (nextIsValue) { options.minDelayMs = toPositiveInteger(next, options.minDelayMs); i += 1 }
235
- continue
236
- }
237
- if (part === "--no-progress-threshold") {
238
- if (nextIsValue) { options.noProgressTokenThreshold = toPositiveInteger(next, options.noProgressTokenThreshold); i += 1 }
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)
239
510
  continue
240
511
  }
241
512
 
242
- condition.push(part.replace(/^["']|["']$/g, ""))
513
+ condition.push(stripWrappingQuotes(part))
243
514
  }
244
515
 
245
516
  return {
246
517
  condition: condition.join(" ").trim(),
247
518
  options,
519
+ errors,
248
520
  }
249
521
  }
250
522
 
@@ -352,6 +624,20 @@ function extractBlockedReason(text) {
352
624
  .find((line) => line.trim())?.trim() || ""
353
625
  }
354
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
+
355
641
  function outputTokensForMessage(message) {
356
642
  return message?.info?.tokens?.output || 0
357
643
  }
@@ -365,6 +651,15 @@ function budgetWrapupNeeded(goal) {
365
651
 
366
652
  export const GoalPlugin = async ({ client }, pluginOptions = {}) => {
367
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
+ }
368
663
 
369
664
  return {
370
665
  "command.execute.before": async (input, output) => {
@@ -372,6 +667,7 @@ export const GoalPlugin = async ({ client }, pluginOptions = {}) => {
372
667
 
373
668
  const args = (input.arguments || "").trim()
374
669
  const sessionID = input.sessionID
670
+ pruneGoalResults(defaultGoalOptions)
375
671
 
376
672
  if (!args || args === "status") {
377
673
  const goal = goalStates.get(sessionID)
@@ -388,9 +684,37 @@ export const GoalPlugin = async ({ client }, pluginOptions = {}) => {
388
684
  return
389
685
  }
390
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
+
391
714
  if (CLEAR_COMMANDS.has(args)) {
392
715
  cleanupGoal(sessionID)
393
716
  lastGoalResults.delete(sessionID)
717
+ await persist()
394
718
  output.parts = [makeTextPart("Goal cleared.")]
395
719
  return
396
720
  }
@@ -404,6 +728,8 @@ export const GoalPlugin = async ({ client }, pluginOptions = {}) => {
404
728
  goal.stopped = true
405
729
  goal.stopReason = "paused"
406
730
  goal.lastStatus = "Goal paused."
731
+ pushHistory(goal, "paused", "User paused the active goal.")
732
+ await persist()
407
733
  output.parts = [makeTextPart(`Goal paused: ${goal.condition}`)]
408
734
  return
409
735
  }
@@ -424,11 +750,17 @@ export const GoalPlugin = async ({ client }, pluginOptions = {}) => {
424
750
  goal.stopReason = ""
425
751
  goal.blockedReason = ""
426
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()
427
755
  output.parts = [makeTextPart(`Goal resumed with fresh limits: ${goal.condition}`)]
428
756
  return
429
757
  }
430
758
 
431
759
  const parsed = parseGoalArguments(args, defaultGoalOptions)
760
+ if (parsed.errors.length > 0) {
761
+ output.parts = [makeTextPart(formatArgumentErrors(parsed.errors))]
762
+ return
763
+ }
432
764
  if (!parsed.condition) {
433
765
  output.parts = [makeTextPart("No goal provided. Set one with `/goal <condition>`.")]
434
766
  return
@@ -444,6 +776,7 @@ export const GoalPlugin = async ({ client }, pluginOptions = {}) => {
444
776
  options: parsed.options,
445
777
  lastStatus: "Goal set.",
446
778
  lastAssistantText: "",
779
+ lastAssistantMessageID: "",
447
780
  lastContinueAt: 0,
448
781
  lastProgressAt: 0,
449
782
  noProgressTurns: 0,
@@ -453,11 +786,21 @@ export const GoalPlugin = async ({ client }, pluginOptions = {}) => {
453
786
  stopReason: "",
454
787
  promptFailures: 0,
455
788
  messageIDs: new Set(),
789
+ history: [],
790
+ checkpoints: [],
791
+ lastCheckpoint: null,
456
792
  }
457
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
+
458
800
  cleanupGoal(sessionID)
459
801
  lastGoalResults.delete(sessionID)
460
802
  goalStates.set(sessionID, goal)
803
+ await persist()
461
804
  output.parts = [
462
805
  makeTextPart(
463
806
  [
@@ -466,6 +809,7 @@ export const GoalPlugin = async ({ client }, pluginOptions = {}) => {
466
809
  "Start working toward this goal now.",
467
810
  "When the goal is fully satisfied, end your response with `[goal:complete]`.",
468
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.",
469
813
  "",
470
814
  `Limits: ${goal.options.maxTurns} auto-continues, ${Math.round(
471
815
  goal.options.maxDurationMs / 1000,
@@ -483,6 +827,7 @@ export const GoalPlugin = async ({ client }, pluginOptions = {}) => {
483
827
  const goal = goalStates.get(message.sessionID)
484
828
  if (!goal) return
485
829
 
830
+ let changed = false
486
831
  const currentOutputTokens = message.tokens?.output || 0
487
832
  const previousOutputTokens = seenOutputTokens.get(message.id) || 0
488
833
  const currentTokens =
@@ -494,17 +839,21 @@ export const GoalPlugin = async ({ client }, pluginOptions = {}) => {
494
839
  goal.totalTokens += currentTokens - previousTokens
495
840
  seenTokens.set(message.id, currentTokens)
496
841
  goal.messageIDs.add(message.id)
842
+ changed = true
497
843
  }
498
844
 
499
845
  if (currentOutputTokens > previousOutputTokens) {
500
846
  seenOutputTokens.set(message.id, currentOutputTokens)
501
847
  goal.messageIDs.add(message.id)
848
+ changed = true
502
849
  }
503
850
 
504
851
  if (message.role === "assistant" && currentOutputTokens > previousOutputTokens) {
505
852
  goal.lastProgressAt = Date.now()
506
- goal.noProgressTurns = 0
853
+ changed = true
507
854
  }
855
+
856
+ if (changed) await persist()
508
857
  return
509
858
  }
510
859
 
@@ -519,23 +868,32 @@ export const GoalPlugin = async ({ client }, pluginOptions = {}) => {
519
868
  try {
520
869
  const messages = await client.session.messages({
521
870
  path: { id: sessionID },
522
- query: { limit: 12 },
871
+ query: { limit: goal.options.maxRecentMessages },
523
872
  })
524
873
  const activeGoalAfterMessages = currentGoal(sessionID, goalID)
525
874
  if (!activeGoalAfterMessages) return
526
875
 
527
- const latestAssistant = [...(messages.data || [])]
528
- .reverse()
529
- .find((message) => message.info?.role === "assistant")
876
+ const latestAssistant = findLatestAssistantMessage(messages.data)
877
+ const latestAssistantID = latestAssistant?.info?.id || ""
530
878
  const latestText = getText(latestAssistant?.parts)
531
- const latestOutputTokens = outputTokensForMessage(latestAssistant)
532
-
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
+ }
533
888
  activeGoalAfterMessages.lastAssistantText = latestText
889
+ activeGoalAfterMessages.lastAssistantMessageID = latestAssistantID
534
890
 
535
891
  if (goalIsComplete(latestText)) {
536
892
  activeGoalAfterMessages.lastStatus = "Goal completed."
893
+ pushHistory(activeGoalAfterMessages, "completed", "Assistant marked the goal complete.")
537
894
  rememberGoalResult(sessionID, activeGoalAfterMessages, "achieved")
538
895
  cleanupGoal(sessionID)
896
+ await persist()
539
897
  return
540
898
  }
541
899
 
@@ -544,6 +902,12 @@ export const GoalPlugin = async ({ client }, pluginOptions = {}) => {
544
902
  activeGoalAfterMessages.lastStatus = "Assistant reported blocked."
545
903
  activeGoalAfterMessages.stopped = true
546
904
  activeGoalAfterMessages.stopReason = "blocked"
905
+ pushHistory(
906
+ activeGoalAfterMessages,
907
+ "blocked",
908
+ activeGoalAfterMessages.blockedReason || "Assistant reported blocked and requested user input.",
909
+ )
910
+ await persist()
547
911
  return
548
912
  }
549
913
 
@@ -554,6 +918,7 @@ export const GoalPlugin = async ({ client }, pluginOptions = {}) => {
554
918
  activeGoalAfterMessages.stopped = true
555
919
  activeGoalAfterMessages.stopReason = limitReason
556
920
  activeGoalAfterMessages.lastStatus = `${limitReason}; requested final handoff.`
921
+ pushHistory(activeGoalAfterMessages, "limit", `${limitReason}; requested a final handoff.`)
557
922
  await client.session.promptAsync({
558
923
  path: { id: sessionID },
559
924
  body: { parts: [makeTextPart(buildContinueMessage(activeGoalAfterMessages, { budgetWrapup: true }))] },
@@ -562,19 +927,44 @@ export const GoalPlugin = async ({ client }, pluginOptions = {}) => {
562
927
  activeGoalAfterMessages.stopped = true
563
928
  activeGoalAfterMessages.stopReason = limitReason
564
929
  activeGoalAfterMessages.lastStatus = limitReason
930
+ pushHistory(activeGoalAfterMessages, "limit", limitReason)
565
931
  }
932
+ await persist()
566
933
  return
567
934
  }
568
935
 
569
- if (
936
+ const lowOutputTurn =
570
937
  activeGoalAfterMessages.turnCount > 0 &&
938
+ latestOutputTokens !== null &&
571
939
  latestOutputTokens < activeGoalAfterMessages.options.noProgressTokenThreshold
572
- ) {
940
+ const lowOutputLooksStalled =
941
+ lowOutputTurn && (assistantRepeated || !latestText || !assistantChanged)
942
+ if (lowOutputLooksStalled) {
573
943
  activeGoalAfterMessages.noProgressTurns += 1
574
- activeGoalAfterMessages.stopped = true
575
- activeGoalAfterMessages.stopReason = "no progress"
576
- activeGoalAfterMessages.lastStatus = `Goal auto-continue paused: last turn produced ${latestOutputTokens} output token(s). Run /goal resume to continue.`
577
- 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
578
968
  }
579
969
 
580
970
  const elapsedSinceLastContinue = Date.now() - activeGoalAfterMessages.lastContinueAt
@@ -619,6 +1009,7 @@ export const GoalPlugin = async ({ client }, pluginOptions = {}) => {
619
1009
  if (activeGoalAfterPrompt) {
620
1010
  activeGoalAfterPrompt.promptFailures += 1
621
1011
  activeGoalAfterPrompt.lastStatus = message
1012
+ pushHistory(activeGoalAfterPrompt, "error", message)
622
1013
  if (activeGoalAfterPrompt.promptFailures >= activeGoalAfterPrompt.options.maxPromptFailures) {
623
1014
  activeGoalAfterPrompt.stopped = true
624
1015
  activeGoalAfterPrompt.stopReason = "auto-continue failures"
@@ -628,19 +1019,31 @@ export const GoalPlugin = async ({ client }, pluginOptions = {}) => {
628
1019
  await logPluginError(client, message, response.error)
629
1020
  } else {
630
1021
  const activeGoalAfterPrompt = currentGoal(sessionID, goalID)
631
- 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
+ }
632
1032
  }
1033
+ await persist()
633
1034
  } catch (error) {
634
1035
  const activeGoalAfterError = currentGoal(sessionID, goalID)
635
1036
  if (activeGoalAfterError) {
636
1037
  activeGoalAfterError.promptFailures += 1
637
1038
  const message = `Auto-continue failed: ${error?.message || error}`
638
1039
  activeGoalAfterError.lastStatus = message
1040
+ pushHistory(activeGoalAfterError, "error", message)
639
1041
  if (activeGoalAfterError.promptFailures >= activeGoalAfterError.options.maxPromptFailures) {
640
1042
  activeGoalAfterError.stopped = true
641
1043
  activeGoalAfterError.stopReason = "auto-continue failures"
642
1044
  activeGoalAfterError.lastStatus = `${message}; paused after ${activeGoalAfterError.promptFailures} failure(s). Run /goal resume to retry.`
643
1045
  }
1046
+ await persist()
644
1047
  }
645
1048
  await logPluginError(client, "Auto-continue failed", error)
646
1049
  } finally {
@@ -683,6 +1086,8 @@ export const testInternals = {
683
1086
  currentGoal,
684
1087
  escapeGoalText,
685
1088
  extractBlockedReason,
1089
+ findLatestAssistantMessage,
1090
+ formatArgumentErrors,
686
1091
  formatStatus,
687
1092
  getSessionID,
688
1093
  goalIsBlocked,
@@ -691,5 +1096,7 @@ export const testInternals = {
691
1096
  normalizeOptions,
692
1097
  outputTokensForMessage,
693
1098
  parseGoalArguments,
1099
+ parsePositiveIntegerStrict,
1100
+ pruneGoalResults,
694
1101
  stopReason,
695
1102
  }