@prevalentware/opencode-goal-plugin 0.1.17 → 0.1.19

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@prevalentware/opencode-goal-plugin",
3
- "version": "0.1.17",
3
+ "version": "0.1.19",
4
4
  "description": "OpenCode goal plugin that adds Codex-style long-running goal mode, /goal commands, persistence, and TUI status for AI coding agents.",
5
5
  "keywords": [
6
6
  "opencode",
@@ -53,14 +53,14 @@
53
53
  "prepublishOnly": "bun run test && bun run build"
54
54
  },
55
55
  "dependencies": {
56
- "@opencode-ai/plugin": "^1.14.39",
56
+ "@opencode-ai/plugin": "^1.17.1",
57
57
  "effect": "^3.21.2",
58
58
  "zod": "^4.1.8"
59
59
  },
60
60
  "devDependencies": {
61
61
  "@eslint/js": "^10.0.1",
62
- "@opentui/core": "^0.2.2",
63
- "@opentui/solid": "^0.2.2",
62
+ "@opentui/core": "^0.4.0",
63
+ "@opentui/solid": "^0.4.0",
64
64
  "@types/bun": "^1.3.13",
65
65
  "eslint": "^10.3.0",
66
66
  "solid-js": "1.9.12",
@@ -68,7 +68,7 @@
68
68
  "typescript-eslint": "^8.59.2"
69
69
  },
70
70
  "engines": {
71
- "opencode": ">=1.14.0"
71
+ "opencode": ">=1.17.1"
72
72
  },
