opencode-copilot-account-switcher 0.2.5 → 0.2.7
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/README.md +107 -116
- package/dist/copilot-network-retry.d.ts +39 -4
- package/dist/copilot-network-retry.js +279 -36
- package/dist/loop-safety-plugin.d.ts +1 -1
- package/dist/loop-safety-plugin.js +3 -0
- package/dist/plugin-hooks.d.ts +27 -3
- package/dist/plugin-hooks.js +13 -3
- package/dist/plugin.js +8 -1
- package/dist/store.js +2 -2
- package/dist/ui/menu.d.ts +29 -1
- package/dist/ui/menu.js +86 -25
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -4,43 +4,43 @@
|
|
|
4
4
|
[](https://www.npmjs.com/package/opencode-copilot-account-switcher)
|
|
5
5
|
[](LICENSE)
|
|
6
6
|
|
|
7
|
-
[
|
|
7
|
+
[中文](#中文) | [English](#english)
|
|
8
8
|
|
|
9
9
|
---
|
|
10
10
|
|
|
11
|
-
<a name="
|
|
11
|
+
<a name="中文"></a>
|
|
12
12
|
|
|
13
|
-
##
|
|
13
|
+
## 中文
|
|
14
14
|
|
|
15
|
-
|
|
15
|
+
在 **OpenCode** 中管理并切换多个 **GitHub Copilot** 账号。本插件提供**账号切换、配额查询**、默认开启的 **Guided Loop Safety** 模式,以及默认关闭的 **Copilot Network Retry** 开关;前者帮助一次 premium request 更容易连续工作好几个小时、减少真正需要你输入之前的汇报打断,后者用于处理可重试的网络与证书类失败。**完全依赖官方 `github-copilot` provider**,无需修改模型配置。
|
|
16
16
|
|
|
17
|
-
##
|
|
17
|
+
## 功能一览
|
|
18
18
|
|
|
19
|
-
-
|
|
20
|
-
-
|
|
21
|
-
-
|
|
22
|
-
- **Guided Loop Safety** —
|
|
23
|
-
- **Copilot Network Retry** —
|
|
24
|
-
-
|
|
19
|
+
- **多账号管理** — 添加多个 Copilot 账号,随时切换
|
|
20
|
+
- **配额查询** — 查看每个账号的剩余额度
|
|
21
|
+
- **导入认证** — 可从 OpenCode 认证存储导入
|
|
22
|
+
- **Guided Loop Safety** — 默认开启;仅对 Copilot 生效的更严格 question-first 提示词策略,推动非阻塞工作持续执行、让一次 premium request 更容易连续工作好几个小时,并通过减少反复中断来降低无谓配额消耗
|
|
23
|
+
- **Copilot Network Retry** — 默认关闭;把可重试的 Copilot 网络或 TLS 失败归一化成 OpenCode 原生重试链路可识别的形态
|
|
24
|
+
- **无需模型配置** — 使用官方 provider,无需改模型
|
|
25
25
|
|
|
26
26
|
---
|
|
27
27
|
|
|
28
|
-
##
|
|
28
|
+
## 安装
|
|
29
29
|
|
|
30
30
|
<details open>
|
|
31
|
-
<summary><b
|
|
31
|
+
<summary><b>面向人类用户</b></summary>
|
|
32
32
|
|
|
33
|
-
|
|
33
|
+
**选项 A:让 LLM 帮你安装**
|
|
34
34
|
|
|
35
|
-
|
|
35
|
+
把下面这段话丢给任意 LLM(OpenCode / Claude Code / Cursor 等):
|
|
36
36
|
|
|
37
37
|
```
|
|
38
|
-
|
|
38
|
+
请按以下说明安装 opencode-copilot-account-switcher 插件:https://raw.githubusercontent.com/jiwangyihao/opencode-copilot-account-switcher/master/README.md
|
|
39
39
|
```
|
|
40
40
|
|
|
41
|
-
|
|
41
|
+
**选项 B:手动安装**
|
|
42
42
|
|
|
43
|
-
1.
|
|
43
|
+
1. **在配置文件中添加插件** `~/.config/opencode/opencode.json`:
|
|
44
44
|
|
|
45
45
|
```json
|
|
46
46
|
{
|
|
@@ -49,9 +49,9 @@ Install the opencode-copilot-account-switcher plugin by following: https://raw.g
|
|
|
49
49
|
}
|
|
50
50
|
```
|
|
51
51
|
|
|
52
|
-
2.
|
|
52
|
+
2. **重启 OpenCode**(会自动从 npm 下载插件)。
|
|
53
53
|
|
|
54
|
-
3.
|
|
54
|
+
3. **登录 GitHub Copilot**:
|
|
55
55
|
|
|
56
56
|
```bash
|
|
57
57
|
opencode auth login --provider github-copilot
|
|
@@ -60,15 +60,15 @@ Install the opencode-copilot-account-switcher plugin by following: https://raw.g
|
|
|
60
60
|
</details>
|
|
61
61
|
|
|
62
62
|
<details>
|
|
63
|
-
<summary><b
|
|
63
|
+
<summary><b>面向 LLM 智能体</b></summary>
|
|
64
64
|
|
|
65
|
-
###
|
|
65
|
+
### 步骤指引
|
|
66
66
|
|
|
67
|
-
1.
|
|
67
|
+
1. 打开 OpenCode 配置文件 `~/.config/opencode/opencode.json`
|
|
68
68
|
|
|
69
|
-
>
|
|
69
|
+
> **说明**:该路径在所有平台通用;Windows 上 `~` 会解析为用户目录(例如 `C:\Users\YourName`)。
|
|
70
70
|
|
|
71
|
-
2.
|
|
71
|
+
2. 在 `plugin` 数组中添加插件:
|
|
72
72
|
|
|
73
73
|
```json
|
|
74
74
|
{
|
|
@@ -77,9 +77,9 @@ Install the opencode-copilot-account-switcher plugin by following: https://raw.g
|
|
|
77
77
|
}
|
|
78
78
|
```
|
|
79
79
|
|
|
80
|
-
3.
|
|
80
|
+
3. 重启 OpenCode,使其自动拉取 npm 包。
|
|
81
81
|
|
|
82
|
-
###
|
|
82
|
+
### 验证
|
|
83
83
|
|
|
84
84
|
```bash
|
|
85
85
|
opencode auth login --provider github-copilot
|
|
@@ -89,55 +89,55 @@ opencode auth login --provider github-copilot
|
|
|
89
89
|
|
|
90
90
|
---
|
|
91
91
|
|
|
92
|
-
##
|
|
92
|
+
## 使用方式
|
|
93
93
|
|
|
94
|
-
|
|
94
|
+
在 Copilot 认证流程中运行:
|
|
95
95
|
|
|
96
96
|
```bash
|
|
97
97
|
opencode auth login --provider github-copilot
|
|
98
98
|
```
|
|
99
99
|
|
|
100
|
-
|
|
100
|
+
会出现交互式菜单(方向键 + 回车):
|
|
101
101
|
|
|
102
|
-
-
|
|
103
|
-
-
|
|
104
|
-
-
|
|
105
|
-
- **Guided Loop Safety
|
|
106
|
-
- **Copilot Network Retry
|
|
107
|
-
-
|
|
108
|
-
-
|
|
109
|
-
-
|
|
102
|
+
- **添加账号**
|
|
103
|
+
- **从 auth.json 导入**
|
|
104
|
+
- **检查配额**
|
|
105
|
+
- **Guided Loop Safety 开关** — 通过提示词引导模型在可用时必须使用 `question` 做用户可见汇报、继续完成非阻塞工作、减少反复中断,并避免不必要的子代理调用
|
|
106
|
+
- **Copilot Network Retry 开关** — 默认关闭;仅影响 Copilot 请求的 `fetch` 路径,只处理可重试的网络/证书类失败
|
|
107
|
+
- **切换账号**
|
|
108
|
+
- **删除账号**
|
|
109
|
+
- **全部删除**
|
|
110
110
|
|
|
111
|
-
|
|
111
|
+
Guided Loop Safety 现在默认开启。实际使用中,它可以让一次 request 更容易连续工作好几个小时:当 `question` 工具在当前会话中可用且被允许时,用户可见汇报必须通过它完成,因此等待你的回复本身不会像反复插入直接状态消息那样继续额外消耗配额;少一次中断,本身就少一次无谓的配额消耗。只要还有安全的非阻塞工作可做,Copilot 就应继续执行而不是提前暂停;只有在当前确实没有可安全执行的动作时,才应通过 `question` 询问下一项任务或所需澄清,同时也会减少不必要的子代理调用。
|
|
112
112
|
|
|
113
|
-
|
|
113
|
+
如果你在切换 Copilot 账号后遇到瞬时 TLS/网络失败,或者遇到由旧 session item ID 残留引起的 `input[*].id too long` 错误,也可以在同一菜单中开启 Copilot Network Retry。它默认关闭。开启后,插件会先保留 upstream 官方 loader 生成的 `baseURL`、认证头和 `fetch` 行为,只在最后一跳 Copilot `fetch` 路径上做最小包装,把可重试的网络类失败归一化成 OpenCode 已有重试链路能识别的形态;对于明确命中的 `input[*].id too long` 400,还会回写命中的 session part,避免旧 item ID 持续污染后续重试。
|
|
114
114
|
|
|
115
115
|
## Copilot Network Retry
|
|
116
116
|
|
|
117
|
-
-
|
|
118
|
-
-
|
|
119
|
-
-
|
|
120
|
-
-
|
|
121
|
-
-
|
|
117
|
+
- 默认:**关闭**
|
|
118
|
+
- 作用范围:仅影响 `auth.loader` 返回的官方 Copilot 请求 `fetch` 路径
|
|
119
|
+
- 用途:有限处理 `failed to fetch`、`ECONNRESET`、`unknown certificate`、`self signed certificate` 等可重试网络/证书类失败
|
|
120
|
+
- 实现策略:尽量保留官方 loader 行为,再把可重试失败归一化给 OpenCode 原生重试链路判断是否重试
|
|
121
|
+
- 风险提示:因为插件仍然包裹了官方 fetch 路径,若 upstream 后续内部实现变化,仍可能产生行为漂移
|
|
122
122
|
|
|
123
|
-
## Upstream
|
|
123
|
+
## Upstream 同步机制
|
|
124
124
|
|
|
125
|
-
|
|
125
|
+
仓库中提交了一份 upstream 快照 `src/upstream/copilot-plugin.snapshot.ts`,并提供同步/校验脚本 `scripts/sync-copilot-upstream.mjs`。
|
|
126
126
|
|
|
127
|
-
|
|
127
|
+
常用命令:
|
|
128
128
|
|
|
129
129
|
```bash
|
|
130
130
|
npm run sync:copilot-snapshot -- --source <file-or-url> --upstream-commit <sha> --sync-date <YYYY-MM-DD>
|
|
131
131
|
npm run check:copilot-sync -- --source <file-or-url> --upstream-commit <sha> --sync-date <YYYY-MM-DD>
|
|
132
132
|
```
|
|
133
133
|
|
|
134
|
-
|
|
134
|
+
该脚本会生成或校验仓库中提交的 snapshot,并要求在更新正式 snapshot 时显式提供 upstream commit 与同步日期,用来尽早发现与官方 `opencode` `copilot.ts` 的行为漂移。
|
|
135
135
|
|
|
136
136
|
---
|
|
137
137
|
|
|
138
|
-
##
|
|
138
|
+
## 存储位置
|
|
139
139
|
|
|
140
|
-
|
|
140
|
+
账号信息保存于:
|
|
141
141
|
|
|
142
142
|
```
|
|
143
143
|
~/.config/opencode/copilot-accounts.json
|
|
@@ -145,52 +145,52 @@ Accounts are stored in:
|
|
|
145
145
|
|
|
146
146
|
---
|
|
147
147
|
|
|
148
|
-
##
|
|
148
|
+
## 常见问题
|
|
149
149
|
|
|
150
|
-
|
|
151
|
-
|
|
150
|
+
**需要改模型配置吗?**
|
|
151
|
+
不需要。本插件只做账号管理,继续使用官方 `github-copilot` provider。
|
|
152
152
|
|
|
153
|
-
|
|
154
|
-
|
|
153
|
+
**会替换官方 provider 吗?**
|
|
154
|
+
不会。它只是在官方 provider 基础上增加账号切换和配额查询。
|
|
155
155
|
|
|
156
|
-
**
|
|
157
|
-
|
|
156
|
+
**Copilot Network Retry 会替代 OpenCode 自己的重试逻辑吗?**
|
|
157
|
+
不会。插件的目标是把可重试的 Copilot 网络/TLS 失败归一化成 OpenCode 已识别的可重试错误形态,真正的是否重试与如何退避仍由 OpenCode 原生链路决定。
|
|
158
158
|
|
|
159
159
|
---
|
|
160
160
|
|
|
161
|
-
<a name="
|
|
161
|
+
<a name="english"></a>
|
|
162
162
|
|
|
163
|
-
##
|
|
163
|
+
## English
|
|
164
164
|
|
|
165
|
-
|
|
165
|
+
Manage and switch between multiple **GitHub Copilot** accounts in **OpenCode**. This plugin adds account switching, quota checks, a default-on **Guided Loop Safety** mode that can keep a single premium request productive for hours with fewer report interruptions before it truly needs user input, and an optional **Copilot Network Retry** switch for retryable network and certificate failures. It **uses the official `github-copilot` provider** and does **not** require model reconfiguration.
|
|
166
166
|
|
|
167
|
-
##
|
|
167
|
+
## What You Get
|
|
168
168
|
|
|
169
|
-
-
|
|
170
|
-
-
|
|
171
|
-
-
|
|
172
|
-
- **Guided Loop Safety** —
|
|
173
|
-
- **Copilot Network Retry** —
|
|
174
|
-
-
|
|
169
|
+
- **Multi-account support** — add multiple Copilot accounts and switch anytime
|
|
170
|
+
- **Quota check** — view remaining quota per account
|
|
171
|
+
- **Auth import** — import Copilot tokens from OpenCode auth storage
|
|
172
|
+
- **Guided Loop Safety** — enabled by default; a stricter Copilot-only question-first policy designed to keep non-blocked work moving, keep one premium request productive for hours, and cut avoidable quota burn by replacing repeated interruption turns with `question`-based waiting
|
|
173
|
+
- **Copilot Network Retry** — optional and off by default; normalizes retryable Copilot network or TLS failures so OpenCode's native retry path can handle them
|
|
174
|
+
- **Zero model config** — no model changes required (official provider only)
|
|
175
175
|
|
|
176
176
|
---
|
|
177
177
|
|
|
178
|
-
##
|
|
178
|
+
## Installation
|
|
179
179
|
|
|
180
180
|
<details open>
|
|
181
|
-
<summary><b
|
|
181
|
+
<summary><b>For Humans</b></summary>
|
|
182
182
|
|
|
183
|
-
|
|
183
|
+
**Option A: Let an LLM do it**
|
|
184
184
|
|
|
185
|
-
|
|
185
|
+
Paste this into any LLM agent (OpenCode, Claude Code, Cursor, etc.):
|
|
186
186
|
|
|
187
187
|
```
|
|
188
|
-
|
|
188
|
+
Install the opencode-copilot-account-switcher plugin by following: https://raw.githubusercontent.com/jiwangyihao/opencode-copilot-account-switcher/master/README.md
|
|
189
189
|
```
|
|
190
190
|
|
|
191
|
-
|
|
191
|
+
**Option B: Manual setup**
|
|
192
192
|
|
|
193
|
-
1.
|
|
193
|
+
1. **Add the plugin** to `~/.config/opencode/opencode.json`:
|
|
194
194
|
|
|
195
195
|
```json
|
|
196
196
|
{
|
|
@@ -199,9 +199,9 @@ No. The plugin keeps retry policy inside OpenCode by normalizing retryable Copil
|
|
|
199
199
|
}
|
|
200
200
|
```
|
|
201
201
|
|
|
202
|
-
2.
|
|
202
|
+
2. **Restart OpenCode** (it will auto-download the plugin from npm).
|
|
203
203
|
|
|
204
|
-
3.
|
|
204
|
+
3. **Login** to GitHub Copilot:
|
|
205
205
|
|
|
206
206
|
```bash
|
|
207
207
|
opencode auth login --provider github-copilot
|
|
@@ -210,15 +210,15 @@ No. The plugin keeps retry policy inside OpenCode by normalizing retryable Copil
|
|
|
210
210
|
</details>
|
|
211
211
|
|
|
212
212
|
<details>
|
|
213
|
-
<summary><b
|
|
213
|
+
<summary><b>For LLM Agents</b></summary>
|
|
214
214
|
|
|
215
|
-
###
|
|
215
|
+
### Step-by-Step Instructions
|
|
216
216
|
|
|
217
|
-
1.
|
|
217
|
+
1. Edit the OpenCode configuration file at `~/.config/opencode/opencode.json`
|
|
218
218
|
|
|
219
|
-
>
|
|
219
|
+
> **Note**: This path works on all platforms. On Windows, `~` resolves to your user home directory (e.g., `C:\Users\YourName`).
|
|
220
220
|
|
|
221
|
-
2.
|
|
221
|
+
2. Add the plugin to the `plugin` array:
|
|
222
222
|
|
|
223
223
|
```json
|
|
224
224
|
{
|
|
@@ -227,9 +227,9 @@ No. The plugin keeps retry policy inside OpenCode by normalizing retryable Copil
|
|
|
227
227
|
}
|
|
228
228
|
```
|
|
229
229
|
|
|
230
|
-
3.
|
|
230
|
+
3. Restart OpenCode so it fetches the plugin automatically from npm.
|
|
231
231
|
|
|
232
|
-
###
|
|
232
|
+
### Verification
|
|
233
233
|
|
|
234
234
|
```bash
|
|
235
235
|
opencode auth login --provider github-copilot
|
|
@@ -239,55 +239,46 @@ opencode auth login --provider github-copilot
|
|
|
239
239
|
|
|
240
240
|
---
|
|
241
241
|
|
|
242
|
-
##
|
|
242
|
+
## Usage
|
|
243
243
|
|
|
244
|
-
|
|
244
|
+
Run inside the GitHub Copilot auth flow:
|
|
245
245
|
|
|
246
246
|
```bash
|
|
247
247
|
opencode auth login --provider github-copilot
|
|
248
248
|
```
|
|
249
249
|
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
- **添加账号**
|
|
253
|
-
- **从 auth.json 导入**
|
|
254
|
-
- **检查配额**
|
|
255
|
-
- **Guided Loop Safety 开关** — 通过提示词引导模型在可用时必须使用 `question` 做用户可见汇报、继续完成非阻塞工作、减少反复中断,并避免不必要的子代理调用
|
|
256
|
-
- **Copilot Network Retry 开关** — 默认关闭;仅影响 Copilot 请求的 `fetch` 路径,只处理可重试的网络/证书类失败
|
|
257
|
-
- **切换账号**
|
|
258
|
-
- **删除账号**
|
|
259
|
-
- **全部删除**
|
|
250
|
+
You will see an interactive menu. Use the built-in language switch action if you want to swap between Chinese and English labels.
|
|
260
251
|
|
|
261
|
-
|
|
252
|
+
Guided Loop Safety is enabled by default. In practice, this can keep one request productive for hours: when `question` is available and permitted, user-facing reports must go through it, so waiting for your reply does not keep burning extra quota the way repeated direct-status interruptions do. Fewer interruptions also means less avoidable quota burn. If safe non-blocked work remains, Copilot should keep going instead of pausing early; only when no safe action remains should it use `question` to ask for the next task or clarification, while also reducing unnecessary subagent calls.
|
|
262
253
|
|
|
263
|
-
|
|
254
|
+
If you switch Copilot accounts and then hit transient TLS/network failures or `input[*].id too long` errors caused by stale session item IDs, enable Copilot Network Retry from the same menu. It is off by default. When enabled, the plugin keeps the official Copilot header/baseURL behavior from the upstream loader, only wraps the final Copilot `fetch` path, and converts retryable network-like failures into a shape that OpenCode already treats as retryable. It also repairs the matched session part after an `input[*].id too long` 400 so later retries can recover instead of repeatedly failing on stale item IDs.
|
|
264
255
|
|
|
265
256
|
## Copilot Network Retry
|
|
266
257
|
|
|
267
|
-
-
|
|
268
|
-
-
|
|
269
|
-
-
|
|
270
|
-
-
|
|
271
|
-
-
|
|
258
|
+
- Default: **disabled**
|
|
259
|
+
- Scope: only the official Copilot request `fetch` path returned by `auth.loader`
|
|
260
|
+
- Purpose: limited handling for retryable network and certificate-style failures such as `failed to fetch`, `ECONNRESET`, `unknown certificate`, or `self signed certificate`
|
|
261
|
+
- Strategy: preserve official loader behavior, then normalize retryable failures so OpenCode's native retry pipeline can decide whether and when to retry
|
|
262
|
+
- Risk: because the plugin still wraps the official fetch path, upstream internal behavior may change over time and drift is possible
|
|
272
263
|
|
|
273
|
-
## Upstream
|
|
264
|
+
## Upstream Sync
|
|
274
265
|
|
|
275
|
-
|
|
266
|
+
The repository includes a committed upstream snapshot at `src/upstream/copilot-plugin.snapshot.ts` plus a sync/check script at `scripts/sync-copilot-upstream.mjs`.
|
|
276
267
|
|
|
277
|
-
|
|
268
|
+
Useful commands:
|
|
278
269
|
|
|
279
270
|
```bash
|
|
280
271
|
npm run sync:copilot-snapshot -- --source <file-or-url> --upstream-commit <sha> --sync-date <YYYY-MM-DD>
|
|
281
272
|
npm run check:copilot-sync -- --source <file-or-url> --upstream-commit <sha> --sync-date <YYYY-MM-DD>
|
|
282
273
|
```
|
|
283
274
|
|
|
284
|
-
|
|
275
|
+
The script generates or checks the committed snapshot, requires upstream metadata for repository snapshot updates, and helps catch drift from the official `opencode` `copilot.ts` implementation.
|
|
285
276
|
|
|
286
277
|
---
|
|
287
278
|
|
|
288
|
-
##
|
|
279
|
+
## Storage
|
|
289
280
|
|
|
290
|
-
|
|
281
|
+
Accounts are stored in:
|
|
291
282
|
|
|
292
283
|
```
|
|
293
284
|
~/.config/opencode/copilot-accounts.json
|
|
@@ -295,16 +286,16 @@ npm run check:copilot-sync -- --source <file-or-url> --upstream-commit <sha> --s
|
|
|
295
286
|
|
|
296
287
|
---
|
|
297
288
|
|
|
298
|
-
##
|
|
289
|
+
## FAQ
|
|
299
290
|
|
|
300
|
-
|
|
301
|
-
|
|
291
|
+
**Do I need to change model configurations?**
|
|
292
|
+
No. This plugin only manages accounts and works with the official `github-copilot` provider.
|
|
302
293
|
|
|
303
|
-
|
|
304
|
-
|
|
294
|
+
**Does it replace the official provider?**
|
|
295
|
+
No. It uses the official provider and only adds account switching + quota checks.
|
|
305
296
|
|
|
306
|
-
**Copilot Network Retry
|
|
307
|
-
|
|
297
|
+
**Does Copilot Network Retry replace OpenCode's retry logic?**
|
|
298
|
+
No. The plugin keeps retry policy inside OpenCode by normalizing retryable Copilot network/TLS failures into a shape that OpenCode already recognizes as retryable.
|
|
308
299
|
|
|
309
300
|
---
|
|
310
301
|
|
|
@@ -1,6 +1,41 @@
|
|
|
1
|
-
type FetchLike = (request: Request | URL | string, init?: RequestInit) => Promise<Response>;
|
|
2
|
-
|
|
3
|
-
export
|
|
1
|
+
export type FetchLike = (request: Request | URL | string, init?: RequestInit) => Promise<Response>;
|
|
2
|
+
type JsonRecord = Record<string, unknown>;
|
|
3
|
+
export type CopilotRetryContext = {
|
|
4
|
+
client?: {
|
|
5
|
+
session?: {
|
|
6
|
+
messages?: (input: {
|
|
7
|
+
path: {
|
|
8
|
+
id: string;
|
|
9
|
+
};
|
|
10
|
+
}) => Promise<{
|
|
11
|
+
data?: Array<{
|
|
12
|
+
info?: {
|
|
13
|
+
id?: string;
|
|
14
|
+
role?: string;
|
|
15
|
+
};
|
|
16
|
+
parts?: Array<JsonRecord>;
|
|
17
|
+
}>;
|
|
18
|
+
}>;
|
|
19
|
+
message?: (input: {
|
|
20
|
+
path: {
|
|
21
|
+
id: string;
|
|
22
|
+
messageID: string;
|
|
23
|
+
};
|
|
24
|
+
}) => Promise<{
|
|
25
|
+
data?: {
|
|
26
|
+
parts?: Array<JsonRecord>;
|
|
27
|
+
};
|
|
28
|
+
}>;
|
|
29
|
+
};
|
|
30
|
+
};
|
|
31
|
+
directory?: string;
|
|
32
|
+
serverUrl?: URL;
|
|
4
33
|
wait?: (ms: number) => Promise<void>;
|
|
5
|
-
|
|
34
|
+
patchPart?: (request: {
|
|
35
|
+
url: string;
|
|
36
|
+
init: RequestInit;
|
|
37
|
+
}) => Promise<unknown>;
|
|
38
|
+
};
|
|
39
|
+
export declare function isRetryableCopilotFetchError(error: unknown): boolean;
|
|
40
|
+
export declare function createCopilotRetryingFetch(baseFetch: FetchLike, options?: CopilotRetryContext): (request: Request | URL | string, init?: RequestInit) => Promise<Response>;
|
|
6
41
|
export {};
|
|
@@ -13,6 +13,8 @@ const RETRYABLE_MESSAGES = [
|
|
|
13
13
|
"unable to verify the first certificate",
|
|
14
14
|
"self-signed certificate in certificate chain",
|
|
15
15
|
];
|
|
16
|
+
const MAX_INPUT_ID_REPAIR_ATTEMPTS = 3;
|
|
17
|
+
const INTERNAL_SESSION_HEADER = "x-opencode-session-id";
|
|
16
18
|
const defaultDebugLogFile = (() => {
|
|
17
19
|
const tmp = process.env.TEMP || process.env.TMP || "/tmp";
|
|
18
20
|
return `${tmp}/opencode-copilot-retry-debug.log`;
|
|
@@ -42,15 +44,44 @@ function isAbortError(error) {
|
|
|
42
44
|
function getErrorMessage(error) {
|
|
43
45
|
return String(error instanceof Error ? error.message : error).toLowerCase();
|
|
44
46
|
}
|
|
45
|
-
function isJsonContentType(headers) {
|
|
46
|
-
return headers.get("content-type")?.toLowerCase().includes("application/json") === true;
|
|
47
|
-
}
|
|
48
47
|
function isInputIdTooLongErrorBody(payload) {
|
|
49
48
|
if (!payload || typeof payload !== "object")
|
|
50
49
|
return false;
|
|
51
50
|
const error = payload.error;
|
|
52
51
|
const message = String(error?.message ?? "").toLowerCase();
|
|
53
|
-
return message.includes("
|
|
52
|
+
return message.includes("string too long") && (message.includes("input id") || message.includes(".id'"));
|
|
53
|
+
}
|
|
54
|
+
function isInputIdTooLongMessage(text) {
|
|
55
|
+
const message = text.toLowerCase();
|
|
56
|
+
return message.includes("string too long") && (message.includes("input id") || message.includes(".id'"));
|
|
57
|
+
}
|
|
58
|
+
function parseInputIdTooLongDetails(text) {
|
|
59
|
+
const matched = isInputIdTooLongMessage(text);
|
|
60
|
+
if (!matched)
|
|
61
|
+
return { matched };
|
|
62
|
+
const index = text.match(/input\[(\d+)\]\.id/i);
|
|
63
|
+
const length = text.match(/got a string with length\s+(\d+)/i) ?? text.match(/length\s+(\d+)/i);
|
|
64
|
+
return {
|
|
65
|
+
matched,
|
|
66
|
+
serverReportedIndex: index ? Number(index[1]) : undefined,
|
|
67
|
+
reportedLength: length ? Number(length[1]) : undefined,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
function buildIdPreview(id) {
|
|
71
|
+
return `${id.slice(0, 12)}...`;
|
|
72
|
+
}
|
|
73
|
+
function getPayloadCandidates(payload) {
|
|
74
|
+
const input = payload.input;
|
|
75
|
+
if (!Array.isArray(input))
|
|
76
|
+
return [];
|
|
77
|
+
return input.flatMap((item, payloadIndex) => {
|
|
78
|
+
const id = item?.id;
|
|
79
|
+
if (typeof id !== "string" || id.length <= 64)
|
|
80
|
+
return [];
|
|
81
|
+
const content = item?.content;
|
|
82
|
+
const itemKind = Array.isArray(content) && typeof content[0]?.type === "string" ? String(content[0].type) : "unknown";
|
|
83
|
+
return [{ payloadIndex, idLength: id.length, itemKind, idPreview: buildIdPreview(id) }];
|
|
84
|
+
});
|
|
54
85
|
}
|
|
55
86
|
function hasLongInputIds(payload) {
|
|
56
87
|
const input = payload.input;
|
|
@@ -58,22 +89,43 @@ function hasLongInputIds(payload) {
|
|
|
58
89
|
return false;
|
|
59
90
|
return input.some((item) => typeof item?.id === "string" && (item.id?.length ?? 0) > 64);
|
|
60
91
|
}
|
|
61
|
-
function
|
|
92
|
+
function getTargetedLongInputId(payload, reportedLength) {
|
|
93
|
+
const input = payload.input;
|
|
94
|
+
if (!Array.isArray(input))
|
|
95
|
+
return undefined;
|
|
96
|
+
const matches = input.filter((item) => typeof item?.id === "string" && (item.id?.length ?? 0) > 64);
|
|
97
|
+
if (matches.length === 0)
|
|
98
|
+
return undefined;
|
|
99
|
+
const lengthMatches = reportedLength
|
|
100
|
+
? matches.filter((item) => String(item.id ?? "").length === reportedLength)
|
|
101
|
+
: matches;
|
|
102
|
+
if (lengthMatches.length === 1)
|
|
103
|
+
return lengthMatches[0];
|
|
104
|
+
if (lengthMatches.length > 1)
|
|
105
|
+
return lengthMatches[0];
|
|
106
|
+
if (matches.length === 1)
|
|
107
|
+
return matches[0];
|
|
108
|
+
return matches.reduce((best, item) => {
|
|
109
|
+
const bestLength = String(best.id ?? "").length;
|
|
110
|
+
const itemLength = String(item.id ?? "").length;
|
|
111
|
+
return itemLength > bestLength ? item : best;
|
|
112
|
+
}, matches[0]);
|
|
113
|
+
}
|
|
114
|
+
function stripTargetedLongInputId(payload, reportedLength) {
|
|
62
115
|
const input = payload.input;
|
|
63
116
|
if (!Array.isArray(input))
|
|
64
117
|
return payload;
|
|
118
|
+
const target = getTargetedLongInputId(payload, reportedLength);
|
|
119
|
+
if (!target)
|
|
120
|
+
return payload;
|
|
65
121
|
let changed = false;
|
|
66
122
|
const nextInput = input.map((item) => {
|
|
67
|
-
if (
|
|
123
|
+
if (item !== target)
|
|
68
124
|
return item;
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
delete clone.id;
|
|
74
|
-
return clone;
|
|
75
|
-
}
|
|
76
|
-
return item;
|
|
125
|
+
changed = true;
|
|
126
|
+
const clone = { ...item };
|
|
127
|
+
delete clone.id;
|
|
128
|
+
return clone;
|
|
77
129
|
});
|
|
78
130
|
if (!changed)
|
|
79
131
|
return payload;
|
|
@@ -97,6 +149,7 @@ function parseJsonBody(init) {
|
|
|
97
149
|
}
|
|
98
150
|
function buildRetryInit(init, payload) {
|
|
99
151
|
const headers = new Headers(init?.headers);
|
|
152
|
+
headers.delete(INTERNAL_SESSION_HEADER);
|
|
100
153
|
if (!headers.has("content-type")) {
|
|
101
154
|
headers.set("content-type", "application/json");
|
|
102
155
|
}
|
|
@@ -106,24 +159,196 @@ function buildRetryInit(init, payload) {
|
|
|
106
159
|
body: JSON.stringify(payload),
|
|
107
160
|
};
|
|
108
161
|
}
|
|
109
|
-
|
|
162
|
+
function stripInternalSessionHeaderFromRequest(request) {
|
|
163
|
+
if (!(request instanceof Request))
|
|
164
|
+
return request;
|
|
165
|
+
if (!request.headers.has(INTERNAL_SESSION_HEADER))
|
|
166
|
+
return request;
|
|
167
|
+
const headers = new Headers(request.headers);
|
|
168
|
+
headers.delete(INTERNAL_SESSION_HEADER);
|
|
169
|
+
return new Request(request, { headers });
|
|
170
|
+
}
|
|
171
|
+
function getHeader(request, init, name) {
|
|
172
|
+
const initHeaders = new Headers(init?.headers);
|
|
173
|
+
const initValue = initHeaders.get(name);
|
|
174
|
+
if (initValue)
|
|
175
|
+
return initValue;
|
|
176
|
+
if (request instanceof Request)
|
|
177
|
+
return request.headers.get(name) ?? undefined;
|
|
178
|
+
return undefined;
|
|
179
|
+
}
|
|
180
|
+
function getTargetedInputId(payload, reportedLength) {
|
|
181
|
+
const target = getTargetedLongInputId(payload, reportedLength);
|
|
182
|
+
const id = target?.id;
|
|
183
|
+
if (typeof id !== "string")
|
|
184
|
+
return undefined;
|
|
185
|
+
return id;
|
|
186
|
+
}
|
|
187
|
+
function stripOpenAIItemId(part) {
|
|
188
|
+
const metadata = part.metadata;
|
|
189
|
+
if (!metadata || typeof metadata !== "object")
|
|
190
|
+
return part;
|
|
191
|
+
const openai = metadata.openai;
|
|
192
|
+
if (!openai || typeof openai !== "object")
|
|
193
|
+
return part;
|
|
194
|
+
if (!Object.hasOwn(openai, "itemId"))
|
|
195
|
+
return part;
|
|
196
|
+
const nextOpenai = { ...openai };
|
|
197
|
+
delete nextOpenai.itemId;
|
|
198
|
+
return {
|
|
199
|
+
...part,
|
|
200
|
+
metadata: {
|
|
201
|
+
...metadata,
|
|
202
|
+
openai: nextOpenai,
|
|
203
|
+
},
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
async function repairSessionPart(sessionID, failingId, ctx) {
|
|
207
|
+
const messages = await ctx?.client?.session?.messages?.({
|
|
208
|
+
path: { id: sessionID },
|
|
209
|
+
});
|
|
210
|
+
const matches = (messages?.data ?? []).flatMap((message) => {
|
|
211
|
+
if (message.info?.role !== "assistant")
|
|
212
|
+
return [];
|
|
213
|
+
return (message.parts ?? []).flatMap((part) => {
|
|
214
|
+
const itemId = part.metadata?.openai?.itemId;
|
|
215
|
+
if (itemId !== failingId || typeof message.info?.id !== "string" || typeof part.id !== "string")
|
|
216
|
+
return [];
|
|
217
|
+
return [{ messageID: message.info.id, partID: part.id, partType: String(part.type ?? "unknown") }];
|
|
218
|
+
});
|
|
219
|
+
});
|
|
220
|
+
debugLog("input-id retry session candidates", {
|
|
221
|
+
sessionID,
|
|
222
|
+
count: matches.length,
|
|
223
|
+
candidates: matches,
|
|
224
|
+
});
|
|
225
|
+
if (matches.length !== 1)
|
|
226
|
+
return false;
|
|
227
|
+
const match = matches[0];
|
|
228
|
+
debugLog("input-id retry session match", match);
|
|
229
|
+
const latest = await ctx?.client?.session?.message?.({
|
|
230
|
+
path: {
|
|
231
|
+
id: sessionID,
|
|
232
|
+
messageID: match.messageID,
|
|
233
|
+
},
|
|
234
|
+
});
|
|
235
|
+
const part = latest?.data?.parts?.find((item) => item.id === match.partID);
|
|
236
|
+
if (!part)
|
|
237
|
+
return false;
|
|
238
|
+
const body = stripOpenAIItemId(part);
|
|
239
|
+
const url = new URL(`/session/${sessionID}/message/${match.messageID}/part/${match.partID}`, ctx?.serverUrl);
|
|
240
|
+
if (ctx?.directory)
|
|
241
|
+
url.searchParams.set("directory", ctx.directory);
|
|
242
|
+
const init = {
|
|
243
|
+
method: "PATCH",
|
|
244
|
+
headers: {
|
|
245
|
+
"content-type": "application/json",
|
|
246
|
+
},
|
|
247
|
+
body: JSON.stringify(body),
|
|
248
|
+
};
|
|
249
|
+
if (ctx?.patchPart) {
|
|
250
|
+
await ctx.patchPart({ url: url.href, init });
|
|
251
|
+
debugLog("input-id retry session repair", {
|
|
252
|
+
partID: match.partID,
|
|
253
|
+
messageID: match.messageID,
|
|
254
|
+
sessionID,
|
|
255
|
+
});
|
|
256
|
+
return true;
|
|
257
|
+
}
|
|
258
|
+
const response = await fetch(url, init);
|
|
259
|
+
debugLog("input-id retry session repair", {
|
|
260
|
+
partID: match.partID,
|
|
261
|
+
messageID: match.messageID,
|
|
262
|
+
sessionID,
|
|
263
|
+
ok: response.ok,
|
|
264
|
+
});
|
|
265
|
+
return response.ok;
|
|
266
|
+
}
|
|
267
|
+
async function maybeRetryInputIdTooLong(request, init, response, baseFetch, ctx, sessionID) {
|
|
110
268
|
if (response.status !== 400)
|
|
111
|
-
return response;
|
|
269
|
+
return { response, retried: false, nextInit: init };
|
|
112
270
|
const requestPayload = parseJsonBody(init);
|
|
113
|
-
if (!requestPayload || !hasLongInputIds(requestPayload))
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
271
|
+
if (!requestPayload || !hasLongInputIds(requestPayload)) {
|
|
272
|
+
debugLog("skip input-id retry: request has no long ids");
|
|
273
|
+
return { response, retried: false, nextInit: init };
|
|
274
|
+
}
|
|
275
|
+
debugLog("input-id retry candidate", {
|
|
276
|
+
status: response.status,
|
|
277
|
+
contentType: response.headers.get("content-type") ?? undefined,
|
|
278
|
+
});
|
|
279
|
+
const responseText = await response
|
|
118
280
|
.clone()
|
|
119
|
-
.
|
|
120
|
-
.catch(() =>
|
|
121
|
-
if (!
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
281
|
+
.text()
|
|
282
|
+
.catch(() => "");
|
|
283
|
+
if (!responseText) {
|
|
284
|
+
debugLog("skip input-id retry: empty response body");
|
|
285
|
+
return { response, retried: false, nextInit: init };
|
|
286
|
+
}
|
|
287
|
+
let parsed = parseInputIdTooLongDetails(responseText);
|
|
288
|
+
let matched = parsed.matched;
|
|
289
|
+
if (!matched) {
|
|
290
|
+
try {
|
|
291
|
+
const bodyPayload = JSON.parse(responseText);
|
|
292
|
+
const error = bodyPayload.error;
|
|
293
|
+
parsed = parseInputIdTooLongDetails(String(error?.message ?? ""));
|
|
294
|
+
matched = parsed.matched || isInputIdTooLongErrorBody(bodyPayload);
|
|
295
|
+
}
|
|
296
|
+
catch {
|
|
297
|
+
matched = false;
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
debugLog("input-id retry detection", {
|
|
301
|
+
matched,
|
|
302
|
+
serverReportedIndex: parsed.serverReportedIndex,
|
|
303
|
+
reportedLength: parsed.reportedLength,
|
|
304
|
+
bodyPreview: responseText.slice(0, 200),
|
|
305
|
+
});
|
|
306
|
+
debugLog("input-id retry parsed", {
|
|
307
|
+
serverReportedIndex: parsed.serverReportedIndex,
|
|
308
|
+
reportedLength: parsed.reportedLength,
|
|
309
|
+
});
|
|
310
|
+
if (!matched)
|
|
311
|
+
return { response, retried: false, nextInit: init };
|
|
312
|
+
if (parsed.serverReportedIndex === undefined) {
|
|
313
|
+
debugLog("skip input-id retry: missing server input index", {
|
|
314
|
+
reportedLength: parsed.reportedLength,
|
|
315
|
+
});
|
|
316
|
+
return { response, retried: false, nextInit: init };
|
|
317
|
+
}
|
|
318
|
+
const payloadCandidates = getPayloadCandidates(requestPayload);
|
|
319
|
+
debugLog("input-id retry payload candidates", {
|
|
320
|
+
serverReportedIndex: parsed.serverReportedIndex,
|
|
321
|
+
candidates: payloadCandidates,
|
|
322
|
+
});
|
|
323
|
+
const failingId = getTargetedInputId(requestPayload, parsed.reportedLength);
|
|
324
|
+
const targetedPayload = payloadCandidates.find((item) => item.idLength === parsed.reportedLength) ?? payloadCandidates[0];
|
|
325
|
+
if (targetedPayload) {
|
|
326
|
+
debugLog("input-id retry payload target", {
|
|
327
|
+
targetedPayloadIndex: targetedPayload.payloadIndex,
|
|
328
|
+
itemKind: targetedPayload.itemKind,
|
|
329
|
+
idLength: targetedPayload.idLength,
|
|
330
|
+
idPreview: targetedPayload.idPreview,
|
|
331
|
+
});
|
|
332
|
+
}
|
|
333
|
+
if (sessionID && failingId) {
|
|
334
|
+
await repairSessionPart(sessionID, failingId, ctx).catch(() => false);
|
|
335
|
+
}
|
|
336
|
+
const sanitized = stripTargetedLongInputId(requestPayload, parsed.reportedLength);
|
|
337
|
+
if (sanitized === requestPayload) {
|
|
338
|
+
debugLog("skip input-id retry: sanitize made no changes");
|
|
339
|
+
return { response, retried: false, nextInit: init };
|
|
340
|
+
}
|
|
341
|
+
debugLog("input-id retry triggered", {
|
|
342
|
+
removedLongIds: true,
|
|
343
|
+
hadPreviousResponseId: typeof requestPayload.previous_response_id === "string",
|
|
344
|
+
});
|
|
345
|
+
const nextInit = buildRetryInit(init, sanitized);
|
|
346
|
+
const retried = await baseFetch(request, nextInit);
|
|
347
|
+
debugLog("input-id retry response", {
|
|
348
|
+
status: retried.status,
|
|
349
|
+
contentType: retried.headers.get("content-type") ?? undefined,
|
|
350
|
+
});
|
|
351
|
+
return { response: retried, retried: true, nextInit };
|
|
127
352
|
}
|
|
128
353
|
function toRetryableSystemError(error) {
|
|
129
354
|
const base = error instanceof Error ? error : new Error(String(error));
|
|
@@ -195,19 +420,37 @@ export function isRetryableCopilotFetchError(error) {
|
|
|
195
420
|
export function createCopilotRetryingFetch(baseFetch, options) {
|
|
196
421
|
void options;
|
|
197
422
|
return async function retryingFetch(request, init) {
|
|
423
|
+
const sessionID = getHeader(request, init, INTERNAL_SESSION_HEADER);
|
|
424
|
+
const safeRequest = stripInternalSessionHeaderFromRequest(request);
|
|
425
|
+
const initHeaders = new Headers(init?.headers);
|
|
426
|
+
initHeaders.delete(INTERNAL_SESSION_HEADER);
|
|
427
|
+
const effectiveInit = init
|
|
428
|
+
? {
|
|
429
|
+
...init,
|
|
430
|
+
headers: initHeaders,
|
|
431
|
+
}
|
|
432
|
+
: undefined;
|
|
198
433
|
debugLog("fetch start", {
|
|
199
|
-
url:
|
|
200
|
-
isCopilot: isCopilotUrl(
|
|
434
|
+
url: safeRequest instanceof Request ? safeRequest.url : safeRequest instanceof URL ? safeRequest.href : String(safeRequest),
|
|
435
|
+
isCopilot: isCopilotUrl(safeRequest),
|
|
201
436
|
});
|
|
202
437
|
try {
|
|
203
|
-
const response = await baseFetch(
|
|
438
|
+
const response = await baseFetch(safeRequest, effectiveInit);
|
|
204
439
|
debugLog("fetch resolved", {
|
|
205
440
|
status: response.status,
|
|
206
441
|
contentType: response.headers.get("content-type") ?? undefined,
|
|
207
442
|
});
|
|
208
|
-
if (isCopilotUrl(
|
|
209
|
-
|
|
210
|
-
|
|
443
|
+
if (isCopilotUrl(safeRequest)) {
|
|
444
|
+
let currentResponse = response;
|
|
445
|
+
let currentInit = effectiveInit;
|
|
446
|
+
for (let attempt = 0; attempt < MAX_INPUT_ID_REPAIR_ATTEMPTS; attempt += 1) {
|
|
447
|
+
const result = await maybeRetryInputIdTooLong(safeRequest, currentInit, currentResponse, baseFetch, options, sessionID);
|
|
448
|
+
currentResponse = result.response;
|
|
449
|
+
currentInit = result.nextInit;
|
|
450
|
+
if (!result.retried)
|
|
451
|
+
break;
|
|
452
|
+
}
|
|
453
|
+
return withStreamDebugLogs(currentResponse, safeRequest);
|
|
211
454
|
}
|
|
212
455
|
return response;
|
|
213
456
|
}
|
|
@@ -216,7 +459,7 @@ export function createCopilotRetryingFetch(baseFetch, options) {
|
|
|
216
459
|
message: getErrorMessage(error),
|
|
217
460
|
retryableByMessage: isRetryableCopilotFetchError(error),
|
|
218
461
|
});
|
|
219
|
-
if (!isCopilotUrl(
|
|
462
|
+
if (!isCopilotUrl(safeRequest) || !isRetryableCopilotFetchError(error)) {
|
|
220
463
|
throw error;
|
|
221
464
|
}
|
|
222
465
|
throw toRetryableSystemError(error);
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { Hooks } from "@opencode-ai/plugin";
|
|
2
2
|
import { type StoreFile } from "./store.js";
|
|
3
|
-
export declare const LOOP_SAFETY_POLICY = "Guided Loop Safety Policy\n- Continue working on any remaining non-blocked task before stopping to report or wait for more instructions.\n- If you are not fully blocked, do not stop just because you feel ready to pause; finish the work that can still be done safely.\n- MANDATORY: When the question tool is available and permitted in the current session, ALL user-facing reports MUST be delivered through the question tool.\n- MANDATORY: Treat progress updates, status reports, summaries, explanations, completion notes, and next-step choices as user-facing reports.\n- MANDATORY: In this workflow, the question tool is both the question channel and the report channel for user-visible communication.\n- MANDATORY: Do not reinterpret \"keep working unless blocked\" as permission to send direct assistant text. If you need to report, report through the question tool.\n- MANDATORY: Do not reinterpret \"don't ask unnecessary questions\" as permission to skip the question tool. Use fewer question batches, not a different reporting channel.\n- MANDATORY: If the user says a report was sent the wrong way, first check whether you used the wrong channel before analyzing verbosity, timing, or wording.\n- MANDATORY: When reflecting on mistakes, check report-channel violations before any secondary critique.\n- MANDATORY: Do not turn a pure report into a preference question, permission question, or route-selection question just to satisfy the question tool requirement.\n- MANDATORY: When no user decision is needed, use the question tool as a report container with the smallest sensible option set.\n- NO EXCEPTIONS: Do not switch to direct assistant text just because the report is long, detailed, complex, or contains multiple related items.\n- The question tool is considered available and permitted when it appears in the active tool list and the current session has not denied its use.\n- Direct assistant text is allowed only when the question tool is unavailable, denied, or absent from the current session.\n- If a report would otherwise be sent as direct text, convert it into one question batch or a short sequence of question batches instead.\n- IMPORTANT: A report-mode violation usually means the wrong tool was used, not that the report was too long.\n- IMPORTANT: Do not reinterpret a tool-usage violation as a verbosity problem.\n- Long reports are allowed. Use pagination or sequential question batches when needed; do not fall back to direct assistant text.\n- A single question tool call may contain multiple well-grouped questions. Prefer that over multiple small interruptions when it keeps the report clear.\n- Group related items into explicit batches such as current progress, key findings, decisions, and next-step choices.\n- Present the highest-priority information first and defer secondary details to later question batches when needed.\n- Even when no explicit decision is required, use brief question-tool status updates instead of direct assistant text whenever the tool is available.\n- Avoid unnecessary question frequency; combine small related updates when a single question call can cover them clearly.\n- When no further action can be taken safely and no non-blocked work remains, use the question tool to ask for the next task or clarification instead of ending with direct assistant text.\n- When the user says \"stop\", do not send assistant text to acknowledge the stop. Use the question tool only if more user-visible communication is still required by policy.\n- Dispatching task or subagent work is expensive and should be avoided unless it materially improves the result.\n- Materially improves the result means clearly beneficial cases such as parallel analysis of independent areas; it does not include routine local searches, small file reads, or straightforward edits.\n- If task or subagent delegation is used, keep the number minimal and explain the reason briefly through the question tool when available.";
|
|
3
|
+
export declare const LOOP_SAFETY_POLICY = "Guided Loop Safety Policy\n- Continue working on any remaining non-blocked task before stopping to report or wait for more instructions.\n- If you are not fully blocked, do not stop just because you feel ready to pause; finish the work that can still be done safely.\n- MANDATORY: When the question tool is available and permitted in the current session, ALL user-facing reports MUST be delivered through the question tool.\n- MANDATORY: Treat progress updates, status reports, summaries, explanations, completion notes, and next-step choices as user-facing reports.\n- MANDATORY: In this workflow, the question tool is both the question channel and the report channel for user-visible communication.\n- MANDATORY: Do not reinterpret \"keep working unless blocked\" as permission to send direct assistant text. If you need to report, report through the question tool.\n- MANDATORY: Do not reinterpret \"don't ask unnecessary questions\" as permission to skip the question tool. Use fewer question batches, not a different reporting channel.\n- MANDATORY: If the user says a report was sent the wrong way, first check whether you used the wrong channel before analyzing verbosity, timing, or wording.\n- MANDATORY: When reflecting on mistakes, check report-channel violations before any secondary critique.\n- MANDATORY: Do not turn a pure report into a preference question, permission question, or route-selection question just to satisfy the question tool requirement.\n- MANDATORY: When no user decision is needed, use the question tool as a report container with the smallest sensible option set.\n- NO EXCEPTIONS: Do not switch to direct assistant text just because the report is long, detailed, complex, or contains multiple related items.\n- The question tool is considered available and permitted when it appears in the active tool list and the current session has not denied its use.\n- Direct assistant text is allowed only when the question tool is unavailable, denied, or absent from the current session.\n- If a report would otherwise be sent as direct text, convert it into one question batch or a short sequence of question batches instead.\n- IMPORTANT: A report-mode violation usually means the wrong tool was used, not that the report was too long.\n- IMPORTANT: Do not reinterpret a tool-usage violation as a verbosity problem.\n- Long reports are allowed. Use pagination or sequential question batches when needed; do not fall back to direct assistant text.\n- A single question tool call may contain multiple well-grouped questions. Prefer that over multiple small interruptions when it keeps the report clear.\n- Group related items into explicit batches such as current progress, key findings, decisions, and next-step choices.\n- Present the highest-priority information first and defer secondary details to later question batches when needed.\n- Even when no explicit decision is required, use brief question-tool status updates instead of direct assistant text whenever the tool is available.\n- Avoid unnecessary question frequency; combine small related updates when a single question call can cover them clearly.\n- MANDATORY: After any successful question-tool report, immediately choose between (a) continue unfinished non-blocked work, or (b) issue a question-tool wait-for-instruction message when no such work remains.\n- MANDATORY: Never send assistant text as a post-report fallback in either branch.\n- MANDATORY: If a post-report branch has no content, suppress assistant output and re-enter question-tool flow.\n- When no further action can be taken safely and no non-blocked work remains, use the question tool to ask for the next task or clarification instead of ending with direct assistant text.\n- When the user says \"stop\", do not send assistant text to acknowledge the stop. Use the question tool only if more user-visible communication is still required by policy.\n- Dispatching task or subagent work is expensive and should be avoided unless it materially improves the result.\n- Materially improves the result means clearly beneficial cases such as parallel analysis of independent areas; it does not include routine local searches, small file reads, or straightforward edits.\n- If task or subagent delegation is used, keep the number minimal and explain the reason briefly through the question tool when available.";
|
|
4
4
|
export type ExperimentalChatSystemTransformHook = (input: {
|
|
5
5
|
sessionID: string;
|
|
6
6
|
model: {
|
|
@@ -23,6 +23,9 @@ export const LOOP_SAFETY_POLICY = `Guided Loop Safety Policy
|
|
|
23
23
|
- Present the highest-priority information first and defer secondary details to later question batches when needed.
|
|
24
24
|
- Even when no explicit decision is required, use brief question-tool status updates instead of direct assistant text whenever the tool is available.
|
|
25
25
|
- Avoid unnecessary question frequency; combine small related updates when a single question call can cover them clearly.
|
|
26
|
+
- MANDATORY: After any successful question-tool report, immediately choose between (a) continue unfinished non-blocked work, or (b) issue a question-tool wait-for-instruction message when no such work remains.
|
|
27
|
+
- MANDATORY: Never send assistant text as a post-report fallback in either branch.
|
|
28
|
+
- MANDATORY: If a post-report branch has no content, suppress assistant output and re-enter question-tool flow.
|
|
26
29
|
- When no further action can be taken safely and no non-blocked work remains, use the question tool to ask for the next task or clarification instead of ending with direct assistant text.
|
|
27
30
|
- When the user says "stop", do not send assistant text to acknowledge the stop. Use the question tool only if more user-visible communication is still required by policy.
|
|
28
31
|
- Dispatching task or subagent work is expensive and should be avoided unless it materially improves the result.
|
package/dist/plugin-hooks.d.ts
CHANGED
|
@@ -1,7 +1,27 @@
|
|
|
1
1
|
import { type CopilotPluginHooks } from "./loop-safety-plugin.js";
|
|
2
|
-
import {
|
|
2
|
+
import { type CopilotRetryContext, type FetchLike } from "./copilot-network-retry.js";
|
|
3
3
|
import { type StoreFile } from "./store.js";
|
|
4
4
|
import { type CopilotAuthState, type CopilotProviderConfig, type OfficialCopilotConfig } from "./upstream/copilot-loader-adapter.js";
|
|
5
|
+
type ChatHeadersHook = (input: {
|
|
6
|
+
sessionID: string;
|
|
7
|
+
agent: string;
|
|
8
|
+
model: {
|
|
9
|
+
providerID: string;
|
|
10
|
+
};
|
|
11
|
+
provider: {
|
|
12
|
+
source: string;
|
|
13
|
+
info: object;
|
|
14
|
+
options: object;
|
|
15
|
+
};
|
|
16
|
+
message: {
|
|
17
|
+
id: string;
|
|
18
|
+
};
|
|
19
|
+
}, output: {
|
|
20
|
+
headers: Record<string, string>;
|
|
21
|
+
}) => Promise<void>;
|
|
22
|
+
type CopilotPluginHooksWithChatHeaders = CopilotPluginHooks & {
|
|
23
|
+
"chat.headers"?: ChatHeadersHook;
|
|
24
|
+
};
|
|
5
25
|
export declare function buildPluginHooks(input: {
|
|
6
26
|
auth: NonNullable<CopilotPluginHooks["auth"]>;
|
|
7
27
|
loadStore?: () => Promise<StoreFile | undefined>;
|
|
@@ -9,5 +29,9 @@ export declare function buildPluginHooks(input: {
|
|
|
9
29
|
getAuth: () => Promise<CopilotAuthState | undefined>;
|
|
10
30
|
provider?: CopilotProviderConfig;
|
|
11
31
|
}) => Promise<OfficialCopilotConfig | undefined>;
|
|
12
|
-
createRetryFetch?:
|
|
13
|
-
|
|
32
|
+
createRetryFetch?: (fetch: FetchLike, ctx?: CopilotRetryContext) => FetchLike;
|
|
33
|
+
client?: CopilotRetryContext["client"];
|
|
34
|
+
directory?: CopilotRetryContext["directory"];
|
|
35
|
+
serverUrl?: CopilotRetryContext["serverUrl"];
|
|
36
|
+
}): CopilotPluginHooksWithChatHeaders;
|
|
37
|
+
export {};
|
package/dist/plugin-hooks.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { createLoopSafetySystemTransform } from "./loop-safety-plugin.js";
|
|
2
|
-
import { createCopilotRetryingFetch } from "./copilot-network-retry.js";
|
|
1
|
+
import { createLoopSafetySystemTransform, isCopilotProvider, } from "./loop-safety-plugin.js";
|
|
2
|
+
import { createCopilotRetryingFetch, } from "./copilot-network-retry.js";
|
|
3
3
|
import { readStoreSafe } from "./store.js";
|
|
4
4
|
import { loadOfficialCopilotConfig, } from "./upstream/copilot-loader-adapter.js";
|
|
5
5
|
export function buildPluginHooks(input) {
|
|
@@ -19,15 +19,25 @@ export function buildPluginHooks(input) {
|
|
|
19
19
|
}
|
|
20
20
|
return {
|
|
21
21
|
...config,
|
|
22
|
-
fetch: createRetryFetch(config.fetch
|
|
22
|
+
fetch: createRetryFetch(config.fetch, {
|
|
23
|
+
client: input.client,
|
|
24
|
+
directory: input.directory,
|
|
25
|
+
serverUrl: input.serverUrl,
|
|
26
|
+
}),
|
|
23
27
|
};
|
|
24
28
|
};
|
|
29
|
+
const chatHeaders = async (hookInput, output) => {
|
|
30
|
+
if (!isCopilotProvider(hookInput.model.providerID))
|
|
31
|
+
return;
|
|
32
|
+
output.headers["x-opencode-session-id"] = hookInput.sessionID;
|
|
33
|
+
};
|
|
25
34
|
return {
|
|
26
35
|
auth: {
|
|
27
36
|
...input.auth,
|
|
28
37
|
provider: input.auth.provider ?? "github-copilot",
|
|
29
38
|
loader,
|
|
30
39
|
},
|
|
40
|
+
"chat.headers": chatHeaders,
|
|
31
41
|
"experimental.chat.system.transform": createLoopSafetySystemTransform(loadStore),
|
|
32
42
|
};
|
|
33
43
|
}
|
package/dist/plugin.js
CHANGED
|
@@ -443,7 +443,10 @@ async function switchAccount(client, entry) {
|
|
|
443
443
|
body: payload,
|
|
444
444
|
});
|
|
445
445
|
}
|
|
446
|
-
export const CopilotAccountSwitcher = async (
|
|
446
|
+
export const CopilotAccountSwitcher = async (input) => {
|
|
447
|
+
const client = input.client;
|
|
448
|
+
const directory = input.directory;
|
|
449
|
+
const serverUrl = input.serverUrl;
|
|
447
450
|
const methods = [
|
|
448
451
|
{
|
|
449
452
|
type: "oauth",
|
|
@@ -700,6 +703,7 @@ export const CopilotAccountSwitcher = async ({ client }) => {
|
|
|
700
703
|
store.active = name;
|
|
701
704
|
store.accounts[name].lastUsed = now();
|
|
702
705
|
await writeStore(store);
|
|
706
|
+
console.log("Switched account. If a later Copilot session hits input[*].id too long after switching, enable Copilot Network Retry from the menu.");
|
|
703
707
|
continue;
|
|
704
708
|
}
|
|
705
709
|
}
|
|
@@ -709,5 +713,8 @@ export const CopilotAccountSwitcher = async ({ client }) => {
|
|
|
709
713
|
provider: "github-copilot",
|
|
710
714
|
methods,
|
|
711
715
|
},
|
|
716
|
+
client,
|
|
717
|
+
directory,
|
|
718
|
+
serverUrl,
|
|
712
719
|
});
|
|
713
720
|
};
|
package/dist/store.js
CHANGED
|
@@ -16,8 +16,8 @@ export function parseStore(raw) {
|
|
|
16
16
|
const data = raw ? JSON.parse(raw) : { accounts: {} };
|
|
17
17
|
if (!data.accounts)
|
|
18
18
|
data.accounts = {};
|
|
19
|
-
if (data.loopSafetyEnabled !==
|
|
20
|
-
data.loopSafetyEnabled =
|
|
19
|
+
if (data.loopSafetyEnabled !== false)
|
|
20
|
+
data.loopSafetyEnabled = true;
|
|
21
21
|
if (data.networkRetryEnabled !== true)
|
|
22
22
|
data.networkRetryEnabled = false;
|
|
23
23
|
for (const [name, entry] of Object.entries(data.accounts)) {
|
package/dist/ui/menu.d.ts
CHANGED
|
@@ -53,6 +53,8 @@ export type MenuAction = {
|
|
|
53
53
|
type: "toggle-refresh";
|
|
54
54
|
} | {
|
|
55
55
|
type: "set-interval";
|
|
56
|
+
} | {
|
|
57
|
+
type: "toggle-language";
|
|
56
58
|
} | {
|
|
57
59
|
type: "toggle-loop-safety";
|
|
58
60
|
} | {
|
|
@@ -68,6 +70,31 @@ export type MenuAction = {
|
|
|
68
70
|
} | {
|
|
69
71
|
type: "cancel";
|
|
70
72
|
};
|
|
73
|
+
export type MenuLanguage = "zh" | "en";
|
|
74
|
+
export declare function getMenuCopy(language?: MenuLanguage): {
|
|
75
|
+
menuTitle: string;
|
|
76
|
+
menuSubtitle: string;
|
|
77
|
+
switchLanguageLabel: string;
|
|
78
|
+
actionsHeading: string;
|
|
79
|
+
addAccount: string;
|
|
80
|
+
addAccountHint: string;
|
|
81
|
+
importAuth: string;
|
|
82
|
+
checkQuotas: string;
|
|
83
|
+
refreshIdentity: string;
|
|
84
|
+
checkModels: string;
|
|
85
|
+
enableRefresh: string;
|
|
86
|
+
disableRefresh: string;
|
|
87
|
+
setRefresh: string;
|
|
88
|
+
enableLoopSafety: string;
|
|
89
|
+
disableLoopSafety: string;
|
|
90
|
+
loopSafetyHint: string;
|
|
91
|
+
enableRetry: string;
|
|
92
|
+
disableRetry: string;
|
|
93
|
+
retryHint: string;
|
|
94
|
+
accountsHeading: string;
|
|
95
|
+
dangerHeading: string;
|
|
96
|
+
removeAll: string;
|
|
97
|
+
};
|
|
71
98
|
export declare function buildMenuItems(input: {
|
|
72
99
|
accounts: AccountInfo[];
|
|
73
100
|
refresh?: {
|
|
@@ -77,9 +104,10 @@ export declare function buildMenuItems(input: {
|
|
|
77
104
|
lastQuotaRefresh?: number;
|
|
78
105
|
loopSafetyEnabled: boolean;
|
|
79
106
|
networkRetryEnabled: boolean;
|
|
107
|
+
language?: MenuLanguage;
|
|
80
108
|
}): MenuItem<MenuAction>[];
|
|
81
109
|
export declare function showMenu(accounts: AccountInfo[], refresh?: {
|
|
82
110
|
enabled: boolean;
|
|
83
111
|
minutes: number;
|
|
84
|
-
}, lastQuotaRefresh?: number, loopSafetyEnabled?: boolean, networkRetryEnabled?: boolean): Promise<MenuAction>;
|
|
112
|
+
}, lastQuotaRefresh?: number, loopSafetyEnabled?: boolean, networkRetryEnabled?: boolean, language?: MenuLanguage): Promise<MenuAction>;
|
|
85
113
|
export declare function showAccountActions(account: AccountInfo): Promise<"switch" | "remove" | "back">;
|
package/dist/ui/menu.js
CHANGED
|
@@ -1,6 +1,58 @@
|
|
|
1
1
|
import { ANSI } from "./ansi.js";
|
|
2
2
|
import { select } from "./select.js";
|
|
3
3
|
import { confirm } from "./confirm.js";
|
|
4
|
+
export function getMenuCopy(language = "zh") {
|
|
5
|
+
if (language === "en") {
|
|
6
|
+
return {
|
|
7
|
+
menuTitle: "GitHub Copilot accounts",
|
|
8
|
+
menuSubtitle: "Select an action or account",
|
|
9
|
+
switchLanguageLabel: "切换到中文",
|
|
10
|
+
actionsHeading: "Actions",
|
|
11
|
+
addAccount: "Add account",
|
|
12
|
+
addAccountHint: "device login or manual",
|
|
13
|
+
importAuth: "Import from auth.json",
|
|
14
|
+
checkQuotas: "Check quotas",
|
|
15
|
+
refreshIdentity: "Refresh identity",
|
|
16
|
+
checkModels: "Check models",
|
|
17
|
+
enableRefresh: "Enable auto refresh",
|
|
18
|
+
disableRefresh: "Disable auto refresh",
|
|
19
|
+
setRefresh: "Set refresh interval",
|
|
20
|
+
enableLoopSafety: "Enable guided loop safety",
|
|
21
|
+
disableLoopSafety: "Disable guided loop safety",
|
|
22
|
+
loopSafetyHint: "Prompt-guided: fewer report interruptions, fewer unnecessary subagents",
|
|
23
|
+
enableRetry: "Enable Copilot network retry",
|
|
24
|
+
disableRetry: "Disable Copilot network retry",
|
|
25
|
+
retryHint: "Overrides official fetch path; may drift from upstream",
|
|
26
|
+
accountsHeading: "Accounts",
|
|
27
|
+
dangerHeading: "Danger zone",
|
|
28
|
+
removeAll: "Remove all accounts",
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
return {
|
|
32
|
+
menuTitle: "GitHub Copilot 账号",
|
|
33
|
+
menuSubtitle: "请选择操作或账号",
|
|
34
|
+
switchLanguageLabel: "Switch to English",
|
|
35
|
+
actionsHeading: "操作",
|
|
36
|
+
addAccount: "添加账号",
|
|
37
|
+
addAccountHint: "设备登录或手动录入",
|
|
38
|
+
importAuth: "从 auth.json 导入",
|
|
39
|
+
checkQuotas: "检查配额",
|
|
40
|
+
refreshIdentity: "刷新身份信息",
|
|
41
|
+
checkModels: "检查模型",
|
|
42
|
+
enableRefresh: "开启自动刷新",
|
|
43
|
+
disableRefresh: "关闭自动刷新",
|
|
44
|
+
setRefresh: "设置刷新间隔",
|
|
45
|
+
enableLoopSafety: "开启 Guided Loop Safety",
|
|
46
|
+
disableLoopSafety: "关闭 Guided Loop Safety",
|
|
47
|
+
loopSafetyHint: "提示词引导:减少汇报打断与不必要子代理",
|
|
48
|
+
enableRetry: "开启 Copilot Network Retry",
|
|
49
|
+
disableRetry: "关闭 Copilot Network Retry",
|
|
50
|
+
retryHint: "包装官方 fetch;可能随 upstream 产生漂移",
|
|
51
|
+
accountsHeading: "账号",
|
|
52
|
+
dangerHeading: "危险操作",
|
|
53
|
+
removeAll: "删除全部账号",
|
|
54
|
+
};
|
|
55
|
+
}
|
|
4
56
|
function formatRelativeTime(timestamp) {
|
|
5
57
|
if (!timestamp)
|
|
6
58
|
return "never";
|
|
@@ -26,35 +78,37 @@ function getStatusBadge(status) {
|
|
|
26
78
|
return "";
|
|
27
79
|
}
|
|
28
80
|
export function buildMenuItems(input) {
|
|
81
|
+
const copy = getMenuCopy(input.language);
|
|
29
82
|
const quotaHint = input.lastQuotaRefresh ? `last ${formatRelativeTime(input.lastQuotaRefresh)}` : undefined;
|
|
30
83
|
return [
|
|
31
|
-
{ label:
|
|
32
|
-
{ label:
|
|
33
|
-
{ label:
|
|
34
|
-
{ label:
|
|
35
|
-
{ label:
|
|
36
|
-
{ label:
|
|
84
|
+
{ label: copy.actionsHeading, value: { type: "cancel" }, kind: "heading" },
|
|
85
|
+
{ label: copy.switchLanguageLabel, value: { type: "toggle-language" }, color: "cyan" },
|
|
86
|
+
{ label: copy.addAccount, value: { type: "add" }, color: "cyan", hint: copy.addAccountHint },
|
|
87
|
+
{ label: copy.importAuth, value: { type: "import" }, color: "cyan" },
|
|
88
|
+
{ label: copy.checkQuotas, value: { type: "quota" }, color: "cyan", hint: quotaHint },
|
|
89
|
+
{ label: copy.refreshIdentity, value: { type: "refresh-identity" }, color: "cyan" },
|
|
90
|
+
{ label: copy.checkModels, value: { type: "check-models" }, color: "cyan" },
|
|
37
91
|
{
|
|
38
|
-
label: input.refresh?.enabled ?
|
|
92
|
+
label: input.refresh?.enabled ? copy.disableRefresh : copy.enableRefresh,
|
|
39
93
|
value: { type: "toggle-refresh" },
|
|
40
94
|
color: "cyan",
|
|
41
95
|
hint: input.refresh ? `${input.refresh.minutes}m` : undefined,
|
|
42
96
|
},
|
|
43
|
-
{ label:
|
|
97
|
+
{ label: copy.setRefresh, value: { type: "set-interval" }, color: "cyan" },
|
|
44
98
|
{
|
|
45
|
-
label: input.loopSafetyEnabled ?
|
|
99
|
+
label: input.loopSafetyEnabled ? copy.disableLoopSafety : copy.enableLoopSafety,
|
|
46
100
|
value: { type: "toggle-loop-safety" },
|
|
47
101
|
color: "cyan",
|
|
48
|
-
hint:
|
|
102
|
+
hint: copy.loopSafetyHint,
|
|
49
103
|
},
|
|
50
104
|
{
|
|
51
|
-
label: input.networkRetryEnabled ?
|
|
105
|
+
label: input.networkRetryEnabled ? copy.disableRetry : copy.enableRetry,
|
|
52
106
|
value: { type: "toggle-network-retry" },
|
|
53
107
|
color: "cyan",
|
|
54
|
-
hint:
|
|
108
|
+
hint: copy.retryHint,
|
|
55
109
|
},
|
|
56
110
|
{ label: "", value: { type: "cancel" }, separator: true },
|
|
57
|
-
{ label:
|
|
111
|
+
{ label: copy.accountsHeading, value: { type: "cancel" }, kind: "heading" },
|
|
58
112
|
...input.accounts.map((account) => {
|
|
59
113
|
const statusBadge = getStatusBadge(account.status);
|
|
60
114
|
const currentBadge = account.isCurrent ? ` ${ANSI.cyan}*${ANSI.reset}` : "";
|
|
@@ -78,26 +132,33 @@ export function buildMenuItems(input) {
|
|
|
78
132
|
};
|
|
79
133
|
}),
|
|
80
134
|
{ label: "", value: { type: "cancel" }, separator: true },
|
|
81
|
-
{ label:
|
|
82
|
-
{ label:
|
|
135
|
+
{ label: copy.dangerHeading, value: { type: "cancel" }, kind: "heading" },
|
|
136
|
+
{ label: copy.removeAll, value: { type: "remove-all" }, color: "red" },
|
|
83
137
|
];
|
|
84
138
|
}
|
|
85
|
-
export async function showMenu(accounts, refresh, lastQuotaRefresh, loopSafetyEnabled = false, networkRetryEnabled = false) {
|
|
86
|
-
|
|
87
|
-
accounts,
|
|
88
|
-
refresh,
|
|
89
|
-
lastQuotaRefresh,
|
|
90
|
-
loopSafetyEnabled,
|
|
91
|
-
networkRetryEnabled,
|
|
92
|
-
});
|
|
139
|
+
export async function showMenu(accounts, refresh, lastQuotaRefresh, loopSafetyEnabled = false, networkRetryEnabled = false, language = "zh") {
|
|
140
|
+
let currentLanguage = language;
|
|
93
141
|
while (true) {
|
|
142
|
+
const copy = getMenuCopy(currentLanguage);
|
|
143
|
+
const items = buildMenuItems({
|
|
144
|
+
accounts,
|
|
145
|
+
refresh,
|
|
146
|
+
lastQuotaRefresh,
|
|
147
|
+
loopSafetyEnabled,
|
|
148
|
+
networkRetryEnabled,
|
|
149
|
+
language: currentLanguage,
|
|
150
|
+
});
|
|
94
151
|
const result = await select(items, {
|
|
95
|
-
message:
|
|
96
|
-
subtitle:
|
|
152
|
+
message: copy.menuTitle,
|
|
153
|
+
subtitle: copy.menuSubtitle,
|
|
97
154
|
clearScreen: true,
|
|
98
155
|
});
|
|
99
156
|
if (!result)
|
|
100
157
|
return { type: "cancel" };
|
|
158
|
+
if (result.type === "toggle-language") {
|
|
159
|
+
currentLanguage = currentLanguage === "zh" ? "en" : "zh";
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
101
162
|
if (result.type === "remove-all") {
|
|
102
163
|
const ok = await confirm("Remove ALL accounts? This cannot be undone.");
|
|
103
164
|
if (!ok)
|