@plannotator/artifact-server-opencode 0.1.1
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/LICENSE +21 -0
- package/README.md +119 -0
- package/index.ts +395 -0
- package/package.json +38 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 backnotprop
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
# @plannotator/artifact-server-opencode
|
|
2
|
+
|
|
3
|
+
The Artifact Server bridge for [OpenCode](https://opencode.ai). It connects
|
|
4
|
+
an OpenCode instance to an Artifact Server installation so that annotation
|
|
5
|
+
bundles sent from the review UI arrive in the active OpenCode session as
|
|
6
|
+
follow-up prompts, and the agent replies to and resolves each comment thread
|
|
7
|
+
through the `artifact_comments` tool.
|
|
8
|
+
|
|
9
|
+
## What it does
|
|
10
|
+
|
|
11
|
+
- Registers this OpenCode instance as an agent (`POST /api/v1/agents`,
|
|
12
|
+
kind `opencode`, capabilities `{beacon: true, evidence: "native"}`),
|
|
13
|
+
self-named after the project directory. Restarts reclaim the same agent
|
|
14
|
+
identity, so pending bundles survive.
|
|
15
|
+
- Long-polls the dispatch mailbox (`POST /api/v1/agents/:id/claims?wait=25`).
|
|
16
|
+
Each claimed bundle is rendered as one message and injected with
|
|
17
|
+
`client.session.promptAsync` into the most recently active top-level
|
|
18
|
+
session — follow-up delivery, never steering or interrupt: OpenCode
|
|
19
|
+
queues the prompt server-side and the running loop picks it up at its
|
|
20
|
+
next work boundary. Subagent (task) sessions are never targeted.
|
|
21
|
+
- Holds delivery while no top-level session exists yet or while the target
|
|
22
|
+
session is compacting (`experimental.session.compacting` hook, cleared by
|
|
23
|
+
the `session.compacted` event, bounded at five minutes).
|
|
24
|
+
- Registers the `artifact_comments` tool (`get_bundle`, `reply`, `resolve`)
|
|
25
|
+
through OpenCode's plugin tool hook, wrapping the comment HTTP routes with
|
|
26
|
+
the same credential, so the agent closes the loop without human action.
|
|
27
|
+
- Sends the best-effort activity beacon at the natural work boundaries
|
|
28
|
+
(bundle accepted → thinking, first reply → replying, last thread
|
|
29
|
+
resolved → idle).
|
|
30
|
+
- Fails open. A plugin context without the expected surface, or no resolved
|
|
31
|
+
configuration, leaves the bridge dormant; an unreachable server only
|
|
32
|
+
produces bounded backoff (1–30 s). No bridge failure is ever thrown into
|
|
33
|
+
OpenCode. Notices go through `client.tui.showToast` and are dropped
|
|
34
|
+
silently on headless hosts.
|
|
35
|
+
|
|
36
|
+
## Install
|
|
37
|
+
|
|
38
|
+
Add the package to your OpenCode config:
|
|
39
|
+
|
|
40
|
+
```json title="opencode.json"
|
|
41
|
+
{
|
|
42
|
+
"$schema": "https://opencode.ai/config.json",
|
|
43
|
+
"plugin": ["@plannotator/artifact-server-opencode"]
|
|
44
|
+
}
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
For development inside this repository, copy or symlink `index.ts` into a
|
|
48
|
+
plugin directory (`.opencode/plugins/` or `~/.config/opencode/plugins/`)
|
|
49
|
+
with `@plannotator/agent-bridge` and `zod` resolvable (see OpenCode's
|
|
50
|
+
plugin-dependency docs).
|
|
51
|
+
|
|
52
|
+
## Configuration
|
|
53
|
+
|
|
54
|
+
Resolved once when the plugin loads, in order:
|
|
55
|
+
|
|
56
|
+
| Source | Setting | Meaning |
|
|
57
|
+
| --- | --- | --- |
|
|
58
|
+
| Environment | `ARTIFACT_SERVER_ORIGIN` | Server origin, e.g. `https://artifacts.example.com`. Used together with the token below. |
|
|
59
|
+
| Environment | `ARTIFACT_SERVER_AGENT_TOKEN` | Bearer credential. Needs `agent:connect` plus comment read/write for the tool; the local API token carries everything. |
|
|
60
|
+
| Environment | `ARTIFACT_SERVER_AGENT_NAME` | Optional display-name override (default: the project directory's basename). |
|
|
61
|
+
| Local discovery | `~/.artifact-server/local-service.json` | The managed local server's discovery record (loopback origin). |
|
|
62
|
+
| Local discovery | `~/.artifact-server/local-api-token` | The local installation's private API credential. |
|
|
63
|
+
|
|
64
|
+
If neither source resolves, the bridge notifies once (when a TUI is
|
|
65
|
+
attached) and stays dormant.
|
|
66
|
+
|
|
67
|
+
## Pinned OpenCode version
|
|
68
|
+
|
|
69
|
+
Coded and verified against the OpenCode source at version **1.18.18**
|
|
70
|
+
(`@opencode-ai/plugin` 1.18.18, repository commit `ad905f8e6c`, dev branch,
|
|
71
|
+
2026-08-18), using the **V1 plugin API** (`(input) => Promise<Hooks>`). A
|
|
72
|
+
beta V2 plugin API (`@opencode-ai/plugin/v2/*`) exists at that commit; this
|
|
73
|
+
adapter deliberately pins V1, the API OpenCode loads for plain exported
|
|
74
|
+
plugin functions.
|
|
75
|
+
|
|
76
|
+
## Verified vs. assumed
|
|
77
|
+
|
|
78
|
+
Verified by reading the pinned source:
|
|
79
|
+
|
|
80
|
+
- Plugins load in-process and receive `client`, an SDK client bound to the
|
|
81
|
+
same OpenCode server instance that backs the TUI.
|
|
82
|
+
- `client.session.promptAsync` (`POST /session/:id/prompt_async`) answers
|
|
83
|
+
204 once the prompt is accepted; while the session is busy the appended
|
|
84
|
+
user message is picked up at the running loop's boundary — real
|
|
85
|
+
follow-up semantics. Interrupt would be `session.abort`; this bridge
|
|
86
|
+
never uses it, and steering is not claimed. The bridge awaits that answer
|
|
87
|
+
before it reports `delivered`; a refusal reports the dispatch `failed` and
|
|
88
|
+
leaves the plugin available for later bundles.
|
|
89
|
+
- Plugins register model-callable tools through the `tool` hook (zod
|
|
90
|
+
argument shapes converted to JSON Schema by OpenCode's tool registry),
|
|
91
|
+
so `artifact_comments` is a native tool — no MCP fallback needed.
|
|
92
|
+
- `client.tui.showToast` exists as the notification surface.
|
|
93
|
+
- Compaction is observable via the `experimental.session.compacting` hook
|
|
94
|
+
and the `session.compacted` event.
|
|
95
|
+
|
|
96
|
+
Verified by the fake plugin context against a real spawned Artifact Server:
|
|
97
|
+
|
|
98
|
+
- Deleting the target session while `promptAsync` is pending makes that
|
|
99
|
+
dispatch fail and releases its comments. Selecting another session lets the
|
|
100
|
+
same bridge deliver the next dispatch without restarting OpenCode.
|
|
101
|
+
|
|
102
|
+
Assumed (recorded honestly, not proven against a live OpenCode):
|
|
103
|
+
|
|
104
|
+
- Cross-instance zod interop: this package ships zod `4.4.3` argument
|
|
105
|
+
schemas; OpenCode composes them with its own zod (`4.1.8` at the pinned
|
|
106
|
+
commit) through the `_zod` protocol. This is the standard path for every
|
|
107
|
+
npm plugin, but it is not executed by this repository's tests.
|
|
108
|
+
- The `experimental.*` hook names are marked experimental by OpenCode and
|
|
109
|
+
may change in later versions; the bridge degrades to "no compaction
|
|
110
|
+
hold" if they stop firing.
|
|
111
|
+
- No live OpenCode smoke test exists here: the adapter is proven through a
|
|
112
|
+
fake plugin context driving the real bridge core against a real spawned
|
|
113
|
+
Artifact Server (`tests/client/opencode-bridge.test.ts`).
|
|
114
|
+
|
|
115
|
+
## Compatibility
|
|
116
|
+
|
|
117
|
+
- The package version tracks Artifact Server releases; it is a client of
|
|
118
|
+
the server's dispatch API (`project/spec/agent-dispatch-spec.md`).
|
|
119
|
+
- Ships TypeScript source; OpenCode loads plugins with Bun, no build step.
|
package/index.ts
ADDED
|
@@ -0,0 +1,395 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Artifact Server bridge — the thin OpenCode-facing entry.
|
|
3
|
+
*
|
|
4
|
+
* Registers the OpenCode instance for this project directory with an
|
|
5
|
+
* Artifact Server installation, receives annotation bundles as follow-up
|
|
6
|
+
* prompts through the claim loop in `@plannotator/agent-bridge`, and
|
|
7
|
+
* registers the `artifact_comments` tool the agent uses to reply to and
|
|
8
|
+
* resolve each thread. All logic lives in the shared core; this file only
|
|
9
|
+
* wires it to OpenCode's plugin API (V1 plugin surface, pinned in the
|
|
10
|
+
* README): `client.session.promptAsync` is the follow-up injection seam,
|
|
11
|
+
* `client.tui.showToast` is the notice surface, and hooks track the target
|
|
12
|
+
* session and its compaction windows.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import {homedir, hostname} from "node:os";
|
|
16
|
+
|
|
17
|
+
import {z} from "zod";
|
|
18
|
+
|
|
19
|
+
import {
|
|
20
|
+
ActivityBeacon,
|
|
21
|
+
type BridgeHandle,
|
|
22
|
+
type BridgeNoticeKind,
|
|
23
|
+
chooseDisplayName,
|
|
24
|
+
type CommentOperations,
|
|
25
|
+
createCommentOperations,
|
|
26
|
+
type EnvironmentConfiguration,
|
|
27
|
+
type HostPort,
|
|
28
|
+
resolveBridgeCredentials,
|
|
29
|
+
startBridge,
|
|
30
|
+
ThreadLocationCache,
|
|
31
|
+
} from "@plannotator/agent-bridge";
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* A compaction flag older than this is treated as expired: an abandoned
|
|
35
|
+
* compaction whose `session.compacted` event never arrives must not hold
|
|
36
|
+
* deliveries forever.
|
|
37
|
+
*/
|
|
38
|
+
const compactionFlagLifetimeMilliseconds = 5 * 60 * 1_000;
|
|
39
|
+
|
|
40
|
+
// ---------------------------------------------------------------------------
|
|
41
|
+
// The narrow, structurally-typed slice of OpenCode's plugin API this entry
|
|
42
|
+
// uses. Typing it locally keeps the package free of a hard dependency on
|
|
43
|
+
// `@opencode-ai/plugin` and `@opencode-ai/sdk` while the real API remains
|
|
44
|
+
// structurally compatible (verified against the pinned version in README).
|
|
45
|
+
// ---------------------------------------------------------------------------
|
|
46
|
+
|
|
47
|
+
/** One SDK call answer: the OpenCode client reports failures as a value. */
|
|
48
|
+
interface OpencodeCallAnswer {
|
|
49
|
+
readonly error?: {readonly message?: string};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
interface OpencodePromptOptions {
|
|
53
|
+
readonly body: {
|
|
54
|
+
readonly parts: readonly {readonly text: string; readonly type: "text"}[];
|
|
55
|
+
};
|
|
56
|
+
readonly path: {readonly id: string};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
interface OpencodeSessionApi {
|
|
60
|
+
promptAsync(options: OpencodePromptOptions): Promise<OpencodeCallAnswer>;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
interface OpencodeToastOptions {
|
|
64
|
+
readonly body: {
|
|
65
|
+
readonly message: string;
|
|
66
|
+
readonly variant: BridgeNoticeKind;
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
interface OpencodeTuiApi {
|
|
71
|
+
showToast(options: OpencodeToastOptions): Promise<boolean>;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** The in-process SDK client OpenCode hands every plugin. */
|
|
75
|
+
export interface OpencodeClient {
|
|
76
|
+
session: OpencodeSessionApi;
|
|
77
|
+
/** Absent or inert on headless hosts; every use is best-effort. */
|
|
78
|
+
tui?: OpencodeTuiApi;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** The V1 plugin context slice this bridge reads. */
|
|
82
|
+
export interface OpencodePluginInput {
|
|
83
|
+
client: OpencodeClient;
|
|
84
|
+
directory: string;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* A degraded plugin context from an unexpected OpenCode build. The boundary
|
|
89
|
+
* guard turns it into a dormant bridge instead of a crash inside the host.
|
|
90
|
+
*/
|
|
91
|
+
export interface OpencodePartialPluginContext {
|
|
92
|
+
readonly client?: undefined;
|
|
93
|
+
readonly directory?: string;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** One bus event as the `event` hook receives it; parsed with schemas. */
|
|
97
|
+
export interface OpencodeEvent {
|
|
98
|
+
readonly properties?: object;
|
|
99
|
+
readonly type: string;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
interface ArtifactCommentsArguments {
|
|
103
|
+
readonly body?: string;
|
|
104
|
+
readonly operation: "get_bundle" | "reply" | "resolve";
|
|
105
|
+
readonly threadId?: string;
|
|
106
|
+
readonly threadIds?: readonly string[];
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** The `Hooks` slice this plugin returns to OpenCode. */
|
|
110
|
+
export interface OpencodeBridgeHooks {
|
|
111
|
+
"chat.message"(input: {sessionID: string}): Promise<void>;
|
|
112
|
+
dispose(): Promise<void>;
|
|
113
|
+
event(input: {event: OpencodeEvent}): Promise<void>;
|
|
114
|
+
"experimental.session.compacting"(input: {sessionID: string}): Promise<void>;
|
|
115
|
+
tool: {
|
|
116
|
+
artifact_comments: {
|
|
117
|
+
args: ReturnType<typeof artifactCommentsToolArguments>;
|
|
118
|
+
description: string;
|
|
119
|
+
execute(args: ArtifactCommentsArguments): Promise<string>;
|
|
120
|
+
};
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// ---------------------------------------------------------------------------
|
|
125
|
+
// Runtime guards
|
|
126
|
+
// ---------------------------------------------------------------------------
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* The plugin context surface the bridge needs, checked at the boundary so a
|
|
130
|
+
* host that lacks it leaves the bridge dormant instead of crashing OpenCode.
|
|
131
|
+
* Only presence is checked; calls keep going through the original `input`
|
|
132
|
+
* object so SDK method bindings stay intact.
|
|
133
|
+
*/
|
|
134
|
+
const pluginSurfaceSchema = z.object({
|
|
135
|
+
client: z.object({
|
|
136
|
+
session: z.object({promptAsync: z.function()}).loose(),
|
|
137
|
+
}).loose(),
|
|
138
|
+
directory: z.string().min(1),
|
|
139
|
+
}).loose();
|
|
140
|
+
|
|
141
|
+
function isCompletePluginInput(
|
|
142
|
+
input: OpencodePartialPluginContext | OpencodePluginInput,
|
|
143
|
+
): input is OpencodePluginInput {
|
|
144
|
+
return pluginSurfaceSchema.safeParse(input).success;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const sessionLifecycleEventSchema = z.object({
|
|
148
|
+
properties: z.object({
|
|
149
|
+
info: z.object({
|
|
150
|
+
id: z.string(),
|
|
151
|
+
parentID: z.string().optional(),
|
|
152
|
+
}).loose(),
|
|
153
|
+
}).loose(),
|
|
154
|
+
type: z.enum(["session.created", "session.deleted", "session.updated"]),
|
|
155
|
+
}).loose();
|
|
156
|
+
|
|
157
|
+
const sessionCompactedEventSchema = z.object({
|
|
158
|
+
properties: z.object({sessionID: z.string()}).loose(),
|
|
159
|
+
type: z.literal("session.compacted"),
|
|
160
|
+
}).loose();
|
|
161
|
+
|
|
162
|
+
function artifactCommentsToolArguments() {
|
|
163
|
+
return {
|
|
164
|
+
body: z.string().optional()
|
|
165
|
+
.describe("Reply text (reply operation only)."),
|
|
166
|
+
operation: z.enum(["get_bundle", "reply", "resolve"]).describe(
|
|
167
|
+
"get_bundle reads threads with their replies; reply posts one reply; " +
|
|
168
|
+
"resolve closes one thread.",
|
|
169
|
+
),
|
|
170
|
+
threadId: z.string().optional()
|
|
171
|
+
.describe("Target thread id (reply and resolve operations)."),
|
|
172
|
+
threadIds: z.array(z.string()).optional()
|
|
173
|
+
.describe("Thread ids to read (get_bundle operation)."),
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function environmentConfiguration(): EnvironmentConfiguration {
|
|
178
|
+
return {
|
|
179
|
+
agentDisplayName: process.env["ARTIFACT_SERVER_AGENT_NAME"],
|
|
180
|
+
agentToken: process.env["ARTIFACT_SERVER_AGENT_TOKEN"],
|
|
181
|
+
origin: process.env["ARTIFACT_SERVER_ORIGIN"],
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const noopHooks: OpencodeBridgeHooks["event"] = () => Promise.resolve();
|
|
186
|
+
|
|
187
|
+
/** The hooks returned when the host context lacks the expected surface. */
|
|
188
|
+
function dormantHooks(): OpencodeBridgeHooks {
|
|
189
|
+
return {
|
|
190
|
+
"chat.message": () => Promise.resolve(),
|
|
191
|
+
dispose: () => Promise.resolve(),
|
|
192
|
+
event: noopHooks,
|
|
193
|
+
"experimental.session.compacting": () => Promise.resolve(),
|
|
194
|
+
tool: {
|
|
195
|
+
artifact_comments: {
|
|
196
|
+
args: artifactCommentsToolArguments(),
|
|
197
|
+
description: artifactCommentsDescription,
|
|
198
|
+
execute: () =>
|
|
199
|
+
Promise.reject(
|
|
200
|
+
new Error(
|
|
201
|
+
"Artifact Server is not configured; the bridge is dormant.",
|
|
202
|
+
),
|
|
203
|
+
),
|
|
204
|
+
},
|
|
205
|
+
},
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const artifactCommentsDescription =
|
|
210
|
+
"Read, reply to, and resolve Artifact Server comment threads that were " +
|
|
211
|
+
"sent to this agent. Use get_bundle to read threads, reply to record " +
|
|
212
|
+
"what you did on a thread, and resolve to close it when done.";
|
|
213
|
+
|
|
214
|
+
// ---------------------------------------------------------------------------
|
|
215
|
+
// The plugin
|
|
216
|
+
// ---------------------------------------------------------------------------
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Artifact Server bridge plugin for OpenCode. Loaded once per OpenCode
|
|
220
|
+
* instance; never throws into the host — a context without the expected
|
|
221
|
+
* surface or without credentials produces a dormant bridge.
|
|
222
|
+
*/
|
|
223
|
+
export async function ArtifactServerBridge(
|
|
224
|
+
input: OpencodePartialPluginContext | OpencodePluginInput,
|
|
225
|
+
): Promise<OpencodeBridgeHooks> {
|
|
226
|
+
if (!isCompletePluginInput(input)) return dormantHooks();
|
|
227
|
+
|
|
228
|
+
// The most recently active top-level session: bundles inject here.
|
|
229
|
+
// Subagent (child) sessions never become the target — a bundle delivered
|
|
230
|
+
// into a task session would vanish with it.
|
|
231
|
+
let targetSessionId: string | null = null;
|
|
232
|
+
const childSessionIds = new Set<string>();
|
|
233
|
+
let compaction: {sessionId: string; startedAt: number} | null = null;
|
|
234
|
+
|
|
235
|
+
const environment = environmentConfiguration();
|
|
236
|
+
const credentials = await resolveBridgeCredentials(environment, homedir());
|
|
237
|
+
const locations = new ThreadLocationCache();
|
|
238
|
+
// One beacon per instance: replies and resolves through the tool count
|
|
239
|
+
// against the bundles this bridge delivered.
|
|
240
|
+
const beacon = new ActivityBeacon();
|
|
241
|
+
const comments: CommentOperations | null = credentials === null
|
|
242
|
+
? null
|
|
243
|
+
: createCommentOperations(credentials, fetch, locations, beacon);
|
|
244
|
+
|
|
245
|
+
const notify = (message: string, kind: BridgeNoticeKind): void => {
|
|
246
|
+
try {
|
|
247
|
+
// Best-effort: a headless host has no TUI and simply drops notices.
|
|
248
|
+
void input.client.tui?.showToast({body: {message, variant: kind}})
|
|
249
|
+
.catch(() => undefined);
|
|
250
|
+
} catch {
|
|
251
|
+
// A notice may be lost; the bridge must never raise into the host.
|
|
252
|
+
}
|
|
253
|
+
};
|
|
254
|
+
|
|
255
|
+
// `POST /session/:id/prompt_async` answers once OpenCode accepts the prompt.
|
|
256
|
+
// The bridge awaits this promise before it reports the dispatch delivered.
|
|
257
|
+
const injectFollowUp = async (
|
|
258
|
+
sessionId: string,
|
|
259
|
+
text: string,
|
|
260
|
+
): Promise<void> => {
|
|
261
|
+
try {
|
|
262
|
+
const answer = await input.client.session.promptAsync({
|
|
263
|
+
body: {parts: [{text, type: "text"}]},
|
|
264
|
+
path: {id: sessionId},
|
|
265
|
+
});
|
|
266
|
+
if (answer.error !== undefined) {
|
|
267
|
+
throw new Error(
|
|
268
|
+
answer.error.message ?? "OpenCode refused the follow-up prompt.",
|
|
269
|
+
);
|
|
270
|
+
}
|
|
271
|
+
} catch (error) {
|
|
272
|
+
notify(
|
|
273
|
+
"Artifact Server: delivering the annotation bundle to the " +
|
|
274
|
+
"OpenCode session failed.",
|
|
275
|
+
"warning",
|
|
276
|
+
);
|
|
277
|
+
throw error;
|
|
278
|
+
}
|
|
279
|
+
};
|
|
280
|
+
|
|
281
|
+
const port: HostPort = {
|
|
282
|
+
// OpenCode queues prompts server-side (`prompt_async` appends a user
|
|
283
|
+
// message that the running loop picks up at its next boundary), so the
|
|
284
|
+
// only pre-delivery hold this port needs is: no target session yet, or
|
|
285
|
+
// the target is inside a bounded compaction window.
|
|
286
|
+
isCompacting: () =>
|
|
287
|
+
targetSessionId === null ||
|
|
288
|
+
(compaction !== null &&
|
|
289
|
+
compaction.sessionId === targetSessionId &&
|
|
290
|
+
Date.now() - compaction.startedAt < compactionFlagLifetimeMilliseconds),
|
|
291
|
+
notify,
|
|
292
|
+
sendUserMessage: async (text) => {
|
|
293
|
+
const sessionId = targetSessionId;
|
|
294
|
+
if (sessionId === null) {
|
|
295
|
+
// Losing a target between the hold check and this asynchronous handoff
|
|
296
|
+
// refuses this dispatch without invalidating the OpenCode plugin.
|
|
297
|
+
throw new Error("No OpenCode session is available for delivery.");
|
|
298
|
+
}
|
|
299
|
+
await injectFollowUp(sessionId, text);
|
|
300
|
+
},
|
|
301
|
+
};
|
|
302
|
+
|
|
303
|
+
const bridge: BridgeHandle = startBridge({
|
|
304
|
+
agentSessionId: null,
|
|
305
|
+
beacon,
|
|
306
|
+
credentials,
|
|
307
|
+
displayName: chooseDisplayName(environment, input.directory),
|
|
308
|
+
fetchImplementation: fetch,
|
|
309
|
+
host: port,
|
|
310
|
+
hostname: hostname(),
|
|
311
|
+
kind: "opencode",
|
|
312
|
+
locations,
|
|
313
|
+
workingDirectory: input.directory,
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
return {
|
|
317
|
+
"chat.message": (chat) => {
|
|
318
|
+
if (!childSessionIds.has(chat.sessionID)) {
|
|
319
|
+
targetSessionId = chat.sessionID;
|
|
320
|
+
}
|
|
321
|
+
return Promise.resolve();
|
|
322
|
+
},
|
|
323
|
+
dispose: () => bridge.stop({disconnect: true}),
|
|
324
|
+
event: (received) => {
|
|
325
|
+
const lifecycle = sessionLifecycleEventSchema.safeParse(received.event);
|
|
326
|
+
if (lifecycle.success) {
|
|
327
|
+
const {id, parentID} = lifecycle.data.properties.info;
|
|
328
|
+
if (lifecycle.data.type === "session.deleted") {
|
|
329
|
+
childSessionIds.delete(id);
|
|
330
|
+
if (targetSessionId === id) targetSessionId = null;
|
|
331
|
+
} else if (parentID !== undefined) {
|
|
332
|
+
childSessionIds.add(id);
|
|
333
|
+
} else if (targetSessionId === null) {
|
|
334
|
+
// A top-level session appearing before any chat activity is the
|
|
335
|
+
// best available target; the next chat message refines it.
|
|
336
|
+
targetSessionId = id;
|
|
337
|
+
}
|
|
338
|
+
return Promise.resolve();
|
|
339
|
+
}
|
|
340
|
+
const compacted = sessionCompactedEventSchema.safeParse(received.event);
|
|
341
|
+
if (
|
|
342
|
+
compacted.success &&
|
|
343
|
+
compaction?.sessionId === compacted.data.properties.sessionID
|
|
344
|
+
) {
|
|
345
|
+
compaction = null;
|
|
346
|
+
}
|
|
347
|
+
return Promise.resolve();
|
|
348
|
+
},
|
|
349
|
+
"experimental.session.compacting": (compacting) => {
|
|
350
|
+
compaction = {sessionId: compacting.sessionID, startedAt: Date.now()};
|
|
351
|
+
return Promise.resolve();
|
|
352
|
+
},
|
|
353
|
+
tool: {
|
|
354
|
+
artifact_comments: {
|
|
355
|
+
args: artifactCommentsToolArguments(),
|
|
356
|
+
description: artifactCommentsDescription,
|
|
357
|
+
async execute(args) {
|
|
358
|
+
if (comments === null) {
|
|
359
|
+
throw new Error(
|
|
360
|
+
"Artifact Server is not configured; the bridge is dormant.",
|
|
361
|
+
);
|
|
362
|
+
}
|
|
363
|
+
if (args.operation === "get_bundle") {
|
|
364
|
+
const threadIds = args.threadIds ?? [];
|
|
365
|
+
if (threadIds.length === 0) {
|
|
366
|
+
throw new Error("get_bundle requires threadIds.");
|
|
367
|
+
}
|
|
368
|
+
const details = [];
|
|
369
|
+
for (const threadId of threadIds) {
|
|
370
|
+
// eslint-disable-next-line no-await-in-loop
|
|
371
|
+
details.push(await comments.getThread(threadId));
|
|
372
|
+
}
|
|
373
|
+
return JSON.stringify(details, null, 2);
|
|
374
|
+
}
|
|
375
|
+
const threadId = args.threadId ?? "";
|
|
376
|
+
if (threadId === "") {
|
|
377
|
+
throw new Error(`${args.operation} requires threadId.`);
|
|
378
|
+
}
|
|
379
|
+
if (args.operation === "reply") {
|
|
380
|
+
const body = args.body ?? "";
|
|
381
|
+
if (body.trim() === "") {
|
|
382
|
+
throw new Error("reply requires a non-empty body.");
|
|
383
|
+
}
|
|
384
|
+
await comments.reply(threadId, body);
|
|
385
|
+
return `Replied to ${threadId}.`;
|
|
386
|
+
}
|
|
387
|
+
await comments.resolve(threadId);
|
|
388
|
+
return `Resolved ${threadId}.`;
|
|
389
|
+
},
|
|
390
|
+
},
|
|
391
|
+
},
|
|
392
|
+
};
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
export default ArtifactServerBridge;
|
package/package.json
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@plannotator/artifact-server-opencode",
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "OpenCode plugin bridge for Artifact Server: receives annotation bundles as follow-up prompts and closes them through the comment API.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"keywords": [
|
|
7
|
+
"opencode-plugin",
|
|
8
|
+
"artifact-server"
|
|
9
|
+
],
|
|
10
|
+
"main": "./index.ts",
|
|
11
|
+
"exports": {
|
|
12
|
+
".": "./index.ts"
|
|
13
|
+
},
|
|
14
|
+
"files": [
|
|
15
|
+
"index.ts",
|
|
16
|
+
"README.md",
|
|
17
|
+
"LICENSE"
|
|
18
|
+
],
|
|
19
|
+
"dependencies": {
|
|
20
|
+
"@plannotator/agent-bridge": "^0.1.1",
|
|
21
|
+
"zod": "4.4.3"
|
|
22
|
+
},
|
|
23
|
+
"devDependencies": {
|
|
24
|
+
"typescript": "7.0.2"
|
|
25
|
+
},
|
|
26
|
+
"license": "MIT",
|
|
27
|
+
"publishConfig": {
|
|
28
|
+
"access": "public"
|
|
29
|
+
},
|
|
30
|
+
"repository": {
|
|
31
|
+
"directory": "integrations/opencode",
|
|
32
|
+
"type": "git",
|
|
33
|
+
"url": "git+https://github.com/plannotator/artifact-server.git"
|
|
34
|
+
},
|
|
35
|
+
"scripts": {
|
|
36
|
+
"typecheck": "tsc -p tsconfig.json --noEmit"
|
|
37
|
+
}
|
|
38
|
+
}
|