@pragma-sh/junie-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/.junie-plugin/plugin.json +8 -0
- package/assets/junie.svg +17 -0
- package/dist/install.mjs +61 -0
- package/dist/pragma-plugin.mjs +700 -0
- package/hooks/hooks.json +81 -0
- package/hooks/report.sh +715 -0
- package/package.json +42 -0
- package/pragma-plugin.json +14 -0
- package/scripts/install-local.ts +127 -0
- package/src/acp.ts +223 -0
- package/src/pragma-plugin.test.ts +82 -0
- package/src/pragma-plugin.ts +193 -0
- package/src/usage-limits.test.ts +95 -0
- package/src/usage-limits.ts +225 -0
|
@@ -0,0 +1,700 @@
|
|
|
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/acp.ts
|
|
308
|
+
var MISSING_STATUS = 20;
|
|
309
|
+
var INITIALIZE_ID = 1;
|
|
310
|
+
var SESSION_ID = 2;
|
|
311
|
+
var PROMPT_ID = 3;
|
|
312
|
+
var POLL_SECONDS = "0.1";
|
|
313
|
+
var POLL_ATTEMPTS = 900;
|
|
314
|
+
var SHELL_PROBE = "(command -v sh >/dev/null 2>&1) && exit 0 || exit 1";
|
|
315
|
+
async function readJunieAcp(ctx, options) {
|
|
316
|
+
const cwd = ctx.project?.path ?? "/tmp";
|
|
317
|
+
const [shell] = await ctx.sdk.exec.run({ cwd, commands: [SHELL_PROBE] });
|
|
318
|
+
if (shell?.status !== 0) {
|
|
319
|
+
return { missing: false, unsupportedShell: true, session: undefined, usageText: null };
|
|
320
|
+
}
|
|
321
|
+
const [result] = await ctx.sdk.exec.run({ cwd, commands: [buildCommand(options.usage)] });
|
|
322
|
+
if (result?.status === MISSING_STATUS) {
|
|
323
|
+
return { missing: true, unsupportedShell: false, session: undefined, usageText: null };
|
|
324
|
+
}
|
|
325
|
+
if (!result || result.status !== 0) {
|
|
326
|
+
throw new Error(result?.stderr.trim() || "Junie ACP request failed");
|
|
327
|
+
}
|
|
328
|
+
return {
|
|
329
|
+
missing: false,
|
|
330
|
+
unsupportedShell: false,
|
|
331
|
+
session: findResponse(result.stdout, SESSION_ID),
|
|
332
|
+
usageText: options.usage ? collectAgentText(result.stdout) : null
|
|
333
|
+
};
|
|
334
|
+
}
|
|
335
|
+
function buildCommand(usage) {
|
|
336
|
+
const finalId = usage ? PROMPT_ID : SESSION_ID;
|
|
337
|
+
return [
|
|
338
|
+
`command -v junie >/dev/null 2>&1 || exit ${MISSING_STATUS};`,
|
|
339
|
+
"work=$(mktemp -d) || exit 1;",
|
|
340
|
+
`trap 'rm -rf "$work"' 0;`,
|
|
341
|
+
'mkfifo "$work/in" || exit 1;',
|
|
342
|
+
"{",
|
|
343
|
+
`printf '%s\\n' ${shellQuote(request(INITIALIZE_ID, "initialize", { protocolVersion: 1, clientCapabilities: {} }))};`,
|
|
344
|
+
`printf '%s\\n' '{"jsonrpc":"2.0","id":${SESSION_ID},"method":"session/new","params":{"cwd":"'"$PWD"'","mcpServers":[]}}';`,
|
|
345
|
+
...usage ? [
|
|
346
|
+
"n=0;",
|
|
347
|
+
`while [ ! -s "$work/next" ] && [ ! -e "$work/done" ] && [ "$n" -lt ${POLL_ATTEMPTS} ]; do sleep ${POLL_SECONDS}; n=$((n + 1)); done;`,
|
|
348
|
+
'[ -s "$work/next" ] && cat "$work/next";'
|
|
349
|
+
] : [],
|
|
350
|
+
"n=0;",
|
|
351
|
+
`while [ ! -e "$work/done" ] && [ "$n" -lt ${POLL_ATTEMPTS} ]; do sleep ${POLL_SECONDS}; n=$((n + 1)); done;`,
|
|
352
|
+
'} >"$work/in" &',
|
|
353
|
+
'junie --acp=true --skip-update-check --cache-dir "$work/cache" <"$work/in" 2>/dev/null |',
|
|
354
|
+
"while IFS= read -r line; do",
|
|
355
|
+
`printf '%s\\n' "$line";`,
|
|
356
|
+
...usage ? [
|
|
357
|
+
`case "$line" in *'"sessionId":"'*)`,
|
|
358
|
+
'if [ ! -s "$work/next" ]; then',
|
|
359
|
+
`sid=$(printf '%s' "$line" | sed -n 's/.*"sessionId":"\\([^"]*\\)".*/\\1/p' | head -n 1);`,
|
|
360
|
+
'if [ -n "$sid" ]; then',
|
|
361
|
+
`printf '{"jsonrpc":"2.0","id":${PROMPT_ID},"method":"session/prompt","params":{"sessionId":"%s","prompt":[{"type":"text","text":"/usage"}]}}\\n' "$sid" >"$work/next";`,
|
|
362
|
+
"fi;",
|
|
363
|
+
"fi;",
|
|
364
|
+
";; esac;"
|
|
365
|
+
] : [],
|
|
366
|
+
`case "$line" in *'"id":${finalId}'*) : >"$work/done" ;; esac;`,
|
|
367
|
+
"done"
|
|
368
|
+
].join(" ");
|
|
369
|
+
}
|
|
370
|
+
function request(id, method, params) {
|
|
371
|
+
return JSON.stringify({ jsonrpc: "2.0", id, method, params });
|
|
372
|
+
}
|
|
373
|
+
function findResponse(stdout, id) {
|
|
374
|
+
for (const line of stdout.split(`
|
|
375
|
+
`)) {
|
|
376
|
+
let message;
|
|
377
|
+
try {
|
|
378
|
+
message = JSON.parse(line);
|
|
379
|
+
} catch {
|
|
380
|
+
continue;
|
|
381
|
+
}
|
|
382
|
+
const record = asRecord(message);
|
|
383
|
+
if (record === null || record.id !== id) {
|
|
384
|
+
continue;
|
|
385
|
+
}
|
|
386
|
+
if ("result" in record) {
|
|
387
|
+
return { ok: true, result: record.result };
|
|
388
|
+
}
|
|
389
|
+
return { ok: false, message: asText(asRecord(record.error)?.message) };
|
|
390
|
+
}
|
|
391
|
+
return;
|
|
392
|
+
}
|
|
393
|
+
function collectAgentText(stdout) {
|
|
394
|
+
const parts = [];
|
|
395
|
+
for (const line of stdout.split(`
|
|
396
|
+
`)) {
|
|
397
|
+
let message;
|
|
398
|
+
try {
|
|
399
|
+
message = JSON.parse(line);
|
|
400
|
+
} catch {
|
|
401
|
+
continue;
|
|
402
|
+
}
|
|
403
|
+
const update = asRecord(asRecord(asRecord(message)?.params)?.update);
|
|
404
|
+
if (update?.sessionUpdate !== "agent_message_chunk") {
|
|
405
|
+
continue;
|
|
406
|
+
}
|
|
407
|
+
const text = asText(asRecord(update.content)?.text);
|
|
408
|
+
if (text !== null) {
|
|
409
|
+
parts.push(text);
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
return parts.join("");
|
|
413
|
+
}
|
|
414
|
+
function shellQuote(value) {
|
|
415
|
+
return `'${value.replaceAll("'", `'"'"'`)}'`;
|
|
416
|
+
}
|
|
417
|
+
function asRecord(value) {
|
|
418
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
419
|
+
return null;
|
|
420
|
+
}
|
|
421
|
+
return value;
|
|
422
|
+
}
|
|
423
|
+
function asText(value) {
|
|
424
|
+
return typeof value === "string" && value.trim() ? value.trim() : null;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
// src/usage-limits.ts
|
|
428
|
+
var PRIMARY_LIMIT_ID = "credits";
|
|
429
|
+
var PEAKS_DIR = '"$HOME"/.pragma/cache';
|
|
430
|
+
var PEAKS_FILE = `${PEAKS_DIR}/junie-usage.json`;
|
|
431
|
+
var PEAKS_VERSION = 1;
|
|
432
|
+
async function loadJunieUsageLimits(ctx) {
|
|
433
|
+
const { missing, unsupportedShell, usageText } = await readJunieAcp(ctx, { usage: true });
|
|
434
|
+
if (unsupportedShell) {
|
|
435
|
+
return {
|
|
436
|
+
status: "unavailable",
|
|
437
|
+
reason: "unsupported",
|
|
438
|
+
message: "Junie usage limits require a POSIX shell on the project's host."
|
|
439
|
+
};
|
|
440
|
+
}
|
|
441
|
+
if (missing) {
|
|
442
|
+
return {
|
|
443
|
+
status: "unavailable",
|
|
444
|
+
reason: "not-configured",
|
|
445
|
+
message: "Install the Junie CLI to load usage limits."
|
|
446
|
+
};
|
|
447
|
+
}
|
|
448
|
+
if (usageText === null || usageText.trim() === "") {
|
|
449
|
+
throw new Error("Junie returned no usage report");
|
|
450
|
+
}
|
|
451
|
+
if (/log ?in|sign ?in|authenticat|not authorized/i.test(usageText)) {
|
|
452
|
+
return {
|
|
453
|
+
status: "unavailable",
|
|
454
|
+
reason: "authentication-required",
|
|
455
|
+
message: "Run `junie` and sign in to your JetBrains account to load usage limits."
|
|
456
|
+
};
|
|
457
|
+
}
|
|
458
|
+
const usage = parseJunieUsage(usageText);
|
|
459
|
+
if (usage === null) {
|
|
460
|
+
return {
|
|
461
|
+
status: "unavailable",
|
|
462
|
+
reason: "unsupported",
|
|
463
|
+
message: "Junie did not report a balance for this license."
|
|
464
|
+
};
|
|
465
|
+
}
|
|
466
|
+
const peak = await recordPeak(ctx, peakKey(usage), usage.remaining);
|
|
467
|
+
return buildResult(usage, peak, Date.now());
|
|
468
|
+
}
|
|
469
|
+
function parseJunieUsage(text) {
|
|
470
|
+
const licenseType = /^\s*-?\s*License:\s*(.+?)\s*$/m.exec(text)?.[1] ?? null;
|
|
471
|
+
const money = /^\s*-?\s*Balance left:\s*([^\d\s]*)\s*([\d.,]+)\s*$/m.exec(text);
|
|
472
|
+
if (money) {
|
|
473
|
+
const remaining = parseAmount(money[2]);
|
|
474
|
+
return remaining === null ? null : { licenseType, remaining, unit: money[1] || "$" };
|
|
475
|
+
}
|
|
476
|
+
const credits = /^\s*-?\s*Quota:\s*([\d.,]+)\s*(\S+)?\s*remaining\s*$/m.exec(text);
|
|
477
|
+
if (credits) {
|
|
478
|
+
const remaining = parseAmount(credits[1]);
|
|
479
|
+
return remaining === null ? null : { licenseType, remaining, unit: credits[2] ?? "credits" };
|
|
480
|
+
}
|
|
481
|
+
return null;
|
|
482
|
+
}
|
|
483
|
+
function buildResult(usage, peak, observedAt) {
|
|
484
|
+
if (peak <= 0) {
|
|
485
|
+
return {
|
|
486
|
+
status: "unavailable",
|
|
487
|
+
reason: "unsupported",
|
|
488
|
+
message: `Junie reports no ${usage.unit === "$" ? "balance" : usage.unit} left.`
|
|
489
|
+
};
|
|
490
|
+
}
|
|
491
|
+
const limit = {
|
|
492
|
+
id: PRIMARY_LIMIT_ID,
|
|
493
|
+
title: usage.licenseType === null ? "Credits" : `${usage.licenseType} credits`,
|
|
494
|
+
used: Math.min(peak, Math.max(0, peak - usage.remaining)),
|
|
495
|
+
limit: peak
|
|
496
|
+
};
|
|
497
|
+
return { status: "ready", observedAt, limits: [limit] };
|
|
498
|
+
}
|
|
499
|
+
function peakKey(usage) {
|
|
500
|
+
return `${usage.licenseType ?? "unknown"}|${usage.unit}`;
|
|
501
|
+
}
|
|
502
|
+
async function recordPeak(ctx, key, remaining) {
|
|
503
|
+
const cwd = ctx.project?.path ?? "/tmp";
|
|
504
|
+
const file = parsePeaksFile(await readPeaksText(ctx, cwd));
|
|
505
|
+
const stored = file.peaks[key] ?? 0;
|
|
506
|
+
if (remaining <= stored) {
|
|
507
|
+
return stored;
|
|
508
|
+
}
|
|
509
|
+
file.peaks[key] = remaining;
|
|
510
|
+
try {
|
|
511
|
+
await writePeaksText(ctx, cwd, serializePeaksFile(file));
|
|
512
|
+
} catch {}
|
|
513
|
+
return remaining;
|
|
514
|
+
}
|
|
515
|
+
async function readPeaksText(ctx, cwd) {
|
|
516
|
+
try {
|
|
517
|
+
const [result] = await ctx.sdk.exec.run({
|
|
518
|
+
cwd,
|
|
519
|
+
commands: [`cat ${PEAKS_FILE} 2>/dev/null || true`]
|
|
520
|
+
});
|
|
521
|
+
return result?.stdout ?? "";
|
|
522
|
+
} catch {
|
|
523
|
+
return "";
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
async function writePeaksText(ctx, cwd, contents) {
|
|
527
|
+
const [result] = await ctx.sdk.exec.run({
|
|
528
|
+
cwd,
|
|
529
|
+
commands: [`mkdir -p ${PEAKS_DIR} && printf '%s\\n' ${shellQuote(contents)} > ${PEAKS_FILE}`]
|
|
530
|
+
});
|
|
531
|
+
if (!result || result.status !== 0) {
|
|
532
|
+
throw new Error(result?.stderr.trim() || "failed to write Junie usage cache");
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
function parsePeaksFile(text) {
|
|
536
|
+
const trimmed = text.trim();
|
|
537
|
+
if (trimmed === "") {
|
|
538
|
+
return { version: PEAKS_VERSION, peaks: {} };
|
|
539
|
+
}
|
|
540
|
+
try {
|
|
541
|
+
const parsed = JSON.parse(trimmed);
|
|
542
|
+
if (typeof parsed !== "object" || parsed === null) {
|
|
543
|
+
return { version: PEAKS_VERSION, peaks: {} };
|
|
544
|
+
}
|
|
545
|
+
const record = parsed;
|
|
546
|
+
if (record.version !== PEAKS_VERSION || typeof record.peaks !== "object" || record.peaks === null) {
|
|
547
|
+
return { version: PEAKS_VERSION, peaks: {} };
|
|
548
|
+
}
|
|
549
|
+
const peaks = {};
|
|
550
|
+
for (const [key, value] of Object.entries(record.peaks)) {
|
|
551
|
+
if (typeof value === "number" && Number.isFinite(value) && value > 0) {
|
|
552
|
+
peaks[key] = value;
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
return { version: PEAKS_VERSION, peaks };
|
|
556
|
+
} catch {
|
|
557
|
+
return { version: PEAKS_VERSION, peaks: {} };
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
function serializePeaksFile(file) {
|
|
561
|
+
return `${JSON.stringify(file, null, 2)}
|
|
562
|
+
`;
|
|
563
|
+
}
|
|
564
|
+
function parseAmount(value) {
|
|
565
|
+
if (value === undefined) {
|
|
566
|
+
return null;
|
|
567
|
+
}
|
|
568
|
+
const parsed = Number.parseFloat(value.replaceAll(",", ""));
|
|
569
|
+
return Number.isFinite(parsed) ? parsed : null;
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
// src/pragma-plugin.ts
|
|
573
|
+
var PREFILL_DELAY_MS = 6000;
|
|
574
|
+
var USAGE_REFRESH_INTERVAL_MS = 900000;
|
|
575
|
+
var baseWatcher = createTuiWatcher({
|
|
576
|
+
agent: "junie",
|
|
577
|
+
handleDecisions: false,
|
|
578
|
+
handleQuestionAnswers: true,
|
|
579
|
+
questionSelectMode: "arrow-space",
|
|
580
|
+
questionFinalizeKeys: "\r"
|
|
581
|
+
});
|
|
582
|
+
var junieAgentPlugin = definePlugin({
|
|
583
|
+
name: "Junie",
|
|
584
|
+
description: "Launch the JetBrains Junie CLI from Pragma.",
|
|
585
|
+
usageLimits: [
|
|
586
|
+
defineUsageLimitProvider({
|
|
587
|
+
id: "junie",
|
|
588
|
+
title: "Junie",
|
|
589
|
+
dashboardUrl: "https://junie.jetbrains.com/cli",
|
|
590
|
+
iconPath: "assets/junie.svg",
|
|
591
|
+
primaryLimitId: PRIMARY_LIMIT_ID,
|
|
592
|
+
refreshIntervalMs: USAGE_REFRESH_INTERVAL_MS,
|
|
593
|
+
load: loadJunieUsageLimits
|
|
594
|
+
})
|
|
595
|
+
],
|
|
596
|
+
watchers: [
|
|
597
|
+
{
|
|
598
|
+
agent: "junie",
|
|
599
|
+
async watch(ctx) {
|
|
600
|
+
try {
|
|
601
|
+
await baseWatcher.watch(ctx);
|
|
602
|
+
} finally {
|
|
603
|
+
try {
|
|
604
|
+
await ctx.sdk.agents.report({
|
|
605
|
+
agent: ctx.agentId,
|
|
606
|
+
tabId: ctx.session.tabId,
|
|
607
|
+
worktreeId: ctx.session.worktreeId,
|
|
608
|
+
status: "cleared",
|
|
609
|
+
attentionKind: null
|
|
610
|
+
});
|
|
611
|
+
} catch {}
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
],
|
|
616
|
+
agents: [
|
|
617
|
+
defineAgent({
|
|
618
|
+
id: "junie",
|
|
619
|
+
name: "Junie",
|
|
620
|
+
icon: () => null,
|
|
621
|
+
iconPath: "assets/junie.svg",
|
|
622
|
+
launch: { command: ["junie"] },
|
|
623
|
+
prefillDelayMs: PREFILL_DELAY_MS,
|
|
624
|
+
prefillMode: "plain",
|
|
625
|
+
prefillSubmit: "\r",
|
|
626
|
+
models: loadJunieModels,
|
|
627
|
+
permissionModes: [
|
|
628
|
+
{ id: "default", name: "Ask for approval" },
|
|
629
|
+
{ id: "brave", name: "Brave mode (no approvals)" }
|
|
630
|
+
],
|
|
631
|
+
excludeFeatures: ["subagents"],
|
|
632
|
+
args: {
|
|
633
|
+
model: (modelId) => ["--model", modelId],
|
|
634
|
+
reasoning: (reasoningId) => ["--effort", reasoningId],
|
|
635
|
+
permissionMode: (permissionModeId) => permissionModeId === "brave" ? ["--brave"] : []
|
|
636
|
+
}
|
|
637
|
+
})
|
|
638
|
+
]
|
|
639
|
+
});
|
|
640
|
+
var pragma_plugin_default = junieAgentPlugin;
|
|
641
|
+
async function loadJunieModels(ctx) {
|
|
642
|
+
let snapshot;
|
|
643
|
+
try {
|
|
644
|
+
snapshot = await readJunieAcp(ctx, { usage: false });
|
|
645
|
+
} catch {
|
|
646
|
+
return [];
|
|
647
|
+
}
|
|
648
|
+
if (snapshot.missing || snapshot.session?.ok !== true) {
|
|
649
|
+
return [];
|
|
650
|
+
}
|
|
651
|
+
return parseJunieModels(snapshot.session.result);
|
|
652
|
+
}
|
|
653
|
+
function parseJunieModels(value) {
|
|
654
|
+
const options = asRecord(value)?.configOptions;
|
|
655
|
+
const reasoning = parseReasoning(findOption(options, "effort"));
|
|
656
|
+
const models = [];
|
|
657
|
+
for (const candidate of records(findOption(options, "model")?.options)) {
|
|
658
|
+
const id = asText(candidate.value);
|
|
659
|
+
if (id === null) {
|
|
660
|
+
continue;
|
|
661
|
+
}
|
|
662
|
+
const name = asText(candidate.name) ?? id;
|
|
663
|
+
models.push(reasoning.length > 0 ? { id, name, reasoning } : { id, name });
|
|
664
|
+
}
|
|
665
|
+
return models;
|
|
666
|
+
}
|
|
667
|
+
function findOption(value, id) {
|
|
668
|
+
return records(value).find((option) => asText(option.id) === id);
|
|
669
|
+
}
|
|
670
|
+
function parseReasoning(option) {
|
|
671
|
+
const entries = [];
|
|
672
|
+
for (const effort of records(option?.options)) {
|
|
673
|
+
const id = asText(effort.value);
|
|
674
|
+
if (id === null) {
|
|
675
|
+
continue;
|
|
676
|
+
}
|
|
677
|
+
entries.push({ id, name: asText(effort.name) ?? titleCase(id) });
|
|
678
|
+
}
|
|
679
|
+
return entries;
|
|
680
|
+
}
|
|
681
|
+
function records(value) {
|
|
682
|
+
if (!Array.isArray(value)) {
|
|
683
|
+
return [];
|
|
684
|
+
}
|
|
685
|
+
return value.flatMap((entry) => {
|
|
686
|
+
const record = asRecord(entry);
|
|
687
|
+
return record === null ? [] : [record];
|
|
688
|
+
});
|
|
689
|
+
}
|
|
690
|
+
function titleCase(value) {
|
|
691
|
+
return `${value[0]?.toUpperCase() ?? ""}${value.slice(1)}`;
|
|
692
|
+
}
|
|
693
|
+
export {
|
|
694
|
+
parseJunieUsage,
|
|
695
|
+
parseJunieModels,
|
|
696
|
+
loadJunieUsageLimits,
|
|
697
|
+
loadJunieModels,
|
|
698
|
+
junieAgentPlugin,
|
|
699
|
+
pragma_plugin_default as default
|
|
700
|
+
};
|