@prevalentware/opencode-goal-plugin 0.1.53 → 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/README.md CHANGED
@@ -317,4 +317,4 @@ OpenCode plugin modules are target-specific. This package exports separate modul
317
317
 
318
318
  Codex goal mode has deeper runtime integration for thread lifecycle control. This plugin implements the same workflow using OpenCode plugin hooks. Token usage is read from OpenCode step-finish usage when available and falls back to message token metadata or text estimation when exact usage is unavailable. Continuation is driven by V2 `session.execution.succeeded` events and legacy `session.idle` / `session.status` idle notifications, never by intermediate model-step completion. V2 execution starts arm busy tracking; native `session.retry.scheduled` events cancel plugin recovery while OpenCode retries. Terminal execution transport failures use bounded recovery, while interruptions (user, shutdown, or superseded) cancel local timers without starting another turn or charging a prompt failure. Interrupted or non-transport-failed executions remain suppressed until the host starts a new execution; this does not change the persisted goal status. Use `/pause_goal` for a durable pause. Each V2 plugin instance handles goal events only for its own location while still observing cross-location child task lifecycles. The optional `max_turn_time` watchdog can retry one goal continuation prompt when a model turn remains busy, without consuming the goal's auto-turn or no-progress budgets; recognized transport failures do count toward the prompt-failure ceiling. By default, continuation is deferred while OpenCode Task child sessions are active or their terminal result still needs an orchestrator turn, bounded by the `max_task_block_seconds` ceiling so an unobservable child cannot stall a goal indefinitely. During compaction on OpenCode 1, the plugin disables OpenCode's generic synthetic auto-continue while an active goal exists so the goal-specific continuation prompt remains authoritative; on OpenCode 2, compaction runs inside the session's execution flow, so the plugin instead injects the goal snapshot through the `session.compaction` hook where the host provides it.
319
319
 
320
- The goal sidebar shows the current status, elapsed time, token usage, auto-continue count, latest checkpoint, latest status message, stop reason, and objective when a goal is active, paused, or safety-limited. Closed goals remain visible briefly through the latest tool state as achieved or unmet.
320
+ The goal sidebar shows the current status, elapsed time, token usage, auto-continue count, latest checkpoint, latest status message, stop reason, and objective when a goal is active, paused, or safety-limited. It checks the shared goal state file every second so usage and checkpoints stay current during a long run. Closed goals remain visible briefly through the latest tool state as achieved or unmet.
package/dist/server.js CHANGED
@@ -5,8 +5,7 @@ import { z } from "zod";
5
5
  // src/state.ts
6
6
  import { randomUUID as randomUUID2 } from "crypto";
7
7
  import { mkdir, readFile } from "fs/promises";
8
- import { homedir } from "os";
9
- import { dirname as dirname2, join } from "path";
8
+ import { dirname as dirname2 } from "path";
10
9
  import { Data, Effect, Schema } from "effect";
11
10
 
12
11
  // src/atomic-write.ts
@@ -117,6 +116,16 @@ async function atomicWriteFile(file, data, ops = defaultAtomicWriteOps) {
117
116
  }
118
117
  }
119
118
 
119
+ // src/state-path.ts
120
+ import { homedir } from "os";
121
+ import { join } from "path";
122
+ function statePath() {
123
+ if (process.env.OPENCODE_GOAL_STATE_PATH)
124
+ return process.env.OPENCODE_GOAL_STATE_PATH;
125
+ const dataHome = process.env.XDG_DATA_HOME || (process.platform === "win32" && process.env.APPDATA ? process.env.APPDATA : join(homedir(), ".local", "share"));
126
+ return join(dataHome, "opencode-goal-plugin", "goals.json");
127
+ }
128
+
120
129
  // src/state.ts
121
130
  class StateReadError extends Data.TaggedError("StateReadError") {
122
131
  }
@@ -232,13 +241,6 @@ var StateSchema = Schema.Struct({
232
241
  })
233
242
  });
234
243
  var PersistedStateSchema = Schema.Union(LegacyStateSchema, StateSchema);
235
- function defaultStateFile() {
236
- const dataHome = process.env.XDG_DATA_HOME || (process.platform === "win32" && process.env.APPDATA ? process.env.APPDATA : join(homedir(), ".local", "share"));
237
- return join(dataHome, "opencode-goal-plugin", "goals.json");
238
- }
239
- function statePath() {
240
- return process.env.OPENCODE_GOAL_STATE_PATH || defaultStateFile();
241
- }
242
244
  function nowSeconds() {
243
245
  return Math.floor(Date.now() / 1000);
244
246
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@prevalentware/opencode-goal-plugin",
3
- "version": "0.1.53",
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
  ],