73
73
  "publishConfig": {
74
74
  "access": "public"
package/src/tui.tsx CHANGED
@@ -1,11 +1,22 @@
1
1
  /** @jsxImportSource @opentui/solid */
2
- import type { TuiPlugin, TuiPluginApi, TuiPluginModule } from "@opencode-ai/plugin/tui"
2
+ import type { TuiCommand, TuiPlugin, TuiPluginApi, TuiPluginModule } from "@opencode-ai/plugin/tui"
3
3
  import { createMemo, createSignal, onCleanup, Show } from "solid-js"
4
4
 
5
+ type GoalCheckpoint = {
6
+ summary: string
7
+ timestamp: number
8
+ }
9
+
10
+ type GoalHistoryEntry = {
11
+ type: string
12
+ detail: string
13
+ timestamp: number
14
+ }
15
+
5
16
  type GoalSnapshot = {
6
17
  sessionID: string
7
18
  objective: string
8
- status: "active" | "paused" | "budgetLimited" | "complete" | "unmet"
19
+ status: "active" | "paused" | "budgetLimited" | "usageLimited" | "complete" | "unmet"
9
20
  tokenBudget: number | null
10
21
  tokensUsed: number
11
22
  timeUsedSeconds: number
@@ -16,6 +27,18 @@ type GoalSnapshot = {
16
27
  closedAt?: number | null
17
28
  continuationFailures: number
18
29
  lastStatus: string | null
30
+ maxAutoTurns: number | null
31
+ maxDurationSeconds: number | null
32
+ noProgressTokenThreshold: number | null
33
+ maxNoProgressTurns: number | null
34
+ noProgressTurns: number
35
+ budgetWrapupSent: boolean
36
+ stopReason: string | null
37
+ history: GoalHistoryEntry[]
38
+ checkpoints: GoalCheckpoint[]
39
+ lastCheckpoint: GoalCheckpoint | null
40
+ lastAssistantText: string
41
+ lastAssistantMessageID: string
19
42
  autoTurns: number
20
43
  lastContinuationAt: number | null
21
44
  remainingTokens: number | null
@@ -41,6 +64,22 @@ type GoalSessionState = {
41
64
  messageIndex: number
42
65
  }
43
66
 
67
+ type ModernTuiApi = TuiPluginApi & {
68
+ keymap?: {
69
+ registerLayer?: (layer: {
70
+ commands: {
71
+ namespace: string
72
+ name: string
73
+ title: string
74
+ desc?: string
75
+ category?: string
76
+ run?: () => void
77
+ }[]
78
+ bindings?: unknown[]
79
+ }) => () => void
80
+ }
81
+ }
82
+
44
83
  const goalCache = new Map<string, GoalSnapshot>()
45
84
 
46
85
  function currentSessionID(api: TuiPluginApi) {
@@ -69,31 +108,45 @@ function clearGoalPrompt() {
69
108
  return "Clear the current session goal by calling clear_goal. Report whether a goal was cleared."
70
109
  }
71
110
 
111
+ function pauseGoalPrompt() {
112
+ return 'Pause the current session goal by calling update_goal_status with status "paused". Report the result briefly.'
113
+ }
114
+
115
+ function resumeGoalPrompt() {
116
+ return 'Resume the current session goal by calling update_goal_status with status "active", then continue working toward it.'
117
+ }
118
+
119
+ function historyGoalPrompt() {
120
+ return "Call get_goal_history for this session and report the current goal history briefly."
121
+ }
122
+
123
+ function actionOption(api: TuiPluginApi, sessionID: string, title: string, value: string, description: string, prompt: string) {
124
+ return {
125
+ title,
126
+ value,
127
+ description,
128
+ onSelect: () => {
129
+ void sendGoalPrompt(api, sessionID, prompt)
130
+ .then(() => api.ui.dialog.clear())
131
+ .catch((error) => toast(api, error instanceof Error ? error.message : String(error), "error"))
132
+ },
133
+ }
134
+ }
135
+
72
136
  function showSummary(api: TuiPluginApi, sessionID: string, goal: GoalSnapshot | null) {
73
137
  const DialogSelect = api.ui.DialogSelect
74
138
  const options = [
75
- {
76
- title: "Refresh",
77
- value: "refresh",
78
- description: "Ask the agent to read the current goal state",
79
- onSelect: () => {
80
- void sendGoalPrompt(api, sessionID, refreshGoalPrompt())
81
- .then(() => api.ui.dialog.clear())
82
- .catch((error) => toast(api, error instanceof Error ? error.message : String(error), "error"))
83
- },
84
- },
139
+ actionOption(api, sessionID, "Refresh", "refresh", "Ask the agent to read the current goal state", refreshGoalPrompt()),
85
140
  ...(goal
86
141
  ? [
87
- {
88
- title: "Clear",
89
- value: "clear",
90
- description: "Ask the agent to clear this session goal",
91
- onSelect: () => {
92
- void sendGoalPrompt(api, sessionID, clearGoalPrompt())
93
- .then(() => api.ui.dialog.clear())
94
- .catch((error) => toast(api, error instanceof Error ? error.message : String(error), "error"))
95
- },
96
- },
142
+ actionOption(api, sessionID, "History", "history", "Ask the agent to show lifecycle history", historyGoalPrompt()),
143
+ ...(goal.status === "active"
144
+ ? [actionOption(api, sessionID, "Pause", "pause", "Pause auto-continuation without clearing", pauseGoalPrompt())]
145
+ : []),
146
+ ...(goal.status === "paused" || goal.status === "budgetLimited" || goal.status === "usageLimited"
147
+ ? [actionOption(api, sessionID, "Resume", "resume", "Resume the goal and continue", resumeGoalPrompt())]
148
+ : []),
149
+ actionOption(api, sessionID, "Clear", "clear", "Ask the agent to clear this session goal", clearGoalPrompt()),
97
150
  ]
98
151
  : []),
99
152
  ]
@@ -137,7 +190,7 @@ function currentEpochSeconds() {
137
190
 
138
191
  export function liveTimeUsedSeconds(goal: GoalSnapshot, nowSeconds = currentEpochSeconds()) {
139
192
  const baseSeconds = Math.max(0, Math.floor(goal.timeUsedSeconds))
140
- if (visibleStatus(goal.status) !== "active") return baseSeconds
193
+ if (goal.status !== "active") return baseSeconds
141
194
  if (typeof goal.sampledAt !== "number") return baseSeconds
142
195
  return baseSeconds + Math.max(0, Math.floor(nowSeconds - goal.sampledAt))
143
196
  }
@@ -146,11 +199,19 @@ function isRecord(value: unknown): value is Record<string, unknown> {
146
199
  return typeof value === "object" && value !== null
147
200
  }
148
201
 
202
+ function isCheckpoint(value: unknown): value is GoalCheckpoint {
203
+ return isRecord(value) && typeof value.summary === "string" && typeof value.timestamp === "number"
204
+ }
205
+
206
+ function isHistoryEntry(value: unknown): value is GoalHistoryEntry {
207
+ return isRecord(value) && typeof value.type === "string" && typeof value.detail === "string" && typeof value.timestamp === "number"
208
+ }
209
+
149
210
  function isGoalSnapshot(value: unknown): value is GoalSnapshot {
150
211
  if (!isRecord(value)) return false
151
212
  if (typeof value.sessionID !== "string") return false
152
213
  if (typeof value.objective !== "string") return false
153
- if (!["active", "paused", "budgetLimited", "complete", "unmet"].includes(String(value.status))) return false
214
+ if (!["active", "paused", "budgetLimited", "usageLimited", "complete", "unmet"].includes(String(value.status))) return false
154
215
  if (value.tokenBudget !== null && typeof value.tokenBudget !== "number") return false
155
216
  if (typeof value.tokensUsed !== "number") return false
156
217
  if (typeof value.timeUsedSeconds !== "number") return false
@@ -161,6 +222,18 @@ function isGoalSnapshot(value: unknown): value is GoalSnapshot {
161
222
  if (value.closedAt != null && typeof value.closedAt !== "number") return false
162
223
  if (typeof value.continuationFailures !== "number") return false
163
224
  if (value.lastStatus != null && typeof value.lastStatus !== "string") return false
225
+ if (value.maxAutoTurns !== null && typeof value.maxAutoTurns !== "number") return false
226
+ if (value.maxDurationSeconds !== null && typeof value.maxDurationSeconds !== "number") return false
227
+ if (value.noProgressTokenThreshold !== null && typeof value.noProgressTokenThreshold !== "number") return false
228
+ if (value.maxNoProgressTurns !== null && typeof value.maxNoProgressTurns !== "number") return false
229
+ if (typeof value.noProgressTurns !== "number") return false
230
+ if (typeof value.budgetWrapupSent !== "boolean") return false
231
+ if (value.stopReason !== null && typeof value.stopReason !== "string") return false
232
+ if (!Array.isArray(value.history) || !value.history.every(isHistoryEntry)) return false
233
+ if (!Array.isArray(value.checkpoints) || !value.checkpoints.every(isCheckpoint)) return false
234
+ if (value.lastCheckpoint !== null && !isCheckpoint(value.lastCheckpoint)) return false
235
+ if (typeof value.lastAssistantText !== "string") return false
236
+ if (typeof value.lastAssistantMessageID !== "string") return false
164
237
  if (typeof value.autoTurns !== "number") return false
165
238
  if (value.lastContinuationAt != null && typeof value.lastContinuationAt !== "number") return false
166
239
  if (value.remainingTokens !== null && typeof value.remainingTokens !== "number") return false
@@ -170,7 +243,19 @@ function isGoalSnapshot(value: unknown): value is GoalSnapshot {
170
243
 
171
244
  function parseGoalToolOutput(part: GoalToolPart): GoalSnapshot | null | undefined {
172
245
  if (part.type !== "tool") return undefined
173
- if (!["get_goal", "create_goal", "update_goal", "update_goal_status", "clear_goal"].includes(part.tool ?? "")) return undefined
246
+ if (
247
+ ![
248
+ "get_goal",
249
+ "get_goal_history",
250
+ "create_goal",
251
+ "set_goal",
252
+ "update_goal",
253
+ "update_goal_objective",
254
+ "update_goal_status",
255
+ "clear_goal",
256
+ ].includes(part.tool ?? "")
257
+ )
258
+ return undefined
174
259
  if (part.state?.status !== "completed") return undefined
175
260
  if (part.tool === "clear_goal") return null
176
261
  if (typeof part.state.output !== "string") return undefined
@@ -207,18 +292,20 @@ function goalFromSession(api: TuiPluginApi, sessionID: string) {
207
292
  return goalStateFromSession(api, sessionID).goal
208
293
  }
209
294
 
210
- function visibleStatus(status: GoalSnapshot["status"]) {
211
- return status === "budgetLimited" ? "active" : status
212
- }
213
-
214
295
  function formatGoal(goal: GoalSnapshot | null) {
215
296
  if (!goal) return "No recent goal state found in this session."
216
297
  const lines = [
217
298
  `Objective: ${goal.objective}`,
218
- `Status: ${visibleStatus(goal.status)}`,
299
+ `Status: ${goal.status}`,
219
300
  `Time used: ${formatDuration(goal.timeUsedSeconds)}`,
220
- `Auto-continues: ${goal.autoTurns}`,
301
+ `Tokens: ${goal.tokensUsed}${goal.tokenBudget == null ? "" : `/${goal.tokenBudget}`}`,
302
+ `Auto-continues: ${goal.autoTurns}${goal.maxAutoTurns == null ? "" : `/${goal.maxAutoTurns}`}`,
221
303
  ]
304
+ if (goal.remainingTokens != null) lines.push(`Tokens remaining: ${goal.remainingTokens}`)
305
+ if (goal.maxDurationSeconds != null) lines.push(`Duration limit: ${formatDuration(goal.maxDurationSeconds)}`)
306
+ if (goal.noProgressTurns > 0) lines.push(`No-progress turns: ${goal.noProgressTurns}`)
307
+ if (goal.lastCheckpoint) lines.push(`Latest checkpoint: ${goal.lastCheckpoint.summary}`)
308
+ if (goal.stopReason) lines.push(`Stop reason: ${goal.stopReason}`)
222
309
  if (goal.lastStatus) lines.push(`Last status: ${goal.lastStatus}`)
223
310
  if (goal.completionEvidence) lines.push(`Completion evidence: ${goal.completionEvidence}`)
224
311
  if (goal.blocker) lines.push(`Blocker: ${goal.blocker}`)
@@ -251,9 +338,22 @@ function GoalSidebar(props: { api: TuiPluginApi; sessionID: string }) {
251
338
  <text fg={theme().text}>
252
339
  <b>Goal</b>
253
340
  </text>
254
- <text fg={theme().textMuted}>Status: {visibleStatus(value().status)}</text>
341
+ <text fg={theme().textMuted}>Status: {value().status}</text>
255
342
  <text fg={theme().textMuted}>Time: {formatDuration(elapsed())}</text>
256
- <text fg={theme().textMuted}>Auto-continues: {value().autoTurns}</text>
343
+ <text fg={theme().textMuted}>
344
+ Tokens: {value().tokensUsed}
345
+ <Show when={value().tokenBudget}>{(budget: () => number) => <>/{budget()}</>}</Show>
346
+ </text>
347
+ <text fg={theme().textMuted}>
348
+ Auto-continues: {value().autoTurns}
349
+ <Show when={value().maxAutoTurns}>{(budget: () => number) => <>/{budget()}</>}</Show>
350
+ </text>
351
+ <Show when={value().lastCheckpoint}>
352
+ {(checkpoint: () => GoalCheckpoint) => <text fg={theme().textMuted}>Checkpoint: {checkpoint().summary}</text>}
353
+ </Show>
354
+ <Show when={value().stopReason}>
355
+ {(reason: () => string) => <text fg={theme().textMuted}>Stop: {reason()}</text>}
356
+ </Show>
257
357
  <Show when={value().lastStatus}>
258
358
  {(status: () => string) => <text fg={theme().textMuted}>{status()}</text>}
259
359
  </Show>
@@ -271,6 +371,27 @@ function GoalSidebar(props: { api: TuiPluginApi; sessionID: string }) {
271
371
  )
272
372
  }
273
373
 
374
+ function registerGoalCommand(api: TuiPluginApi, command: TuiCommand) {
375
+ const modern = api as ModernTuiApi
376
+ if (modern.keymap?.registerLayer) {
377
+ modern.keymap.registerLayer({
378
+ commands: [
379
+ {
380
+ namespace: "palette",
381
+ name: command.value,
382
+ title: command.title,
383
+ desc: command.description,
384
+ category: command.category,
385
+ run: command.onSelect,
386
+ },
387
+ ],
388
+ bindings: [],
389
+ })
390
+ return
391
+ }
392
+ api.command?.register(() => [command])
393
+ }
394
+
274
395
  const tui: TuiPlugin = async (api) => {
275
396
  api.slots.register({
276
397
  order: 125,
@@ -281,19 +402,17 @@ const tui: TuiPlugin = async (api) => {
281
402
  },
282
403
  })
283
404
 
284
- api.command.register(() => [
285
- {
286
- title: "Goal",
287
- value: "goal.show",
288
- category: "Goal",
289
- description: "View or clear the long-running session goal",
290
- onSelect: () => {
291
- const sessionID = sessionIDOrToast(api)
292
- if (!sessionID) return
293
- showSummary(api, sessionID, goalFromSession(api, sessionID))
294
- },
405
+ registerGoalCommand(api, {
406
+ title: "Goal",
407
+ value: "goal.show",
408
+ category: "Goal",
409
+ description: "View, pause, resume, or clear the long-running session goal",
410
+ onSelect: () => {
411
+ const sessionID = sessionIDOrToast(api)
412
+ if (!sessionID) return
413
+ showSummary(api, sessionID, goalFromSession(api, sessionID))
295
414
  },
296
- ])
415
+ })
297
416
  }
298
417
 
299
418
  const plugin: TuiPluginModule = {