@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 +1 -1
- package/dist/server.js +11 -9
- package/package.json +2 -1
- package/src/state-path.ts +10 -0
- package/src/tui.ts +114 -32
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 {
|
|
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.
|
|
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
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
const
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
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
|
-
|
|
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
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
: []),
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
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 =
|
|
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 (
|
|
754
|
+
if (!active()) return
|
|
673
755
|
const timer = setInterval(() => setNowSeconds(currentEpochSeconds()), 1000)
|
|
674
756
|
onCleanup(() => clearInterval(timer))
|
|
675
757
|
})
|