opencode-claude-tailguard 0.0.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Cabbage Lettuce
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,15 @@
1
+ # opencode-claude-tailguard
2
+
3
+ To install dependencies:
4
+
5
+ ```bash
6
+ bun install
7
+ ```
8
+
9
+ To run:
10
+
11
+ ```bash
12
+ bun run index.ts
13
+ ```
14
+
15
+ This project was created using `bun init` in bun v1.3.11. [Bun](https://bun.com) is a fast all-in-one JavaScript runtime.
@@ -0,0 +1,2 @@
1
+ import type { Plugin } from "@opencode-ai/plugin";
2
+ export declare const ClaudeTailguardPlugin: Plugin;
package/dist/index.js ADDED
@@ -0,0 +1,8 @@
1
+ import { transformMessages } from "./transform";
2
+ export const ClaudeTailguardPlugin = async (_ctx) => {
3
+ return {
4
+ "experimental.chat.messages.transform": async (_input, output) => {
5
+ transformMessages(output.messages);
6
+ },
7
+ };
8
+ };
@@ -0,0 +1,3 @@
1
+ export declare const logger: {
2
+ log(...args: unknown[]): void;
3
+ };
package/dist/logger.js ADDED
@@ -0,0 +1,25 @@
1
+ import { createWriteStream } from "fs";
2
+ import { homedir } from "os";
3
+ import { join } from "path";
4
+ import { stdout } from "process";
5
+ const defaultLogPath = join(homedir(), ".local", "share", "opencode", "claude-tailguard-plugin.log");
6
+ function createLogger(options) {
7
+ const combinedOptions = {
8
+ stderr: true,
9
+ logfile: defaultLogPath,
10
+ ...options,
11
+ };
12
+ const stream = combinedOptions.logfile
13
+ ? createWriteStream(combinedOptions.logfile)
14
+ : null;
15
+ return {
16
+ log(...args) {
17
+ const line = `[${new Date().toISOString()}] ${args.join(' ')}\n`;
18
+ if (combinedOptions.stderr)
19
+ stdout.write(line);
20
+ if (stream)
21
+ stream.write(line);
22
+ }
23
+ };
24
+ }
25
+ export const logger = createLogger();
@@ -0,0 +1,8 @@
1
+ import type { Message, Part } from "@opencode-ai/sdk";
2
+ export type MessageEntry = {
3
+ info: Message;
4
+ parts: Part[];
5
+ };
6
+ export declare function isTargetModel(modelID: string): boolean;
7
+ export declare function hasContent(part: Part): boolean;
8
+ export declare function transformMessages(messages: MessageEntry[]): void;
@@ -0,0 +1,102 @@
1
+ import { logger } from "./logger";
2
+ // Matches Claude 4.5/4.6 variants where prefill is not supported
3
+ // Handles separators: -, ., _ between major.minor version
4
+ const TARGET_PATTERN = /claude-(opus|sonnet|haiku)-4[._-][56]/;
5
+ export function isTargetModel(modelID) {
6
+ return TARGET_PATTERN.test(modelID);
7
+ }
8
+ // Returns true if a part carries meaningful content
9
+ // Structure-only parts (step-start, step-finish, etc.) return false
10
+ export function hasContent(part) {
11
+ switch (part.type) {
12
+ case "text":
13
+ return part.text.length > 0;
14
+ case "reasoning":
15
+ return (part.text.length > 0 ||
16
+ (part.metadata !== undefined && Object.keys(part.metadata).length > 0));
17
+ case "tool":
18
+ case "file":
19
+ return true;
20
+ case "agent":
21
+ return part.source !== undefined;
22
+ default:
23
+ return false;
24
+ }
25
+ }
26
+ function isEmptyAssistantMessage(entry) {
27
+ return entry.info.role === "assistant" && !entry.parts.some(hasContent);
28
+ }
29
+ function findLatestUserMessage(messages) {
30
+ for (let i = messages.length - 1; i >= 0; i--) {
31
+ const msg = messages[i];
32
+ if (msg !== undefined && msg.info.role === "user") {
33
+ return msg;
34
+ }
35
+ }
36
+ return undefined;
37
+ }
38
+ function createSyntheticUserMessage(sessionID, agent, model) {
39
+ const id = crypto.randomUUID();
40
+ const info = {
41
+ role: "user",
42
+ id,
43
+ sessionID,
44
+ time: { created: Date.now() },
45
+ agent,
46
+ model,
47
+ };
48
+ const part = {
49
+ id: crypto.randomUUID(),
50
+ sessionID,
51
+ messageID: id,
52
+ type: "text",
53
+ text: "Continue.",
54
+ };
55
+ return { info, parts: [part] };
56
+ }
57
+ // Transforms the message array in-place to ensure it ends with a user message
58
+ // when targeting Claude 4.6 models.
59
+ //
60
+ // Algorithm:
61
+ // 1. No-op if last message is not assistant
62
+ // 2. No-op if latest user message is not a target model
63
+ // 3. Pop consecutive empty assistant messages from tail
64
+ // 4. If tail is still assistant (has content), append synthetic "Continue." user message
65
+ export function transformMessages(messages) {
66
+ logger.log("transform called:", messages.length, "messages");
67
+ const last = messages[messages.length - 1];
68
+ if (!last || last.info.role !== "assistant") {
69
+ logger.log("skip: last message is not assistant");
70
+ return;
71
+ }
72
+ const latestUser = findLatestUserMessage(messages);
73
+ if (!latestUser) {
74
+ logger.log("skip: no user message found");
75
+ return;
76
+ }
77
+ if (!isTargetModel(latestUser.info.model.modelID)) {
78
+ logger.log("skip: non-target model:", latestUser.info.model.modelID);
79
+ return;
80
+ }
81
+ logger.log("target model:", latestUser.info.model.modelID);
82
+ let removed = 0;
83
+ while (messages.length > 0) {
84
+ const tail = messages[messages.length - 1];
85
+ if (!tail || !isEmptyAssistantMessage(tail))
86
+ break;
87
+ messages.pop();
88
+ removed++;
89
+ }
90
+ if (removed > 0) {
91
+ logger.log("removed", removed, "empty assistant message(s)");
92
+ }
93
+ const newLast = messages[messages.length - 1];
94
+ if (newLast && newLast.info.role === "assistant") {
95
+ logger.log("appending synthetic 'Continue.' user message");
96
+ messages.push(createSyntheticUserMessage(newLast.info.sessionID, latestUser.info.agent, latestUser.info.model));
97
+ }
98
+ else if (removed > 0) {
99
+ logger.log("no synthetic message needed: empty assistant(s) removed");
100
+ }
101
+ logger.log("transform complete:", messages.length, "messages");
102
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,297 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { hasContent, isTargetModel, transformMessages } from "./transform";
3
+ const SESSION_ID = "test-session";
4
+ const AGENT = "test-agent";
5
+ let _idCounter = 0;
6
+ function nextId() {
7
+ return `id-${++_idCounter}`;
8
+ }
9
+ function makeUserEntry(modelID = "claude-opus-4-6") {
10
+ return {
11
+ info: {
12
+ role: "user",
13
+ id: nextId(),
14
+ sessionID: SESSION_ID,
15
+ time: { created: Date.now() },
16
+ agent: AGENT,
17
+ model: { providerID: "anthropic", modelID },
18
+ },
19
+ parts: [],
20
+ };
21
+ }
22
+ function makeAssistantEntry(parts) {
23
+ return {
24
+ info: {
25
+ role: "assistant",
26
+ id: nextId(),
27
+ sessionID: SESSION_ID,
28
+ parentID: nextId(),
29
+ modelID: "claude-opus-4-6",
30
+ providerID: "anthropic",
31
+ mode: "chat",
32
+ path: { cwd: "/", root: "/" },
33
+ cost: 0,
34
+ tokens: {
35
+ input: 0,
36
+ output: 0,
37
+ reasoning: 0,
38
+ cache: { read: 0, write: 0 },
39
+ },
40
+ time: { created: Date.now() },
41
+ },
42
+ parts,
43
+ };
44
+ }
45
+ function makeTextPart(text) {
46
+ return {
47
+ id: nextId(),
48
+ sessionID: SESSION_ID,
49
+ messageID: nextId(),
50
+ type: "text",
51
+ text,
52
+ };
53
+ }
54
+ function makeReasoningPart(text, metadata) {
55
+ return {
56
+ id: nextId(),
57
+ sessionID: SESSION_ID,
58
+ messageID: nextId(),
59
+ type: "reasoning",
60
+ text,
61
+ metadata,
62
+ time: { start: Date.now() },
63
+ };
64
+ }
65
+ function makeToolPart() {
66
+ return {
67
+ id: nextId(),
68
+ sessionID: SESSION_ID,
69
+ messageID: nextId(),
70
+ type: "tool",
71
+ callID: nextId(),
72
+ tool: "bash",
73
+ state: {
74
+ status: "completed",
75
+ input: {},
76
+ output: "ok",
77
+ title: "bash",
78
+ metadata: {},
79
+ time: { start: 0, end: 1 },
80
+ },
81
+ };
82
+ }
83
+ function makeStepStartPart() {
84
+ return {
85
+ id: nextId(),
86
+ sessionID: SESSION_ID,
87
+ messageID: nextId(),
88
+ type: "step-start",
89
+ };
90
+ }
91
+ function makeStepFinishPart() {
92
+ return {
93
+ id: nextId(),
94
+ sessionID: SESSION_ID,
95
+ messageID: nextId(),
96
+ type: "step-finish",
97
+ reason: "end_turn",
98
+ cost: 0,
99
+ tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
100
+ };
101
+ }
102
+ // ─── isTargetModel ─────────────────────────────────────────────────────────
103
+ describe("isTargetModel", () => {
104
+ test("matches claude-opus-4-6 (hyphen separator)", () => {
105
+ expect(isTargetModel("claude-opus-4-6")).toBe(true);
106
+ });
107
+ test("matches claude-opus-4.6 (dot separator)", () => {
108
+ expect(isTargetModel("claude-opus-4.6")).toBe(true);
109
+ });
110
+ test("matches claude-sonnet-4-6", () => {
111
+ expect(isTargetModel("claude-sonnet-4-6")).toBe(true);
112
+ });
113
+ test("matches claude-haiku-4-5", () => {
114
+ expect(isTargetModel("claude-haiku-4-5")).toBe(true);
115
+ });
116
+ test("matches claude-sonnet-4.5", () => {
117
+ expect(isTargetModel("claude-sonnet-4.5")).toBe(true);
118
+ });
119
+ test("does not match claude-3-opus", () => {
120
+ expect(isTargetModel("claude-3-opus")).toBe(false);
121
+ });
122
+ test("does not match gpt-4", () => {
123
+ expect(isTargetModel("gpt-4")).toBe(false);
124
+ });
125
+ test("does not match claude-opus-4-4", () => {
126
+ expect(isTargetModel("claude-opus-4-4")).toBe(false);
127
+ });
128
+ });
129
+ // ─── hasContent ─────────────────────────────────────────────────────────────
130
+ describe("hasContent", () => {
131
+ test("TextPart with content returns true", () => {
132
+ expect(hasContent(makeTextPart("hello"))).toBe(true);
133
+ });
134
+ test("TextPart with empty string returns false", () => {
135
+ expect(hasContent(makeTextPart(""))).toBe(false);
136
+ });
137
+ test("ReasoningPart with text returns true", () => {
138
+ expect(hasContent(makeReasoningPart("thinking..."))).toBe(true);
139
+ });
140
+ test("ReasoningPart with empty text and no metadata returns false", () => {
141
+ expect(hasContent(makeReasoningPart(""))).toBe(false);
142
+ });
143
+ test("ReasoningPart with empty text and signature in metadata returns true", () => {
144
+ expect(hasContent(makeReasoningPart("", { signature: "abc123" }))).toBe(true);
145
+ });
146
+ test("ReasoningPart with empty text and empty metadata returns false", () => {
147
+ expect(hasContent(makeReasoningPart("", {}))).toBe(false);
148
+ });
149
+ test("ToolPart always returns true", () => {
150
+ expect(hasContent(makeToolPart())).toBe(true);
151
+ });
152
+ test("StepStartPart returns false", () => {
153
+ expect(hasContent(makeStepStartPart())).toBe(false);
154
+ });
155
+ test("StepFinishPart returns false", () => {
156
+ expect(hasContent(makeStepFinishPart())).toBe(false);
157
+ });
158
+ });
159
+ // ─── transformMessages ──────────────────────────────────────────────────────
160
+ describe("transformMessages", () => {
161
+ test("empty array: no-op", () => {
162
+ const messages = [];
163
+ transformMessages(messages);
164
+ expect(messages).toHaveLength(0);
165
+ });
166
+ test("last message is user: no-op", () => {
167
+ const messages = [makeUserEntry()];
168
+ transformMessages(messages);
169
+ expect(messages).toHaveLength(1);
170
+ expect(messages[0]?.info.role).toBe("user");
171
+ });
172
+ test("non-target model: no-op", () => {
173
+ const messages = [
174
+ makeUserEntry("gpt-4o"),
175
+ makeAssistantEntry([makeTextPart("hello")]),
176
+ ];
177
+ transformMessages(messages);
178
+ expect(messages).toHaveLength(2);
179
+ });
180
+ test("no user message at all: no-op", () => {
181
+ const messages = [makeAssistantEntry([makeTextPart("hello")])];
182
+ transformMessages(messages);
183
+ expect(messages).toHaveLength(1);
184
+ });
185
+ // Pattern 1: [U, A(text+thinking)] → [U, A(text+thinking), U("Continue.")]
186
+ test("pattern 1: content-bearing assistant → append Continue", () => {
187
+ const messages = [
188
+ makeUserEntry(),
189
+ makeAssistantEntry([
190
+ makeTextPart("hello"),
191
+ makeReasoningPart("thinking"),
192
+ ]),
193
+ ];
194
+ transformMessages(messages);
195
+ expect(messages).toHaveLength(3);
196
+ expect(messages[2]?.info.role).toBe("user");
197
+ expect(messages[2]?.parts[0]).toMatchObject({
198
+ type: "text",
199
+ text: "Continue.",
200
+ });
201
+ });
202
+ // Pattern 2: [U, A(text+thinking), A(empty)] → [U, A(text+thinking), U("Continue.")]
203
+ test("pattern 2: empty assistant after content-bearing → remove empty + append Continue", () => {
204
+ const messages = [
205
+ makeUserEntry(),
206
+ makeAssistantEntry([
207
+ makeTextPart("hello"),
208
+ makeReasoningPart("thinking"),
209
+ ]),
210
+ makeAssistantEntry([makeStepStartPart(), makeStepFinishPart()]),
211
+ ];
212
+ transformMessages(messages);
213
+ expect(messages).toHaveLength(3);
214
+ expect(messages[1]?.parts).toHaveLength(2);
215
+ expect(messages[2]?.info.role).toBe("user");
216
+ expect(messages[2]?.parts[0]).toMatchObject({
217
+ type: "text",
218
+ text: "Continue.",
219
+ });
220
+ });
221
+ // Pattern 3: [U, A(empty)] → [U]
222
+ test("pattern 3: single empty assistant → remove only", () => {
223
+ const messages = [
224
+ makeUserEntry(),
225
+ makeAssistantEntry([makeStepStartPart(), makeStepFinishPart()]),
226
+ ];
227
+ transformMessages(messages);
228
+ expect(messages).toHaveLength(1);
229
+ expect(messages[0]?.info.role).toBe("user");
230
+ });
231
+ // Pattern 4: [U, A(empty), A(empty)] → [U]
232
+ test("pattern 4: multiple empty assistants → remove all", () => {
233
+ const messages = [
234
+ makeUserEntry(),
235
+ makeAssistantEntry([makeStepStartPart()]),
236
+ makeAssistantEntry([makeStepFinishPart()]),
237
+ ];
238
+ transformMessages(messages);
239
+ expect(messages).toHaveLength(1);
240
+ expect(messages[0]?.info.role).toBe("user");
241
+ });
242
+ // Pattern 5: [U, A(reasoning with signature, text="")] → [U, A, U("Continue.")]
243
+ test("pattern 5: signed reasoning with empty text → preserve assistant + append Continue", () => {
244
+ const messages = [
245
+ makeUserEntry(),
246
+ makeAssistantEntry([makeReasoningPart("", { signature: "abc123" })]),
247
+ ];
248
+ transformMessages(messages);
249
+ expect(messages).toHaveLength(3);
250
+ expect(messages[1]?.info.role).toBe("assistant");
251
+ expect(messages[2]?.info.role).toBe("user");
252
+ expect(messages[2]?.parts[0]).toMatchObject({
253
+ type: "text",
254
+ text: "Continue.",
255
+ });
256
+ });
257
+ // Pattern 6: [U, A(tool+thinking)] → [U, A(tool+thinking), U("Continue.")]
258
+ test("pattern 6: assistant with tool + reasoning → append Continue", () => {
259
+ const messages = [
260
+ makeUserEntry(),
261
+ makeAssistantEntry([makeToolPart(), makeReasoningPart("thinking")]),
262
+ ];
263
+ transformMessages(messages);
264
+ expect(messages).toHaveLength(3);
265
+ expect(messages[2]?.info.role).toBe("user");
266
+ expect(messages[2]?.parts[0]).toMatchObject({
267
+ type: "text",
268
+ text: "Continue.",
269
+ });
270
+ });
271
+ test("synthetic message sessionID matches last assistant", () => {
272
+ const assistant = makeAssistantEntry([makeTextPart("hello")]);
273
+ const messages = [makeUserEntry(), assistant];
274
+ const assistantSessionID = assistant.info.sessionID;
275
+ transformMessages(messages);
276
+ expect(messages[2]?.info.sessionID).toBe(assistantSessionID);
277
+ });
278
+ test("synthetic message agent and model come from latest user message", () => {
279
+ const user = makeUserEntry("claude-sonnet-4-6");
280
+ const messages = [user, makeAssistantEntry([makeTextPart("hello")])];
281
+ transformMessages(messages);
282
+ const synthetic = messages[2]?.info;
283
+ expect(synthetic.agent).toBe(AGENT);
284
+ expect(synthetic.model.modelID).toBe("claude-sonnet-4-6");
285
+ });
286
+ test("synthetic text part has messageID matching synthetic message id", () => {
287
+ const messages = [
288
+ makeUserEntry(),
289
+ makeAssistantEntry([makeTextPart("hello")]),
290
+ ];
291
+ transformMessages(messages);
292
+ expect(messages).toHaveLength(3);
293
+ const syntheticMsg = messages[2];
294
+ const syntheticPart = syntheticMsg.parts[0];
295
+ expect(syntheticPart.messageID).toBe(syntheticMsg.info.id);
296
+ });
297
+ });
package/package.json ADDED
@@ -0,0 +1,36 @@
1
+ {
2
+ "name": "opencode-claude-tailguard",
3
+ "version": "0.0.0",
4
+ "keywords": [
5
+ "opencode",
6
+ "plugin"
7
+ ],
8
+ "license": "MIT",
9
+ "author": {
10
+ "name": "Cabbage Lettuce",
11
+ "url": "https://github.com/pycabbage/",
12
+ "email": "33714346+pycabbage@users.noreply.github.com"
13
+ },
14
+ "repository": {
15
+ "type": "git",
16
+ "url": "https://github.com/pycabbage/opencode-claude-tailguard/"
17
+ },
18
+ "files": [
19
+ "dist/**/*"
20
+ ],
21
+ "type": "module",
22
+ "main": "dist/index.js",
23
+ "module": "dist/index.js",
24
+ "types": "dist/index.d.ts",
25
+ "scripts": {
26
+ "lint": "biome check"
27
+ },
28
+ "devDependencies": {
29
+ "@biomejs/biome": "^2.4.9",
30
+ "@types/bun": "latest"
31
+ },
32
+ "peerDependencies": {
33
+ "typescript": "^5",
34
+ "@opencode-ai/plugin": "^1.3.3"
35
+ }
36
+ }