@prevalentware/opencode-goal-plugin 0.1.5 → 0.1.7

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/README.md CHANGED
@@ -61,7 +61,7 @@ Server options can be configured in `opencode.json`:
61
61
  "auto_continue": true,
62
62
  "max_auto_turns": 25,
63
63
  "min_continue_interval_seconds": 3,
64
- "default_token_budget": null
64
+ "default_token_budget": 1000000
65
65
  }
66
66
  ]
67
67
  ]
@@ -75,7 +75,7 @@ Defaults:
75
75
  - `min_continue_interval_seconds`: `3`
76
76
  - `register_command`: `true`
77
77
  - `command_name`: `"goal"`
78
- - `default_token_budget`: `null`
78
+ - `default_token_budget`: `1000000`
79
79
 
80
80
  ## Goal Workflow
81
81
 
@@ -87,7 +87,7 @@ Use `/goal <objective>` in a fresh OpenCode chat to create a long-running goal:
87
87
 
88
88
  Bare `/goal` reports the current goal state. `/goal clear` clears the goal. The TUI also includes a `Goal` command-palette entry for viewing, refreshing, or clearing the current goal state without creating a new goal.
89
89
 
90
- By default, `/goal <objective>` omits `token_budget`, matching Codex TUI behavior. If you want every new slash-created goal to use a fixed token budget without prompting the user, set `default_token_budget` to a positive integer in `opencode.json`.
90
+ By default, `/goal <objective>` creates the goal with `token_budget: 1000000`. To omit the budget, set `default_token_budget` to `null`. To use a different fixed budget without prompting the user, set `default_token_budget` to another positive integer in `opencode.json`.
91
91
 
92
92
  When writing the objective, include the scope, non-goals, and verification path when they matter. The agent is reminded to audit real files, command output, tests, or PR state before closing the goal.
93
93
 
package/dist/server.js CHANGED
@@ -72,7 +72,8 @@ function isClosed(status) {
72
72
  return status === "complete" || status === "unmet";
73
73
  }
