@prevalentware/opencode-goal-plugin 0.1.52 → 0.1.54

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.52",
3
+ "version": "0.1.54",
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",
@@ -40,6 +40,7 @@
40
40
  "dist",
41
41
  "src/tui.ts",
42
42
  "src/i18n.ts",
43
+ "src/state-path.ts",
43
44
  "LICENSE",
44
45
  "README.md"
45
46
  ],
package/src/i18n.ts CHANGED
@@ -20,6 +20,8 @@ export type GoalMessages = {
20
20
  updateGoalObjective: string
21
21
  updateGoal: string
22
22
  updateGoalStatus: string
23
+ stopGoal: string
24
+ replaceGoal: string
23
25
  clearGoal: string
24
26
  objective: string
25
27
  modelObjective: string
@@ -123,7 +125,15 @@ const EN_MESSAGES: GoalMessages = {
123
125
  updateGoalStatus:
124
126
  "Pause or resume the current OpenCode goal when the user explicitly asks to pause or resume it. Resuming is not " +
125
127
  "allowed while the session is in Plan mode; the user must switch to Build mode first.",
126
- clearGoal: "Clear the current OpenCode goal for this session when the user explicitly asks to clear it.",
128
+ stopGoal:
129
+ "Cancel the current OpenCode goal when the user explicitly asks to stop or cancel it. Cancellation is terminal " +
130
+ "and prevents further autonomous continuation while preserving the goal and its history.",
131
+ replaceGoal:
132
+ "Atomically cancel and archive the current goal, then create a new independent goal in the same session. Use only " +
133
+ "when the user explicitly asks to replace the goal.",
134
+ clearGoal:
135
+ "Detach the current OpenCode goal from this session when the user explicitly asks to clear it. The goal is " +
136
+ "archived instead of deleted; an active goal is cancelled before it is cleared.",
127
137
  objective: "The concrete objective to start pursuing.",
128
138
  modelObjective: "The model-formulated concrete objective to start pursuing.",
129
139
  updatedObjective: "The updated concrete objective.",
@@ -232,7 +242,9 @@ const ZH_CN_MESSAGES: GoalMessages = {
232
242
  "只有在依据真实证据完成审计后才能关闭现有目标。仅当目标已经达成且没有剩余必需工作时使用 complete,并提供证据;仅当目标无法达成或被阻塞时使用 unmet,并提供阻塞原因。不要仅因为准备停止工作就关闭目标。",
233
243
  updateGoalStatus:
234
244
  "仅当用户明确要求暂停或继续目标时,暂停或继续当前 OpenCode 目标。在 Plan 模式下不能继续目标;用户必须先切换到 Build 模式。",
235
- clearGoal: "仅当用户明确要求清除目标时,清除当前 OpenCode 会话的目标。",
245
+ stopGoal: "仅当用户明确要求停止或取消目标时,取消当前 OpenCode 目标。取消是终态,会阻止后续自动继续,并保留目标及其历史。",
246
+ replaceGoal: "仅当用户明确要求替换目标时,原子地取消并归档当前目标,然后在同一会话中创建新的独立目标。",
247
+ clearGoal: "仅当用户明确要求清除目标时,将当前目标从会话中分离并归档;若目标仍在活动,会先取消再清除。",
236
248
  objective: "要开始执行的具体目标。",
237
249
  modelObjective: "由模型制定、要开始执行的具体目标。",
238
250
  updatedObjective: "更新后的具体目标。",
@@ -362,6 +374,7 @@ const STATUS_PRESENTATIONS: Record<GoalLocale, Record<string, string>> = {
362
374
  usageLimited: "usage limited",
363
375
  complete: "complete",
364
376
  unmet: "unmet",
377
+ cancelled: "cancelled",
365
378
  },
366
379
  "zh-CN": {
367
380
  active: "进行中",
@@ -370,6 +383,7 @@ const STATUS_PRESENTATIONS: Record<GoalLocale, Record<string, string>> = {
370
383
  usageLimited: "使用量已达上限",
371
384
  complete: "已完成",
372
385
  unmet: "未达成",
386
+ cancelled: "已取消",
373
387
  },
374
388
  }
375
389
 
@@ -388,6 +402,9 @@ export function presentGoalStopReason(reason: string, locale: GoalLocale): strin
388
402
  const direct: Record<string, string> = {
389
403
  paused: "已暂停",
390
404
  blocked: "已阻塞",
405
+ cancelled: "已取消",
406
+ cleared: "已清除",
407
+ replaced: "已替换",
391
408
  "plan mode": "Plan 模式",
392
409
  "no progress": "无进展",
393
410
  "auto-continue failures": "自动继续失败",
@@ -428,6 +445,8 @@ export function presentGoalLastStatus(status: string, locale: GoalLocale): strin
428
445
  "Goal paused.": "目标已暂停。",
429
446
  "Goal completed.": "目标已完成。",
430
447
  "Goal marked unmet.": "目标已标记为未达成。",
448
+ "Goal cancelled.": "目标已取消。",
449
+ "Goal cancelled because it was replaced.": "目标因被替换而取消。",
431
450
  "Auto-continue attempt canceled before delivery.": "自动继续尝试已在发送前取消。",
432
451
  "Auto-continue prompt sent.": "自动继续提示已发送。",
433
452
  "Auto-continue prompt failed repeatedly. Resume the goal to retry.": "自动继续提示反复失败。请继续目标后重试。",
@@ -468,6 +487,8 @@ const HISTORY_TYPE_PRESENTATIONS: Record<GoalLocale, Record<string, string>> = {
468
487
  resumed: "已继续",
469
488
  completed: "已完成",
470
489
  unmet: "未达成",
490
+ cancelled: "已取消",
491
+ cleared: "已清除",
471
492
  autoContinue: "自动继续",
472
493
  checkpoint: "检查点",
473
494
  warning: "警告",
@@ -0,0 +1,10 @@
1
+ import { homedir } from "node:os"
2
+ import { join } from "node:path"
3
+
4
+ export function statePath() {
5
+ if (process.env.OPENCODE_GOAL_STATE_PATH) return process.env.OPENCODE_GOAL_STATE_PATH
6
+ const dataHome =
7
+ process.env.XDG_DATA_HOME ||
8
+ (process.platform === "win32" && process.env.APPDATA ? process.env.APPDATA : join(homedir(), ".local", "share"))
9
+ return join(dataHome, "opencode-goal-plugin", "goals.json")
10
+ }
package/src/tui.ts CHANGED
@@ -3,8 +3,10 @@ import type { Plugin as TuiPluginV2 } from "@opencode/plugin/tui"
3
3
  import type { SessionMessageInfo } from "@opencode/client"
4
4
  import { createElement, insert, setProp } from "@opentui/solid"
5
5
  import { createEffect, createMemo, createSignal, onCleanup } from "solid-js"
6
+ import { readFile, stat } from "node:fs/promises"
6
7
  import type { GoalMessages } from "./i18n"
7
8
  import { messagesFor, presentGoalLastStatus, presentGoalStatus, presentGoalStopReason, resolveLocale } from "./i18n"
9
+ import { statePath } from "./state-path"
8
10
 
9
11
  type GoalCheckpoint = {
10
12
  summary: string
@@ -20,7 +22,7 @@ type GoalHistoryEntry = {
20
22
  type GoalSnapshot = {
21
23
  sessionID: string
22
24
  objective: string
23
- status: "active" | "paused" | "budgetLimited" | "usageLimited" | "complete" | "unmet"
25
+ status: "active" | "paused" | "budgetLimited" | "usageLimited" | "complete" | "unmet" | "cancelled"
24
26
  tokenBudget: number | null
25
27
  tokensUsed: number
26
28
  timeUsedSeconds: number
@@ -95,6 +97,8 @@ const GOAL_TOOL_NAMES: readonly string[] = [
95
97
  "update_goal",
96
98
  "update_goal_objective",
97
99
  "update_goal_status",
100
+ "stop_goal",
101
+ "replace_goal",
98
102
  "clear_goal",
99
103
  ]
100
104
 
@@ -381,7 +385,7 @@ function isGoalSnapshot(value: unknown): value is GoalSnapshot {
381
385
  if (!isRecord(value)) return false
382
386
  if (typeof value.sessionID !== "string") return false
383
387
  if (typeof value.objective !== "string") return false
384
- if (!["active", "paused", "budgetLimited", "usageLimited", "complete", "unmet"].includes(String(value.status))) return false
388
+ if (!["active", "paused", "budgetLimited", "usageLimited", "complete", "unmet", "cancelled"].includes(String(value.status))) return false
385
389
  if (value.tokenBudget !== null && typeof value.tokenBudget !== "number") return false
386
390
  if (typeof value.tokensUsed !== "number") return false
387
391
  if (typeof value.timeUsedSeconds !== "number") return false
@@ -411,6 +415,66 @@ function isGoalSnapshot(value: unknown): value is GoalSnapshot {
411
415
  return true
412
416
  }
413
417
 
418
+ /** Read only the current session from the server's atomic state file. */
419
+ export async function readPersistedGoal(sessionID: string): Promise<GoalSnapshot | null | undefined> {
420
+ try {
421
+ const state: unknown = JSON.parse(await readFile(statePath(), "utf8"))
422
+ if (!isRecord(state) || (state.version !== 1 && state.version !== 2) || !isRecord(state.goals)) return undefined
423
+ const stored = state.goals[sessionID]
424
+ if (stored === undefined) return null
425
+ if (!isRecord(stored) || stored.sessionID !== sessionID) return undefined
426
+ const sampledAt = currentEpochSeconds()
427
+ const timeUsedSeconds = typeof stored.timeUsedSeconds === "number" ? stored.timeUsedSeconds : 0
428
+ const activeSeconds = stored.status === "active" && typeof stored.lastAccountedAt === "number"
429
+ ? Math.max(0, sampledAt - stored.lastAccountedAt)
430
+ : 0
431
+ const tokenBudget = stored.tokenBudget
432
+ const tokensUsed = stored.tokensUsed
433
+ const snapshot = {
434
+ ...stored,
435
+ timeUsedSeconds: timeUsedSeconds + activeSeconds,
436
+ sampledAt,
437
+ remainingTokens: typeof tokenBudget === "number" && typeof tokensUsed === "number"
438
+ ? Math.max(0, tokenBudget - tokensUsed)
439
+ : null,
440
+ }
441
+ return isGoalSnapshot(snapshot) ? snapshot : undefined
442
+ } catch {
443
+ // Keep the last valid snapshot when the file is unavailable or incomplete.
444
+ return undefined
445
+ }
446
+ }
447
+
448
+ function usePersistedGoal(sessionID: string) {
449
+ const [persisted, setPersisted] = createSignal<GoalSnapshot | null | undefined>(undefined)
450
+ let disposed = false
451
+ let reading = false
452
+ let lastFile: { ino: number; mtimeMs: number; size: number } | undefined
453
+ const refresh = async () => {
454
+ if (reading) return
455
+ reading = true
456
+ try {
457
+ const file = await stat(statePath())
458
+ if (lastFile && lastFile.ino === file.ino && lastFile.mtimeMs === file.mtimeMs && lastFile.size === file.size) return
459
+ const next = await readPersistedGoal(sessionID)
460
+ if (next === undefined) return
461
+ lastFile = { ino: file.ino, mtimeMs: file.mtimeMs, size: file.size }
462
+ if (!disposed && (next !== null || persisted() !== undefined)) setPersisted(() => next)
463
+ } catch {
464
+ // Missing or unreadable state leaves the last valid view in place.
465
+ } finally {
466
+ reading = false
467
+ }
468
+ }
469
+ void refresh()
470
+ const timer = setInterval(() => void refresh(), 1000)
471
+ onCleanup(() => {
472
+ disposed = true
473
+ clearInterval(timer)
474
+ })
475
+ return persisted
476
+ }
477
+
414
478
  function parseGoalToolOutput(part: GoalToolPart): GoalSnapshot | null | undefined {
415
479
  if (part.type !== "tool") return undefined
416
480
  if (!GOAL_TOOL_NAMES.includes(part.tool ?? "")) return undefined
@@ -472,39 +536,50 @@ export function formatGoal(goal: GoalSnapshot | null, messages: GoalMessages, lo
472
536
  function GoalSidebar(api: TuiPluginApi, messages: GoalMessages, locale: ReturnType<typeof resolveLocale>, sessionID: string) {
473
537
  const theme = api.theme.current
474
538
  const state = goalStateFromSession(api, sessionID)
475
- const goal = state.goal
476
- if (!goal) return null
477
- if (goal.status === "complete" || goal.status === "unmet") {
478
- const elapsed = liveTimeUsedSeconds(goal)
479
- const label = goal.status === "complete" ? messages.tui.achieved : messages.tui.unmet
480
- return text({ fg: goal.status === "complete" ? theme.primary : theme.textMuted }, [
481
- `${label} (${formatDurationBadge(elapsed)})`,
482
- ])
483
- }
539
+ if (!state.goal) return null
540
+ const persisted = usePersistedGoal(sessionID)
541
+ const goal = createMemo(() => {
542
+ const live = persisted()
543
+ if (live === undefined) return state.goal
544
+ if (live && state.goal && (state.goal.createdAt > live.createdAt || state.goal.updatedAt > live.updatedAt)) return state.goal
545
+ return live
546
+ })
484
547
  const [nowSeconds, setNowSeconds] = createSignal(currentEpochSeconds())
485
- if (goal.status === "active") {
548
+ const active = createMemo(() => goal()?.status === "active")
549
+ createEffect(() => {
550
+ if (!active()) return
486
551
  const timer = setInterval(() => setNowSeconds(currentEpochSeconds()), 1000)
487
552
  onCleanup(() => clearInterval(timer))
488
- }
489
- return box({}, [
490
- text({ fg: theme.text }, [messages.tui.title]),
491
- text({ fg: theme.textMuted }, [`${messages.tui.status}: ${presentGoalStatus(goal.status, locale)}`]),
492
- text({ fg: theme.textMuted }, [() => `${messages.tui.time}: ${formatDuration(liveTimeUsedSeconds(goal, nowSeconds()))}`]),
493
- text({ fg: theme.textMuted }, [
494
- `${messages.tui.tokens}: ${goal.tokensUsed}${goal.tokenBudget == null ? "" : `/${goal.tokenBudget}`}`,
495
- ]),
496
- text({ fg: theme.textMuted }, [
497
- `${messages.tui.autoContinues}: ${goal.autoTurns}${goal.maxAutoTurns == null ? "" : `/${goal.maxAutoTurns}`}`,
498
- ]),
499
- ...(goal.lastCheckpoint
500
- ? [text({ fg: theme.textMuted }, [`${messages.tui.checkpoint}: ${goal.lastCheckpoint.summary}`])]
501
- : []),
502
- ...(goal.stopReason
503
- ? [text({ fg: theme.textMuted }, [`${messages.tui.stop}: ${presentGoalStopReason(goal.stopReason, locale)}`])]
504
- : []),
505
- ...(goal.lastStatus ? [text({ fg: theme.textMuted }, [presentGoalLastStatus(goal.lastStatus, locale)])] : []),
506
- text({ fg: theme.textMuted }, [goal.objective]),
507
- ])
553
+ })
554
+ return box({}, [() => {
555
+ const snapshot = goal()
556
+ if (!snapshot) return null
557
+ if (snapshot.status === "complete" || snapshot.status === "unmet") {
558
+ const elapsed = liveTimeUsedSeconds(snapshot)
559
+ return text({ fg: snapshot.status === "complete" ? theme.primary : theme.textMuted }, [
560
+ `${snapshot.status === "complete" ? messages.tui.achieved : messages.tui.unmet} (${formatDurationBadge(elapsed)})`,
561
+ ])
562
+ }
563
+ return box({}, [
564
+ text({ fg: theme.text }, [messages.tui.title]),
565
+ text({ fg: theme.textMuted }, [`${messages.tui.status}: ${presentGoalStatus(snapshot.status, locale)}`]),
566
+ text({ fg: theme.textMuted }, [`${messages.tui.time}: ${formatDuration(liveTimeUsedSeconds(snapshot, nowSeconds()))}`]),
567
+ text({ fg: theme.textMuted }, [
568
+ `${messages.tui.tokens}: ${snapshot.tokensUsed}${snapshot.tokenBudget == null ? "" : `/${snapshot.tokenBudget}`}`,
569
+ ]),
570
+ text({ fg: theme.textMuted }, [
571
+ `${messages.tui.autoContinues}: ${snapshot.autoTurns}${snapshot.maxAutoTurns == null ? "" : `/${snapshot.maxAutoTurns}`}`,
572
+ ]),
573
+ ...(snapshot.lastCheckpoint
574
+ ? [text({ fg: theme.textMuted }, [`${messages.tui.checkpoint}: ${snapshot.lastCheckpoint.summary}`])]
575
+ : []),
576
+ ...(snapshot.stopReason
577
+ ? [text({ fg: theme.textMuted }, [`${messages.tui.stop}: ${presentGoalStopReason(snapshot.stopReason, locale)}`])]
578
+ : []),
579
+ ...(snapshot.lastStatus ? [text({ fg: theme.textMuted }, [presentGoalLastStatus(snapshot.lastStatus, locale)])] : []),
580
+ text({ fg: theme.textMuted }, [snapshot.objective]),
581
+ ])
582
+ }])
508
583
  }
509
584
 
510
585
  function registerGoalCommand(api: TuiPluginApi, command: TuiCommand) {
@@ -655,8 +730,16 @@ function GoalSidebarV2(
655
730
  const [cache, setCache] = api.storage.memory<{ goal: GoalSnapshot | null }>(`goal-mode.v2.${sessionID}`, {
656
731
  initial: { goal: null },
657
732
  })
733
+ const persisted = usePersistedGoal(sessionID)
734
+ const messageGoal = createMemo(() => goalFromV2Messages(api.data.session.message.list(sessionID)))
658
735
  const goal = createMemo<GoalSnapshot | null>(() => {
659
- const found = goalFromV2Messages(api.data.session.message.list(sessionID))
736
+ const found = messageGoal()
737
+ if (found === null) return null
738
+ const live = persisted()
739
+ if (live !== undefined) {
740
+ if (found && live && (found.createdAt > live.createdAt || found.updatedAt > live.updatedAt)) return found
741
+ return live
742
+ }
660
743
  return found === undefined ? cache.goal : found
661
744
  })
662
745
  createEffect(() =>
@@ -666,8 +749,9 @@ function GoalSidebarV2(
666
749
  )
667
750
 
668
751
  const [nowSeconds, setNowSeconds] = createSignal(currentEpochSeconds())
752
+ const active = createMemo(() => goal()?.status === "active")
669
753
  createEffect(() => {
670
- if (goal()?.status !== "active") return
754
+ if (!active()) return
671
755
  const timer = setInterval(() => setNowSeconds(currentEpochSeconds()), 1000)
672
756
  onCleanup(() => clearInterval(timer))
673
757
  })