opencode-goal-plugin 0.1.9 → 0.1.11

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/.nvmrc ADDED
@@ -0,0 +1 @@
1
+ 22
package/CHANGELOG.md CHANGED
@@ -1,5 +1,19 @@
1
1
  # Changelog
2
2
 
3
+ ## Unreleased
4
+
5
+ ## 0.1.11 — 2026-06-04
6
+
7
+ - Add `npm run smoke`, a package-export smoke test that exercises the `/goal` command hook without invoking a model.
8
+ - Run CI across Node 18, 20, and 22, and wire the package-entry smoke test into the workflow.
9
+ - Harden persisted-state loading with schema validation and explicit skipping of malformed goal/result entries.
10
+ - Make hook handling more defensive around message payload shapes and `system` block normalization.
11
+ - Expand docs around compatibility, release checks, smoke testing, and security reporting fallback.
12
+
13
+ ## 0.1.10 — 2026-05-30
14
+
15
+ - Fix `experimental.chat.system.transform` to merge the goal continuation block into the primary system entry instead of pushing a separate one. Prevents `"System message must be at the beginning."` errors on strict-template backends (Qwen on vLLM, several Llama.cpp/Mistral templates). See issue #1.
16
+
3
17
  ## 0.1.9 — 2026-05-18
4
18
 
5
19
  > 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.
package/CONTRIBUTING.md CHANGED
@@ -4,9 +4,18 @@ Thanks for helping improve `opencode-goal-plugin`.
4
4
 
5
5
  ## Development
6
6
 
7
+ Use the pinned Node version when possible:
8
+
9
+ ```sh
10
+ nvm use
11
+ ```
12
+
7
13
  Run the local checks before submitting changes:
8
14
 
9
15
  ```sh
16
+ npm test
17
+ npm run test:coverage
18
+ npm run smoke
10
19
  npm run check
11
20
  npm run pack:check
12
21
  ```
@@ -15,11 +24,27 @@ For behavior changes, add or update tests in `test/goal-plugin.test.js`.
15
24
 
16
25
  ## OpenCode Compatibility
17
26
 
18
- This plugin depends on OpenCode plugin hooks, including experimental hooks. When changing hook usage or command behavior:
27
+ This plugin depends on OpenCode plugin hooks, including experimental hooks. When changing hook usage, command behavior, or system-prompt transforms:
19
28
 
20
29
  1. Check the current OpenCode plugin and command documentation.
21
- 2. Test against a real OpenCode install when possible.
22
- 3. Update the README compatibility note if the tested version changes.
30
+ 2. Run `npm run smoke` to verify the packaged entrypoint and command hook surface.
31
+ 3. Test against a real OpenCode install when possible.
32
+ 4. Update the README compatibility snapshot if the tested surface changes.
33
+
34
+ `npm run smoke` verifies the package export path and `/goal` command hook without invoking a model. It does not replace a real OpenCode smoke test after hook or command behavior changes.
35
+
36
+ ## Release checklist
37
+
38
+ Before publishing or tagging a release:
39
+
40
+ - update `CHANGELOG.md`
41
+ - run `npm test`
42
+ - run `npm run test:coverage`
43
+ - run `npm run smoke`
44
+ - run `npm run check`
45
+ - run `npm run pack:check`
46
+ - perform at least one manual OpenCode smoke test if hook behavior changed
47
+ - refresh compatibility notes if the tested OpenCode surface changed
23
48
 
24
49
  ## Pull Requests
25
50
 
@@ -29,4 +54,3 @@ Keep pull requests focused. Include:
29
54
  - why it changed
30
55
  - the checks you ran
31
56
  - any manual OpenCode smoke testing performed