@@ -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
@@ -413,6 +415,66 @@ function isGoalSnapshot(value: unknown): value is GoalSnapshot {
413
415
  return true
414
416
  }
415
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
+
416
478
  function parseGoalToolOutput(part: GoalToolPart): GoalSnapshot | null | undefined {
417
479
  if (part.type !== "tool") return undefined
418
480
  if (!GOAL_TOOL_NAMES.includes(part.tool ?? "")) return undefined
@@ -474,39 +536,50 @@ export function formatGoal(goal: GoalSnapshot | null, messages: GoalMessages, lo
474
536
  function GoalSidebar(api: TuiPluginApi, messages: GoalMessages, locale: ReturnType<typeof resolveLocale>, sessionID: string) {
475
537
  const theme = api.theme.current
476
538
  const state = goalStateFromSession(api, sessionID)
477
- const goal = state.goal
478
- if (!goal) return null
479
- if (goal.status === "complete" || goal.status === "unmet") {
480
- const elapsed = liveTimeUsedSeconds(goal)
481
- const label = goal.status === "complete" ? messages.tui.achieved : messages.tui.unmet
482
- return text({ fg: goal.status === "complete" ? theme.primary : theme.textMuted }, [
483
- `${label} (${formatDurationBadge(elapsed)})`,
484
- ])
485
- }
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
+ })
486
547
  const [nowSeconds, setNowSeconds] = createSignal(currentEpochSeconds())
487
- if (goal.status === "active") {
548
+ const active = createMemo(() => goal()?.status === "active")
549
+ createEffect(() => {
550
+ if (!active()) return
488
551
  const timer = setInterval(() => setNowSeconds(currentEpochSeconds()), 1000)
489
552
  onCleanup(() => clearInterval(timer))
490
- }
491
- return box({}, [
492
- text({ fg: theme.text }, [messages.tui.title]),
493
- text({ fg: theme.textMuted }, [`${messages.tui.status}: ${presentGoalStatus(goal.status, locale)}`]),
494
- text({ fg: theme.textMuted }, [() => `${messages.tui.time}: ${formatDuration(liveTimeUsedSeconds(goal, nowSeconds()))}`]),
495
- text({ fg: theme.textMuted }, [
496
- `${messages.tui.tokens}: ${goal.tokensUsed}${goal.tokenBudget == null ? "" : `/${goal.tokenBudget}`}`,
497
- ]),
498
- text({ fg: theme.textMuted }, [
499
- `${messages.tui.autoContinues}: ${goal.autoTurns}${goal.maxAutoTurns == null ? "" : `/${goal.maxAutoTurns}`}`,
500
- ]),
501
- ...(goal.lastCheckpoint
502
- ? [text({ fg: theme.textMuted }, [`${messages.tui.checkpoint}: ${goal.lastCheckpoint.summary}`])]
503
- : []),
504
- ...(goal.stopReason
505
- ? [text({ fg: theme.textMuted }, [`${messages.tui.stop}: ${presentGoalStopReason(goal.stopReason, locale)}`])]
506
- : []),
507
- ...(goal.lastStatus ? [text({ fg: theme.textMuted }, [presentGoalLastStatus(goal.lastStatus, locale)])] : []),
508
- text({ fg: theme.textMuted }, [goal.objective]),
509
- ])
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
+ }])
510
583
  }
511
584
 
512
585
  function registerGoalCommand(api: TuiPluginApi, command: TuiCommand) {
@@ -657,8 +730,16 @@ function GoalSidebarV2(
657
730
  const [cache, setCache] = api.storage.memory<{ goal: GoalSnapshot | null }>(`goal-mode.v2.${sessionID}`, {
658
731
  initial: { goal: null },
659
732
  })
733
+ const persisted = usePersistedGoal(sessionID)
734
+ const messageGoal = createMemo(() => goalFromV2Messages(api.data.session.message.list(sessionID)))
660
735
  const goal = createMemo<GoalSnapshot | null>(() => {
661
- 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
+ }
662
743
  return found === undefined ? cache.goal : found
663
744
  })
664
745
  createEffect(() =>
@@ -668,8 +749,9 @@ function GoalSidebarV2(
668
749
  )
669
750
 
670
751
  const [nowSeconds, setNowSeconds] = createSignal(currentEpochSeconds())
752
+ const active = createMemo(() => goal()?.status === "active")
671
753
  createEffect(() => {
672
- if (goal()?.status !== "active") return
754
+ if (!active()) return
673
755
  const timer = setInterval(() => setNowSeconds(currentEpochSeconds()), 1000)
674
756
  onCleanup(() => clearInterval(timer))
675
757
  })