@pragma-sh/opencode-plugin 0.1.0-alpha.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/assets/opencode.svg +1 -0
- package/dist/index.mjs +1277 -0
- package/dist/pragma-plugin.mjs +586 -0
- package/package.json +47 -0
- package/pragma-plugin.json +14 -0
- package/scripts/install.mjs +23 -0
|
@@ -0,0 +1,586 @@
|
|
|
1
|
+
// ../plugin/dist/shared/chunk-k5kqv14t.js
|
|
2
|
+
var PLUGIN_API_VERSION = "0.0.0";
|
|
3
|
+
|
|
4
|
+
// ../plugin/dist/shared/chunk-0rpz2gg0.js
|
|
5
|
+
function defineAgent(input) {
|
|
6
|
+
return input;
|
|
7
|
+
}
|
|
8
|
+
function definePlugin(input) {
|
|
9
|
+
return { ...input, __apiVersion: PLUGIN_API_VERSION };
|
|
10
|
+
}
|
|
11
|
+
function defineUsageLimitProvider(input) {
|
|
12
|
+
return input;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// ../watcher-kit/src/index.ts
|
|
16
|
+
var DEFAULT_APPROVE_KEYS = "\r";
|
|
17
|
+
var RIGHT_ARROW = "\x1B[C";
|
|
18
|
+
var DOWN_ARROW = "\x1B[B";
|
|
19
|
+
var DEFAULT_DENY_KEYS = `${RIGHT_ARROW}${RIGHT_ARROW}\r`;
|
|
20
|
+
var DEFAULT_SUBMIT_KEYS = "\r";
|
|
21
|
+
var BRACKETED_PASTE_START = "\x1B[200~";
|
|
22
|
+
var BRACKETED_PASTE_END = "\x1B[201~";
|
|
23
|
+
var QUESTION_REJECT_KEYS = "\x1B";
|
|
24
|
+
var QUESTION_DIGIT_MAX = 9;
|
|
25
|
+
var QUESTION_PROMPT_MOUNT_DELAY_MS = 150;
|
|
26
|
+
var QUESTION_OTHER_INPUT_DELAY_MS = 150;
|
|
27
|
+
var QUESTION_NEXT_PROMPT_DELAY_MS = 500;
|
|
28
|
+
var ANSWER_SEPARATOR = "\x1F";
|
|
29
|
+
var RESUBSCRIBE_DELAY_MS = 500;
|
|
30
|
+
var DEFAULT_INTERJECT_SUBMIT_DELAY_MS = 200;
|
|
31
|
+
function createTuiWatcher(options) {
|
|
32
|
+
const {
|
|
33
|
+
agent,
|
|
34
|
+
handleDecisions,
|
|
35
|
+
handleQuestionAnswers = handleDecisions,
|
|
36
|
+
interjectSubmitDelayMs = DEFAULT_INTERJECT_SUBMIT_DELAY_MS,
|
|
37
|
+
interjectMode = "bracketed",
|
|
38
|
+
interjectSubmitKeys,
|
|
39
|
+
questionSelectMode = "digit",
|
|
40
|
+
questionOtherMode = "navigate-enter",
|
|
41
|
+
questionDismissKeys = QUESTION_REJECT_KEYS,
|
|
42
|
+
questionFinalizeKeys = ""
|
|
43
|
+
} = options;
|
|
44
|
+
return {
|
|
45
|
+
agent,
|
|
46
|
+
async watch(ctx) {
|
|
47
|
+
const watcherContext = ctx;
|
|
48
|
+
const runtime = {
|
|
49
|
+
keys: resolveKeys(watcherContext.config, interjectSubmitKeys),
|
|
50
|
+
handleDecisions,
|
|
51
|
+
handleQuestionAnswers,
|
|
52
|
+
interjectSubmitDelayMs,
|
|
53
|
+
interjectMode,
|
|
54
|
+
questionSelectMode,
|
|
55
|
+
questionOtherMode,
|
|
56
|
+
questionDismissKeys,
|
|
57
|
+
questionFinalizeKeys,
|
|
58
|
+
seenRequestIds: new Set,
|
|
59
|
+
questionsByRequestId: new Map,
|
|
60
|
+
outstandingCommandRequestId: null
|
|
61
|
+
};
|
|
62
|
+
let failures = 0;
|
|
63
|
+
while (!ctx.signal.aborted) {
|
|
64
|
+
try {
|
|
65
|
+
await consumeControlEvents(watcherContext, runtime);
|
|
66
|
+
failures = 0;
|
|
67
|
+
} catch (error) {
|
|
68
|
+
failures += 1;
|
|
69
|
+
reportStreamFailure(agent, failures, error);
|
|
70
|
+
}
|
|
71
|
+
if (ctx.signal.aborted) {
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
await delay(RESUBSCRIBE_DELAY_MS, ctx.signal);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
function reportStreamFailure(agent, consecutive, error) {
|
|
80
|
+
if (consecutive !== 1 && consecutive % STREAM_FAILURE_LOG_EVERY !== 0) {
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
84
|
+
console.warn(JSON.stringify({ type: "watcher.streamError", agent, consecutive, error: message }));
|
|
85
|
+
}
|
|
86
|
+
var STREAM_FAILURE_LOG_EVERY = 240;
|
|
87
|
+
function resolveKeys(config, interjectSubmitKeys) {
|
|
88
|
+
const c = config ?? {};
|
|
89
|
+
return {
|
|
90
|
+
approveKeys: c.approveKeys ?? DEFAULT_APPROVE_KEYS,
|
|
91
|
+
denyKeys: c.denyKeys ?? DEFAULT_DENY_KEYS,
|
|
92
|
+
submitKeys: c.submitKeys ?? interjectSubmitKeys ?? DEFAULT_SUBMIT_KEYS
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
async function consumeControlEvents(ctx, runtime) {
|
|
96
|
+
const connection = await ctx.sdk.agents.connect({
|
|
97
|
+
agent: ctx.agentId,
|
|
98
|
+
tabId: ctx.session.tabId,
|
|
99
|
+
worktreeId: ctx.session.worktreeId,
|
|
100
|
+
signal: ctx.signal
|
|
101
|
+
});
|
|
102
|
+
for await (const event of connection) {
|
|
103
|
+
if (ctx.signal.aborted) {
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
await handleControlEvent(ctx, runtime, event);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
async function handleControlEvent(ctx, runtime, event) {
|
|
110
|
+
if (event.type === "agent") {
|
|
111
|
+
if (runtime.handleQuestionAnswers)
|
|
112
|
+
rememberQuestion(runtime.questionsByRequestId, event);
|
|
113
|
+
if (runtime.handleDecisions)
|
|
114
|
+
rememberCommandAttention(runtime, event);
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
if (runtime.handleDecisions && await handleDecision(ctx, runtime, event)) {
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
if (runtime.handleQuestionAnswers && await handleAnswer(ctx, runtime, event)) {
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
if (event.type === "agentInput") {
|
|
124
|
+
await handleInterjection(ctx, runtime.keys.submitKeys, runtime.interjectSubmitDelayMs, runtime.interjectMode, event.input.text);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
async function handleDecision(ctx, runtime, event) {
|
|
128
|
+
if (event.type !== "agentDecision")
|
|
129
|
+
return false;
|
|
130
|
+
if (runtime.seenRequestIds.has(event.decision.requestId))
|
|
131
|
+
return true;
|
|
132
|
+
runtime.seenRequestIds.add(event.decision.requestId);
|
|
133
|
+
if (runtime.outstandingCommandRequestId !== null && runtime.outstandingCommandRequestId !== event.decision.requestId) {
|
|
134
|
+
return true;
|
|
135
|
+
}
|
|
136
|
+
runtime.outstandingCommandRequestId = null;
|
|
137
|
+
await writeKeys(ctx, event.decision.approved ? runtime.keys.approveKeys : runtime.keys.denyKeys);
|
|
138
|
+
return true;
|
|
139
|
+
}
|
|
140
|
+
async function handleAnswer(ctx, runtime, event) {
|
|
141
|
+
if (event.type !== "agentAnswer")
|
|
142
|
+
return false;
|
|
143
|
+
const { answer } = event;
|
|
144
|
+
const cached = runtime.questionsByRequestId.get(answer.requestId);
|
|
145
|
+
if (!cached)
|
|
146
|
+
return true;
|
|
147
|
+
if (runtime.seenRequestIds.has(answer.requestId))
|
|
148
|
+
return true;
|
|
149
|
+
runtime.seenRequestIds.add(answer.requestId);
|
|
150
|
+
runtime.questionsByRequestId.delete(answer.requestId);
|
|
151
|
+
await delay(QUESTION_PROMPT_MOUNT_DELAY_MS, ctx.signal);
|
|
152
|
+
if (ctx.signal.aborted)
|
|
153
|
+
return true;
|
|
154
|
+
const reply = answer.answer?.trim() ?? null;
|
|
155
|
+
if (cached.questions.length > 1) {
|
|
156
|
+
if (!answer.dismissed && reply) {
|
|
157
|
+
await writeMultipleQuestionAnswers(ctx, runtime, cached.questions, reply);
|
|
158
|
+
} else if (answer.dismissed || !reply) {
|
|
159
|
+
await writeKeys(ctx, runtime.questionDismissKeys);
|
|
160
|
+
}
|
|
161
|
+
return true;
|
|
162
|
+
}
|
|
163
|
+
const single = cached.questions[0] ?? { question: "", options: [] };
|
|
164
|
+
await writeQuestionAnswer(ctx, runtime, single, reply, answer.dismissed);
|
|
165
|
+
if (!answer.dismissed && reply)
|
|
166
|
+
await finalizeQuestionAnswers(ctx, runtime);
|
|
167
|
+
return true;
|
|
168
|
+
}
|
|
169
|
+
async function writeQuestionAnswer(ctx, runtime, question, reply, dismissed) {
|
|
170
|
+
if (dismissed || !reply) {
|
|
171
|
+
await writeKeys(ctx, runtime.questionDismissKeys);
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
if (!dismissed && reply && !question.options.includes(reply)) {
|
|
175
|
+
await writeFreeTextAnswer(ctx, question.options.length, reply, runtime.questionSelectMode, runtime.questionOtherMode);
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
const strokes = questionAnswerKeys({
|
|
179
|
+
dismissed,
|
|
180
|
+
reply,
|
|
181
|
+
options: question.options,
|
|
182
|
+
selectMode: runtime.questionSelectMode
|
|
183
|
+
});
|
|
184
|
+
if (strokes)
|
|
185
|
+
await writeKeys(ctx, strokes);
|
|
186
|
+
}
|
|
187
|
+
async function writeFreeTextAnswer(ctx, optionCount, reply, selectMode, otherMode) {
|
|
188
|
+
await writeKeys(ctx, openOtherEditorKeys(optionCount, selectMode, otherMode));
|
|
189
|
+
await delay(QUESTION_OTHER_INPUT_DELAY_MS, ctx.signal);
|
|
190
|
+
if (!ctx.signal.aborted)
|
|
191
|
+
await writeKeys(ctx, `${reply}\r`);
|
|
192
|
+
}
|
|
193
|
+
async function writeMultipleQuestionAnswers(ctx, runtime, questions, combined) {
|
|
194
|
+
const replies = combined.split(ANSWER_SEPARATOR).map((reply) => reply.trim());
|
|
195
|
+
for (const [index, question] of questions.entries()) {
|
|
196
|
+
const reply = replies[index];
|
|
197
|
+
if (!reply || ctx.signal.aborted)
|
|
198
|
+
return;
|
|
199
|
+
await writeQuestionAnswer(ctx, runtime, question, reply, false);
|
|
200
|
+
if (index < questions.length - 1) {
|
|
201
|
+
await delay(QUESTION_NEXT_PROMPT_DELAY_MS, ctx.signal);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
await finalizeQuestionAnswers(ctx, runtime);
|
|
205
|
+
}
|
|
206
|
+
async function finalizeQuestionAnswers(ctx, runtime) {
|
|
207
|
+
if (!runtime.questionFinalizeKeys || ctx.signal.aborted)
|
|
208
|
+
return;
|
|
209
|
+
await delay(QUESTION_NEXT_PROMPT_DELAY_MS, ctx.signal);
|
|
210
|
+
if (!ctx.signal.aborted)
|
|
211
|
+
await writeKeys(ctx, runtime.questionFinalizeKeys);
|
|
212
|
+
}
|
|
213
|
+
async function handleInterjection(ctx, submitKeys, submitDelayMs, mode, text) {
|
|
214
|
+
const input = mode === "plain" ? text : `${BRACKETED_PASTE_START}${text}${BRACKETED_PASTE_END}`;
|
|
215
|
+
await writeKeys(ctx, input);
|
|
216
|
+
if (!submitKeys) {
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
await delay(submitDelayMs, ctx.signal);
|
|
220
|
+
if (!ctx.signal.aborted)
|
|
221
|
+
await writeKeys(ctx, submitKeys);
|
|
222
|
+
}
|
|
223
|
+
function rememberCommandAttention(runtime, event) {
|
|
224
|
+
if (event.status === "attention" && event.attentionKind === "command" && typeof event.requestId === "string" && event.requestId.length > 0) {
|
|
225
|
+
runtime.outstandingCommandRequestId = event.requestId;
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
if (event.status !== "attention") {
|
|
229
|
+
runtime.outstandingCommandRequestId = null;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
function rememberQuestion(cache, event) {
|
|
233
|
+
if (event.status === "attention" && event.attentionKind === "question" && typeof event.requestId === "string" && event.requestId.length > 0) {
|
|
234
|
+
const entries = (event.questions ?? []).flatMap(questionEntry);
|
|
235
|
+
if (entries.length > 0) {
|
|
236
|
+
cache.set(event.requestId, { questions: entries });
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
const options = (event.options ?? []).map((option) => option.label).filter((option) => option.trim() !== "");
|
|
240
|
+
cache.set(event.requestId, {
|
|
241
|
+
questions: [{ question: event.question ?? "", options }]
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
function questionEntry(value) {
|
|
246
|
+
if (typeof value !== "object" || value === null || !("question" in value)) {
|
|
247
|
+
return [];
|
|
248
|
+
}
|
|
249
|
+
const record = value;
|
|
250
|
+
if (typeof record.question !== "string" || !record.question.trim()) {
|
|
251
|
+
return [];
|
|
252
|
+
}
|
|
253
|
+
const options = Array.isArray(record.options) ? record.options.filter((option) => Boolean(option) && typeof option === "object").map((option) => option.label).filter((label) => typeof label === "string" && label.trim() !== "") : [];
|
|
254
|
+
return [{ question: record.question, options }];
|
|
255
|
+
}
|
|
256
|
+
function questionAnswerKeys(input) {
|
|
257
|
+
if (input.dismissed || input.reply === null) {
|
|
258
|
+
return QUESTION_REJECT_KEYS;
|
|
259
|
+
}
|
|
260
|
+
const reply = input.reply.trim();
|
|
261
|
+
if (!reply) {
|
|
262
|
+
return QUESTION_REJECT_KEYS;
|
|
263
|
+
}
|
|
264
|
+
const options = input.options;
|
|
265
|
+
const selectMode = input.selectMode ?? "digit";
|
|
266
|
+
const matchIndex = options.findIndex((option) => option === reply);
|
|
267
|
+
if (matchIndex >= 0) {
|
|
268
|
+
return selectOptionKeys(matchIndex, options.length, selectMode);
|
|
269
|
+
}
|
|
270
|
+
return `${openOtherEditorKeys(options.length, selectMode, "navigate-enter")}${reply}\r`;
|
|
271
|
+
}
|
|
272
|
+
function openOtherEditorKeys(optionCount, selectMode, otherMode) {
|
|
273
|
+
if (otherMode === "shortcut-z")
|
|
274
|
+
return "z";
|
|
275
|
+
const navigate = DOWN_ARROW.repeat(optionCount);
|
|
276
|
+
return selectMode === "arrow-space" || otherMode === "navigate" ? navigate : `${navigate}\r`;
|
|
277
|
+
}
|
|
278
|
+
function selectOptionKeys(index, optionCount, selectMode) {
|
|
279
|
+
if (selectMode === "arrow-space") {
|
|
280
|
+
return `${DOWN_ARROW.repeat(index)} \r`;
|
|
281
|
+
}
|
|
282
|
+
const total = optionCount + 1;
|
|
283
|
+
if (index < QUESTION_DIGIT_MAX && index < total) {
|
|
284
|
+
return String(index + 1);
|
|
285
|
+
}
|
|
286
|
+
return `${DOWN_ARROW.repeat(index)}\r`;
|
|
287
|
+
}
|
|
288
|
+
async function writeKeys(ctx, data) {
|
|
289
|
+
try {
|
|
290
|
+
await ctx.sendKeys(data);
|
|
291
|
+
} catch {}
|
|
292
|
+
}
|
|
293
|
+
function delay(ms, signal) {
|
|
294
|
+
if (signal.aborted) {
|
|
295
|
+
return Promise.resolve();
|
|
296
|
+
}
|
|
297
|
+
return new Promise((resolve) => {
|
|
298
|
+
const finish = () => resolve();
|
|
299
|
+
const timer = setTimeout(finish, ms);
|
|
300
|
+
signal.addEventListener("abort", () => {
|
|
301
|
+
clearTimeout(timer);
|
|
302
|
+
finish();
|
|
303
|
+
}, { once: true });
|
|
304
|
+
});
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// src/cwd.ts
|
|
308
|
+
function pluginCwd(ctx) {
|
|
309
|
+
return ctx.project?.path ?? "/tmp";
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// src/usage-limits.ts
|
|
313
|
+
var FIVE_HOURS_MS = 5 * 60 * 60 * 1000;
|
|
314
|
+
var GO_LIMITS = {
|
|
315
|
+
rolling: 12,
|
|
316
|
+
weekly: 30,
|
|
317
|
+
monthly: 60
|
|
318
|
+
};
|
|
319
|
+
var USAGE_QUERY = `
|
|
320
|
+
WITH bounds AS (
|
|
321
|
+
SELECT
|
|
322
|
+
CAST(strftime('%s', 'now') AS INTEGER) * 1000 AS observed_at,
|
|
323
|
+
CAST(strftime('%s', datetime('now', 'start of day', printf('-%d days', (CAST(strftime('%w', 'now') AS INTEGER) + 6) % 7))) AS INTEGER) * 1000 AS week_start,
|
|
324
|
+
CAST(strftime('%s', 'now', 'start of month') AS INTEGER) * 1000 AS month_start,
|
|
325
|
+
CAST(strftime('%s', 'now', 'start of month', '+1 month') AS INTEGER) * 1000 AS month_end
|
|
326
|
+
), usage AS (
|
|
327
|
+
SELECT
|
|
328
|
+
bounds.*,
|
|
329
|
+
COALESCE(SUM(CASE WHEN time_created >= observed_at - ${FIVE_HOURS_MS} THEN CAST(json_extract(data, '$.cost') AS REAL) ELSE 0 END), 0) AS rolling_used,
|
|
330
|
+
MIN(CASE WHEN time_created >= observed_at - ${FIVE_HOURS_MS} THEN time_created END) AS rolling_start,
|
|
331
|
+
COALESCE(SUM(CASE WHEN time_created >= week_start THEN CAST(json_extract(data, '$.cost') AS REAL) ELSE 0 END), 0) AS weekly_used,
|
|
332
|
+
COALESCE(SUM(CASE WHEN time_created >= month_start THEN CAST(json_extract(data, '$.cost') AS REAL) ELSE 0 END), 0) AS monthly_used,
|
|
333
|
+
COUNT(message.id) AS message_count
|
|
334
|
+
FROM bounds
|
|
335
|
+
LEFT JOIN message ON json_extract(data, '$.role') = 'assistant'
|
|
336
|
+
AND json_extract(data, '$.providerID') = 'opencode-go'
|
|
337
|
+
)
|
|
338
|
+
SELECT
|
|
339
|
+
observed_at AS observedAt,
|
|
340
|
+
rolling_used AS rollingUsed,
|
|
341
|
+
CASE WHEN rolling_start IS NULL THEN NULL ELSE rolling_start + ${FIVE_HOURS_MS} END AS rollingResetsAt,
|
|
342
|
+
weekly_used AS weeklyUsed,
|
|
343
|
+
week_start + ${7 * 24 * 60 * 60 * 1000} AS weeklyResetsAt,
|
|
344
|
+
monthly_used AS monthlyUsed,
|
|
345
|
+
month_end AS monthlyResetsAt,
|
|
346
|
+
message_count AS messageCount
|
|
347
|
+
FROM usage
|
|
348
|
+
`.trim();
|
|
349
|
+
function usageCommand() {
|
|
350
|
+
return globalThis.process?.platform === "win32" ? `if (-not (Get-Command opencode -ErrorAction SilentlyContinue)) { exit 20 }; opencode db ${powershellQuote(USAGE_QUERY)} --format json` : "command -v opencode >/dev/null 2>&1 || exit 20; " + `opencode db ${shellQuote(USAGE_QUERY)} --format json`;
|
|
351
|
+
}
|
|
352
|
+
async function loadOpenCodeGoUsageLimits(ctx) {
|
|
353
|
+
const [result] = await ctx.sdk.exec.run({
|
|
354
|
+
cwd: pluginCwd(ctx),
|
|
355
|
+
commands: [usageCommand()]
|
|
356
|
+
});
|
|
357
|
+
if (result?.status === 20) {
|
|
358
|
+
return {
|
|
359
|
+
status: "unavailable",
|
|
360
|
+
reason: "not-configured",
|
|
361
|
+
message: "Install OpenCode to load OpenCode Go usage limits."
|
|
362
|
+
};
|
|
363
|
+
}
|
|
364
|
+
if (!result || result.status !== 0) {
|
|
365
|
+
throw new Error(result?.stderr.trim() || "OpenCode Go usage query failed");
|
|
366
|
+
}
|
|
367
|
+
return parseOpenCodeGoUsage(result.stdout);
|
|
368
|
+
}
|
|
369
|
+
function parseOpenCodeGoUsage(stdout) {
|
|
370
|
+
let parsed;
|
|
371
|
+
try {
|
|
372
|
+
parsed = JSON.parse(stdout);
|
|
373
|
+
} catch {
|
|
374
|
+
throw new Error("OpenCode Go usage query returned invalid JSON");
|
|
375
|
+
}
|
|
376
|
+
const row = Array.isArray(parsed) ? parsed[0] : null;
|
|
377
|
+
if (!isRecord(row)) {
|
|
378
|
+
throw new Error("OpenCode Go usage query returned no aggregate row");
|
|
379
|
+
}
|
|
380
|
+
const messageCount = finiteNumber(row.messageCount);
|
|
381
|
+
if (messageCount === null) {
|
|
382
|
+
throw new Error("OpenCode Go usage query returned a malformed message count");
|
|
383
|
+
}
|
|
384
|
+
if (messageCount === 0) {
|
|
385
|
+
return {
|
|
386
|
+
status: "unavailable",
|
|
387
|
+
reason: "unsupported",
|
|
388
|
+
message: "No device-local OpenCode Go usage was found."
|
|
389
|
+
};
|
|
390
|
+
}
|
|
391
|
+
const observedAt = requiredNumber(row.observedAt, "observation time");
|
|
392
|
+
const limits = [
|
|
393
|
+
usageLimit("rolling", "5-hour limit", row.rollingUsed, GO_LIMITS.rolling, row.rollingResetsAt, observedAt),
|
|
394
|
+
usageLimit("weekly", "Weekly limit", row.weeklyUsed, GO_LIMITS.weekly, row.weeklyResetsAt, observedAt),
|
|
395
|
+
usageLimit("monthly", "Monthly limit", row.monthlyUsed, GO_LIMITS.monthly, row.monthlyResetsAt, observedAt)
|
|
396
|
+
];
|
|
397
|
+
return { status: "ready", observedAt, limits };
|
|
398
|
+
}
|
|
399
|
+
function usageLimit(id, title, usedValue, limit, resetsAtValue, observedAt) {
|
|
400
|
+
const used = requiredNumber(usedValue, `${title} usage`);
|
|
401
|
+
const resetsAt = finiteNumber(resetsAtValue);
|
|
402
|
+
return {
|
|
403
|
+
id,
|
|
404
|
+
title,
|
|
405
|
+
used: Math.max(0, used),
|
|
406
|
+
limit,
|
|
407
|
+
...resetsAt === null ? {} : { resetsInMs: Math.max(0, resetsAt - observedAt) }
|
|
408
|
+
};
|
|
409
|
+
}
|
|
410
|
+
function requiredNumber(value, field) {
|
|
411
|
+
const number = finiteNumber(value);
|
|
412
|
+
if (number === null) {
|
|
413
|
+
throw new Error(`OpenCode Go usage query returned malformed ${field}`);
|
|
414
|
+
}
|
|
415
|
+
return number;
|
|
416
|
+
}
|
|
417
|
+
function finiteNumber(value) {
|
|
418
|
+
return typeof value === "number" && Number.isFinite(value) ? value : null;
|
|
419
|
+
}
|
|
420
|
+
function isRecord(value) {
|
|
421
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
422
|
+
}
|
|
423
|
+
function shellQuote(value) {
|
|
424
|
+
return `'${value.replace(/'/g, `'"'"'`)}'`;
|
|
425
|
+
}
|
|
426
|
+
function powershellQuote(value) {
|
|
427
|
+
return `'${value.replace(/'/g, "''")}'`;
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
// src/pragma-plugin.ts
|
|
431
|
+
var ansiEscapePattern = new RegExp(`${String.fromCharCode(27)}\\[[0-9;]*[A-Za-z]`, "g");
|
|
432
|
+
var opencodeAgentPlugin = definePlugin({
|
|
433
|
+
name: "OpenCode",
|
|
434
|
+
description: "Launch OpenCode from Pragma.",
|
|
435
|
+
watchers: [
|
|
436
|
+
createTuiWatcher({
|
|
437
|
+
agent: "opencode",
|
|
438
|
+
handleDecisions: true,
|
|
439
|
+
questionFinalizeKeys: "\r"
|
|
440
|
+
})
|
|
441
|
+
],
|
|
442
|
+
usageLimits: [
|
|
443
|
+
defineUsageLimitProvider({
|
|
444
|
+
id: "opencode-go",
|
|
445
|
+
title: "OpenCode Go",
|
|
446
|
+
dashboardUrl: "https://opencode.ai/auth",
|
|
447
|
+
iconPath: "assets/opencode.svg",
|
|
448
|
+
primaryLimitId: "rolling",
|
|
449
|
+
load: loadOpenCodeGoUsageLimits
|
|
450
|
+
})
|
|
451
|
+
],
|
|
452
|
+
agents: [
|
|
453
|
+
defineAgent({
|
|
454
|
+
id: "opencode",
|
|
455
|
+
name: "OpenCode",
|
|
456
|
+
icon: () => null,
|
|
457
|
+
iconPath: "assets/opencode.svg",
|
|
458
|
+
launch: { command: ["opencode"] },
|
|
459
|
+
prefillDelayMs: 6000,
|
|
460
|
+
models: async (ctx) => parseOpenCodeModels(await execFirst(ctx, "opencode models --json 2>/dev/null || opencode models --verbose 2>/dev/null || opencode models 2>/dev/null")),
|
|
461
|
+
permissionModes: [],
|
|
462
|
+
args: {
|
|
463
|
+
model: (modelId) => ["--model", modelId],
|
|
464
|
+
reasoning: () => [],
|
|
465
|
+
permissionMode: () => []
|
|
466
|
+
}
|
|
467
|
+
})
|
|
468
|
+
]
|
|
469
|
+
});
|
|
470
|
+
var pragma_plugin_default = opencodeAgentPlugin;
|
|
471
|
+
async function execFirst(ctx, command) {
|
|
472
|
+
const [result] = await ctx.sdk.exec.run({ cwd: pluginCwd(ctx), commands: [command] });
|
|
473
|
+
return result?.stdout ?? "";
|
|
474
|
+
}
|
|
475
|
+
function parseOpenCodeModels(output) {
|
|
476
|
+
const jsonModels = parseJsonModels(output);
|
|
477
|
+
if (jsonModels.length > 0) {
|
|
478
|
+
return jsonModels;
|
|
479
|
+
}
|
|
480
|
+
return output.replaceAll(ansiEscapePattern, "").split(`
|
|
481
|
+
`).map((line) => line.trim().replace(/^│|│$/g, "").trim()).flatMap(modelFromLine);
|
|
482
|
+
}
|
|
483
|
+
function modelFromLine(line) {
|
|
484
|
+
if (isModelTableDecoration(line))
|
|
485
|
+
return [];
|
|
486
|
+
const model = parseModelLine(line);
|
|
487
|
+
return model ? [model] : [];
|
|
488
|
+
}
|
|
489
|
+
function parseModelLine(line) {
|
|
490
|
+
const [id, name] = modelColumns(line);
|
|
491
|
+
if (!id)
|
|
492
|
+
return null;
|
|
493
|
+
if (!name)
|
|
494
|
+
return null;
|
|
495
|
+
if (/\s/.test(id))
|
|
496
|
+
return null;
|
|
497
|
+
return { id, name: withProvider(id, name) };
|
|
498
|
+
}
|
|
499
|
+
function modelColumns(line) {
|
|
500
|
+
if (!line.includes(" - "))
|
|
501
|
+
return splitWhitespaceModel(line);
|
|
502
|
+
const [id, name] = line.split(" - ", 2);
|
|
503
|
+
return [id, name];
|
|
504
|
+
}
|
|
505
|
+
function isModelTableDecoration(line) {
|
|
506
|
+
return !line || /^(model|provider|tip|usage)/i.test(line) || /^[─━│┃┌┐└┘├┤┬┴┼\-+]+$/.test(line);
|
|
507
|
+
}
|
|
508
|
+
function parseJsonModels(output) {
|
|
509
|
+
try {
|
|
510
|
+
const parsed = JSON.parse(output);
|
|
511
|
+
const models = [];
|
|
512
|
+
walk(parsed, models);
|
|
513
|
+
return uniqueModels(models);
|
|
514
|
+
} catch {
|
|
515
|
+
return [];
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
function walk(value, models) {
|
|
519
|
+
if (Array.isArray(value)) {
|
|
520
|
+
for (const child of value)
|
|
521
|
+
walk(child, models);
|
|
522
|
+
return;
|
|
523
|
+
}
|
|
524
|
+
if (!isRecord2(value)) {
|
|
525
|
+
return;
|
|
526
|
+
}
|
|
527
|
+
const model = modelFromRecord(value);
|
|
528
|
+
if (model) {
|
|
529
|
+
models.push(model);
|
|
530
|
+
}
|
|
531
|
+
for (const child of Object.values(value))
|
|
532
|
+
walk(child, models);
|
|
533
|
+
}
|
|
534
|
+
function isRecord2(value) {
|
|
535
|
+
return Boolean(value) && typeof value === "object";
|
|
536
|
+
}
|
|
537
|
+
function modelFromRecord(record) {
|
|
538
|
+
const id = firstString(record.id, record.model);
|
|
539
|
+
const name = modelName(record, id);
|
|
540
|
+
if (!id || !name || !hasModelMetadata(record)) {
|
|
541
|
+
return null;
|
|
542
|
+
}
|
|
543
|
+
return { id, name: withProvider(id, name, stringValue(record.provider)) };
|
|
544
|
+
}
|
|
545
|
+
function firstString(...values) {
|
|
546
|
+
for (const value of values) {
|
|
547
|
+
const string = stringValue(value);
|
|
548
|
+
if (string)
|
|
549
|
+
return string;
|
|
550
|
+
}
|
|
551
|
+
return;
|
|
552
|
+
}
|
|
553
|
+
function modelName(record, id) {
|
|
554
|
+
return stringValue(record.name) ?? stringValue(record.displayName) ?? stringValue(record.label) ?? id;
|
|
555
|
+
}
|
|
556
|
+
function hasModelMetadata(record) {
|
|
557
|
+
return Boolean(record.provider || record.cost || record.limits || record.modalities);
|
|
558
|
+
}
|
|
559
|
+
function uniqueModels(models) {
|
|
560
|
+
const seen = new Set;
|
|
561
|
+
return models.filter((model) => {
|
|
562
|
+
if (seen.has(model.id))
|
|
563
|
+
return false;
|
|
564
|
+
seen.add(model.id);
|
|
565
|
+
return true;
|
|
566
|
+
});
|
|
567
|
+
}
|
|
568
|
+
function stringValue(value) {
|
|
569
|
+
return typeof value === "string" && value.trim() ? value.trim() : undefined;
|
|
570
|
+
}
|
|
571
|
+
function splitWhitespaceModel(line) {
|
|
572
|
+
const parts = line.split(/\s+/);
|
|
573
|
+
const id = parts[0];
|
|
574
|
+
return id?.includes("/") ? [id, parts.slice(1).join(" ") || displayName(id)] : [undefined, undefined];
|
|
575
|
+
}
|
|
576
|
+
function displayName(id) {
|
|
577
|
+
return id.split("/").at(-1)?.replaceAll(/[-_]/g, " ") ?? id;
|
|
578
|
+
}
|
|
579
|
+
function withProvider(id, name, provider = id.includes("/") ? id.split("/", 1)[0] : "") {
|
|
580
|
+
return provider && !name.endsWith(`(${provider})`) ? `${name} (${provider})` : name;
|
|
581
|
+
}
|
|
582
|
+
export {
|
|
583
|
+
parseOpenCodeModels,
|
|
584
|
+
opencodeAgentPlugin,
|
|
585
|
+
pragma_plugin_default as default
|
|
586
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@pragma-sh/opencode-plugin",
|
|
3
|
+
"version": "0.1.0-alpha.0",
|
|
4
|
+
"description": "opencode plugin that reports agent status to Pragma automatically.",
|
|
5
|
+
"license": "AGPL-3.0-only",
|
|
6
|
+
"files": [
|
|
7
|
+
"assets",
|
|
8
|
+
"dist",
|
|
9
|
+
"pragma-plugin.json",
|
|
10
|
+
"scripts"
|
|
11
|
+
],
|
|
12
|
+
"type": "module",
|
|
13
|
+
"main": "./dist/index.mjs",
|
|
14
|
+
"module": "./dist/index.mjs",
|
|
15
|
+
"types": "./dist/index.d.ts",
|
|
16
|
+
"exports": {
|
|
17
|
+
".": {
|
|
18
|
+
"types": "./dist/index.d.ts",
|
|
19
|
+
"import": "./dist/index.mjs"
|
|
20
|
+
},
|
|
21
|
+
"./package.json": "./package.json"
|
|
22
|
+
},
|
|
23
|
+
"publishConfig": {
|
|
24
|
+
"access": "public"
|
|
25
|
+
},
|
|
26
|
+
"scripts": {
|
|
27
|
+
"build": "bun build src/index.ts src/pragma-plugin.ts --outdir dist --format esm --target node --packages bundle && mv dist/index.js dist/index.mjs && mv dist/pragma-plugin.js dist/pragma-plugin.mjs",
|
|
28
|
+
"prepack": "bun run build",
|
|
29
|
+
"typecheck": "tsc -p tsconfig.typecheck.json --noEmit",
|
|
30
|
+
"test": "bun --bun vitest run",
|
|
31
|
+
"lint": "oxlint ."
|
|
32
|
+
},
|
|
33
|
+
"devDependencies": {
|
|
34
|
+
"@opencode-ai/plugin": "^1.17.8",
|
|
35
|
+
"@pragma/plugin": "workspace:*",
|
|
36
|
+
"@pragma/sdk": "workspace:*",
|
|
37
|
+
"@pragma/watcher-kit": "workspace:*",
|
|
38
|
+
"@types/node": "^24.12.3",
|
|
39
|
+
"bunup": "^0.16.32",
|
|
40
|
+
"typescript": "^6.0.3",
|
|
41
|
+
"vitest": "^4.1.8"
|
|
42
|
+
},
|
|
43
|
+
"pragma": {
|
|
44
|
+
"pluginId": "pragma.opencode",
|
|
45
|
+
"main": "./dist/pragma-plugin.mjs"
|
|
46
|
+
}
|
|
47
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://raw.githubusercontent.com/pragma-sh/pragma/main/packages/plugin-registry/pragma-plugin.schema.json",
|
|
3
|
+
"name": "OpenCode",
|
|
4
|
+
"description": "Launch OpenCode in Pragma with streaming status, approvals, questions, and usage limits.",
|
|
5
|
+
"categories": ["agent-plugin"],
|
|
6
|
+
"images": [
|
|
7
|
+
{
|
|
8
|
+
"url": "https://raw.githubusercontent.com/pragma-sh/pragma/main/packages/opencode-plugin/assets/opencode.svg",
|
|
9
|
+
"alt": "OpenCode logo"
|
|
10
|
+
}
|
|
11
|
+
],
|
|
12
|
+
"install": { "command": "node", "args": ["scripts/install.mjs"] },
|
|
13
|
+
"agentBinary": "opencode"
|
|
14
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
|
|
5
|
+
const configPath =
|
|
6
|
+
process.env.OPENCODE_CONFIG ?? join(homedir(), ".config", "opencode", "opencode.json");
|
|
7
|
+
let config = {};
|
|
8
|
+
try {
|
|
9
|
+
config = JSON.parse(await readFile(configPath, "utf8"));
|
|
10
|
+
} catch (error) {
|
|
11
|
+
if (error?.code !== "ENOENT") {
|
|
12
|
+
throw new Error(
|
|
13
|
+
`${configPath} must be valid JSON before Pragma can install its OpenCode plugin`,
|
|
14
|
+
{ cause: error },
|
|
15
|
+
);
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
const plugins = Array.isArray(config.plugin) ? config.plugin : [];
|
|
19
|
+
if (!plugins.includes("@pragma-sh/opencode-plugin")) plugins.push("@pragma-sh/opencode-plugin");
|
|
20
|
+
await mkdir(dirname(configPath), { recursive: true });
|
|
21
|
+
await writeFile(configPath, `${JSON.stringify({ ...config, plugin: plugins }, null, 2)}\n`, {
|
|
22
|
+
mode: 0o600,
|
|
23
|
+
});
|