@prevalentware/opencode-goal-plugin 0.1.16 → 0.1.18

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.16",
3
+ "version": "0.1.18",
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
@@ -14,6 +25,22 @@ type GoalSnapshot = {
14
25
  completionEvidence?: string | null
15
26
  blocker?: string | null
16
27
  closedAt?: number | null
28
+ continuationFailures: number
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
42
+ autoTurns: number
43
+ lastContinuationAt: number | null
17
44
  remainingTokens: number | null
18
45
  sampledAt?: number
19
46
  }
@@ -37,6 +64,22 @@ type GoalSessionState = {
37
64
  messageIndex: number
38
65
  }
39
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
+
40
83
  const goalCache = new Map<string, GoalSnapshot>()
41
84
 
42
85
  function currentSessionID(api: TuiPluginApi) {
@@ -65,31 +108,45 @@ function clearGoalPrompt() {
65
108
  return "Clear the current session goal by calling clear_goal. Report whether a goal was cleared."
66
109
  }
67
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
+
68
136
  function showSummary(api: TuiPluginApi, sessionID: string, goal: GoalSnapshot | null) {
69
137
  const DialogSelect = api.ui.DialogSelect
70
138
  const options = [
71
- {
72
- title: "Refresh",
73
- value: "refresh",
74
- description: "Ask the agent to read the current goal state",
75
- onSelect: () => {
76
- void sendGoalPrompt(api, sessionID, refreshGoalPrompt())
77
- .then(() => api.ui.dialog.clear())
78
- .catch((error) => toast(api, error instanceof Error ? error.message : String(error), "error"))
79
- },
80
- },
139
+ actionOption(api, sessionID, "Refresh", "refresh", "Ask the agent to read the current goal state", refreshGoalPrompt()),
81
140
  ...(goal
82
141
  ? [
83
- {
84
- title: "Clear",
85
- value: "clear",
86
- description: "Ask the agent to clear this session goal",
87
- onSelect: () => {
88
- void sendGoalPrompt(api, sessionID, clearGoalPrompt())
89
- .then(() => api.ui.dialog.clear())
90
- .catch((error) => toast(api, error instanceof Error ? error.message : String(error), "error"))
91
- },
92
- },
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()),
93
150
  ]
94
151
  : []),
95
152
  ]
@@ -133,7 +190,7 @@ function currentEpochSeconds() {
133
190
 
134
191
  export function liveTimeUsedSeconds(goal: GoalSnapshot, nowSeconds = currentEpochSeconds()) {
135
192
  const baseSeconds = Math.max(0, Math.floor(goal.timeUsedSeconds))
136
- if (visibleStatus(goal.status) !== "active") return baseSeconds
193
+ if (goal.status !== "active") return baseSeconds
137
194
  if (typeof goal.sampledAt !== "number") return baseSeconds
138
195
  return baseSeconds + Math.max(0, Math.floor(nowSeconds - goal.sampledAt))
139
196
  }
@@ -142,11 +199,19 @@ function isRecord(value: unknown): value is Record<string, unknown> {
142
199
  return typeof value === "object" && value !== null
143
200
  }
144
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
+
145
210
  function isGoalSnapshot(value: unknown): value is GoalSnapshot {
146
211
  if (!isRecord(value)) return false
147
212
  if (typeof value.sessionID !== "string") return false
148
213
  if (typeof value.objective !== "string") return false
149
- 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
150
215
  if (value.tokenBudget !== null && typeof value.tokenBudget !== "number") return false
151
216
  if (typeof value.tokensUsed !== "number") return false
152
217
  if (typeof value.timeUsedSeconds !== "number") return false
@@ -155,6 +220,22 @@ function isGoalSnapshot(value: unknown): value is GoalSnapshot {
155
220
  if (value.completionEvidence != null && typeof value.completionEvidence !== "string") return false
156
221
  if (value.blocker != null && typeof value.blocker !== "string") return false
157
222
  if (value.closedAt != null && typeof value.closedAt !== "number") return false
223
+ if (typeof value.continuationFailures !== "number") return false
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
237
+ if (typeof value.autoTurns !== "number") return false
238
+ if (value.lastContinuationAt != null && typeof value.lastContinuationAt !== "number") return false
158
239
  if (value.remainingTokens !== null && typeof value.remainingTokens !== "number") return false
159
240
  if (value.sampledAt != null && typeof value.sampledAt !== "number") return false
160
241
  return true
@@ -162,7 +243,19 @@ function isGoalSnapshot(value: unknown): value is GoalSnapshot {
162
243
 
163
244
  function parseGoalToolOutput(part: GoalToolPart): GoalSnapshot | null | undefined {
164
245
  if (part.type !== "tool") return undefined
165
- if (!["get_goal", "create_goal", "update_goal", "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
166
259
  if (part.state?.status !== "completed") return undefined
167
260
  if (part.tool === "clear_goal") return null
168
261
  if (typeof part.state.output !== "string") return undefined
@@ -199,17 +292,21 @@ function goalFromSession(api: TuiPluginApi, sessionID: string) {
199
292
  return goalStateFromSession(api, sessionID).goal
200
293
  }
201
294
 
202
- function visibleStatus(status: GoalSnapshot["status"]) {
203
- return status === "budgetLimited" ? "active" : status
204
- }
205
-
206
295
  function formatGoal(goal: GoalSnapshot | null) {
207
296
  if (!goal) return "No recent goal state found in this session."
208
297
  const lines = [
209
298
  `Objective: ${goal.objective}`,
210
- `Status: ${visibleStatus(goal.status)}`,
299
+ `Status: ${goal.status}`,
211
300
  `Time used: ${formatDuration(goal.timeUsedSeconds)}`,
301
+ `Tokens: ${goal.tokensUsed}${goal.tokenBudget == null ? "" : `/${goal.tokenBudget}`}`,
302
+ `Auto-continues: ${goal.autoTurns}${goal.maxAutoTurns == null ? "" : `/${goal.maxAutoTurns}`}`,
212
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}`)
309
+ if (goal.lastStatus) lines.push(`Last status: ${goal.lastStatus}`)
213
310
  if (goal.completionEvidence) lines.push(`Completion evidence: ${goal.completionEvidence}`)
214
311
  if (goal.blocker) lines.push(`Blocker: ${goal.blocker}`)
215
312
  return lines.join("\n")
@@ -241,8 +338,25 @@ function GoalSidebar(props: { api: TuiPluginApi; sessionID: string }) {
241
338
  <text fg={theme().text}>
242
339
  <b>Goal</b>
243
340
  </text>
244
- <text fg={theme().textMuted}>Status: {visibleStatus(value().status)}</text>
341
+ <text fg={theme().textMuted}>Status: {value().status}</text>
245
342
  <text fg={theme().textMuted}>Time: {formatDuration(elapsed())}</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>
357
+ <Show when={value().lastStatus}>
358
+ {(status: () => string) => <text fg={theme().textMuted}>{status()}</text>}
359
+ </Show>
246
360
  <text fg={theme().textMuted}>{objective()}</text>
247
361
  </box>
248
362
  }
@@ -257,6 +371,27 @@ function GoalSidebar(props: { api: TuiPluginApi; sessionID: string }) {
257
371
  )
258
372
  }
259
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
+
260
395
  const tui: TuiPlugin = async (api) => {
261
396
  api.slots.register({
262
397
  order: 125,
@@ -267,19 +402,17 @@ const tui: TuiPlugin = async (api) => {
267
402
  },
268
403
  })
269
404
 
270
- api.command.register(() => [
271
- {
272
- title: "Goal",
273
- value: "goal.show",
274
- category: "Goal",
275
- description: "View or clear the long-running session goal",
276
- onSelect: () => {
277
- const sessionID = sessionIDOrToast(api)
278
- if (!sessionID) return
279
- showSummary(api, sessionID, goalFromSession(api, sessionID))
280
- },
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))
281
414
  },
282
- ])
415
+ })
283
416
  }
284
417
 
285
418
  const plugin: TuiPluginModule = {