opencode-goal-plugin 0.5.0 → 0.6.0

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.
@@ -0,0 +1,18 @@
1
+ export function goalToolSuccess(message, data) {
2
+ return { ok: true, message, ...(data === undefined ? {} : { data }) }
3
+ }
4
+
5
+ export function goalToolFailure(code, message) {
6
+ return { ok: false, code, message }
7
+ }
8
+
9
+ export function serializeGoalToolResult(operation, result) {
10
+ return JSON.stringify({
11
+ version: 1,
12
+ operation,
13
+ ok: result.ok,
14
+ ...(!result.ok ? { error: result.code } : {}),
15
+ message: result.message,
16
+ ...(result.data === undefined ? {} : { data: result.data }),
17
+ })
18
+ }
@@ -0,0 +1,69 @@
1
+ const GOAL_AGENT_PROMPT = `Execute explicit goals persistently. Use goal tools to track state and checkpoints. Make concrete progress; claim completion only with verification evidence. Report only genuine blockers.`
2
+
3
+ const VERIFIER_AGENT_PROMPT = `Independently verify the claim against the goal, constraints, evidence, and workspace. Use read-only checks; never edit or mutate goal state. Approve only when proven; otherwise give one actionable reason.`
4
+
5
+ export function applyNativeGoalConfig(config, options = {}) {
6
+ if (!config || typeof config !== "object" || Array.isArray(config)) {
7
+ throw new TypeError("OpenCode config hook requires a mutable config object")
8
+ }
9
+ if (options.registerAgents === false) return config
10
+
11
+ const goalAgentName = options.goalAgentName ?? "goal"
12
+ const verifierAgentName = options.verifierAgentName ?? "goal-verify"
13
+ if (typeof goalAgentName !== "string" || !goalAgentName.trim()) {
14
+ throw new TypeError("goalAgentName must be a non-empty string")
15
+ }
16
+ if (typeof verifierAgentName !== "string" || !verifierAgentName.trim()) {
17
+ throw new TypeError("verifierAgentName must be a non-empty string")
18
+ }
19
+ if (goalAgentName !== goalAgentName.trim() || verifierAgentName !== verifierAgentName.trim()) {
20
+ throw new TypeError("goal and verifier agent names cannot have surrounding whitespace")
21
+ }
22
+ if (goalAgentName === verifierAgentName) {
23
+ throw new TypeError("goalAgentName and verifierAgentName must be different")
24
+ }
25
+
26
+ config.agent ||= {}
27
+ if (options.requireVerifierOwnership && config.agent[verifierAgentName]) {
28
+ throw new Error(
29
+ `completionAudit cannot safely use existing agent ${JSON.stringify(verifierAgentName)}; choose an unused verifierAgentName`,
30
+ )
31
+ }
32
+ config.agent[goalAgentName] ||= {
33
+ description: "Execute an explicit user goal with persistent progress and evidence-gated completion.",
34
+ mode: "primary",
35
+ prompt: GOAL_AGENT_PROMPT,
36
+ }
37
+ config.agent[verifierAgentName] ||= {
38
+ description: "Independently verify a goal completion claim without modifying the workspace.",
39
+ mode: "subagent",
40
+ hidden: true,
41
+ prompt: VERIFIER_AGENT_PROMPT,
42
+ permission: {
43
+ edit: "deny",
44
+ bash: "deny",
45
+ },
46
+ tools: {
47
+ bash: false,
48
+ write: false,
49
+ edit: false,
50
+ patch: false,
51
+ goal_set: false,
52
+ goal_update: false,
53
+ goal_pause: false,
54
+ goal_resume: false,
55
+ goal_block: false,
56
+ goal_complete: false,
57
+ goal_cancel: false,
58
+ set_goal: false,
59
+ update_goal: false,
60
+ clear_goal: false,
61
+ },
62
+ }
63
+ return config
64
+ }
65
+
66
+ export const nativeAgentConfigInternals = Object.freeze({
67
+ GOAL_AGENT_PROMPT,
68
+ VERIFIER_AGENT_PROMPT,
69
+ })
@@ -0,0 +1,100 @@
1
+ const SHAPE_ERROR_PATTERNS = [
2
+ /(?:missing|required).*(?:sessionID|path|body|query)/i,
3
+ /(?:unknown|unrecognized|unexpected|invalid).*(?:sessionID|path|body|query|argument|field|key)/i,
4
+ /(?:expected|must be).*(?:object|path|body|query|sessionID)/i,
5
+ /(?:validation|schema|invalid input|invalid argument)/i,
6
+ ]
7
+
8
+ function isArgumentShapeError(error) {
9
+ if (!(error instanceof TypeError)) return false
10
+ const message = String(error.message || "")
11
+ return SHAPE_ERROR_PATTERNS.some((pattern) => pattern.test(message))
12
+ }
13
+
14
+ function unwrapData(response) {
15
+ return response && typeof response === "object" && "data" in response
16
+ ? response.data
17
+ : response
18
+ }
19
+
20
+ /**
21
+ * Present both historical and current OpenCode session SDKs through one API.
22
+ * A successful shape is remembered independently for every operation.
23
+ */
24
+ export function createOpenCodeSessionApi(client, options = {}) {
25
+ if (!client?.session || typeof client.session !== "object") {
26
+ throw new TypeError("OpenCode client.session is required")
27
+ }
28
+
29
+ const preferredShape = options.preferredShape || "flat"
30
+ if (preferredShape !== "flat" && preferredShape !== "legacy") {
31
+ throw new TypeError('preferredShape must be "flat" or "legacy"')
32
+ }
33
+ const shapes = new Map()
34
+
35
+ async function invoke(operation, flatInput, legacyInput) {
36
+ const method = client.session[operation]
37
+ if (typeof method !== "function") {
38
+ throw new TypeError(`OpenCode client.session.${operation} is not available`)
39
+ }
40
+
41
+ const knownShape = shapes.get(operation)
42
+ const firstShape = knownShape || preferredShape
43
+ const firstInput = firstShape === "flat" ? flatInput : legacyInput
44
+ try {
45
+ const response = await method.call(client.session, firstInput)
46
+ shapes.set(operation, firstShape)
47
+ return unwrapData(response)
48
+ } catch (error) {
49
+ if (knownShape || !isArgumentShapeError(error)) throw error
50
+ const fallbackShape = firstShape === "flat" ? "legacy" : "flat"
51
+ const fallbackInput = fallbackShape === "flat" ? flatInput : legacyInput
52
+ const response = await method.call(client.session, fallbackInput)
53
+ shapes.set(operation, fallbackShape)
54
+ return unwrapData(response)
55
+ }
56
+ }
57
+
58
+ return Object.freeze({
59
+ messages(sessionID, options = {}) {
60
+ return invoke(
61
+ "messages",
62
+ { sessionID, ...options },
63
+ { path: { id: sessionID }, query: options },
64
+ )
65
+ },
66
+ promptAsync(sessionID, input = {}) {
67
+ return invoke(
68
+ "promptAsync",
69
+ { sessionID, ...input },
70
+ { path: { id: sessionID }, body: input },
71
+ )
72
+ },
73
+ createChild(parentID, input = {}) {
74
+ const body = { ...input, parentID }
75
+ return invoke("create", body, { body })
76
+ },
77
+ prompt(sessionID, input = {}) {
78
+ return invoke(
79
+ "prompt",
80
+ { sessionID, ...input },
81
+ { path: { id: sessionID }, body: input },
82
+ )
83
+ },
84
+ update(sessionID, input = {}) {
85
+ return invoke(
86
+ "update",
87
+ { sessionID, ...input },
88
+ { path: { id: sessionID }, body: input },
89
+ )
90
+ },
91
+ get(sessionID) {
92
+ return invoke("get", { sessionID }, { path: { id: sessionID } })
93
+ },
94
+ abort(sessionID) {
95
+ return invoke("abort", { sessionID }, { path: { id: sessionID } })
96
+ },
97
+ })
98
+ }
99
+
100
+ export const sessionApiInternals = Object.freeze({ isArgumentShapeError, unwrapData })
@@ -0,0 +1,82 @@
1
+ import { randomUUID } from "node:crypto"
2
+ import { promises as fs } from "node:fs"
3
+ import { hostname } from "node:os"
4
+ import { dirname } from "node:path"
5
+
6
+ function processIsAlive(pid) {
7
+ if (!Number.isSafeInteger(pid) || pid <= 0) return null
8
+ try {
9
+ process.kill(pid, 0)
10
+ return true
11
+ } catch (error) {
12
+ if (error?.code === "ESRCH") return false
13
+ return true
14
+ }
15
+ }
16
+
17
+ async function readOwner(lockPath) {
18
+ try {
19
+ return JSON.parse(await fs.readFile(`${lockPath}/owner.json`, "utf8"))
20
+ } catch {
21
+ return null
22
+ }
23
+ }
24
+
25
+ /**
26
+ * Hold an exclusive workspace lease for the plugin instance lifetime. This
27
+ * deliberately rejects a second writer instead of allowing stale full-state
28
+ * snapshots to overwrite each other.
29
+ */
30
+ export async function acquirePersistenceLease(stateFilePath) {
31
+ const lockPath = `${stateFilePath}.lock`
32
+ await fs.mkdir(dirname(stateFilePath), { recursive: true, mode: 0o700 })
33
+ const owner = {
34
+ token: randomUUID(),
35
+ pid: process.pid,
36
+ hostname: hostname(),
37
+ createdAt: Date.now(),
38
+ }
39
+
40
+ for (let attempt = 0; attempt < 3; attempt += 1) {
41
+ try {
42
+ await fs.mkdir(lockPath, { mode: 0o700 })
43
+ await fs.writeFile(`${lockPath}/owner.json`, JSON.stringify(owner), { mode: 0o600 })
44
+ return {
45
+ lockPath,
46
+ owner,
47
+ async release() {
48
+ const current = await readOwner(lockPath)
49
+ if (current?.token !== owner.token) return false
50
+ await fs.rm(lockPath, { recursive: true, force: true })
51
+ return true
52
+ },
53
+ }
54
+ } catch (error) {
55
+ if (error?.code !== "EEXIST") {
56
+ await fs.rm(lockPath, { recursive: true, force: true }).catch(() => {})
57
+ throw error
58
+ }
59
+ const existing = await readOwner(lockPath)
60
+ const sameHost = existing?.hostname === owner.hostname
61
+ if (sameHost && processIsAlive(existing?.pid) === false) {
62
+ const stalePath = `${lockPath}.stale.${randomUUID()}`
63
+ try {
64
+ await fs.rename(lockPath, stalePath)
65
+ await fs.rm(stalePath, { recursive: true, force: true })
66
+ continue
67
+ } catch (reclaimError) {
68
+ if (reclaimError?.code === "ENOENT") continue
69
+ }
70
+ }
71
+ const description = existing
72
+ ? `pid ${existing.pid} on ${existing.hostname}`
73
+ : "an unknown owner"
74
+ throw new Error(
75
+ `goal persistence is already owned by ${description}; close the other OpenCode instance or configure a different stateFilePath`,
76
+ )
77
+ }
78
+ }
79
+ throw new Error("could not acquire goal persistence lease")
80
+ }
81
+
82
+ export const persistenceLeaseInternals = Object.freeze({ processIsAlive, readOwner })