opencode-heartbeat-approval 0.1.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.
package/index.ts ADDED
@@ -0,0 +1,3 @@
1
+ import { HeartbeatApprovalPlugin } from "./src/plugin";
2
+ export { HeartbeatApprovalPlugin };
3
+ export default HeartbeatApprovalPlugin;
package/package.json ADDED
@@ -0,0 +1,30 @@
1
+ {
2
+ "name": "opencode-heartbeat-approval",
3
+ "version": "0.1.0",
4
+ "description": "OpenCode plugin providing request_human_approval MCP tool for Heartbeat pipeline",
5
+ "type": "module",
6
+ "main": "dist/index.js",
7
+ "module": "dist/index.js",
8
+ "files": [
9
+ "dist/",
10
+ "index.ts",
11
+ "src/",
12
+ "README.md"
13
+ ],
14
+ "scripts": {
15
+ "build": "bun build index.ts src/plugin.ts --outdir dist --target bun && mv dist/src/plugin.js dist/plugin.js && rm -rf dist/src",
16
+ "prepublishOnly": "bun run build"
17
+ },
18
+ "keywords": [
19
+ "opencode",
20
+ "plugin",
21
+ "heartbeat",
22
+ "approval",
23
+ "human-in-the-loop"
24
+ ],
25
+ "author": "chuck-ma",
26
+ "license": "MIT",
27
+ "peerDependencies": {
28
+ "@opencode-ai/plugin": "^1.2.4"
29
+ }
30
+ }
package/src/plugin.ts ADDED
@@ -0,0 +1,181 @@
1
+ import { tool } from "@opencode-ai/plugin";
2
+ import type { PluginInput } from "@opencode-ai/plugin";
3
+
4
+ const DEFAULT_RUNNER_URL = "http://127.0.0.1:3210";
5
+ const POLL_INTERVAL_MS = 10_000;
6
+
7
+ interface CreateResponse {
8
+ approval_id: string;
9
+ status: string;
10
+ created: boolean;
11
+ }
12
+
13
+ interface PollResponse {
14
+ approval_id: string | null;
15
+ status: string;
16
+ response?: string;
17
+ }
18
+
19
+ function getRunnerUrl(): string {
20
+ return process.env.HEARTBEAT_RUNNER_URL ?? DEFAULT_RUNNER_URL;
21
+ }
22
+
23
+ function buildHeaders(): Record<string, string> {
24
+ const headers: Record<string, string> = { "Content-Type": "application/json" };
25
+ const token = process.env.HEARTBEAT_AUTH_TOKEN;
26
+ if (token) {
27
+ headers["Authorization"] = `Bearer ${token}`;
28
+ }
29
+ return headers;
30
+ }
31
+
32
+ interface ConflictResponse {
33
+ error: string;
34
+ message: string;
35
+ existing_approval_id: string;
36
+ }
37
+
38
+ async function createGate(params: {
39
+ prompt: string;
40
+ artifacts?: string[];
41
+ deadline_hours?: number;
42
+ phase?: string;
43
+ }): Promise<CreateResponse | ConflictResponse> {
44
+ const resp = await fetch(`${getRunnerUrl()}/api/approval`, {
45
+ method: "POST",
46
+ headers: buildHeaders(),
47
+ body: JSON.stringify(params),
48
+ });
49
+ if (resp.status === 409) {
50
+ return (await resp.json()) as ConflictResponse;
51
+ }
52
+ if (!resp.ok) {
53
+ const text = await resp.text().catch(() => "");
54
+ throw new Error(`Runner API error: ${resp.status} ${text}`);
55
+ }
56
+ return (await resp.json()) as CreateResponse;
57
+ }
58
+
59
+ async function pollGate(): Promise<PollResponse> {
60
+ const resp = await fetch(`${getRunnerUrl()}/api/approval`, {
61
+ method: "GET",
62
+ headers: buildHeaders(),
63
+ });
64
+ if (!resp.ok) {
65
+ const text = await resp.text().catch(() => "");
66
+ throw new Error(`Runner API error: ${resp.status} ${text}`);
67
+ }
68
+ return (await resp.json()) as PollResponse;
69
+ }
70
+
71
+ function sleep(ms: number, signal?: AbortSignal): Promise<void> {
72
+ return new Promise((resolve) => {
73
+ if (signal?.aborted) { resolve(); return; }
74
+ const id = setTimeout(resolve, ms);
75
+ signal?.addEventListener("abort", () => { clearTimeout(id); resolve(); }, { once: true });
76
+ });
77
+ }
78
+
79
+ export const HeartbeatApprovalPlugin = async (_input: PluginInput) => {
80
+ return {
81
+ tool: {
82
+ request_human_approval: tool({
83
+ description: `Request human approval before proceeding with a significant action.
84
+
85
+ Call this tool when you need explicit human confirmation — for example before publishing content, executing a trade, or making an irreversible change. The tool sends a Feishu interactive card with approve/reject buttons and blocks until the human responds (or the deadline expires).
86
+
87
+ Only call this when genuinely necessary. Do not use it as a routine step.
88
+
89
+ Returns: { status: "approved" | "rejected" | "expired" | "unavailable" | "cancelled" | "conflict", response?: string, approval_id: string | null }`,
90
+ args: {
91
+ prompt: tool.schema
92
+ .string()
93
+ .describe("What you need human approval for — be specific and concise"),
94
+ artifacts: tool.schema
95
+ .string()
96
+ .optional()
97
+ .describe("Comma-separated list of relevant file paths or artifact names"),
98
+ deadline_hours: tool.schema
99
+ .number()
100
+ .optional()
101
+ .describe("Hours before the request expires (default: 24, max: 168)"),
102
+ },
103
+ async execute(args, ctx) {
104
+ const artifacts = args.artifacts
105
+ ? args.artifacts.split(",").map((s: string) => s.trim()).filter(Boolean)
106
+ : undefined;
107
+
108
+ let gateResult: CreateResponse | ConflictResponse;
109
+ try {
110
+ gateResult = await createGate({
111
+ prompt: args.prompt,
112
+ artifacts,
113
+ deadline_hours: args.deadline_hours,
114
+ });
115
+ } catch (err) {
116
+ return JSON.stringify({
117
+ status: "unavailable",
118
+ error: err instanceof Error ? err.message : String(err),
119
+ approval_id: null,
120
+ });
121
+ }
122
+
123
+ if ("error" in gateResult) {
124
+ return JSON.stringify({
125
+ status: "conflict",
126
+ error: gateResult.message,
127
+ existing_approval_id: gateResult.existing_approval_id,
128
+ approval_id: null,
129
+ });
130
+ }
131
+
132
+ const approvalId = gateResult.approval_id;
133
+
134
+ while (true) {
135
+ if (ctx.abort?.aborted) {
136
+ return JSON.stringify({
137
+ status: "cancelled",
138
+ response: null,
139
+ approval_id: approvalId,
140
+ });
141
+ }
142
+
143
+ await sleep(POLL_INTERVAL_MS, ctx.abort);
144
+
145
+ let poll: PollResponse;
146
+ try {
147
+ poll = await pollGate();
148
+ } catch {
149
+ await sleep(POLL_INTERVAL_MS, ctx.abort);
150
+ continue;
151
+ }
152
+
153
+ if (poll.status === "approved") {
154
+ return JSON.stringify({
155
+ status: "approved",
156
+ response: poll.response ?? null,
157
+ approval_id: approvalId,
158
+ });
159
+ }
160
+
161
+ if (poll.status === "rejected") {
162
+ return JSON.stringify({
163
+ status: "rejected",
164
+ response: poll.response ?? null,
165
+ approval_id: approvalId,
166
+ });
167
+ }
168
+
169
+ if (poll.status === "expired") {
170
+ return JSON.stringify({
171
+ status: "expired",
172
+ response: null,
173
+ approval_id: approvalId,
174
+ });
175
+ }
176
+ }
177
+ },
178
+ }),
179
+ },
180
+ };
181
+ };