@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
package/package.json
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@pragma-sh/junie-plugin",
|
|
3
|
+
"version": "0.1.0-alpha.0",
|
|
4
|
+
"description": "JetBrains Junie CLI plugin that reports agent status to Pragma via native hooks.",
|
|
5
|
+
"license": "AGPL-3.0-only",
|
|
6
|
+
"files": [
|
|
7
|
+
".junie-plugin",
|
|
8
|
+
"assets",
|
|
9
|
+
"dist",
|
|
10
|
+
"hooks",
|
|
11
|
+
"pragma-plugin.json",
|
|
12
|
+
"scripts",
|
|
13
|
+
"src"
|
|
14
|
+
],
|
|
15
|
+
"type": "module",
|
|
16
|
+
"exports": {
|
|
17
|
+
"./pragma-plugin": "./src/pragma-plugin.ts"
|
|
18
|
+
},
|
|
19
|
+
"publishConfig": {
|
|
20
|
+
"access": "public"
|
|
21
|
+
},
|
|
22
|
+
"scripts": {
|
|
23
|
+
"build": "bun build src/pragma-plugin.ts --outdir dist --format esm --target node --packages bundle && mv dist/pragma-plugin.js dist/pragma-plugin.mjs && bun build scripts/install-local.ts --outfile dist/install.mjs --format esm --target node --packages bundle",
|
|
24
|
+
"install:local": "bun run scripts/install-local.ts",
|
|
25
|
+
"prepack": "bun run build",
|
|
26
|
+
"typecheck": "tsc --noEmit",
|
|
27
|
+
"test": "bun --bun vitest run",
|
|
28
|
+
"lint": "oxlint ."
|
|
29
|
+
},
|
|
30
|
+
"devDependencies": {
|
|
31
|
+
"@pragma/plugin": "workspace:*",
|
|
32
|
+
"@pragma/watcher-kit": "workspace:*",
|
|
33
|
+
"@types/node": "^24.12.3",
|
|
34
|
+
"bunup": "^0.16.32",
|
|
35
|
+
"typescript": "^6.0.3",
|
|
36
|
+
"vitest": "^4.1.8"
|
|
37
|
+
},
|
|
38
|
+
"pragma": {
|
|
39
|
+
"pluginId": "pragma.junie",
|
|
40
|
+
"main": "./dist/pragma-plugin.mjs"
|
|
41
|
+
}
|
|
42
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://raw.githubusercontent.com/pragma-sh/pragma/main/packages/plugin-registry/pragma-plugin.schema.json",
|
|
3
|
+
"name": "Junie CLI",
|
|
4
|
+
"description": "Launch JetBrains Junie CLI in Pragma with live status, approvals, questions, usage limits, and session naming.",
|
|
5
|
+
"categories": ["agent-plugin"],
|
|
6
|
+
"images": [
|
|
7
|
+
{
|
|
8
|
+
"url": "https://raw.githubusercontent.com/pragma-sh/pragma/main/packages/junie-plugin/assets/junie.svg",
|
|
9
|
+
"alt": "Junie logo"
|
|
10
|
+
}
|
|
11
|
+
],
|
|
12
|
+
"install": { "command": "node", "args": ["dist/install.mjs"] },
|
|
13
|
+
"agentBinary": "junie"
|
|
14
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
// Installs the Pragma status bridge into Junie's global configuration.
|
|
2
|
+
//
|
|
3
|
+
// Junie has exactly one hook-discovery mechanism: a `hooks` object in
|
|
4
|
+
// `~/.junie/config.json` (or a file passed with `--config-location`). A
|
|
5
|
+
// project-local `.junie/config.json` is ignored by default "for safety", so a
|
|
6
|
+
// per-checkout install would never fire — the global file is the only reliable
|
|
7
|
+
// target.
|
|
8
|
+
//
|
|
9
|
+
// `hooks/hooks.json` stays the single source of truth for the event map; this
|
|
10
|
+
// only rewrites `${JUNIE_PLUGIN_ROOT}` to this package's absolute path and
|
|
11
|
+
// merges the result into the existing config, preserving every key Junie or the
|
|
12
|
+
// user already owns. Entries are matched by their command containing this
|
|
13
|
+
// package's root, so re-running replaces the previous install instead of
|
|
14
|
+
// stacking duplicates. Because the installed commands point back at this
|
|
15
|
+
// checkout, edits to `hooks/report.sh` take effect on Junie's next session with
|
|
16
|
+
// no reinstall.
|
|
17
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
18
|
+
import { homedir } from "node:os";
|
|
19
|
+
import { dirname, join, resolve } from "node:path";
|
|
20
|
+
import { fileURLToPath } from "node:url";
|
|
21
|
+
|
|
22
|
+
/** One command Junie runs for a hook event. */
|
|
23
|
+
interface JunieHookCommand {
|
|
24
|
+
type: string;
|
|
25
|
+
command: string;
|
|
26
|
+
timeout?: number;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** One matcher group inside a hook event's array. */
|
|
30
|
+
interface JunieHookEntry {
|
|
31
|
+
matcher?: string;
|
|
32
|
+
hooks: JunieHookCommand[];
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Junie's global configuration file, of which only `hooks` is ours. */
|
|
36
|
+
interface JunieConfig {
|
|
37
|
+
hooks?: Record<string, JunieHookEntry[]>;
|
|
38
|
+
[key: string]: unknown;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Where Junie reads its global configuration from. */
|
|
42
|
+
const JUNIE_HOME = process.env.JUNIE_HOME ?? join(homedir(), ".junie");
|
|
43
|
+
const CONFIG_PATH = join(JUNIE_HOME, "config.json");
|
|
44
|
+
/**
|
|
45
|
+
* Path fragment that identifies this package's hook script in an installed
|
|
46
|
+
* command. The absolute plugin root changes per checkout (worktrees), so this
|
|
47
|
+
* marker lets a reinstall from a different checkout replace the previous
|
|
48
|
+
* install instead of stacking a second set of hooks.
|
|
49
|
+
*/
|
|
50
|
+
const HOOK_SCRIPT_MARKER = join("packages", "junie-plugin", "hooks", "report.sh");
|
|
51
|
+
|
|
52
|
+
const root = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
|
53
|
+
const template = readFileSync(join(root, "hooks", "hooks.json"), "utf8");
|
|
54
|
+
const ours = (JSON.parse(template.replaceAll("${JUNIE_PLUGIN_ROOT}", root)) as JunieConfig).hooks;
|
|
55
|
+
if (ours === undefined) {
|
|
56
|
+
throw new Error("hooks/hooks.json has no `hooks` object");
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const existing = readConfig(CONFIG_PATH);
|
|
60
|
+
const installed: JunieConfig = { ...existing, hooks: mergeHooks(existing.hooks ?? {}, ours, root) };
|
|
61
|
+
|
|
62
|
+
mkdirSync(JUNIE_HOME, { recursive: true });
|
|
63
|
+
writeFileSync(CONFIG_PATH, `${JSON.stringify(installed, null, 2)}\n`);
|
|
64
|
+
|
|
65
|
+
process.stdout.write(`Installed the Pragma Junie hooks into ${CONFIG_PATH}\n`);
|
|
66
|
+
process.stdout.write(`They run \`${join(root, "hooks", "report.sh")}\`.\n`);
|
|
67
|
+
process.stdout.write("Start a new Junie session to load them.\n");
|
|
68
|
+
|
|
69
|
+
/** Reads Junie's config, treating a missing file as an empty one. */
|
|
70
|
+
function readConfig(path: string): JunieConfig {
|
|
71
|
+
if (!existsSync(path)) {
|
|
72
|
+
return {};
|
|
73
|
+
}
|
|
74
|
+
const parsed: unknown = JSON.parse(readFileSync(path, "utf8"));
|
|
75
|
+
if (!isPlainObject(parsed)) {
|
|
76
|
+
throw new Error(`${path} is not a JSON object`);
|
|
77
|
+
}
|
|
78
|
+
return parsed as JunieConfig;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Merges this package's hook entries into the user's, dropping any entry a
|
|
83
|
+
* previous install of *this* package left behind so the result is idempotent.
|
|
84
|
+
* Hooks contributed by other tools are kept untouched.
|
|
85
|
+
*/
|
|
86
|
+
function mergeHooks(
|
|
87
|
+
existingHooks: Record<string, JunieHookEntry[]>,
|
|
88
|
+
ownHooks: Record<string, JunieHookEntry[]>,
|
|
89
|
+
pluginRoot: string,
|
|
90
|
+
): Record<string, JunieHookEntry[]> {
|
|
91
|
+
const merged = dropOwnEntries(existingHooks, pluginRoot);
|
|
92
|
+
for (const [event, entries] of Object.entries(ownHooks)) {
|
|
93
|
+
merged[event] = [...(merged[event] ?? []), ...entries];
|
|
94
|
+
}
|
|
95
|
+
return merged;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Keeps only hook entries that do not belong to this package. */
|
|
99
|
+
function dropOwnEntries(
|
|
100
|
+
existingHooks: Record<string, JunieHookEntry[]>,
|
|
101
|
+
pluginRoot: string,
|
|
102
|
+
): Record<string, JunieHookEntry[]> {
|
|
103
|
+
const merged: Record<string, JunieHookEntry[]> = {};
|
|
104
|
+
for (const [event, entries] of Object.entries(existingHooks)) {
|
|
105
|
+
const kept = entries.filter((entry) => !isOwnEntry(entry, pluginRoot));
|
|
106
|
+
if (kept.length > 0) {
|
|
107
|
+
merged[event] = kept;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return merged;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** True when every command in an entry comes from this package. */
|
|
114
|
+
function isOwnEntry(entry: JunieHookEntry, pluginRoot: string): boolean {
|
|
115
|
+
return (entry.hooks ?? []).some((hook) => {
|
|
116
|
+
const command = hook.command ?? "";
|
|
117
|
+
// The absolute root changes per checkout (worktrees), so also match the
|
|
118
|
+
// package's hook-script path: reinstalling from a different worktree must
|
|
119
|
+
// replace the previous install instead of stacking a second set of hooks.
|
|
120
|
+
return command.includes(pluginRoot) || command.includes(HOOK_SCRIPT_MARKER);
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Narrows an unknown to a plain object (not an array). */
|
|
125
|
+
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
126
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
127
|
+
}
|
package/src/acp.ts
ADDED
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
// Shared transport for the two things Pragma needs to read out of Junie: the
|
|
2
|
+
// launcher's model catalog and the account's quota. Both come from a
|
|
3
|
+
// short-lived `junie --acp=true` process speaking ACP (JSON-RPC 2.0) — Junie's
|
|
4
|
+
// documented programmatic entry point — so neither the launcher nor the usage
|
|
5
|
+
// provider ever reads Junie's credentials. Junie owns them and refreshes them
|
|
6
|
+
// itself; Pragma never sees a token.
|
|
7
|
+
//
|
|
8
|
+
// Unlike a single-shot extension method, both answers require a *session*:
|
|
9
|
+
// `session/new` returns the model catalog as ACP config options, and `/usage`
|
|
10
|
+
// is a session slash command whose reply arrives as an `agent_message_chunk`
|
|
11
|
+
// notification. The session id is only known after `session/new` answers, so
|
|
12
|
+
// the shell below keeps Junie's stdin open through a FIFO and feeds the prompt
|
|
13
|
+
// back once it has read the id. The program is POSIX-only (mkfifo, trap,
|
|
14
|
+
// case…esac), so `readJunieAcp` probes for a POSIX shell before sending it —
|
|
15
|
+
// `sdk.exec.run` uses the host's default shell, which is PowerShell or cmd on
|
|
16
|
+
// Windows, where the program would only fail to parse. A Windows host
|
|
17
|
+
// therefore reports Junie as missing rather than an empty catalog.
|
|
18
|
+
// `--cache-dir` points Junie's caches at a throwaway directory so these probe
|
|
19
|
+
// sessions leave nothing behind but an empty session folder (they never reach
|
|
20
|
+
// `sessions/index.jsonl`, so they do not show up in `junie --resume`).
|
|
21
|
+
import type { PluginContext } from "@pragma/plugin/catalog";
|
|
22
|
+
|
|
23
|
+
/** Exit status the wrapper uses to say `junie` is not installed. */
|
|
24
|
+
const MISSING_STATUS = 20;
|
|
25
|
+
|
|
26
|
+
/** Request id of the `initialize` handshake. */
|
|
27
|
+
const INITIALIZE_ID = 1;
|
|
28
|
+
/** Request id of `session/new`, whose result carries the model catalog. */
|
|
29
|
+
const SESSION_ID = 2;
|
|
30
|
+
/** Request id of the `/usage` prompt. */
|
|
31
|
+
const PROMPT_ID = 3;
|
|
32
|
+
|
|
33
|
+
/** Poll cadence and bound the shell uses while waiting on Junie. */
|
|
34
|
+
const POLL_SECONDS = "0.1";
|
|
35
|
+
const POLL_ATTEMPTS = 900;
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Cheap POSIX-shell availability probe. Under PowerShell this parses as a
|
|
39
|
+
* subexpression and fails; under cmd `command` is not found; under any POSIX
|
|
40
|
+
* `sh` it exits 0. Written to fail in every non-POSIX shell rather than to
|
|
41
|
+
* succeed by accident.
|
|
42
|
+
*/
|
|
43
|
+
const SHELL_PROBE = "(command -v sh >/dev/null 2>&1) && exit 0 || exit 1";
|
|
44
|
+
|
|
45
|
+
/** One JSON-RPC response line, already narrowed to result-or-error. */
|
|
46
|
+
export type AcpResponse =
|
|
47
|
+
| { ok: true; result: unknown }
|
|
48
|
+
| { ok: false; message: string | null }
|
|
49
|
+
| undefined;
|
|
50
|
+
|
|
51
|
+
/** Result of one `junie --acp=true` round trip. */
|
|
52
|
+
export interface AcpSnapshot {
|
|
53
|
+
/** True when `junie` is not on PATH; every field below is then `undefined`. */
|
|
54
|
+
missing: boolean;
|
|
55
|
+
/**
|
|
56
|
+
* True when the host has no POSIX shell to run the ACP driver in (e.g. a
|
|
57
|
+
* Windows host, where `sdk.exec.run` lands in PowerShell or cmd). Treated
|
|
58
|
+
* like `missing` by callers, but lets the usage provider say why.
|
|
59
|
+
*/
|
|
60
|
+
unsupportedShell: boolean;
|
|
61
|
+
/** The `session/new` response, whose `configOptions` hold the model catalog. */
|
|
62
|
+
session: AcpResponse;
|
|
63
|
+
/** Concatenated assistant text of the `/usage` reply, or null when not requested. */
|
|
64
|
+
usageText: string | null;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Runs the ACP handshake and returns the session response, optionally following
|
|
69
|
+
* it with the `/usage` slash command.
|
|
70
|
+
*/
|
|
71
|
+
export async function readJunieAcp(
|
|
72
|
+
ctx: PluginContext,
|
|
73
|
+
options: { usage: boolean },
|
|
74
|
+
): Promise<AcpSnapshot> {
|
|
75
|
+
const cwd = ctx.project?.path ?? "/tmp";
|
|
76
|
+
// `sdk.exec.run` runs the command through the host's default shell. On
|
|
77
|
+
// Windows that is PowerShell or cmd, neither of which can parse the POSIX
|
|
78
|
+
// `sh` program below (mkfifo, trap, case…esac). Probe for `sh` first: with
|
|
79
|
+
// no POSIX shell there is no transport, and the model catalog and the usage
|
|
80
|
+
// refresh degrade to empty instead of failing on a parse error.
|
|
81
|
+
const [shell] = await ctx.sdk.exec.run({ cwd, commands: [SHELL_PROBE] });
|
|
82
|
+
if (shell?.status !== 0) {
|
|
83
|
+
return { missing: false, unsupportedShell: true, session: undefined, usageText: null };
|
|
84
|
+
}
|
|
85
|
+
const [result] = await ctx.sdk.exec.run({ cwd, commands: [buildCommand(options.usage)] });
|
|
86
|
+
if (result?.status === MISSING_STATUS) {
|
|
87
|
+
return { missing: true, unsupportedShell: false, session: undefined, usageText: null };
|
|
88
|
+
}
|
|
89
|
+
if (!result || result.status !== 0) {
|
|
90
|
+
throw new Error(result?.stderr.trim() || "Junie ACP request failed");
|
|
91
|
+
}
|
|
92
|
+
return {
|
|
93
|
+
missing: false,
|
|
94
|
+
unsupportedShell: false,
|
|
95
|
+
session: findResponse(result.stdout, SESSION_ID),
|
|
96
|
+
usageText: options.usage ? collectAgentText(result.stdout) : null,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Builds the POSIX `sh` program that drives one ACP conversation.
|
|
102
|
+
*
|
|
103
|
+
* A FIFO carries Junie's stdin so the writer can stay open while the reader
|
|
104
|
+
* decides what to send next: the prompt request is written to `$work/next` by
|
|
105
|
+
* the reader as soon as `session/new` answers, and the writer forwards it. The
|
|
106
|
+
* conversation ends when the reader sees the response it is waiting for, which
|
|
107
|
+
* closes the FIFO and makes Junie exit on EOF.
|
|
108
|
+
*/
|
|
109
|
+
function buildCommand(usage: boolean): string {
|
|
110
|
+
const finalId = usage ? PROMPT_ID : SESSION_ID;
|
|
111
|
+
return [
|
|
112
|
+
`command -v junie >/dev/null 2>&1 || exit ${MISSING_STATUS};`,
|
|
113
|
+
"work=$(mktemp -d) || exit 1;",
|
|
114
|
+
`trap 'rm -rf "$work"' 0;`,
|
|
115
|
+
'mkfifo "$work/in" || exit 1;',
|
|
116
|
+
"{",
|
|
117
|
+
`printf '%s\\n' ${shellQuote(request(INITIALIZE_ID, "initialize", { protocolVersion: 1, clientCapabilities: {} }))};`,
|
|
118
|
+
`printf '%s\\n' '{"jsonrpc":"2.0","id":${SESSION_ID},"method":"session/new","params":{"cwd":"'"$PWD"'","mcpServers":[]}}';`,
|
|
119
|
+
...(usage
|
|
120
|
+
? [
|
|
121
|
+
"n=0;",
|
|
122
|
+
`while [ ! -s "$work/next" ] && [ ! -e "$work/done" ] && [ "$n" -lt ${POLL_ATTEMPTS} ]; do sleep ${POLL_SECONDS}; n=$((n + 1)); done;`,
|
|
123
|
+
'[ -s "$work/next" ] && cat "$work/next";',
|
|
124
|
+
]
|
|
125
|
+
: []),
|
|
126
|
+
"n=0;",
|
|
127
|
+
`while [ ! -e "$work/done" ] && [ "$n" -lt ${POLL_ATTEMPTS} ]; do sleep ${POLL_SECONDS}; n=$((n + 1)); done;`,
|
|
128
|
+
'} >"$work/in" &',
|
|
129
|
+
'junie --acp=true --skip-update-check --cache-dir "$work/cache" <"$work/in" 2>/dev/null |',
|
|
130
|
+
"while IFS= read -r line; do",
|
|
131
|
+
`printf '%s\\n' "$line";`,
|
|
132
|
+
...(usage
|
|
133
|
+
? [
|
|
134
|
+
`case "$line" in *'"sessionId":"'*)`,
|
|
135
|
+
'if [ ! -s "$work/next" ]; then',
|
|
136
|
+
`sid=$(printf '%s' "$line" | sed -n 's/.*"sessionId":"\\([^"]*\\)".*/\\1/p' | head -n 1);`,
|
|
137
|
+
'if [ -n "$sid" ]; then',
|
|
138
|
+
`printf '{"jsonrpc":"2.0","id":${PROMPT_ID},"method":"session/prompt","params":{"sessionId":"%s","prompt":[{"type":"text","text":"/usage"}]}}\\n' "$sid" >"$work/next";`,
|
|
139
|
+
"fi;",
|
|
140
|
+
"fi;",
|
|
141
|
+
";; esac;",
|
|
142
|
+
]
|
|
143
|
+
: []),
|
|
144
|
+
`case "$line" in *'"id":${finalId}'*) : >"$work/done" ;; esac;`,
|
|
145
|
+
"done",
|
|
146
|
+
].join(" ");
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Builds one JSON-RPC request line. */
|
|
150
|
+
function request(id: number, method: string, params: unknown): string {
|
|
151
|
+
return JSON.stringify({ jsonrpc: "2.0", id, method, params });
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Scans NDJSON output for the response to `id`. Junie interleaves notifications
|
|
156
|
+
* and answers, so lines that fail to parse or carry another id are skipped
|
|
157
|
+
* rather than treated as an error.
|
|
158
|
+
*/
|
|
159
|
+
export function findResponse(stdout: string, id: number): AcpResponse {
|
|
160
|
+
for (const line of stdout.split("\n")) {
|
|
161
|
+
let message: unknown;
|
|
162
|
+
try {
|
|
163
|
+
message = JSON.parse(line);
|
|
164
|
+
} catch {
|
|
165
|
+
continue;
|
|
166
|
+
}
|
|
167
|
+
const record = asRecord(message);
|
|
168
|
+
if (record === null || record.id !== id) {
|
|
169
|
+
continue;
|
|
170
|
+
}
|
|
171
|
+
if ("result" in record) {
|
|
172
|
+
return { ok: true, result: record.result };
|
|
173
|
+
}
|
|
174
|
+
return { ok: false, message: asText(asRecord(record.error)?.message) };
|
|
175
|
+
}
|
|
176
|
+
return undefined;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** Concatenates every `agent_message_chunk` notification in an ACP stream. */
|
|
180
|
+
export function collectAgentText(stdout: string): string {
|
|
181
|
+
const parts: string[] = [];
|
|
182
|
+
for (const line of stdout.split("\n")) {
|
|
183
|
+
let message: unknown;
|
|
184
|
+
try {
|
|
185
|
+
message = JSON.parse(line);
|
|
186
|
+
} catch {
|
|
187
|
+
continue;
|
|
188
|
+
}
|
|
189
|
+
const update = asRecord(asRecord(asRecord(message)?.params)?.update);
|
|
190
|
+
if (update?.sessionUpdate !== "agent_message_chunk") {
|
|
191
|
+
continue;
|
|
192
|
+
}
|
|
193
|
+
const text = asText(asRecord(update.content)?.text);
|
|
194
|
+
if (text !== null) {
|
|
195
|
+
parts.push(text);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
return parts.join("");
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** Single-quotes a value for POSIX `sh`. */
|
|
202
|
+
export function shellQuote(value: string): string {
|
|
203
|
+
return `'${value.replaceAll("'", `'"'"'`)}'`;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Narrows an unknown to a plain object, or null.
|
|
208
|
+
*
|
|
209
|
+
* Returning null rather than acting as a type predicate lets callers chain
|
|
210
|
+
* (`asRecord(x)?.y`) through Junie's deeply optional payloads without a nested
|
|
211
|
+
* `if` per level.
|
|
212
|
+
*/
|
|
213
|
+
export function asRecord(value: unknown): Record<string, unknown> | null {
|
|
214
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
215
|
+
return null;
|
|
216
|
+
}
|
|
217
|
+
return value as Record<string, unknown>;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** Returns a trimmed non-empty string, or null. */
|
|
221
|
+
export function asText(value: unknown): string | null {
|
|
222
|
+
return typeof value === "string" && value.trim() ? value.trim() : null;
|
|
223
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
|
|
3
|
+
import { collectAgentText, findResponse } from "./acp";
|
|
4
|
+
import { parseJunieModels } from "./pragma-plugin";
|
|
5
|
+
|
|
6
|
+
/** A trimmed `session/new` result in the shape Junie 26.8.3 answers with. */
|
|
7
|
+
const SESSION_RESULT = {
|
|
8
|
+
sessionId: "session-260806-110849-19an",
|
|
9
|
+
configOptions: [
|
|
10
|
+
{
|
|
11
|
+
type: "select",
|
|
12
|
+
id: "model",
|
|
13
|
+
currentValue: "gemini-3-flash-preview",
|
|
14
|
+
options: [
|
|
15
|
+
{ value: "gemini-3-flash-preview", name: "Gemini 3 Flash Preview" },
|
|
16
|
+
{ value: "claude-opus-5", name: "Claude Opus 5" },
|
|
17
|
+
{ value: "broken" },
|
|
18
|
+
],
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
type: "select",
|
|
22
|
+
id: "effort",
|
|
23
|
+
currentValue: "high",
|
|
24
|
+
options: [
|
|
25
|
+
{ value: "low", name: "◎ Low effort" },
|
|
26
|
+
{ value: "high", name: "◕ High effort" },
|
|
27
|
+
],
|
|
28
|
+
},
|
|
29
|
+
{ type: "select", id: "brave_mode", currentValue: "auto", options: [{ value: "off" }] },
|
|
30
|
+
],
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
describe("parseJunieModels", () => {
|
|
34
|
+
it("reads the model catalog and attaches the shared effort levels", () => {
|
|
35
|
+
const reasoning = [
|
|
36
|
+
{ id: "low", name: "◎ Low effort" },
|
|
37
|
+
{ id: "high", name: "◕ High effort" },
|
|
38
|
+
];
|
|
39
|
+
expect(parseJunieModels(SESSION_RESULT)).toEqual([
|
|
40
|
+
{ id: "gemini-3-flash-preview", name: "Gemini 3 Flash Preview", reasoning },
|
|
41
|
+
{ id: "claude-opus-5", name: "Claude Opus 5", reasoning },
|
|
42
|
+
{ id: "broken", name: "broken", reasoning },
|
|
43
|
+
]);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("returns nothing for a result without config options", () => {
|
|
47
|
+
expect(parseJunieModels({ sessionId: "s" })).toEqual([]);
|
|
48
|
+
expect(parseJunieModels(null)).toEqual([]);
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
describe("findResponse", () => {
|
|
53
|
+
const stdout = [
|
|
54
|
+
'{"type":"…","method":"session/update","params":{}}',
|
|
55
|
+
"not json",
|
|
56
|
+
'{"id":2,"result":{"sessionId":"s"},"jsonrpc":"2.0"}',
|
|
57
|
+
'{"id":3,"error":{"message":"Method not found"},"jsonrpc":"2.0"}',
|
|
58
|
+
].join("\n");
|
|
59
|
+
|
|
60
|
+
it("finds a result by request id", () => {
|
|
61
|
+
expect(findResponse(stdout, 2)).toEqual({ ok: true, result: { sessionId: "s" } });
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("surfaces an error response", () => {
|
|
65
|
+
expect(findResponse(stdout, 3)).toEqual({ ok: false, message: "Method not found" });
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("returns undefined when the response never arrived", () => {
|
|
69
|
+
expect(findResponse(stdout, 9)).toBeUndefined();
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
describe("collectAgentText", () => {
|
|
74
|
+
it("concatenates the agent message chunks of a slash-command reply", () => {
|
|
75
|
+
const stdout = [
|
|
76
|
+
'{"method":"session/update","params":{"update":{"sessionUpdate":"available_commands_update"}}}',
|
|
77
|
+
'{"method":"session/update","params":{"update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"License: X"}}}}',
|
|
78
|
+
'{"method":"session/update","params":{"update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"Balance left: $1.00"}}}}',
|
|
79
|
+
].join("\n");
|
|
80
|
+
expect(collectAgentText(stdout)).toBe("License: XBalance left: $1.00");
|
|
81
|
+
});
|
|
82
|
+
});
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import {
|
|
2
|
+
defineAgent,
|
|
3
|
+
definePlugin,
|
|
4
|
+
defineUsageLimitProvider,
|
|
5
|
+
type AgentModelEntry,
|
|
6
|
+
type PluginContext,
|
|
7
|
+
type PluginDefinition,
|
|
8
|
+
} from "@pragma/plugin/catalog";
|
|
9
|
+
import { createTuiWatcher } from "@pragma/watcher-kit";
|
|
10
|
+
|
|
11
|
+
import { asRecord, asText, readJunieAcp } from "./acp";
|
|
12
|
+
import { loadJunieUsageLimits, PRIMARY_LIMIT_ID } from "./usage-limits";
|
|
13
|
+
|
|
14
|
+
export { loadJunieUsageLimits, parseJunieUsage } from "./usage-limits";
|
|
15
|
+
|
|
16
|
+
/** Junie boots a JVM and paints its TUI a few seconds later; prefill after it. */
|
|
17
|
+
const PREFILL_DELAY_MS = 6000;
|
|
18
|
+
/** Each refresh spawns a short-lived `junie --acp=true` JVM; don't poll it hard. */
|
|
19
|
+
const USAGE_REFRESH_INTERVAL_MS = 900_000;
|
|
20
|
+
|
|
21
|
+
const baseWatcher = createTuiWatcher({
|
|
22
|
+
agent: "junie",
|
|
23
|
+
// Command approvals go through Junie's blocking `PermissionRequest` hook (see
|
|
24
|
+
// hooks/report.sh), so the watcher must not also answer them.
|
|
25
|
+
handleDecisions: false,
|
|
26
|
+
// Questions are different: `ask_user` / `ask_user_choice` are ordinary tools
|
|
27
|
+
// whose prompt Junie's own TUI owns, and no hook can return an answer to
|
|
28
|
+
// them, so a remote reply has to arrive as keystrokes.
|
|
29
|
+
handleQuestionAnswers: true,
|
|
30
|
+
// Junie's question list ignores digit shortcuts: it navigates with Down,
|
|
31
|
+
// marks the row with Space ("space to select"), and submits with Enter.
|
|
32
|
+
questionSelectMode: "arrow-space",
|
|
33
|
+
// Selection opens Junie's answer summary; one more Enter submits it.
|
|
34
|
+
questionFinalizeKeys: "\r",
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Pragma plugin for the JetBrains Junie CLI, bundled to `dist/pragma-plugin.mjs`.
|
|
39
|
+
*
|
|
40
|
+
* Lifecycle reporting is a declarative hook bundle (`hooks/hooks.json` ->
|
|
41
|
+
* `hooks/report.sh`) merged into Junie's global `~/.junie/config.json` by
|
|
42
|
+
* `scripts/install-local.ts`, because Junie loads no in-process JavaScript
|
|
43
|
+
* plugin and ignores project-local hook config by default. This module
|
|
44
|
+
* contributes only the Pragma-side launcher, model provider, usage-limit
|
|
45
|
+
* provider and watcher.
|
|
46
|
+
*/
|
|
47
|
+
export const junieAgentPlugin: PluginDefinition = definePlugin({
|
|
48
|
+
name: "Junie",
|
|
49
|
+
description: "Launch the JetBrains Junie CLI from Pragma.",
|
|
50
|
+
usageLimits: [
|
|
51
|
+
defineUsageLimitProvider({
|
|
52
|
+
id: "junie",
|
|
53
|
+
title: "Junie",
|
|
54
|
+
dashboardUrl: "https://junie.jetbrains.com/cli",
|
|
55
|
+
iconPath: "assets/junie.svg",
|
|
56
|
+
primaryLimitId: PRIMARY_LIMIT_ID,
|
|
57
|
+
refreshIntervalMs: USAGE_REFRESH_INTERVAL_MS,
|
|
58
|
+
load: loadJunieUsageLimits,
|
|
59
|
+
}),
|
|
60
|
+
],
|
|
61
|
+
watchers: [
|
|
62
|
+
{
|
|
63
|
+
agent: "junie",
|
|
64
|
+
async watch(ctx) {
|
|
65
|
+
try {
|
|
66
|
+
await baseWatcher.watch(ctx);
|
|
67
|
+
} finally {
|
|
68
|
+
// `SessionEnd` already clears a graceful exit; this covers a session
|
|
69
|
+
// killed hard enough that no hook runs.
|
|
70
|
+
try {
|
|
71
|
+
await ctx.sdk.agents.report({
|
|
72
|
+
agent: ctx.agentId,
|
|
73
|
+
tabId: ctx.session.tabId,
|
|
74
|
+
worktreeId: ctx.session.worktreeId,
|
|
75
|
+
status: "cleared",
|
|
76
|
+
attentionKind: null,
|
|
77
|
+
});
|
|
78
|
+
} catch {
|
|
79
|
+
// Session-exit cleanup must never disrupt watcher shutdown.
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
},
|
|
83
|
+
},
|
|
84
|
+
],
|
|
85
|
+
agents: [
|
|
86
|
+
defineAgent({
|
|
87
|
+
id: "junie",
|
|
88
|
+
name: "Junie",
|
|
89
|
+
icon: () => null,
|
|
90
|
+
iconPath: "assets/junie.svg",
|
|
91
|
+
launch: { command: ["junie"] },
|
|
92
|
+
prefillDelayMs: PREFILL_DELAY_MS,
|
|
93
|
+
prefillMode: "plain",
|
|
94
|
+
prefillSubmit: "\r",
|
|
95
|
+
models: loadJunieModels,
|
|
96
|
+
// Junie's approval behaviour is the `brave_mode` setting, whose only
|
|
97
|
+
// command-line lever is `--brave` (equivalent to `Brave: on`). Leaving it
|
|
98
|
+
// off keeps whatever the user configured, which defaults to `auto`.
|
|
99
|
+
permissionModes: [
|
|
100
|
+
{ id: "default", name: "Ask for approval" },
|
|
101
|
+
{ id: "brave", name: "Brave mode (no approvals)" },
|
|
102
|
+
],
|
|
103
|
+
// `subagents`: Junie runs subagents inside the same process and fires no
|
|
104
|
+
// per-agent hook (`PreToolUse` carries no agent or session id), so their
|
|
105
|
+
// start and finish are not observable from a hook bridge.
|
|
106
|
+
excludeFeatures: ["subagents"],
|
|
107
|
+
args: {
|
|
108
|
+
model: (modelId: string) => ["--model", modelId],
|
|
109
|
+
reasoning: (reasoningId: string) => ["--effort", reasoningId],
|
|
110
|
+
permissionMode: (permissionModeId: string) =>
|
|
111
|
+
permissionModeId === "brave" ? ["--brave"] : [],
|
|
112
|
+
},
|
|
113
|
+
}),
|
|
114
|
+
],
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
export default junieAgentPlugin;
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Reads the launcher's model list from the ACP session handshake. Junie has no
|
|
121
|
+
* machine-readable `models` subcommand, but `session/new` answers with the same
|
|
122
|
+
* catalog its `/model` picker shows, as ACP config options.
|
|
123
|
+
*/
|
|
124
|
+
export async function loadJunieModels(ctx: PluginContext): Promise<AgentModelEntry[]> {
|
|
125
|
+
let snapshot;
|
|
126
|
+
try {
|
|
127
|
+
snapshot = await readJunieAcp(ctx, { usage: false });
|
|
128
|
+
} catch {
|
|
129
|
+
// The launcher must still open when Junie cannot be queried (offline, not
|
|
130
|
+
// signed in); Junie then starts on its own configured default model.
|
|
131
|
+
return [];
|
|
132
|
+
}
|
|
133
|
+
if (snapshot.missing || snapshot.session?.ok !== true) {
|
|
134
|
+
return [];
|
|
135
|
+
}
|
|
136
|
+
return parseJunieModels(snapshot.session.result);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Normalizes a `session/new` result's `configOptions` into launcher entries.
|
|
141
|
+
*
|
|
142
|
+
* Junie's reasoning effort is a session-wide setting rather than a per-model
|
|
143
|
+
* one, so the same effort list is attached to every model.
|
|
144
|
+
*/
|
|
145
|
+
export function parseJunieModels(value: unknown): AgentModelEntry[] {
|
|
146
|
+
const options = asRecord(value)?.configOptions;
|
|
147
|
+
const reasoning = parseReasoning(findOption(options, "effort"));
|
|
148
|
+
const models: AgentModelEntry[] = [];
|
|
149
|
+
for (const candidate of records(findOption(options, "model")?.options)) {
|
|
150
|
+
const id = asText(candidate.value);
|
|
151
|
+
if (id === null) {
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
154
|
+
const name = asText(candidate.name) ?? id;
|
|
155
|
+
models.push(reasoning.length > 0 ? { id, name, reasoning } : { id, name });
|
|
156
|
+
}
|
|
157
|
+
return models;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** Finds one entry of a `configOptions` array by its `id`. */
|
|
161
|
+
function findOption(value: unknown, id: string): Record<string, unknown> | undefined {
|
|
162
|
+
return records(value).find((option) => asText(option.id) === id);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** Reads the selectable reasoning efforts, if Junie reported any. */
|
|
166
|
+
function parseReasoning(
|
|
167
|
+
option: Record<string, unknown> | undefined,
|
|
168
|
+
): NonNullable<AgentModelEntry["reasoning"]> {
|
|
169
|
+
const entries: NonNullable<AgentModelEntry["reasoning"]> = [];
|
|
170
|
+
for (const effort of records(option?.options)) {
|
|
171
|
+
const id = asText(effort.value);
|
|
172
|
+
if (id === null) {
|
|
173
|
+
continue;
|
|
174
|
+
}
|
|
175
|
+
entries.push({ id, name: asText(effort.name) ?? titleCase(id) });
|
|
176
|
+
}
|
|
177
|
+
return entries;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** Keeps only the array entries that are plain objects. */
|
|
181
|
+
function records(value: unknown): Record<string, unknown>[] {
|
|
182
|
+
if (!Array.isArray(value)) {
|
|
183
|
+
return [];
|
|
184
|
+
}
|
|
185
|
+
return value.flatMap((entry) => {
|
|
186
|
+
const record = asRecord(entry);
|
|
187
|
+
return record === null ? [] : [record];
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function titleCase(value: string): string {
|
|
192
|
+
return `${value[0]?.toUpperCase() ?? ""}${value.slice(1)}`;
|
|
193
|
+
}
|