@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/README.md +6 -4
- package/dist/server.js +444 -102
- package/package.json +2 -1
- package/src/i18n.ts +23 -2
- package/src/state-path.ts +10 -0
- package/src/tui.ts +118 -34
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
|
],
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
const
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
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
|
-
|
|
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
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
: []),
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
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 =
|
|
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 (
|
|
754
|
+
if (!active()) return
|
|
671
755
|
const timer = setInterval(() => setNowSeconds(currentEpochSeconds()), 1000)
|
|
672
756
|
onCleanup(() => clearInterval(timer))
|
|
673
757
|
})
|