32
-
package/README.md CHANGED
@@ -4,7 +4,16 @@ An experimental session-scoped `/goal` command for [OpenCode](https://opencode.a
4
4
 
5
5
  Set a goal and the plugin keeps it in context, auto-continues the session whenever the assistant goes idle, and stops when the goal is marked complete, a blocker is reported, or a safety limit is reached.
6
6
 
7
- Compatibility: tested against OpenCode 1.15.4. The plugin relies on experimental OpenCode hooks; pin or re-test against your OpenCode version before using it for unattended long-running work.
7
+ Compatibility: this plugin relies on experimental OpenCode hooks. Re-test against the exact OpenCode build and provider/backend stack you plan to use for unattended work.
8
+
9
+ ## Compatibility snapshot
10
+
11
+ | Surface | Status |
12
+ |---|---|
13
+ | Node.js | Declared support: `>=18`; CI covers Node 18, 20, and 22 |
14
+ | Package entrypoint | `npm run smoke` verifies the package export path plus `/goal` command-hook behavior from a local install without invoking a model |
15
+ | OpenCode host | Manually smoke-tested against OpenCode 1.15.10 using the `opencode-go` provider (`qwen3.7-plus`) on this repo's local hardening branch; re-test your own version/provider stack before relying on unattended runs |
16
+ | Provider/backend quirks | Strict-template backends require the goal block to merge into the primary `system` message; covered by regression tests |
8
17
 
9
18
  ## Install
10
19
 
@@ -209,19 +218,28 @@ Keep test files outside OpenCode's auto-loaded plugin directory — OpenCode wil
209
218
 
210
219
  ### Smoke-test checklist
211
220
 
212
- 1. Install or file-load the plugin in a temporary OpenCode config.
213
- 2. Add a `goal` command with `"template": "$ARGUMENTS"`.
214
- 3. Run `/goal status` should report no active goal.
215
- 4. Run `/goal inspect this repo and stop immediately with [goal:blocked] if you need user input`.
216
- 5. Verify `/goal status`, `/goal pause`, `/goal resume`, and `/goal clear` behave as expected.
221
+ 1. Run `npm run smoke` to verify the package export path and `/goal` command hook without a model call.
222
+ 2. Install or file-load the plugin in a temporary OpenCode config.
223
+ 3. Add a `goal` command with `"template": "$ARGUMENTS"`.
224
+ 4. Run `/goal status` should report no active goal.
225
+ 5. Run `/goal inspect this repo and stop immediately with [goal:blocked] if you need user input`.
226
+ 6. Verify `/goal status`, `/goal pause`, `/goal resume`, and `/goal clear` behave as expected.
227
+ 7. If you changed hook payload handling or command behavior, repeat the smoke test against the exact OpenCode version and provider/backend combination you care about.
228
+
229
+ ### Release checklist
230
+
231
+ - Confirm `npm test`, `npm run test:coverage`, `npm run check`, `npm run smoke`, and `npm run pack:check` all pass.
232
+ - Re-test against a real OpenCode install when touching command hooks, idle-event handling, or system-prompt transforms.
233
+ - Update compatibility notes and changelog entries when behavior or tested surfaces change.
217
234
 
218
235
  ## Development
219
236
 
220
237
  ```sh
221
- npm test # run the test suite
222
- npm run test:coverage # run tests with coverage
223
- npm run check # syntax check + tests
224
- npm run pack:check # verify package contents before publishing
238
+ npm test # run the test suite
239
+ npm run test:coverage # run tests with coverage
240
+ npm run smoke # verify package export + command hook without a model call
241
+ npm run check # syntax check + tests
242
+ npm run pack:check # verify package contents before publishing
225
243
  ```
226
244
 
227
245
  ## License
package/SECURITY.md CHANGED
@@ -6,13 +6,18 @@ This project is experimental. Security fixes are provided for the latest publish
6
6
 
7
7
  ## Reporting a Vulnerability
8
8
 
9
- Please report security issues through GitHub's private vulnerability reporting for this repository.
9
+ GitHub private vulnerability reporting may not always be enabled for this repository.
10
10
 
11
- Do not open a public issue for vulnerabilities that could expose user data, credentials, or local system access.
11
+ Until a dedicated private reporting channel is documented here, do **not** open a public issue with exploit details, credentials, local paths, or reproduction steps that could expose user data or local system access.
12
+
13
+ Instead:
14
+
15
+ 1. open a minimal public issue asking for a private contact path, or
16
+ 2. contact the maintainer through their GitHub profile and request a private handoff.
12
17
 
13
18
  ## Scope
14
19
 
15
- This plugin does not intentionally read credentials, write files, or execute shell commands. It observes OpenCode session events, injects goal context into prompts, and sends continuation prompts through OpenCode's SDK client.
20
+ This plugin does not intentionally read credentials, write arbitrary user files, or execute shell commands. It observes OpenCode session events, injects goal context into prompts, and sends continuation prompts through OpenCode's SDK client.
16
21
 
17
22
  Relevant security-sensitive areas include:
18
23
 
@@ -20,5 +25,6 @@ Relevant security-sensitive areas include:
20
25
  - unexpected auto-continuation behavior
21
26
  - incorrect command or hook handling across OpenCode versions
22
27
  - leakage of goal text through logs or status output
28
+ - malformed persisted state causing stale or unexpected goal recovery
23
29
 
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.
30
+ 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 still receives plaintext rather than true privileged structure, but you should still treat goal text as trusted local input rather than pasting in arbitrary third-party content.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-goal-plugin",
3
- "version": "0.1.9",
3
+ "version": "0.1.11",
4
4
  "description": "Session-scoped /goal workflow for OpenCode.",
5
5
  "type": "module",
6
6
  "main": "./src/goal-plugin.js",
@@ -10,16 +10,19 @@
10
10
  },
