@zumikua/dsh-junie-subagent 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +88 -2
- package/README.zh.md +79 -0
- package/client.js +178 -0
- package/cordis.patch.yml +18 -0
- package/icon.svg +5 -0
- package/index.js +651 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +64 -4
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Zumi Kua
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,89 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @zumikua/dsh-junie-subagent
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
English | [中文](README.zh.md)
|
|
4
|
+
|
|
5
|
+
Delegates one task to the JetBrains Junie CLI over the Agent Client Protocol and
|
|
6
|
+
returns Junie's final answer as a subagent result.
|
|
7
|
+
|
|
8
|
+
## Requirements
|
|
9
|
+
|
|
10
|
+
- DSH `>= 0.2.1-rc.2`;
|
|
11
|
+
- the JetBrains Junie CLI installed and signed in — the provider resolves
|
|
12
|
+
`junie.exe` from `~/.local/share/junie/current` and fails loudly when it
|
|
13
|
+
cannot start it.
|
|
14
|
+
|
|
15
|
+
## Install
|
|
16
|
+
|
|
17
|
+
Open the **Plugins** page in the DSH Web UI and install `@zumikua/dsh-junie-subagent`
|
|
18
|
+
from the catalog; the plugin manager installs the package and selects its bundle
|
|
19
|
+
in one step. The bundle patch inserts one provider and one `dsh-tool-subagent`
|
|
20
|
+
instance named `junie`.
|
|
21
|
+
|
|
22
|
+
Restart DSH once after installing: the browser half (`client.js`) enters the
|
|
23
|
+
page's module table at startup. Then open **Settings → Junie** and pick a model
|
|
24
|
+
and thinking depth.
|
|
25
|
+
|
|
26
|
+
## How one run works
|
|
27
|
+
|
|
28
|
+
```
|
|
29
|
+
spawn <junie.exe> --acp=true --skip-update-check
|
|
30
|
+
→ initialize
|
|
31
|
+
→ session/new { cwd, mcpServers: [] }
|
|
32
|
+
→ session/set_config_option (model / effort / brave_mode, when configured)
|
|
33
|
+
→ session/prompt { prompt: [{ type: "text", text }] }
|
|
34
|
+
← session/update … agent_message_chunk text …
|
|
35
|
+
← result { stopReason }
|
|
36
|
+
→ dispose (session/cancel, stdin EOF, terminate, wait for exit)
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
Each run owns its own process and session: the subagent seam has no
|
|
40
|
+
continuation surface for out-of-process children, so a run accepts no follow-up
|
|
41
|
+
message.
|
|
42
|
+
|
|
43
|
+
## Confinement
|
|
44
|
+
|
|
45
|
+
The child is spawned through `ctx.subprocess` **without** a sandbox policy. It
|
|
46
|
+
therefore runs as the signed-in user with that user's full file access — which
|
|
47
|
+
is what lets Junie persist sessions under `~/.junie`. Confinement is Junie's own
|
|
48
|
+
permission model: `brave_mode`, the action allowlist, and
|
|
49
|
+
`session/request_permission`, which this provider answers from `permission`.
|
|
50
|
+
|
|
51
|
+
## Configuration
|
|
52
|
+
|
|
53
|
+
| Key | Default | Meaning |
|
|
54
|
+
|---|---|---|
|
|
55
|
+
| `providerName` | `junie` | Name on `ctx.subagents` |
|
|
56
|
+
| `command` | managed install | `junie.exe` path; resolved from `~/.local/share/junie/current` when omitted |
|
|
57
|
+
| `args` | `["--acp=true"]` | Extra arguments; `--acp=true` and `--skip-update-check` are always ensured |
|
|
58
|
+
| `model` | `mix.normal` | Fallback model when the settings page has stored none |
|
|
59
|
+
| `effort` | `high` | Fallback effort when the settings page has stored none |
|
|
60
|
+
| `braveMode` | `brave-auto` | `brave-auto` / `on` / `off` |
|
|
61
|
+
| `permission` | `reject` | Auto-answer `session/request_permission` with reject (default) or the first allow option |
|
|
62
|
+
| `timeoutMs` | `900000` | Whole-run bound |
|
|
63
|
+
| `graceMs` | `3000` | Subprocess teardown grace |
|
|
64
|
+
|
|
65
|
+
## Choosing a model
|
|
66
|
+
|
|
67
|
+
The bundle registers exactly one provider (`junie`) and one model-facing tool
|
|
68
|
+
(`junie`). The model and the thinking depth are chosen on the settings page and
|
|
69
|
+
stored by the Host; the `model` / `effort` keys above are only the fallback used
|
|
70
|
+
before anything is stored.
|
|
71
|
+
|
|
72
|
+
Per-call model selection through the shared `provider` / `model` /
|
|
73
|
+
`reasoning_effort` tool fields is **not** available: `dsh-tool-subagent`
|
|
74
|
+
validates those against the Host's LLM catalog (`llm.resolveCallConfig`), and
|
|
75
|
+
Junie model ids are not LLM routes — which is also why the official Codex,
|
|
76
|
+
Claude Code, and ACP providers advertise no `agentOptions` capability.
|
|
77
|
+
|
|
78
|
+
## Settings page and storage
|
|
79
|
+
|
|
80
|
+
1. The page is the **Junie** section in the settings panel; it picks the model
|
|
81
|
+
and the thinking depth.
|
|
82
|
+
2. Values are read and written through a same-origin route the Host registers —
|
|
83
|
+
`GET/PUT /junie-subagent/config` — and stored in `~/.dsh/junie-settings.json`.
|
|
84
|
+
3. The model list comes from the `configOptions` of Junie's own ACP
|
|
85
|
+
`session/new` (rendered by group, cached for ten minutes, costs no credits).
|
|
86
|
+
4. Thinking depths are model-specific. Saving validates the pair against a
|
|
87
|
+
throwaway Junie session; an unsupported combination answers `400` with the
|
|
88
|
+
values that model accepts, and the option list in the page is left untouched.
|
|
89
|
+
5. A change applies from the next delegation.
|
package/README.zh.md
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# @zumikua/dsh-junie-subagent
|
|
2
|
+
|
|
3
|
+
[English](README.md) | 中文
|
|
4
|
+
|
|
5
|
+
把一次性任务通过 Agent Client Protocol 交给 JetBrains Junie CLI 执行,并把
|
|
6
|
+
Junie 的最终答复作为子智能体结果返回。
|
|
7
|
+
|
|
8
|
+
## 前置条件
|
|
9
|
+
|
|
10
|
+
- DSH `>= 0.2.1-rc.2`;
|
|
11
|
+
- 已安装并登录 JetBrains Junie CLI —— provider 会从 `~/.local/share/junie/current`
|
|
12
|
+
解析 `junie.exe`,启动失败时会明确报错。
|
|
13
|
+
|
|
14
|
+
## 安装
|
|
15
|
+
|
|
16
|
+
在 DSH Web UI 的**插件**页里安装 `@zumikua/dsh-junie-subagent` 即可:插件管理器会一步完成
|
|
17
|
+
"安装包 + 选中 bundle"。bundle 的 patch 会插入一个 provider 和一个名为 `junie` 的
|
|
18
|
+
`dsh-tool-subagent` 实例。
|
|
19
|
+
|
|
20
|
+
安装完成后需要重启一次 DSH:浏览器半边(`client.js`)是在启动时进入页面模块表的。
|
|
21
|
+
之后打开**设置 → Junie**,选择模型和思考深度。
|
|
22
|
+
|
|
23
|
+
## 一次运行的时序
|
|
24
|
+
|
|
25
|
+
```
|
|
26
|
+
spawn <junie.exe> --acp=true --skip-update-check
|
|
27
|
+
→ initialize
|
|
28
|
+
→ session/new { cwd, mcpServers: [] }
|
|
29
|
+
→ session/set_config_option(model / effort / brave_mode,按配置下发)
|
|
30
|
+
→ session/prompt { prompt: [{ type: "text", text }] }
|
|
31
|
+
← session/update … agent_message_chunk 文本 …
|
|
32
|
+
← result { stopReason }
|
|
33
|
+
→ dispose(session/cancel、stdin EOF、terminate、等待退出)
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
每次运行独占一个进程和一个会话:子智能体 seam 对外部子级没有续接接口,因此一次运行
|
|
37
|
+
不会接受后续消息。
|
|
38
|
+
|
|
39
|
+
## 隔离边界
|
|
40
|
+
|
|
41
|
+
子进程通过 `ctx.subprocess` 启动,**不携带沙箱策略**,因此它以当前登录用户的身份运行、
|
|
42
|
+
拥有该用户的完整文件权限 —— 这也是 Junie 能把会话写进 `~/.junie` 的前提。约束来自
|
|
43
|
+
Junie 自己的权限模型:`brave_mode`、命令允许列表,以及
|
|
44
|
+
`session/request_permission`(由本 provider 按 `permission` 回答)。
|
|
45
|
+
|
|
46
|
+
## 配置
|
|
47
|
+
|
|
48
|
+
| 键 | 默认值 | 含义 |
|
|
49
|
+
|---|---|---|
|
|
50
|
+
| `providerName` | `junie` | 在 `ctx.subagents` 上的名字 |
|
|
51
|
+
| `command` | 自动解析 | `junie.exe` 路径;省略时从 `~/.local/share/junie/current` 解析 |
|
|
52
|
+
| `args` | `["--acp=true"]` | 额外参数;`--acp=true` 与 `--skip-update-check` 始终会补齐 |
|
|
53
|
+
| `model` | `mix.normal` | 设置页尚未保存任何值时的兜底模型 |
|
|
54
|
+
| `effort` | `high` | 设置页尚未保存任何值时的兜底思考深度 |
|
|
55
|
+
| `braveMode` | `brave-auto` | `brave-auto` / `on` / `off` |
|
|
56
|
+
| `permission` | `reject` | 自动回答 `session/request_permission`:默认拒绝,或选第一个允许项 |
|
|
57
|
+
| `timeoutMs` | `900000` | 单次运行的总时限 |
|
|
58
|
+
| `graceMs` | `3000` | 子进程回收宽限 |
|
|
59
|
+
|
|
60
|
+
## 模型选择
|
|
61
|
+
|
|
62
|
+
bundle 只注册一个 provider(`junie`)和一个面向模型的工具(`junie`)。模型与思考
|
|
63
|
+
深度在设置页选择、由宿主保存;上面的 `model` / `effort` 只是"尚未保存过"时的兜底。
|
|
64
|
+
|
|
65
|
+
工具层面的 `provider` / `model` / `reasoning_effort` 参数**不可用**:
|
|
66
|
+
`dsh-tool-subagent` 会用宿主的 LLM 目录(`llm.resolveCallConfig`)校验它们,而 Junie
|
|
67
|
+
的模型 id 并不是 LLM 路由 —— 官方 Codex、Claude Code 与 ACP provider 之所以不声明
|
|
68
|
+
`agentOptions` 能力,也是同一个原因。
|
|
69
|
+
|
|
70
|
+
## 设置页与存储
|
|
71
|
+
|
|
72
|
+
1. 设置页位于设置面板左侧的「Junie」分区,可以选择模型和思考深度;
|
|
73
|
+
2. 读写走宿主注册的同源路由 `GET/PUT /junie-subagent/config`,数据存放在
|
|
74
|
+
`~/.dsh/junie-settings.json`;
|
|
75
|
+
3. 模型下拉列表来自 Junie 自己 ACP `session/new` 返回的 `configOptions`
|
|
76
|
+
(分组渲染,缓存 10 分钟,不消耗额度);
|
|
77
|
+
4. 思考深度的可选项取决于模型。保存时会用一个临时会话向 Junie 校验,不支持的组合
|
|
78
|
+
返回 `400` 并提示该模型支持哪些档位,且不会改动界面上的选项列表;
|
|
79
|
+
5. 修改从下一次委派开始生效。
|
package/client.js
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
window.__ModuleLoader__.load({
|
|
2
|
+
id: '@zumikua/dsh-junie-subagent',
|
|
3
|
+
factory(require) {
|
|
4
|
+
const React = require('react')
|
|
5
|
+
const h = React.createElement
|
|
6
|
+
const { useCallback, useEffect, useState } = React
|
|
7
|
+
|
|
8
|
+
// Same-origin route registered by this bundle's Host half.
|
|
9
|
+
const ROUTE = '/junie-subagent/config'
|
|
10
|
+
const CUSTOM = '__custom__'
|
|
11
|
+
const EFFORT_LABEL = { low: '低', medium: '中', high: '高', xhigh: '很高', max: '最高' }
|
|
12
|
+
|
|
13
|
+
// Inherit the host theme instead of inventing colors: these tokens exist in
|
|
14
|
+
// both light and dark themes, which is what keeps the panel legible.
|
|
15
|
+
const rowStyle = { display: 'flex', alignItems: 'center', gap: '12px', margin: '12px 0' }
|
|
16
|
+
const labelStyle = { width: '120px', color: 'var(--dsw-alias-label-secondary)', flex: '0 0 auto', fontSize: '13px' }
|
|
17
|
+
const controlStyle = {
|
|
18
|
+
minWidth: '300px',
|
|
19
|
+
padding: '6px 8px',
|
|
20
|
+
background: 'var(--dsw-alias-bg-layer-1)',
|
|
21
|
+
color: 'var(--dsw-alias-label-primary)',
|
|
22
|
+
border: '1px solid var(--dsw-alias-border-l1)',
|
|
23
|
+
borderRadius: '8px',
|
|
24
|
+
fontSize: '13px',
|
|
25
|
+
outline: 'none',
|
|
26
|
+
}
|
|
27
|
+
const buttonStyle = {
|
|
28
|
+
...controlStyle,
|
|
29
|
+
minWidth: '88px',
|
|
30
|
+
width: 'auto',
|
|
31
|
+
cursor: 'pointer',
|
|
32
|
+
color: 'var(--dsw-alias-label-primary)',
|
|
33
|
+
}
|
|
34
|
+
const hintStyle = { color: 'var(--dsw-alias-label-secondary)', fontSize: '12px', marginTop: '12px', lineHeight: 1.6 }
|
|
35
|
+
const noteStyle = { fontSize: '12px', marginTop: '8px', color: 'var(--dsw-alias-label-secondary)' }
|
|
36
|
+
|
|
37
|
+
function groupOptions(models) {
|
|
38
|
+
const groups = new Map()
|
|
39
|
+
for (const entry of models) {
|
|
40
|
+
const key = entry.group && entry.group.length > 0 ? entry.group : ''
|
|
41
|
+
if (!groups.has(key)) groups.set(key, [])
|
|
42
|
+
groups.get(key).push(entry)
|
|
43
|
+
}
|
|
44
|
+
return [...groups.entries()]
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function JunieSettings() {
|
|
48
|
+
const [model, setModel] = useState('')
|
|
49
|
+
const [effort, setEffort] = useState('high')
|
|
50
|
+
const [models, setModels] = useState([])
|
|
51
|
+
const [custom, setCustom] = useState(false)
|
|
52
|
+
const [loaded, setLoaded] = useState(false)
|
|
53
|
+
const [note, setNote] = useState(null)
|
|
54
|
+
const [busy, setBusy] = useState(false)
|
|
55
|
+
|
|
56
|
+
const load = useCallback(async (keepNote = false) => {
|
|
57
|
+
try {
|
|
58
|
+
const response = await fetch(ROUTE, { credentials: 'same-origin', cache: 'no-store' })
|
|
59
|
+
if (!response.ok) {
|
|
60
|
+
setNote(`读取失败:HTTP ${response.status}`)
|
|
61
|
+
return
|
|
62
|
+
}
|
|
63
|
+
const body = await response.json()
|
|
64
|
+
const nextModel = typeof body.model === 'string' ? body.model : 'mix.normal'
|
|
65
|
+
const catalog = Array.isArray(body.models) ? body.models : []
|
|
66
|
+
setModel(nextModel)
|
|
67
|
+
setEffort(typeof body.effort === 'string' ? body.effort : 'high')
|
|
68
|
+
setModels(catalog)
|
|
69
|
+
setCustom(catalog.length === 0 || !catalog.some((entry) => entry.value === nextModel))
|
|
70
|
+
setLoaded(true)
|
|
71
|
+
if (!keepNote) setNote(null)
|
|
72
|
+
} catch (error) {
|
|
73
|
+
setNote(`读取失败:${String(error?.message ?? error)}`)
|
|
74
|
+
}
|
|
75
|
+
}, [])
|
|
76
|
+
|
|
77
|
+
useEffect(() => {
|
|
78
|
+
load()
|
|
79
|
+
}, [load])
|
|
80
|
+
|
|
81
|
+
const save = useCallback(async () => {
|
|
82
|
+
setBusy(true)
|
|
83
|
+
try {
|
|
84
|
+
const response = await fetch(ROUTE, {
|
|
85
|
+
method: 'PUT',
|
|
86
|
+
credentials: 'same-origin',
|
|
87
|
+
headers: { 'content-type': 'application/json' },
|
|
88
|
+
body: JSON.stringify({ model, effort }),
|
|
89
|
+
})
|
|
90
|
+
const body = await response.json().catch(() => ({}))
|
|
91
|
+
if (!response.ok) {
|
|
92
|
+
// The refusal already names the values this model accepts; the
|
|
93
|
+
// option list itself stays complete so switching models recovers.
|
|
94
|
+
setNote(body.error ?? `保存失败:HTTP ${response.status}`)
|
|
95
|
+
return
|
|
96
|
+
}
|
|
97
|
+
await load(true)
|
|
98
|
+
setNote('已保存,下一次委派生效')
|
|
99
|
+
} catch (error) {
|
|
100
|
+
setNote(`保存失败:${String(error?.message ?? error)}`)
|
|
101
|
+
} finally {
|
|
102
|
+
setBusy(false)
|
|
103
|
+
}
|
|
104
|
+
}, [model, effort, load])
|
|
105
|
+
|
|
106
|
+
if (!loaded) return h('div', { style: hintStyle }, note ?? '正在读取 Junie 配置…')
|
|
107
|
+
|
|
108
|
+
const known = models.some((entry) => entry.value === model)
|
|
109
|
+
return h('div', null,
|
|
110
|
+
h('div', { style: rowStyle },
|
|
111
|
+
h('span', { style: labelStyle }, '模型'),
|
|
112
|
+
custom
|
|
113
|
+
? h('input', {
|
|
114
|
+
style: { ...controlStyle, width: '300px' },
|
|
115
|
+
value: model,
|
|
116
|
+
disabled: busy,
|
|
117
|
+
spellCheck: false,
|
|
118
|
+
placeholder: '例如 mix.normal 或 v1:12:jetbrains-ai:gemini-3.7-flash',
|
|
119
|
+
onChange: (event) => setModel(event.target.value),
|
|
120
|
+
})
|
|
121
|
+
: h('select', {
|
|
122
|
+
style: controlStyle,
|
|
123
|
+
value: known ? model : CUSTOM,
|
|
124
|
+
disabled: busy,
|
|
125
|
+
onChange: (event) => {
|
|
126
|
+
if (event.target.value === CUSTOM) {
|
|
127
|
+
setCustom(true)
|
|
128
|
+
return
|
|
129
|
+
}
|
|
130
|
+
setModel(event.target.value)
|
|
131
|
+
},
|
|
132
|
+
},
|
|
133
|
+
models.length === 0 ? h('option', { value: model }, model) : null,
|
|
134
|
+
groupOptions(models).map(([group, entries]) =>
|
|
135
|
+
group.length > 0
|
|
136
|
+
? h('optgroup', { key: group, label: group },
|
|
137
|
+
entries.map((entry) => h('option', { key: entry.value, value: entry.value }, entry.name)))
|
|
138
|
+
: entries.map((entry) => h('option', { key: entry.value, value: entry.value }, entry.name))),
|
|
139
|
+
h('option', { value: CUSTOM }, '自定义…')),
|
|
140
|
+
h('button', {
|
|
141
|
+
type: 'button',
|
|
142
|
+
style: buttonStyle,
|
|
143
|
+
disabled: busy,
|
|
144
|
+
onClick: () => setCustom((value) => !value),
|
|
145
|
+
}, custom ? '用列表' : '手动输入')),
|
|
146
|
+
h('div', { style: rowStyle },
|
|
147
|
+
h('span', { style: labelStyle }, '思考深度'),
|
|
148
|
+
h('select', {
|
|
149
|
+
style: controlStyle,
|
|
150
|
+
value: effort,
|
|
151
|
+
disabled: busy,
|
|
152
|
+
onChange: (event) => setEffort(event.target.value),
|
|
153
|
+
}, ['low', 'medium', 'high', 'xhigh', 'max'].map((id) =>
|
|
154
|
+
h('option', { key: id, value: id }, `${EFFORT_LABEL[id] ?? id}(${id})`)))),
|
|
155
|
+
h('div', { style: rowStyle },
|
|
156
|
+
h('span', { style: labelStyle }, ''),
|
|
157
|
+
h('button', { type: 'button', style: buttonStyle, disabled: busy, onClick: save },
|
|
158
|
+
busy ? '保存中…' : '保存')),
|
|
159
|
+
note === null ? null : h('div', { style: noteStyle }, note),
|
|
160
|
+
h('div', { style: hintStyle },
|
|
161
|
+
'修改后下一次 Junie 委派生效。思考深度的可选项取决于模型,不支持的组合会在保存时提示,不会改动这里的选项。'),
|
|
162
|
+
h('div', { style: hintStyle },
|
|
163
|
+
'模型列表来自 Junie 自己的 ACP 配置项,设置保存在 ~/.dsh/junie-settings.json。'))
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
return {
|
|
167
|
+
inject: ['slots'],
|
|
168
|
+
apply(ctx) {
|
|
169
|
+
ctx.effect(() => ctx.slots.inject('settings.section', () => ctx.slots.register({
|
|
170
|
+
name: 'settings.section',
|
|
171
|
+
id: 'junie',
|
|
172
|
+
order: 40,
|
|
173
|
+
label: () => 'Junie',
|
|
174
|
+
}, JunieSettings)))
|
|
175
|
+
},
|
|
176
|
+
}
|
|
177
|
+
},
|
|
178
|
+
})
|
package/cordis.patch.yml
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# Junie as a one-shot subagent backend.
|
|
2
|
+
#
|
|
3
|
+
# One provider, one tool. The model and thinking depth are `Config` fields, so
|
|
4
|
+
# they belong to the settings projection: change them in Settings and the next
|
|
5
|
+
# delegation uses the new pair. The tool exists exactly while the provider is
|
|
6
|
+
# registered, so the two rows belong together.
|
|
7
|
+
- insert:
|
|
8
|
+
- id: junie-subagent
|
|
9
|
+
name: '@zumikua/dsh-junie-subagent'
|
|
10
|
+
config:
|
|
11
|
+
model: mix.normal
|
|
12
|
+
effort: high
|
|
13
|
+
- id: subagent-junie
|
|
14
|
+
name: '@deepseek-ai/dsh-tool-subagent'
|
|
15
|
+
config:
|
|
16
|
+
provider: junie
|
|
17
|
+
toolName: junie
|
|
18
|
+
maxDepth: provider-managed
|
package/icon.svg
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64" role="img" aria-label="Junie subagent">
|
|
2
|
+
<rect x="4" y="4" width="56" height="56" rx="14" fill="#101828"/>
|
|
3
|
+
<path d="M38 18v20a8 8 0 0 1-16 0" fill="none" stroke="#36d399" stroke-width="6" stroke-linecap="round"/>
|
|
4
|
+
<circle cx="46" cy="20" r="4" fill="#fbbf24"/>
|
|
5
|
+
</svg>
|
package/index.js
ADDED
|
@@ -0,0 +1,651 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Junie CLI as a one-shot subagent provider over the Agent Client Protocol.
|
|
3
|
+
*
|
|
4
|
+
* The child process is spawned through the `dsh-subprocess` seam with no
|
|
5
|
+
* sandbox policy, so Junie runs as the signed-in user: it can persist its own
|
|
6
|
+
* session state under `~/.junie` and is confined only by Junie's own permission
|
|
7
|
+
* model (brave mode, allowlist, `session/request_permission`).
|
|
8
|
+
*
|
|
9
|
+
* One run = one `junie --acp` process = one ACP session, because the provider
|
|
10
|
+
* contract has no continuation surface for out-of-process children.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
|
14
|
+
import { dirname, join } from 'node:path'
|
|
15
|
+
import { homedir } from 'node:os'
|
|
16
|
+
import { randomUUID } from 'node:crypto'
|
|
17
|
+
import {
|
|
18
|
+
NO_START_CAPABILITIES,
|
|
19
|
+
assertPositiveFinite,
|
|
20
|
+
assertUsableCwd,
|
|
21
|
+
settleRunResult,
|
|
22
|
+
subprocessRunHandle,
|
|
23
|
+
} from '@deepseek-ai/dsh-subagent'
|
|
24
|
+
import Schema from '@deepseek-ai/schemastery'
|
|
25
|
+
|
|
26
|
+
export const name = 'junie-subagent'
|
|
27
|
+
|
|
28
|
+
/** Provider, subprocess seam, and the web route the settings page reads. */
|
|
29
|
+
export const inject = ['subagents', 'subprocess', 'webServer']
|
|
30
|
+
|
|
31
|
+
/** Diagnostic prefix shared by every failure this plugin reports. */
|
|
32
|
+
const PREFIX = 'junie-subagent'
|
|
33
|
+
|
|
34
|
+
/** Same-origin route of the settings page; the Host owns the response. */
|
|
35
|
+
const ROUTE_PATH = '/junie-subagent/config'
|
|
36
|
+
|
|
37
|
+
/** Host-side store the provider reads on every run. */
|
|
38
|
+
const SETTINGS_FILE = join(homedir(), '.dsh', 'junie-settings.json')
|
|
39
|
+
|
|
40
|
+
/** Efforts Junie accepts through its ACP `effort` session option. */
|
|
41
|
+
const EFFORTS = new Set(['low', 'medium', 'high', 'xhigh', 'max'])
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Read the stored settings, tolerating a missing or malformed file: the row
|
|
45
|
+
* config remains the fallback so a broken store cannot break delegation.
|
|
46
|
+
* @returns the stored `model`/`effort` subset, possibly empty.
|
|
47
|
+
*/
|
|
48
|
+
function readStoredSettings() {
|
|
49
|
+
try {
|
|
50
|
+
const parsed = JSON.parse(readFileSync(SETTINGS_FILE, 'utf8'))
|
|
51
|
+
if (parsed === null || typeof parsed !== 'object') return {}
|
|
52
|
+
const next = {}
|
|
53
|
+
if (typeof parsed.model === 'string' && parsed.model.length > 0) next.model = parsed.model
|
|
54
|
+
if (typeof parsed.effort === 'string' && EFFORTS.has(parsed.effort)) next.effort = parsed.effort
|
|
55
|
+
return next
|
|
56
|
+
} catch {
|
|
57
|
+
return {}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Persist the settings page's choice.
|
|
63
|
+
* @param value - the validated `model`/`effort` pair.
|
|
64
|
+
*/
|
|
65
|
+
function writeStoredSettings(value) {
|
|
66
|
+
mkdirSync(dirname(SETTINGS_FILE), { recursive: true })
|
|
67
|
+
writeFileSync(SETTINGS_FILE, `${JSON.stringify(value, null, 2)}\n`, 'utf8')
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Collect a JSON request body.
|
|
72
|
+
* @param req - the incoming request.
|
|
73
|
+
* @returns the parsed body, or undefined when it is absent or invalid.
|
|
74
|
+
*/
|
|
75
|
+
async function readJsonBody(req) {
|
|
76
|
+
const chunks = []
|
|
77
|
+
for await (const chunk of req) chunks.push(chunk)
|
|
78
|
+
const text = Buffer.concat(chunks).toString('utf8').trim()
|
|
79
|
+
if (text.length === 0) return undefined
|
|
80
|
+
try {
|
|
81
|
+
return JSON.parse(text)
|
|
82
|
+
} catch {
|
|
83
|
+
return undefined
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Ask Junie for its ACP configuration options: no prompt is sent, so this costs
|
|
89
|
+
* no credits. The process exists only for the handshake.
|
|
90
|
+
* @param ctx - the plugin context (subprocess seam).
|
|
91
|
+
* @param cfg - the effective configuration.
|
|
92
|
+
* @returns the `configOptions` array from `session/new`.
|
|
93
|
+
*/
|
|
94
|
+
async function probeConfigOptions(ctx, cfg) {
|
|
95
|
+
const cwd = homedir()
|
|
96
|
+
const handle = ctx.subprocess.spawn({
|
|
97
|
+
argv: [cfg.command, ...cfg.args],
|
|
98
|
+
cwd,
|
|
99
|
+
stdio: { stdin: 'pipe', stdout: 'pipe', stderr: 'pipe' },
|
|
100
|
+
graceMs: cfg.graceMs,
|
|
101
|
+
env: { ...cfg.env },
|
|
102
|
+
})
|
|
103
|
+
const client = createClient(handle, { permission: 'reject', onText: () => {} })
|
|
104
|
+
let sessionId
|
|
105
|
+
try {
|
|
106
|
+
await client.request('initialize', {
|
|
107
|
+
protocolVersion: 1,
|
|
108
|
+
clientCapabilities: { fs: { readTextFile: true, writeTextFile: true } },
|
|
109
|
+
})
|
|
110
|
+
const session = await client.request('session/new', { cwd, mcpServers: [] })
|
|
111
|
+
sessionId = session?.sessionId
|
|
112
|
+
return Array.isArray(session?.configOptions) ? session.configOptions : []
|
|
113
|
+
} finally {
|
|
114
|
+
try {
|
|
115
|
+
if (typeof sessionId === 'string') client.notify('session/cancel', { sessionId })
|
|
116
|
+
} catch {
|
|
117
|
+
// The child may already be gone.
|
|
118
|
+
}
|
|
119
|
+
try {
|
|
120
|
+
handle.stdin?.end()
|
|
121
|
+
} catch {
|
|
122
|
+
// Already closed.
|
|
123
|
+
}
|
|
124
|
+
try {
|
|
125
|
+
handle.terminate()
|
|
126
|
+
} catch {
|
|
127
|
+
// The seam owns final escalation.
|
|
128
|
+
}
|
|
129
|
+
client.close()
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Cached model catalog; Junie's list changes with plan and authentication. */
|
|
134
|
+
const modelCache = { at: 0, models: [] }
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Flatten Junie's grouped model options into one select list.
|
|
138
|
+
* @param ctx - the plugin context.
|
|
139
|
+
* @param cfg - the effective configuration.
|
|
140
|
+
* @returns the model entries, empty when the probe fails.
|
|
141
|
+
*/
|
|
142
|
+
async function listModels(ctx, cfg) {
|
|
143
|
+
if (Date.now() - modelCache.at < 600000 && modelCache.models.length > 0) return modelCache.models
|
|
144
|
+
try {
|
|
145
|
+
const options = await probeConfigOptions(ctx, cfg)
|
|
146
|
+
const modelOption = options.find((entry) => entry !== null && typeof entry === 'object' && entry.id === 'model')
|
|
147
|
+
const models = []
|
|
148
|
+
for (const entry of Array.isArray(modelOption?.options) ? modelOption.options : []) {
|
|
149
|
+
if (Array.isArray(entry?.options)) {
|
|
150
|
+
for (const item of entry.options) {
|
|
151
|
+
if (typeof item?.value === 'string') models.push({ value: item.value, name: String(item.name ?? item.value), group: String(entry.name ?? '') })
|
|
152
|
+
}
|
|
153
|
+
} else if (typeof entry?.value === 'string') {
|
|
154
|
+
models.push({ value: entry.value, name: String(entry.name ?? entry.value), group: '' })
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
if (models.length > 0) {
|
|
158
|
+
modelCache.at = Date.now()
|
|
159
|
+
modelCache.models = models
|
|
160
|
+
}
|
|
161
|
+
return models
|
|
162
|
+
} catch {
|
|
163
|
+
return modelCache.models
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Apply one model/effort pair against a throwaway Junie session. Junie narrows
|
|
169
|
+
* the effort choices per model, so the page cannot know them offline; this
|
|
170
|
+
* answers the pair it actually accepts and the values the model supports.
|
|
171
|
+
* @param ctx - the plugin context.
|
|
172
|
+
* @param cfg - the effective configuration.
|
|
173
|
+
* @param model - the model id to apply.
|
|
174
|
+
* @param effort - the effort to apply.
|
|
175
|
+
* @returns the verdict plus the model's supported efforts.
|
|
176
|
+
*/
|
|
177
|
+
async function validateChoice(ctx, cfg, model, effort) {
|
|
178
|
+
const cwd = homedir()
|
|
179
|
+
const handle = ctx.subprocess.spawn({
|
|
180
|
+
argv: [cfg.command, ...cfg.args],
|
|
181
|
+
cwd,
|
|
182
|
+
stdio: { stdin: 'pipe', stdout: 'pipe', stderr: 'pipe' },
|
|
183
|
+
graceMs: cfg.graceMs,
|
|
184
|
+
env: { ...cfg.env },
|
|
185
|
+
})
|
|
186
|
+
const client = createClient(handle, { permission: 'reject', onText: () => {} })
|
|
187
|
+
let sessionId
|
|
188
|
+
try {
|
|
189
|
+
await client.request('initialize', {
|
|
190
|
+
protocolVersion: 1,
|
|
191
|
+
clientCapabilities: { fs: { readTextFile: true, writeTextFile: true } },
|
|
192
|
+
})
|
|
193
|
+
const session = await client.request('session/new', { cwd, mcpServers: [] })
|
|
194
|
+
sessionId = session?.sessionId
|
|
195
|
+
const afterModel = await client.request('session/set_config_option', {
|
|
196
|
+
sessionId,
|
|
197
|
+
configId: 'model',
|
|
198
|
+
value: model,
|
|
199
|
+
})
|
|
200
|
+
const effortOption = (afterModel?.configOptions ?? []).find(
|
|
201
|
+
(entry) => entry !== null && typeof entry === 'object' && entry.id === 'effort',
|
|
202
|
+
)
|
|
203
|
+
const allowed = (Array.isArray(effortOption?.options) ? effortOption.options : [])
|
|
204
|
+
.map((entry) => entry?.value)
|
|
205
|
+
.filter((value) => typeof value === 'string')
|
|
206
|
+
if (allowed.length === 0) return { ok: true, allowed: [...EFFORTS] }
|
|
207
|
+
if (!allowed.includes(effort)) {
|
|
208
|
+
return { ok: false, allowed, error: `模型 ${model} 只支持这些思考深度:${allowed.join(' / ')}` }
|
|
209
|
+
}
|
|
210
|
+
await client.request('session/set_config_option', { sessionId, configId: 'effort', value: effort })
|
|
211
|
+
return { ok: true, allowed }
|
|
212
|
+
} catch (error) {
|
|
213
|
+
const message = String(error?.message ?? error)
|
|
214
|
+
const match = message.match(/Supported values:\s*\[([^\]]+)\]/i)
|
|
215
|
+
const allowed = match === null
|
|
216
|
+
? []
|
|
217
|
+
: match[1].split(',').map((value) => value.trim()).filter((value) => value.length > 0)
|
|
218
|
+
return { ok: false, allowed, error: message }
|
|
219
|
+
} finally {
|
|
220
|
+
try {
|
|
221
|
+
if (typeof sessionId === 'string') client.notify('session/cancel', { sessionId })
|
|
222
|
+
} catch {
|
|
223
|
+
// The child may already be gone.
|
|
224
|
+
}
|
|
225
|
+
try {
|
|
226
|
+
handle.stdin?.end()
|
|
227
|
+
} catch {
|
|
228
|
+
// Already closed.
|
|
229
|
+
}
|
|
230
|
+
try {
|
|
231
|
+
handle.terminate()
|
|
232
|
+
} catch {
|
|
233
|
+
// The seam owns final escalation.
|
|
234
|
+
}
|
|
235
|
+
client.close()
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Answer the settings page: GET returns the effective pair plus the model
|
|
241
|
+
* catalog, PUT validates and stores one pair.
|
|
242
|
+
* @param ctx - the plugin context.
|
|
243
|
+
* @param cfg - the row configuration, used as the fallback values.
|
|
244
|
+
* @returns the route handler.
|
|
245
|
+
*/
|
|
246
|
+
function configRouteHandler(ctx, cfg) {
|
|
247
|
+
return async (req, res) => {
|
|
248
|
+
const send = (status, body) => {
|
|
249
|
+
const text = JSON.stringify(body)
|
|
250
|
+
res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' })
|
|
251
|
+
res.end(text)
|
|
252
|
+
}
|
|
253
|
+
if (req.method === 'GET') {
|
|
254
|
+
const stored = readStoredSettings()
|
|
255
|
+
const models = await listModels(ctx, cfg)
|
|
256
|
+
send(200, {
|
|
257
|
+
model: stored.model ?? cfg.model,
|
|
258
|
+
effort: stored.effort ?? cfg.effort,
|
|
259
|
+
stored,
|
|
260
|
+
models,
|
|
261
|
+
efforts: [...EFFORTS],
|
|
262
|
+
})
|
|
263
|
+
return
|
|
264
|
+
}
|
|
265
|
+
if (req.method === 'PUT' || req.method === 'POST') {
|
|
266
|
+
const body = await readJsonBody(req)
|
|
267
|
+
if (body === undefined || typeof body !== 'object' || body === null) {
|
|
268
|
+
send(400, { ok: false, error: 'invalid JSON body' })
|
|
269
|
+
return
|
|
270
|
+
}
|
|
271
|
+
const model = typeof body.model === 'string' ? body.model.trim() : ''
|
|
272
|
+
const effort = typeof body.effort === 'string' ? body.effort.trim() : ''
|
|
273
|
+
if (model.length === 0) {
|
|
274
|
+
send(400, { ok: false, error: 'model is required' })
|
|
275
|
+
return
|
|
276
|
+
}
|
|
277
|
+
if (effort.length > 0 && !EFFORTS.has(effort)) {
|
|
278
|
+
send(400, { ok: false, error: `effort must be one of ${[...EFFORTS].join(', ')}` })
|
|
279
|
+
return
|
|
280
|
+
}
|
|
281
|
+
const next = { model, effort: effort.length > 0 ? effort : 'high' }
|
|
282
|
+
const verdict = await validateChoice(ctx, cfg, next.model, next.effort)
|
|
283
|
+
if (!verdict.ok) {
|
|
284
|
+
send(400, { ok: false, error: verdict.error, allowed: verdict.allowed })
|
|
285
|
+
return
|
|
286
|
+
}
|
|
287
|
+
try {
|
|
288
|
+
writeStoredSettings(next)
|
|
289
|
+
} catch (error) {
|
|
290
|
+
send(500, { ok: false, error: `cannot write settings: ${String(error?.message ?? error)}` })
|
|
291
|
+
return
|
|
292
|
+
}
|
|
293
|
+
send(200, { ok: true, ...next, allowed: verdict.allowed })
|
|
294
|
+
return
|
|
295
|
+
}
|
|
296
|
+
send(405, { ok: false, error: 'GET or PUT only' })
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* Row configuration, also the schema the settings service projects: `model`
|
|
302
|
+
* and `effort` are the two values a person changes from the settings page.
|
|
303
|
+
*/
|
|
304
|
+
export const Config = Schema.object({
|
|
305
|
+
providerName: Schema.string().default('junie'),
|
|
306
|
+
command: Schema.string().default(''),
|
|
307
|
+
args: Schema.array(Schema.string()).default([]),
|
|
308
|
+
model: Schema.string().default('mix.normal'),
|
|
309
|
+
effort: Schema.string().default('high'),
|
|
310
|
+
braveMode: Schema.string().default('brave-auto'),
|
|
311
|
+
permission: Schema.string().default('reject'),
|
|
312
|
+
timeoutMs: Schema.number().default(900000),
|
|
313
|
+
graceMs: Schema.number().default(3000),
|
|
314
|
+
})
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
/** ACP terminal reasons mapped onto the seam's stop-reason vocabulary. */
|
|
319
|
+
const STOP_REASONS = new Map([
|
|
320
|
+
['end_turn', 'completed'],
|
|
321
|
+
['max_tokens', 'max-tokens'],
|
|
322
|
+
['refusal', 'refusal'],
|
|
323
|
+
['cancelled', 'aborted'],
|
|
324
|
+
])
|
|
325
|
+
|
|
326
|
+
const DIAGNOSTIC_LIMIT = 4000
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* Resolve the Junie executable: explicit config first, then the managed
|
|
330
|
+
* launcher's `current` pointer, then whatever `junie.exe` is on PATH.
|
|
331
|
+
* @param config - the raw plugin config.
|
|
332
|
+
* @returns an absolute path when the managed install is present.
|
|
333
|
+
*/
|
|
334
|
+
function resolveCommand(config) {
|
|
335
|
+
if (typeof config.command === 'string' && config.command.length > 0) return config.command
|
|
336
|
+
const root = join(homedir(), '.local', 'share', 'junie')
|
|
337
|
+
try {
|
|
338
|
+
const current = readFileSync(join(root, 'current'), 'utf8').split(/\r?\n/)[0].trim()
|
|
339
|
+
if (current.length > 0) {
|
|
340
|
+
const exe = join(root, 'versions', current, 'junie', 'junie.exe')
|
|
341
|
+
if (existsSync(exe)) return exe
|
|
342
|
+
}
|
|
343
|
+
} catch {
|
|
344
|
+
// No managed install: fall through to PATH.
|
|
345
|
+
}
|
|
346
|
+
return 'junie.exe'
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/**
|
|
350
|
+
* Flatten the seam's prompt blocks into the single text prompt ACP accepts.
|
|
351
|
+
* @param prompt - the request's content blocks.
|
|
352
|
+
* @returns the joined task text, empty when the request carries no text.
|
|
353
|
+
*/
|
|
354
|
+
function taskText(prompt) {
|
|
355
|
+
const parts = []
|
|
356
|
+
for (const block of prompt ?? []) {
|
|
357
|
+
if (block && block.type === 'text' && typeof block.text === 'string') parts.push(block.text)
|
|
358
|
+
}
|
|
359
|
+
return parts.join('\n\n').trim()
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/**
|
|
363
|
+
* Normalise the row config, applying documented defaults.
|
|
364
|
+
* @param config - the raw plugin config.
|
|
365
|
+
* @returns the effective configuration.
|
|
366
|
+
*/
|
|
367
|
+
function normalize(config) {
|
|
368
|
+
const raw = config ?? {}
|
|
369
|
+
const args = Array.isArray(raw.args) && raw.args.length > 0 ? raw.args.map(String) : ['--acp=true']
|
|
370
|
+
if (!args.some((arg) => arg.startsWith('--acp'))) args.push('--acp=true')
|
|
371
|
+
if (!args.includes('--skip-update-check')) args.push('--skip-update-check')
|
|
372
|
+
return {
|
|
373
|
+
providerName: typeof raw.providerName === 'string' && raw.providerName.length > 0 ? raw.providerName : 'junie',
|
|
374
|
+
command: resolveCommand(raw),
|
|
375
|
+
args,
|
|
376
|
+
model: typeof raw.model === 'string' && raw.model.length > 0 ? raw.model : undefined,
|
|
377
|
+
effort: typeof raw.effort === 'string' && raw.effort.length > 0 ? raw.effort : undefined,
|
|
378
|
+
braveMode: typeof raw.braveMode === 'string' && raw.braveMode.length > 0 ? raw.braveMode : undefined,
|
|
379
|
+
permission: raw.permission === 'allow' ? 'allow' : 'reject',
|
|
380
|
+
timeoutMs: Number.isFinite(raw.timeoutMs) && raw.timeoutMs > 0 ? raw.timeoutMs : 900000,
|
|
381
|
+
graceMs: Number.isFinite(raw.graceMs) && raw.graceMs > 0 ? raw.graceMs : 3000,
|
|
382
|
+
env: raw.env && typeof raw.env === 'object' ? raw.env : {},
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/**
|
|
387
|
+
* Minimal newline-delimited JSON-RPC client for the ACP wire.
|
|
388
|
+
* @param handle - the spawned subprocess handle.
|
|
389
|
+
* @param options - permission policy and a text-chunk sink.
|
|
390
|
+
* @returns the client verbs used by one run.
|
|
391
|
+
*/
|
|
392
|
+
function createClient(handle, options) {
|
|
393
|
+
let buffer = ''
|
|
394
|
+
let closed = false
|
|
395
|
+
let nextId = 0
|
|
396
|
+
let stderrTail = ''
|
|
397
|
+
const pending = new Map()
|
|
398
|
+
|
|
399
|
+
const fail = (error) => {
|
|
400
|
+
closed = true
|
|
401
|
+
for (const { reject } of pending.values()) reject(error)
|
|
402
|
+
pending.clear()
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
const write = (message) => {
|
|
406
|
+
if (closed || !handle.stdin) return
|
|
407
|
+
try {
|
|
408
|
+
handle.stdin.write(`${JSON.stringify(message)}\n`)
|
|
409
|
+
} catch {
|
|
410
|
+
fail(new Error('junie acp channel is not writable'))
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
const replyPermission = (message) => {
|
|
415
|
+
const choices = Array.isArray(message.params?.options) ? message.params.options : []
|
|
416
|
+
const wanted = options.permission === 'allow'
|
|
417
|
+
? ['allow_once', 'allow_always', 'allow']
|
|
418
|
+
: ['reject_once', 'reject_always', 'reject']
|
|
419
|
+
const kindOf = (option) => String(option?.kind ?? option?.optionId ?? '')
|
|
420
|
+
const chosen = wanted
|
|
421
|
+
.map((kind) => choices.find((option) => kindOf(option) === kind))
|
|
422
|
+
.find((option) => option !== undefined)
|
|
423
|
+
write({
|
|
424
|
+
jsonrpc: '2.0',
|
|
425
|
+
id: message.id,
|
|
426
|
+
result: chosen === undefined
|
|
427
|
+
? { outcome: { outcome: 'cancelled' } }
|
|
428
|
+
: { outcome: { outcome: 'selected', optionId: chosen.optionId } },
|
|
429
|
+
})
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
const dispatch = (message) => {
|
|
433
|
+
if (message.id !== undefined && pending.has(message.id)) {
|
|
434
|
+
const entry = pending.get(message.id)
|
|
435
|
+
pending.delete(message.id)
|
|
436
|
+
if (message.error) entry.reject(new Error(String(message.error.message ?? 'ACP request failed')))
|
|
437
|
+
else entry.resolve(message.result)
|
|
438
|
+
return
|
|
439
|
+
}
|
|
440
|
+
if (message.method === 'session/update') {
|
|
441
|
+
const update = message.params?.update
|
|
442
|
+
if (update?.sessionUpdate === 'agent_message_chunk' && update.content?.type === 'text') {
|
|
443
|
+
options.onText(String(update.content.text ?? ''))
|
|
444
|
+
}
|
|
445
|
+
return
|
|
446
|
+
}
|
|
447
|
+
if (message.method === 'session/request_permission') replyPermission(message)
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
handle.stdout?.on('data', (data) => {
|
|
451
|
+
buffer += data.toString('utf8')
|
|
452
|
+
let index = buffer.indexOf('\n')
|
|
453
|
+
while (index >= 0) {
|
|
454
|
+
const line = buffer.slice(0, index).trim()
|
|
455
|
+
buffer = buffer.slice(index + 1)
|
|
456
|
+
if (line.length > 0) {
|
|
457
|
+
try {
|
|
458
|
+
dispatch(JSON.parse(line))
|
|
459
|
+
} catch {
|
|
460
|
+
// A malformed frame is not a protocol reply; keep reading.
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
index = buffer.indexOf('\n')
|
|
464
|
+
}
|
|
465
|
+
})
|
|
466
|
+
handle.stdout?.on('end', () => fail(new Error('junie acp stream closed')))
|
|
467
|
+
handle.stderr?.on('data', (data) => {
|
|
468
|
+
stderrTail = `${stderrTail}${data.toString('utf8')}`.slice(-DIAGNOSTIC_LIMIT)
|
|
469
|
+
})
|
|
470
|
+
|
|
471
|
+
return {
|
|
472
|
+
request(method, params) {
|
|
473
|
+
return new Promise((resolve, reject) => {
|
|
474
|
+
if (closed) {
|
|
475
|
+
reject(new Error('junie acp channel is closed'))
|
|
476
|
+
return
|
|
477
|
+
}
|
|
478
|
+
const id = ++nextId
|
|
479
|
+
pending.set(id, { resolve, reject })
|
|
480
|
+
write({ jsonrpc: '2.0', id, method, params })
|
|
481
|
+
})
|
|
482
|
+
},
|
|
483
|
+
notify(method, params) {
|
|
484
|
+
write({ jsonrpc: '2.0', method, params })
|
|
485
|
+
},
|
|
486
|
+
stderrTail: () => stderrTail,
|
|
487
|
+
close: () => fail(new Error('junie acp channel closed by caller')),
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
/**
|
|
492
|
+
* Format a short, credential-free diagnostic line.
|
|
493
|
+
* @param handle - the subprocess handle, for its settled outcome.
|
|
494
|
+
* @param stderrTail - the tail of the child's stderr.
|
|
495
|
+
* @returns one bounded diagnostic line.
|
|
496
|
+
*/
|
|
497
|
+
function diagnosticOf(handle, stderrTail) {
|
|
498
|
+
const firstLine = String(stderrTail ?? '')
|
|
499
|
+
.split(/\r?\n/)
|
|
500
|
+
.map((line) => line.trim())
|
|
501
|
+
.find((line) => line.length > 0)
|
|
502
|
+
const detail = firstLine === undefined ? 'no child output' : firstLine.slice(0, 300)
|
|
503
|
+
return `Junie run failed (${detail})`
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
/**
|
|
507
|
+
* Run one task: spawn, handshake, configure, prompt, settle, tear down.
|
|
508
|
+
* @param ctx - the plugin context.
|
|
509
|
+
* @param cfg - the effective configuration.
|
|
510
|
+
* @param request - the resolved start request.
|
|
511
|
+
* @returns the published run handle.
|
|
512
|
+
*/
|
|
513
|
+
async function startRun(ctx, cfg, request) {
|
|
514
|
+
const cwd = assertUsableCwd(PREFIX, 'request cwd', request.cwd)
|
|
515
|
+
let handle
|
|
516
|
+
try {
|
|
517
|
+
handle = ctx.subprocess.spawn({
|
|
518
|
+
argv: [cfg.command, ...cfg.args],
|
|
519
|
+
cwd,
|
|
520
|
+
stdio: { stdin: 'pipe', stdout: 'pipe', stderr: 'pipe' },
|
|
521
|
+
graceMs: cfg.graceMs,
|
|
522
|
+
env: { ...cfg.env },
|
|
523
|
+
})
|
|
524
|
+
} catch (error) {
|
|
525
|
+
throw new Error(`${PREFIX}: cannot start ${cfg.command}: ${error instanceof Error ? error.message : String(error)}`)
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
const chunks = []
|
|
529
|
+
const client = createClient(handle, {
|
|
530
|
+
permission: cfg.permission,
|
|
531
|
+
onText: (text) => chunks.push(text),
|
|
532
|
+
})
|
|
533
|
+
|
|
534
|
+
let sessionId
|
|
535
|
+
try {
|
|
536
|
+
await client.request('initialize', {
|
|
537
|
+
protocolVersion: 1,
|
|
538
|
+
clientCapabilities: { fs: { readTextFile: true, writeTextFile: true } },
|
|
539
|
+
})
|
|
540
|
+
const session = await client.request('session/new', { cwd, mcpServers: [] })
|
|
541
|
+
sessionId = session?.sessionId
|
|
542
|
+
if (typeof sessionId !== 'string' || sessionId.length === 0) {
|
|
543
|
+
throw new Error('ACP session/new returned no session id')
|
|
544
|
+
}
|
|
545
|
+
const stored = readStoredSettings()
|
|
546
|
+
const model = stored.model ?? cfg.model
|
|
547
|
+
const effort = stored.effort ?? cfg.effort
|
|
548
|
+
for (const [configId, value] of [['model', model], ['effort', effort], ['brave_mode', cfg.braveMode]]) {
|
|
549
|
+
if (value !== undefined) await client.request('session/set_config_option', { sessionId, configId, value })
|
|
550
|
+
}
|
|
551
|
+
} catch (error) {
|
|
552
|
+
client.close()
|
|
553
|
+
try {
|
|
554
|
+
handle.terminate()
|
|
555
|
+
} catch {
|
|
556
|
+
// Teardown failures cannot improve the startup rejection.
|
|
557
|
+
}
|
|
558
|
+
throw new Error(`${PREFIX}: ACP startup failed: ${error instanceof Error ? error.message : String(error)}`)
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
const collected = () => (chunks.length === 0 ? [] : [{ type: 'text', text: chunks.join('') }])
|
|
562
|
+
|
|
563
|
+
const requestStop = () => {
|
|
564
|
+
try {
|
|
565
|
+
client.notify('session/cancel', { sessionId })
|
|
566
|
+
} catch {
|
|
567
|
+
// The child may already be gone; termination below still applies.
|
|
568
|
+
}
|
|
569
|
+
try {
|
|
570
|
+
handle.terminate()
|
|
571
|
+
} catch {
|
|
572
|
+
// The seam owns final escalation.
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
const onAbort = () => requestStop()
|
|
577
|
+
request.signal.addEventListener('abort', onAbort, { once: true })
|
|
578
|
+
|
|
579
|
+
const timer = setTimeout(requestStop, cfg.timeoutMs)
|
|
580
|
+
if (typeof timer.unref === 'function') timer.unref()
|
|
581
|
+
|
|
582
|
+
const attempt = async () => {
|
|
583
|
+
const reply = await client.request('session/prompt', {
|
|
584
|
+
sessionId,
|
|
585
|
+
prompt: [{ type: 'text', text: taskText(request.prompt) || 'Continue.' }],
|
|
586
|
+
})
|
|
587
|
+
const mapped = STOP_REASONS.get(String(reply?.stopReason ?? ''))
|
|
588
|
+
if (mapped === undefined) {
|
|
589
|
+
throw new Error(`junie stopped with ${String(reply?.stopReason ?? 'an unknown reason')}`)
|
|
590
|
+
}
|
|
591
|
+
return { output: collected(), stopReason: mapped }
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
const result = settleRunResult({
|
|
595
|
+
attempt,
|
|
596
|
+
collectOutput: collected,
|
|
597
|
+
collectDiagnostic: () => diagnosticOf(handle, client.stderrTail()),
|
|
598
|
+
cancelled: () => request.signal.aborted,
|
|
599
|
+
signal: request.signal,
|
|
600
|
+
onAbort,
|
|
601
|
+
})
|
|
602
|
+
|
|
603
|
+
return subprocessRunHandle({
|
|
604
|
+
id: `junie-${randomUUID()}`,
|
|
605
|
+
result,
|
|
606
|
+
signal: request.signal,
|
|
607
|
+
onAbort,
|
|
608
|
+
requestCancel: requestStop,
|
|
609
|
+
teardown: async () => {
|
|
610
|
+
clearTimeout(timer)
|
|
611
|
+
request.signal.removeEventListener('abort', onAbort)
|
|
612
|
+
try {
|
|
613
|
+
handle.stdin?.end()
|
|
614
|
+
} catch {
|
|
615
|
+
// Already closed.
|
|
616
|
+
}
|
|
617
|
+
try {
|
|
618
|
+
await handle.waitForExit()
|
|
619
|
+
} catch {
|
|
620
|
+
// Teardown is best effort; the seam owns final escalation.
|
|
621
|
+
}
|
|
622
|
+
client.close()
|
|
623
|
+
},
|
|
624
|
+
})
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
/**
|
|
628
|
+
* Register the Junie provider and the settings route it shares with the page.
|
|
629
|
+
* Both registrations live in effects so Cordis disposes them with the fiber —
|
|
630
|
+
* an `apply` return value is not used as cleanup.
|
|
631
|
+
* @param ctx - the plugin context.
|
|
632
|
+
* @param config - the row config.
|
|
633
|
+
*/
|
|
634
|
+
export function apply(ctx, config) {
|
|
635
|
+
const cfg = normalize(config)
|
|
636
|
+
assertPositiveFinite(PREFIX, 'timeoutMs', cfg.timeoutMs)
|
|
637
|
+
assertPositiveFinite(PREFIX, 'graceMs', cfg.graceMs)
|
|
638
|
+
|
|
639
|
+
ctx.effect(() => ctx.subagents.registerProvider({
|
|
640
|
+
name: cfg.providerName,
|
|
641
|
+
capabilities: NO_START_CAPABILITIES,
|
|
642
|
+
inheritsParentContext: false,
|
|
643
|
+
start: (request) => startRun(ctx, cfg, request),
|
|
644
|
+
}))
|
|
645
|
+
|
|
646
|
+
ctx.effect(() => ctx.webServer.register({
|
|
647
|
+
kind: 'exact',
|
|
648
|
+
path: ROUTE_PATH,
|
|
649
|
+
handler: configRouteHandler(ctx, cfg),
|
|
650
|
+
}))
|
|
651
|
+
}
|
package/locale/en.json
ADDED
package/locale/zh.json
ADDED
package/package.json
CHANGED
|
@@ -1,6 +1,66 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zumikua/dsh-junie-subagent",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
|
|
6
|
-
}
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"publishConfig": {
|
|
5
|
+
"access": "public"
|
|
6
|
+
},
|
|
7
|
+
"description": "JetBrains Junie CLI as a one-shot DeepSeek Harness subagent provider over the Agent Client Protocol, with a settings page for model and thinking depth.",
|
|
8
|
+
"type": "module",
|
|
9
|
+
"license": "MIT",
|
|
10
|
+
"author": "Zumi Kua",
|
|
11
|
+
"keywords": [
|
|
12
|
+
"dsh",
|
|
13
|
+
"dsh-plugin",
|
|
14
|
+
"deepseek-harness",
|
|
15
|
+
"junie",
|
|
16
|
+
"jetbrains",
|
|
17
|
+
"subagent",
|
|
18
|
+
"acp",
|
|
19
|
+
"agent-client-protocol"
|
|
20
|
+
],
|
|
21
|
+
"repository": {
|
|
22
|
+
"type": "git",
|
|
23
|
+
"url": "git+https://github.com/ZumiKua/dsh-junie-subagent.git"
|
|
24
|
+
},
|
|
25
|
+
"homepage": "https://github.com/ZumiKua/dsh-junie-subagent#readme",
|
|
26
|
+
"bugs": {
|
|
27
|
+
"url": "https://github.com/ZumiKua/dsh-junie-subagent/issues"
|
|
28
|
+
},
|
|
29
|
+
"exports": {
|
|
30
|
+
".": "./index.js",
|
|
31
|
+
"./client": "./client.js",
|
|
32
|
+
"./locale/*.json": "./locale/*.json",
|
|
33
|
+
"./icon": "./icon.svg"
|
|
34
|
+
},
|
|
35
|
+
"files": [
|
|
36
|
+
"index.js",
|
|
37
|
+
"client.js",
|
|
38
|
+
"cordis.patch.yml",
|
|
39
|
+
"locale/*.json",
|
|
40
|
+
"icon.svg",
|
|
41
|
+
"README.md",
|
|
42
|
+
"README.zh.md",
|
|
43
|
+
"LICENSE"
|
|
44
|
+
],
|
|
45
|
+
"engines": {
|
|
46
|
+
"dsh": ">=0.2.1-rc.2"
|
|
47
|
+
},
|
|
48
|
+
"peerDependencies": {
|
|
49
|
+
"@deepseek-ai/cordis": "^4.0.4",
|
|
50
|
+
"@deepseek-ai/dsh-subagent": "0.2.1-rc.2",
|
|
51
|
+
"@deepseek-ai/schemastery": "^3.0.0"
|
|
52
|
+
},
|
|
53
|
+
"dsh": {
|
|
54
|
+
"bundle": {
|
|
55
|
+
"patch": "./cordis.patch.yml"
|
|
56
|
+
},
|
|
57
|
+
"client": {
|
|
58
|
+
"platform": "web",
|
|
59
|
+
"immediately": true,
|
|
60
|
+
"inject": [
|
|
61
|
+
"@deepseek-ai/dsh-api-remotes",
|
|
62
|
+
"@deepseek-ai/dsh-client-ui-settings"
|
|
63
|
+
]
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|