74
74
  function snapshot(goal) {
75
- const activeSeconds = goal.status === "active" && goal.lastAccountedAt != null ? Math.max(0, nowSeconds() - goal.lastAccountedAt) : 0;
75
+ const sampledAt = nowSeconds();
76
+ const activeSeconds = goal.status === "active" && goal.lastAccountedAt != null ? Math.max(0, sampledAt - goal.lastAccountedAt) : 0;
76
77
  const timeUsedSeconds = goal.timeUsedSeconds + activeSeconds;
77
78
  return {
78
79
  sessionID: goal.sessionID,
@@ -86,7 +87,8 @@ function snapshot(goal) {
86
87
  completionEvidence: goal.completionEvidence ?? null,
87
88
  blocker: goal.blocker ?? null,
88
89
  closedAt: goal.closedAt ?? null,
89
- remainingTokens: goal.tokenBudget == null ? null : Math.max(0, goal.tokenBudget - goal.tokensUsed)
90
+ remainingTokens: goal.tokenBudget == null ? null : Math.max(0, goal.tokenBudget - goal.tokensUsed),
91
+ sampledAt
90
92
  };
91
93
  }
92
94
  async function getGoal(sessionID) {
@@ -298,10 +300,13 @@ Preserve the goal objective, status, budget, elapsed time, token count, and any
298
300
  var DEFAULT_MAX_AUTO_TURNS = 25;
299
301
  var DEFAULT_CONTINUE_INTERVAL_SECONDS = 3;
300
302
  var DEFAULT_COMMAND_NAME = "goal";
303
+ var DEFAULT_TOKEN_BUDGET = 1e6;
301
304
  function defaultTokenBudgetFromOptions(options) {
302
305
  const budget = options?.default_token_budget;
303
- if (budget == null)
306
+ if (budget === null)
304
307
  return null;
308
+ if (budget === undefined)
309
+ return DEFAULT_TOKEN_BUDGET;
305
310
  return Number.isInteger(budget) && budget > 0 ? budget : null;
306
311
  }
307
312
  function goalCommandTemplate(commandName, defaultTokenBudget) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@prevalentware/opencode-goal-plugin",
3
- "version": "0.1.5",
3
+ "version": "0.1.7",
4
4
  "description": "Codex-style long-running goal mode for OpenCode.",
5
5
  "keywords": [
6
6
  "opencode",
package/src/tui.tsx CHANGED
@@ -1,6 +1,6 @@
1
1
  /** @jsxImportSource @opentui/solid */
2
2
  import type { TuiPlugin, TuiPluginApi, TuiPluginModule } from "@opencode-ai/plugin/tui"
3
- import { createMemo, Show } from "solid-js"
3
+ import { createMemo, createSignal, onCleanup, onMount, Show } from "solid-js"
4
4
 
5
5
  type GoalSnapshot = {
6
6
  sessionID: string
@@ -15,15 +15,30 @@ type GoalSnapshot = {
15
15
  blocker?: string | null
16
16
  closedAt?: number | null
17
17
  remainingTokens: number | null
18
+ sampledAt?: number
18
19
  }
19
20
 
20
21
  type GoalToolPart = {
21
22
  type: string
23
+ text?: string
24
+ content?: string
22
25
  tool?: string
23
26
  state?: {
24
27
  status?: string
25
28
  output?: string
26
29
  }
30
+ tokens?: unknown
31
+ }
32
+
33
+ type SessionMessage = {
34
+ id: string
35
+ info?: unknown
36
+ tokens?: unknown
37
+ }
38
+
39
+ type GoalSessionState = {
40
+ goal: GoalSnapshot | null
41
+ messageIndex: number
27
42
  }
28
43
 
29
44
  function currentSessionID(api: TuiPluginApi) {
@@ -125,6 +140,10 @@ function compactNumber(value: number) {
125
140
  return String(value)
126
141
  }
127
142
 
143
+ function nowSeconds() {
144
+ return Math.floor(Date.now() / 1000)
145
+ }
146
+
128
147
  function isRecord(value: unknown): value is Record<string, unknown> {
129
148
  return typeof value === "object" && value !== null
130
149
  }
@@ -143,6 +162,7 @@ function isGoalSnapshot(value: unknown): value is GoalSnapshot {
143
162
  if (value.blocker != null && typeof value.blocker !== "string") return false
144
163
  if (value.closedAt != null && typeof value.closedAt !== "number") return false
145
164
  if (value.remainingTokens !== null && typeof value.remainingTokens !== "number") return false
165
+ if (value.sampledAt != null && typeof value.sampledAt !== "number") return false
146
166
  return true
147
167
  }
148
168
 
@@ -163,16 +183,67 @@ function parseGoalToolOutput(part: GoalToolPart): GoalSnapshot | null | undefine
163
183
  }
164
184
  }
165
185
 
166
- function goalFromSession(api: TuiPluginApi, sessionID: string) {
167
- const messages = [...api.state.session.messages(sessionID)].reverse()
168
- for (const message of messages) {
186
+ function goalStateFromSession(api: TuiPluginApi, sessionID: string): GoalSessionState {
187
+ const messages = [...api.state.session.messages(sessionID)] as SessionMessage[]
188
+ for (let messageIndex = messages.length - 1; messageIndex >= 0; messageIndex -= 1) {
189
+ const message = messages[messageIndex]
190
+ if (!message) continue
169
191
  const parts = [...api.state.part(message.id)].reverse() as GoalToolPart[]
170
192
  for (const part of parts) {
171
193
  const goal = parseGoalToolOutput(part)
172
- if (goal !== undefined) return goal
194
+ if (goal !== undefined) return { goal, messageIndex }
173
195
  }
174
196
  }
175
- return null
197
+ return { goal: null, messageIndex: -1 }
198
+ }
199
+
200
+ function goalFromSession(api: TuiPluginApi, sessionID: string) {
201
+ return goalStateFromSession(api, sessionID).goal
202
+ }
203
+
204
+ function tokensFromRecord(value: unknown): number | undefined {
205
+ if (!isRecord(value)) return undefined
206
+ if (typeof value.total === "number" && Number.isFinite(value.total)) return value.total
207
+ const cache = isRecord(value.cache) ? value.cache : {}
208
+ const fields = [value.input, value.output, value.reasoning, cache.read, cache.write]
209
+ if (!fields.some((field) => typeof field === "number" && Number.isFinite(field))) return undefined
210
+ return fields.reduce<number>((sum, field) => sum + (typeof field === "number" && Number.isFinite(field) ? field : 0), 0)
211
+ }
212
+
213
+ function textFromPart(part: GoalToolPart) {
214
+ if (part.type === "text" && typeof part.text === "string") return part.text
215
+ if (typeof part.content === "string") return part.content
216
+ return ""
217
+ }
218
+
219
+ function estimateTokensFromText(text: string) {
220
+ return Math.ceil(text.length / 4)
221
+ }
222
+
223
+ function estimatedTokensFromParts(parts: GoalToolPart[]) {
224
+ return parts.reduce<number>((sum, part) => sum + estimateTokensFromText(textFromPart(part)), 0)
225
+ }
226
+
227
+ function tokensFromMessage(api: TuiPluginApi, message: SessionMessage) {
228
+ const parts = [...api.state.part(message.id)] as GoalToolPart[]
229
+ const partTotal = parts.reduce<number>((sum, part) => sum + (tokensFromRecord(part.tokens) ?? 0), 0)
230
+ if (partTotal > 0) return partTotal
231
+ const infoTokens = isRecord(message.info) ? tokensFromRecord(message.info.tokens) : undefined
232
+ const exact = tokensFromRecord(message.tokens) ?? infoTokens
233
+ return exact && exact > 0 ? exact : estimatedTokensFromParts(parts)
234
+ }
235
+
236
+ function tokensSinceGoalSnapshot(api: TuiPluginApi, sessionID: string, messageIndex: number) {
237
+ if (messageIndex < 0) return 0
238
+ const messages = [...api.state.session.messages(sessionID)] as SessionMessage[]
239
+ return messages
240
+ .slice(messageIndex)
241
+ .reduce<number>((sum, message) => sum + tokensFromMessage(api, message), 0)
242
+ }
243
+
244
+ function liveTimeUsed(goal: GoalSnapshot, currentSeconds: number) {
245
+ if (goal.status !== "active" || goal.sampledAt == null) return goal.timeUsedSeconds
246
+ return goal.timeUsedSeconds + Math.max(0, currentSeconds - goal.sampledAt)
176
247
  }
177
248
 
178
249
  function formatGoal(goal: GoalSnapshot | null) {
@@ -192,20 +263,36 @@ function formatGoal(goal: GoalSnapshot | null) {
192
263
 
193
264
  function GoalSidebar(props: { api: TuiPluginApi; sessionID: string }) {
194
265
  const theme = () => props.api.theme.current
195
- const goal = createMemo(() => {
266
+ const [currentSeconds, setCurrentSeconds] = createSignal(nowSeconds())
267
+ onMount(() => {
268
+ const interval = setInterval(() => setCurrentSeconds(nowSeconds()), 1000)
269
+ onCleanup(() => clearInterval(interval))
270
+ })
271
+ const state = createMemo(() => {
196
272
  props.api.state.session.messages(props.sessionID)
197
- return goalFromSession(props.api, props.sessionID)
273
+ return goalStateFromSession(props.api, props.sessionID)
274
+ })
275
+ const goal = createMemo(() => state().goal)
276
+ const tokensUsed = createMemo(() => {
277
+ const value = state().goal
278
+ if (!value) return 0
279
+ return value.tokensUsed + tokensSinceGoalSnapshot(props.api, props.sessionID, state().messageIndex)
198
280
  })
199
281
  const tokens = createMemo(() => {
200
282
  const value = goal()
201
283
  if (!value) return ""
202
- if (value.tokenBudget == null) return compactNumber(value.tokensUsed)
203
- return `${compactNumber(value.tokensUsed)} / ${compactNumber(value.tokenBudget)}`
284
+ if (value.tokenBudget == null) return compactNumber(tokensUsed())
285
+ return `${compactNumber(tokensUsed())} / ${compactNumber(value.tokenBudget)}`
204
286
  })
205
287
  const remaining = createMemo(() => {
206
288
  const value = goal()
207
289
  if (!value) return ""
208
- return value.remainingTokens == null ? "unbounded" : compactNumber(value.remainingTokens)
290
+ if (value.tokenBudget == null) return "unbounded"
291
+ return compactNumber(Math.max(0, value.tokenBudget - tokensUsed()))
292
+ })
293
+ const elapsed = createMemo(() => {
294
+ const value = goal()
295
+ return value ? liveTimeUsed(value, currentSeconds()) : 0
209
296
  })
210
297
  const objective = createMemo(() => {
211
298
  const value = goal()?.objective ?? ""
@@ -223,7 +310,7 @@ function GoalSidebar(props: { api: TuiPluginApi; sessionID: string }) {
223
310
  <b>Goal</b>
224
311
  </text>
225
312
  <text fg={theme().textMuted}>Status: {value().status}</text>
226
- <text fg={theme().textMuted}>Time: {formatDuration(value().timeUsedSeconds)}</text>
313
+ <text fg={theme().textMuted}>Time: {formatDuration(elapsed())}</text>
227
314
  <text fg={theme().textMuted}>Tokens: {tokens()}</text>
228
315
  <text fg={theme().textMuted}>Remaining: {remaining()}</text>
229
316
  <text fg={theme().textMuted}>{objective()}</text>
@@ -232,7 +319,7 @@ function GoalSidebar(props: { api: TuiPluginApi; sessionID: string }) {
232
319
  >
233
320
  <text fg={value().status === "complete" ? theme().primary : theme().textMuted}>
234
321
  <b>{value().status === "complete" ? "Goal achieved" : "Goal unmet"}</b> (
235
- {formatDurationBadge(value().timeUsedSeconds)})
322
+ {formatDurationBadge(elapsed())})
236
323
  </text>
237
324
  </Show>
238
325
  )}