11
11
  "files": [
12
12
  "src",
13
+ "scripts",
13
14
  "examples",
14
15
  "README.md",
15
16
  "CHANGELOG.md",
16
17
  "CONTRIBUTING.md",
17
18
  "SECURITY.md",
18
- "LICENSE"
19
+ "LICENSE",
20
+ ".nvmrc"
19
21
  ],
20
22
  "scripts": {
21
23
  "test": "node --test",
22
24
  "test:coverage": "node --test --experimental-test-coverage",
25
+ "smoke": "node scripts/smoke-command-hook.mjs",
23
26
  "check": "node -c src/goal-plugin.js && npm test",
24
27
  "pack:check": "npm pack --dry-run"
25
28
  },
@@ -0,0 +1,49 @@
1
+ import assert from "node:assert/strict"
2
+ import pluginModule, { GoalPlugin } from "opencode-goal-plugin"
3
+
4
+ const sessionID = `smoke-${Date.now()}`
5
+ const promptCalls = []
6
+ const logCalls = []
7
+
8
+ const client = {
9
+ app: {
10
+ log: async (input) => {
11
+ logCalls.push(input)
12
+ },
13
+ },
14
+ session: {
15
+ messages: async () => ({ data: [] }),
16
+ promptAsync: async (input) => {
17
+ promptCalls.push(input)
18
+ return {}
19
+ },
20
+ },
21
+ }
22
+
23
+ assert.equal(pluginModule.id, "opencode-goal-plugin")
24
+ assert.equal(pluginModule.server, GoalPlugin)
25
+
26
+ const hooks = await GoalPlugin({ client }, { minDelayMs: 1 })
27
+ assert.equal(typeof hooks["command.execute.before"], "function")
28
+ assert.equal(typeof hooks.event, "function")
29
+ assert.equal(typeof hooks["experimental.chat.system.transform"], "function")
30
+
31
+ const commandHook = hooks["command.execute.before"]
32
+
33
+ async function runGoalCommand(args) {
34
+ const output = { parts: [] }
35
+ await commandHook({ command: "goal", sessionID, arguments: args }, output)
36
+ assert.equal(output.parts.length, 1)
37
+ assert.equal(output.parts[0].type, "text")
38
+ return output.parts[0].text
39
+ }
40
+
41
+ assert.match(await runGoalCommand("status"), /No active goal/)
42
+ assert.match(await runGoalCommand("ship a smoke test --max-turns 1"), /New active goal/)
43
+ assert.match(await runGoalCommand("status"), /Active goal: ship a smoke test/)
44
+ assert.match(await runGoalCommand("clear"), /Goal cleared/)
45
+ assert.match(await runGoalCommand("status"), /No active goal/)
46
+ assert.equal(promptCalls.length, 0)
47
+ assert.equal(logCalls.length, 0)
48
+
49
+ console.log("opencode-goal-plugin command hook smoke passed")
@@ -295,6 +295,11 @@ function parsePositiveIntegerStrict(value) {
295
295
  return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : null
296
296
  }
297
297
 
298
+ function toNonNegativeInteger(value, fallback = 0) {
299
+ const parsed = Number(value)
300
+ return Number.isSafeInteger(parsed) && parsed >= 0 ? parsed : fallback
301
+ }
302
+
298
303
  function stripWrappingQuotes(value) {
299
304
  return value.replace(/^["']|["']$/g, "")
300
305
  }
@@ -358,6 +363,109 @@ function normalizePersistenceOptions(options = {}) {
358
363
  }
359
364
  }
360
365
 
366
+ function isPlainObject(value) {
367
+ return value !== null && typeof value === "object" && !Array.isArray(value)
368
+ }
369
+
370
+ function normalizeTimestamp(value, fallback = Date.now()) {
371
+ const parsed = Number(value)
372
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback
373
+ }
374
+
375
+ function normalizeHistoryEntries(entries) {
376
+ if (!Array.isArray(entries)) return []
377
+ return entries
378
+ .filter(isPlainObject)
379
+ .map((entry) =>
380
+ makeHistoryEntry(
381
+ typeof entry.type === "string" && entry.type.trim() ? entry.type.trim() : "event",
382
+ typeof entry.detail === "string" ? entry.detail : "",
383
+ normalizeTimestamp(entry.timestamp),
384
+ ),
385
+ )
386
+ }
387
+
388
+ function normalizeCheckpointEntry(entry) {
389
+ if (!isPlainObject(entry)) return null
390
+ const summary = summarizeText(entry.summary)
391
+ if (!summary) return null
392
+ return {
393
+ summary,
394
+ timestamp: normalizeTimestamp(entry.timestamp),
395
+ }
396
+ }
397
+
398
+ function normalizeCheckpointEntries(entries) {
399
+ if (!Array.isArray(entries)) return []
400
+ return entries.map(normalizeCheckpointEntry).filter(Boolean)
401
+ }
402
+
403
+ function normalizePersistedGoal(rawGoal) {
404
+ if (!isPlainObject(rawGoal)) return null
405
+ if (typeof rawGoal.sessionID !== "string" || !rawGoal.sessionID.trim()) return null
406
+ if (typeof rawGoal.condition !== "string" || !rawGoal.condition.trim()) return null
407
+
408
+ const checkpoints = normalizeCheckpointEntries(rawGoal.checkpoints)
409
+ const lastCheckpoint = normalizeCheckpointEntry(rawGoal.lastCheckpoint) || checkpoints.at(-1) || null
410
+
411
+ return {
412
+ goalId:
413
+ typeof rawGoal.goalId === "string" && rawGoal.goalId.trim()
414
+ ? rawGoal.goalId
415
+ : randomUUID(),
416
+ condition: rawGoal.condition.trim(),
417
+ sessionID: rawGoal.sessionID.trim(),
418
+ turnCount: toNonNegativeInteger(rawGoal.turnCount),
419
+ startedAt: normalizeTimestamp(rawGoal.startedAt),
420
+ totalTokens: toNonNegativeInteger(rawGoal.totalTokens),
421
+ options: normalizeOptions(isPlainObject(rawGoal.options) ? rawGoal.options : {}),
422
+ lastStatus: typeof rawGoal.lastStatus === "string" ? rawGoal.lastStatus : "Goal recovered.",
423
+ lastAssistantText:
424
+ typeof rawGoal.lastAssistantText === "string" ? rawGoal.lastAssistantText : "",
425
+ lastAssistantMessageID:
426
+ typeof rawGoal.lastAssistantMessageID === "string" ? rawGoal.lastAssistantMessageID : "",
427
+ lastContinueAt: toNonNegativeInteger(rawGoal.lastContinueAt),
428
+ lastProgressAt: toNonNegativeInteger(rawGoal.lastProgressAt),
429
+ noProgressTurns: toNonNegativeInteger(rawGoal.noProgressTurns),
430
+ blockedReason: typeof rawGoal.blockedReason === "string" ? rawGoal.blockedReason : "",
431
+ budgetWrapupSent: rawGoal.budgetWrapupSent === true,
432
+ stopped: rawGoal.stopped === true,
433
+ stopReason: typeof rawGoal.stopReason === "string" ? rawGoal.stopReason : "",
434
+ promptFailures: toNonNegativeInteger(rawGoal.promptFailures),
435
+ messageIDs: Array.isArray(rawGoal.messageIDs)
436
+ ? rawGoal.messageIDs.filter((messageID) => typeof messageID === "string" && messageID)
437
+ : [],
438
+ history: normalizeHistoryEntries(rawGoal.history).slice(-MAX_HISTORY_ENTRIES),
439
+ checkpoints: checkpoints.slice(-MAX_CHECKPOINTS),
440
+ lastCheckpoint,
441
+ }
442
+ }
443
+
444
+ function normalizePersistedResult(rawResult) {
445
+ if (!isPlainObject(rawResult)) return null
446
+ if (typeof rawResult.sessionID !== "string" || !rawResult.sessionID.trim()) return null
447
+ if (typeof rawResult.condition !== "string" || !rawResult.condition.trim()) return null
448
+
449
+ const checkpoints = normalizeCheckpointEntries(rawResult.checkpoints)
450
+ const lastCheckpoint = normalizeCheckpointEntry(rawResult.lastCheckpoint) || checkpoints.at(-1) || null
451
+
452
+ return {
453
+ sessionID: rawResult.sessionID.trim(),
454
+ condition: rawResult.condition.trim(),
455
+ state: typeof rawResult.state === "string" && rawResult.state.trim() ? rawResult.state : "unknown",
456
+ reason: typeof rawResult.reason === "string" ? rawResult.reason : "",
457
+ blockedReason: typeof rawResult.blockedReason === "string" ? rawResult.blockedReason : "",
458
+ turnCount: toNonNegativeInteger(rawResult.turnCount),
459
+ totalTokens: toNonNegativeInteger(rawResult.totalTokens),
460
+ startedAt: normalizeTimestamp(rawResult.startedAt),
461
+ finishedAt: normalizeTimestamp(rawResult.finishedAt),
462
+ lastStatus: typeof rawResult.lastStatus === "string" ? rawResult.lastStatus : "",
463
+ lastCheckpoint,
464
+ checkpoints: checkpoints.slice(-MAX_CHECKPOINTS),
465
+ history: normalizeHistoryEntries(rawResult.history).slice(-MAX_HISTORY_ENTRIES),
466
+ }
467
+ }
468
+
361
469
  function serializeGoal(goal) {
362
470
  return {
363
471
  ...goal,
@@ -405,13 +513,47 @@ async function loadPersistedState(persistenceOptions, client) {
405
513
  return "invalid"
406
514
  }
407
515
 
516
+ if (!Array.isArray(parsed.goals) || !Array.isArray(parsed.results)) {
517
+ await logPluginError(client, "Skipped persisted goal state: malformed goals/results arrays.")
518
+ return "invalid"
519
+ }
520
+
521
+ const loadedGoals = []
522
+ let skippedGoals = 0
523
+ for (const rawGoal of parsed.goals) {
524
+ const normalizedGoal = normalizePersistedGoal(rawGoal)
525
+ if (normalizedGoal) {
526
+ loadedGoals.push(normalizedGoal)
527
+ } else {
528
+ skippedGoals += 1
529
+ }
530
+ }
531
+
532
+ const loadedResults = []
533
+ let skippedResults = 0
534
+ for (const rawResult of parsed.results) {
535
+ const normalizedResult = normalizePersistedResult(rawResult)
536
+ if (normalizedResult) {
537
+ loadedResults.push(normalizedResult)
538
+ } else {
539
+ skippedResults += 1
540
+ }
541
+ }
542
+
543
+ if (skippedGoals > 0 || skippedResults > 0) {
544
+ await logPluginError(
545
+ client,
546
+ `Skipped invalid persisted entries: ${skippedGoals} goal(s), ${skippedResults} result(s).`,
547
+ )
548
+ }
549
+
408
550
  clearRuntimeState()
409
551
 
410
- for (const goal of parsed.goals || []) {
552
+ for (const goal of loadedGoals) {
411
553
  goalStates.set(goal.sessionID, deserializeGoal(goal))
412
554
  }
413
555
 
414
- for (const result of parsed.results || []) {
556
+ for (const result of loadedResults) {
415
557
  lastGoalResults.set(result.sessionID, result)
416
558
  }
417
559
 
@@ -634,12 +776,104 @@ function formatArgumentErrors(errors) {
634
776
  ].join("\n")
635
777
  }
636
778
 
779
+ function messageRole(message) {
780
+ return message?.info?.role || message?.role || ""
781
+ }
782
+
783
+ function messageID(message) {
784
+ return message?.info?.id || message?.id || ""
785
+ }
786
+
787
+ function messageSessionID(message) {
788
+ return message?.info?.sessionID || message?.sessionID || ""
789
+ }
790
+
791
+ function messageTokens(message) {
792
+ return isPlainObject(message?.info?.tokens)
793
+ ? message.info.tokens
794
+ : isPlainObject(message?.tokens)
795
+ ? message.tokens
796
+ : {}
797
+ }
798
+
799
+ function totalTokensForMessage(message) {
800
+ const tokens = messageTokens(message)
801
+ return (
802
+ toNonNegativeInteger(tokens.input) +
803
+ toNonNegativeInteger(tokens.output) +
804
+ toNonNegativeInteger(tokens.reasoning)
805
+ )
806
+ }
807
+
808
+ function messageInfoFromEvent(event) {
809
+ const candidates = [
810
+ event?.properties?.info,
811
+ event?.properties?.message?.info,
812
+ event?.properties?.message,
813
+ ]
814
+ return candidates.find(isPlainObject) || null
815
+ }
816
+
817
+ function appendGoalToSystemBlock(block, goalBlock) {
818
+ if (typeof block === "string") {
819
+ return `${block}\n\n${goalBlock}`
820
+ }
821
+
822
+ if (!isPlainObject(block)) return null
823
+
824
+ if (typeof block.text === "string") {
825
+ return {
826
+ ...block,
827
+ text: `${block.text}\n\n${goalBlock}`,
828
+ }
829
+ }
830
+
831
+ if (typeof block.content === "string") {
832
+ return {
833
+ ...block,
834
+ content: `${block.content}\n\n${goalBlock}`,
835
+ }
836
+ }
837
+
838
+ if (Array.isArray(block.content)) {
839
+ const content = [...block.content]
840
+ const firstTextIndex = content.findIndex(
841
+ (part) => isPlainObject(part) && typeof part.text === "string",
842
+ )
843
+ if (firstTextIndex >= 0) {
844
+ content[firstTextIndex] = {
845
+ ...content[firstTextIndex],
846
+ text: `${content[firstTextIndex].text}\n\n${goalBlock}`,
847
+ }
848
+ return {
849
+ ...block,
850
+ content,
851
+ }
852
+ }
853
+ }
854
+
855
+ return null
856
+ }
857
+
858
+ function systemBlockContainsGoal(block) {
859
+ if (typeof block === "string") return block.includes("<goal_objective>")
860
+ if (!isPlainObject(block)) return false
861
+ if (typeof block.text === "string") return block.text.includes("<goal_objective>")
862
+ if (typeof block.content === "string") return block.content.includes("<goal_objective>")
863
+ if (Array.isArray(block.content)) {
864
+ return block.content.some(
865
+ (part) => isPlainObject(part) && typeof part.text === "string" && part.text.includes("<goal_objective>"),
866
+ )
867
+ }
868
+ return false
869
+ }
870
+
637
871
  function findLatestAssistantMessage(messages) {
638
- return [...(messages || [])].reverse().find((message) => message.info?.role === "assistant") || null
872
+ return [...(messages || [])].reverse().find((message) => messageRole(message) === "assistant") || null
639
873
  }
640
874
 
641
875
  function outputTokensForMessage(message) {
642
- return message?.info?.tokens?.output || 0
876
+ return toNonNegativeInteger(messageTokens(message).output)
643
877
  }
644
878
 
645
879
  function budgetWrapupNeeded(goal) {
@@ -821,34 +1055,34 @@ export const GoalPlugin = async ({ client }, pluginOptions = {}) => {
821
1055
 
822
1056
  event: async ({ event }) => {
823
1057
  if (event.type === "message.updated") {
824
- const message = event.properties?.info
1058
+ const message = messageInfoFromEvent(event)
825
1059
  if (!message) return
826
1060
 
827
- const goal = goalStates.get(message.sessionID)
1061
+ const goal = goalStates.get(messageSessionID(message))
828
1062
  if (!goal) return
829
1063
 
1064
+ const currentMessageID = messageID(message)
1065
+ if (!currentMessageID) return
1066
+
830
1067
  let changed = false
831
- const currentOutputTokens = message.tokens?.output || 0
832
- const previousOutputTokens = seenOutputTokens.get(message.id) || 0
833
- const currentTokens =
834
- (message.tokens?.input || 0) +
835
- currentOutputTokens +
836
- (message.tokens?.reasoning || 0)
837
- const previousTokens = seenTokens.get(message.id) || 0
1068
+ const currentOutputTokens = outputTokensForMessage(message)
1069
+ const previousOutputTokens = seenOutputTokens.get(currentMessageID) || 0
1070
+ const currentTokens = totalTokensForMessage(message)
1071
+ const previousTokens = seenTokens.get(currentMessageID) || 0
838
1072
  if (currentTokens > previousTokens) {
839
1073
  goal.totalTokens += currentTokens - previousTokens
840
- seenTokens.set(message.id, currentTokens)
841
- goal.messageIDs.add(message.id)
1074
+ seenTokens.set(currentMessageID, currentTokens)
1075
+ goal.messageIDs.add(currentMessageID)
842
1076
  changed = true
843
1077
  }
844
1078
 
845
1079
  if (currentOutputTokens > previousOutputTokens) {
846
- seenOutputTokens.set(message.id, currentOutputTokens)
847
- goal.messageIDs.add(message.id)
1080
+ seenOutputTokens.set(currentMessageID, currentOutputTokens)
1081
+ goal.messageIDs.add(currentMessageID)
848
1082
  changed = true
849
1083
  }
850
1084
 
851
- if (message.role === "assistant" && currentOutputTokens > previousOutputTokens) {
1085
+ if (messageRole(message) === "assistant" && currentOutputTokens > previousOutputTokens) {
852
1086
  goal.lastProgressAt = Date.now()
853
1087
  changed = true
854
1088
  }
@@ -1057,17 +1291,29 @@ export const GoalPlugin = async ({ client }, pluginOptions = {}) => {
1057
1291
  const goal = goalStates.get(input.sessionID)
1058
1292
  if (!goal) return
1059
1293
  if (goal.stopped) return
1060
- if (output.system.some((block) => block.includes("<goal_objective>"))) return
1061
-
1062
- output.system.push(
1063
- [
1064
- buildGoalBlock(goal),
1065
- "Keep working until the goal is fully satisfied.",
1066
- "When fully satisfied, end the response with `[goal:complete]`.",
1067
- "If user input is required, explain the blocker in the line immediately before `[goal:blocked]`.",
1068
- buildLimitWarning(goal),
1069
- ].filter(Boolean).join("\n"),
1070
- )
1294
+ const systemBlocks = Array.isArray(output.system) ? [...output.system] : []
1295
+ if (systemBlocks.some(systemBlockContainsGoal)) return
1296
+
1297
+ const goalBlock = [
1298
+ buildGoalBlock(goal),
1299
+ "Keep working until the goal is fully satisfied.",
1300
+ "When fully satisfied, end the response with `[goal:complete]`.",
1301
+ "If user input is required, explain the blocker in the line immediately before `[goal:blocked]`.",
1302
+ buildLimitWarning(goal),
1303
+ ].filter(Boolean).join("\n")
1304
+
1305
+ if (systemBlocks.length === 0) {
1306
+ output.system = [goalBlock]
1307
+ return
1308
+ }
1309
+
1310
+ const mergedFirstBlock = appendGoalToSystemBlock(systemBlocks[0], goalBlock)
1311
+ if (mergedFirstBlock) {
1312
+ systemBlocks[0] = mergedFirstBlock
1313
+ } else {
1314
+ systemBlocks.unshift(goalBlock)
1315
+ }
1316
+ output.system = systemBlocks
1071
1317
  },
1072
1318
  }
1073
1319
  }