opencode-copilot-account-switcher 0.2.6 → 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 CHANGED
@@ -4,43 +4,43 @@
4
4
  [![npm downloads](https://img.shields.io/npm/dw/opencode-copilot-account-switcher.svg)](https://www.npmjs.com/package/opencode-copilot-account-switcher)
5
5
  [![License: MPL-2.0](https://img.shields.io/badge/License-MPL--2.0-brightgreen.svg)](LICENSE)
6
6
 
7
- [English](#english) | [中文](#中文)
7
+ [中文](#中文) | [English](#english)
8
8
 
9
9
  ---
10
10
 
11
- <a name="english"></a>
11
+ <a name="中文"></a>
12
12
 
13
- ## English
13
+ ## 中文
14
14
 
15
- Manage and switch between multiple **GitHub Copilot** accounts in **OpenCode**. This plugin adds account switching, quota checks, an optional **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.
15
+ **OpenCode** 中管理并切换多个 **GitHub Copilot** 账号。本插件提供**账号切换、配额查询**、默认开启的 **Guided Loop Safety** 模式,以及默认关闭的 **Copilot Network Retry** 开关;前者帮助一次 premium request 更容易连续工作好几个小时、减少真正需要你输入之前的汇报打断,后者用于处理可重试的网络与证书类失败。**完全依赖官方 `github-copilot` provider**,无需修改模型配置。
16
16
 
17
- ## What You Get
17
+ ## 功能一览
18
18
 
19
- - **Multi-account support** add multiple Copilot accounts and switch anytime
20
- - **Quota check** view remaining quota per account
21
- - **Auth import** import Copilot tokens from OpenCode auth storage
22
- - **Guided Loop Safety** — 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
23
- - **Copilot Network Retry** — optional and off by default; normalizes retryable Copilot network or TLS failures so OpenCode's native retry path can handle them
24
- - **Zero model config** no model changes required (official provider only)
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
- ## Installation
28
+ ## 安装
29
29
 
30
30
  <details open>
31
- <summary><b>For Humans</b></summary>
31
+ <summary><b>面向人类用户</b></summary>
32
32
 
33
- **Option A: Let an LLM do it**
33
+ **选项 A:让 LLM 帮你安装**
34
34
 
35
- Paste this into any LLM agent (OpenCode, Claude Code, Cursor, etc.):
35
+ 把下面这段话丢给任意 LLM(OpenCode / Claude Code / Cursor 等):
36
36
 
37
37
  ```
38
- Install the opencode-copilot-account-switcher plugin by following: https://raw.githubusercontent.com/jiwangyihao/opencode-copilot-account-switcher/master/README.md
38
+ 请按以下说明安装 opencode-copilot-account-switcher 插件:https://raw.githubusercontent.com/jiwangyihao/opencode-copilot-account-switcher/master/README.md
39
39
  ```
40
40
 
41
- **Option B: Manual setup**
41
+ **选项 B:手动安装**
42
42
 
43
- 1. **Add the plugin** to `~/.config/opencode/opencode.json`:
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. **Restart OpenCode** (it will auto-download the plugin from npm).
52
+ 2. **重启 OpenCode**(会自动从 npm 下载插件)。
53
53
 
54
- 3. **Login** to GitHub Copilot:
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>For LLM Agents</b></summary>
63
+ <summary><b>面向 LLM 智能体</b></summary>
64
64
 
65
- ### Step-by-Step Instructions
65
+ ### 步骤指引
66
66
 
67
- 1. Edit the OpenCode configuration file at `~/.config/opencode/opencode.json`
67
+ 1. 打开 OpenCode 配置文件 `~/.config/opencode/opencode.json`
68
68
 
69
- > **Note**: This path works on all platforms. On Windows, `~` resolves to your user home directory (e.g., `C:\Users\YourName`).
69
+ > **说明**:该路径在所有平台通用;Windows `~` 会解析为用户目录(例如 `C:\Users\YourName`)。
70
70
 
71
- 2. Add the plugin to the `plugin` array:
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. Restart OpenCode so it fetches the plugin automatically from npm.
80
+ 3. 重启 OpenCode,使其自动拉取 npm 包。
81
81
 
82
- ### Verification
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
- ## Usage
92
+ ## 使用方式
93
93
 
94
- Run inside the GitHub Copilot auth flow:
94
+ Copilot 认证流程中运行:
95
95
 
96
96
  ```bash
97
97
  opencode auth login --provider github-copilot
98
98
  ```
99
99
 
100
- You will see an interactive menu (arrow keys + enter) with actions:
100
+ 会出现交互式菜单(方向键 + 回车):
101
101
 
102
- - **Add account**
103
- - **Import from auth.json**
104
- - **Check quotas**
105
- - **Guided Loop Safety**prompt-guided question-first reporting that requires `question` for user-facing reports when available, keeps non-blocked work moving, reduces repeated interruptions, and avoids unnecessary subagent calls
106
- - **Copilot Network Retry**off by default; only affects the Copilot request `fetch` path and only for retryable network/certificate-style failures
107
- - **Switch account**
108
- - **Remove account**
109
- - **Remove all**
102
+ - **添加账号**
103
+ - **从 auth.json 导入**
104
+ - **检查配额**
105
+ - **Guided Loop Safety 开关** 通过提示词引导模型在可用时必须使用 `question` 做用户可见汇报、继续完成非阻塞工作、减少反复中断,并避免不必要的子代理调用
106
+ - **Copilot Network Retry 开关** 默认关闭;仅影响 Copilot 请求的 `fetch` 路径,只处理可重试的网络/证书类失败
107
+ - **切换账号**
108
+ - **删除账号**
109
+ - **全部删除**
110
110
 
111
- If you want GitHub Copilot sessions to stay in a single premium request longer, enable Guided Loop Safety from the account menu. 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.
111
+ Guided Loop Safety 现在默认开启。实际使用中,它可以让一次 request 更容易连续工作好几个小时:当 `question` 工具在当前会话中可用且被允许时,用户可见汇报必须通过它完成,因此等待你的回复本身不会像反复插入直接状态消息那样继续额外消耗配额;少一次中断,本身就少一次无谓的配额消耗。只要还有安全的非阻塞工作可做,Copilot 就应继续执行而不是提前暂停;只有在当前确实没有可安全执行的动作时,才应通过 `question` 询问下一项任务或所需澄清,同时也会减少不必要的子代理调用。
112
112
 
113
- If you hit transient Copilot TLS or network failures, you can 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. This keeps request retry policy aligned with OpenCode instead of re-implementing a second retry system inside the plugin.
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
- - Default: **disabled**
118
- - Scope: only the official Copilot request `fetch` path returned by `auth.loader`
119
- - Purpose: limited handling for retryable network and certificate-style failures such as `failed to fetch`, `ECONNRESET`, `unknown certificate`, or `self signed certificate`
120
- - Strategy: preserve official loader behavior, then normalize retryable failures so OpenCode's native retry pipeline can decide whether and when to retry
121
- - Risk: because the plugin still wraps the official fetch path, upstream internal behavior may change over time and drift is possible
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 Sync
123
+ ## Upstream 同步机制
124
124
 
125
- 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`.
125
+ 仓库中提交了一份 upstream 快照 `src/upstream/copilot-plugin.snapshot.ts`,并提供同步/校验脚本 `scripts/sync-copilot-upstream.mjs`。
126
126
 
127
- Useful commands:
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
- 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.
134
+ 该脚本会生成或校验仓库中提交的 snapshot,并要求在更新正式 snapshot 时显式提供 upstream commit 与同步日期,用来尽早发现与官方 `opencode` `copilot.ts` 的行为漂移。
135
135
 
136
136
  ---
137
137
 
138
- ## Storage
138
+ ## 存储位置
139
139
 
140
- Accounts are stored in:
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
- ## FAQ
148
+ ## 常见问题
149
149
 
150
- **Do I need to change model configurations?**
151
- No. This plugin only manages accounts and works with the official `github-copilot` provider.
150
+ **需要改模型配置吗?**
151
+ 不需要。本插件只做账号管理,继续使用官方 `github-copilot` provider
152
152
 
153
- **Does it replace the official provider?**
154
- No. It uses the official provider and only adds account switching + quota checks.
153
+ **会替换官方 provider 吗?**
154
+ 不会。它只是在官方 provider 基础上增加账号切换和配额查询。
155
155
 
156
- **Does Copilot Network Retry replace OpenCode's retry logic?**
157
- No. The plugin keeps retry policy inside OpenCode by normalizing retryable Copilot network/TLS failures into a shape that OpenCode already recognizes as retryable.
156
+ **Copilot Network Retry 会替代 OpenCode 自己的重试逻辑吗?**
157
+ 不会。插件的目标是把可重试的 Copilot 网络/TLS 失败归一化成 OpenCode 已识别的可重试错误形态,真正的是否重试与如何退避仍由 OpenCode 原生链路决定。
158
158
 
159
159
  ---
160
160
 
161
- <a name="中文"></a>
161
+ <a name="english"></a>
162
162
 
163
- ## 中文
163
+ ## English
164
164
 
165
- **OpenCode** 中管理并切换多个 **GitHub Copilot** 账号。本插件提供**账号切换、配额查询**、可选的 **Guided Loop Safety** 模式,以及默认关闭的 **Copilot Network Retry** 开关;前者帮助一次 premium request 更容易连续工作好几个小时、减少真正需要你输入之前的汇报打断,后者用于处理可重试的网络与证书类失败。**完全依赖官方 `github-copilot` provider**,无需修改模型配置。
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
- - **多账号管理**添加多个 Copilot 账号,随时切换
170
- - **配额查询**查看每个账号的剩余额度
171
- - **导入认证**可从 OpenCode 认证存储导入
172
- - **Guided Loop Safety** — 仅对 Copilot 生效的更严格 question-first 提示词策略,推动非阻塞工作持续执行、让一次 premium request 更容易连续工作好几个小时,并通过减少反复中断来降低无谓配额消耗
173
- - **Copilot Network Retry** — 默认关闭;把可重试的 Copilot 网络或 TLS 失败归一化成 OpenCode 原生重试链路可识别的形态
174
- - **无需模型配置**使用官方 provider,无需改模型
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>面向人类用户</b></summary>
181
+ <summary><b>For Humans</b></summary>
182
182
 
183
- **选项 A:让 LLM 帮你安装**
183
+ **Option A: Let an LLM do it**
184
184
 
185
- 把下面这段话丢给任意 LLMOpenCode / Claude Code / Cursor 等):
185
+ Paste this into any LLM agent (OpenCode, Claude Code, Cursor, etc.):
186
186
 
187
187
  ```
188
- 请按以下说明安装 opencode-copilot-account-switcher 插件:https://raw.githubusercontent.com/jiwangyihao/opencode-copilot-account-switcher/master/README.md
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
- **选项 B:手动安装**
191
+ **Option B: Manual setup**
192
192
 
193
- 1. **在配置文件中添加插件** `~/.config/opencode/opencode.json`:
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. **重启 OpenCode**(会自动从 npm 下载插件)。
202
+ 2. **Restart OpenCode** (it will auto-download the plugin from npm).
203
203
 
204
- 3. **登录 GitHub Copilot**:
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>面向 LLM 智能体</b></summary>
213
+ <summary><b>For LLM Agents</b></summary>
214
214
 
215
- ### 步骤指引
215
+ ### Step-by-Step Instructions
216
216
 
217
- 1. 打开 OpenCode 配置文件 `~/.config/opencode/opencode.json`
217
+ 1. Edit the OpenCode configuration file at `~/.config/opencode/opencode.json`
218
218
 
219
- > **说明**:该路径在所有平台通用;Windows `~` 会解析为用户目录(例如 `C:\Users\YourName`)。
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. `plugin` 数组中添加插件:
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. 重启 OpenCode,使其自动拉取 npm 包。
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
- Copilot 认证流程中运行:
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
- 如果你希望 GitHub Copilot 会话在一次 premium request 中尽量持续工作、更少被汇报打断,可以在账号菜单中开启 Guided Loop Safety。实际使用中,它可以让一次 request 更容易连续工作好几个小时:当 `question` 工具在当前会话中可用且被允许时,用户可见汇报必须通过它完成,因此等待你的回复本身不会像反复插入直接状态消息那样继续额外消耗配额;少一次中断,本身就少一次无谓的配额消耗。只要还有安全的非阻塞工作可做,Copilot 就应继续执行而不是提前暂停;只有在当前确实没有可安全执行的动作时,才应通过 `question` 询问下一项任务或所需澄清,同时也会减少不必要的子代理调用。
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
- 如果你遇到 Copilot 的瞬时 TLS 或网络失败,也可以在同一菜单中开启 Copilot Network Retry。它默认关闭。开启后,插件会先保留 upstream 官方 loader 生成的 `baseURL`、认证头和 `fetch` 行为,只在最后一跳 Copilot `fetch` 路径上做最小包装,把可重试的网络类失败归一化成 OpenCode 已有重试链路能识别的形态,而不是在插件内部重新定义一套独立的请求重试策略。
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
- - 作用范围:仅影响 `auth.loader` 返回的官方 Copilot 请求 `fetch` 路径
269
- - 用途:有限处理 `failed to fetch`、`ECONNRESET`、`unknown certificate`、`self signed certificate` 等可重试网络/证书类失败
270
- - 实现策略:尽量保留官方 loader 行为,再把可重试失败归一化给 OpenCode 原生重试链路判断是否重试
271
- - 风险提示:因为插件仍然包裹了官方 fetch 路径,若 upstream 后续内部实现变化,仍可能产生行为漂移
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
- 仓库中提交了一份 upstream 快照 `src/upstream/copilot-plugin.snapshot.ts`,并提供同步/校验脚本 `scripts/sync-copilot-upstream.mjs`。
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
- 该脚本会生成或校验仓库中提交的 snapshot,并要求在更新正式 snapshot 时显式提供 upstream commit 与同步日期,用来尽早发现与官方 `opencode` `copilot.ts` 的行为漂移。
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
- 不需要。本插件只做账号管理,继续使用官方 `github-copilot` provider
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
- **会替换官方 provider 吗?**
304
- 不会。它只是在官方 provider 基础上增加账号切换和配额查询。
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 会替代 OpenCode 自己的重试逻辑吗?**
307
- 不会。插件的目标是把可重试的 Copilot 网络/TLS 失败归一化成 OpenCode 已识别的可重试错误形态,真正的是否重试与如何退避仍由 OpenCode 原生链路决定。
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
- export declare function isRetryableCopilotFetchError(error: unknown): boolean;
3
- export declare function createCopilotRetryingFetch(baseFetch: FetchLike, options?: {
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
- }): (request: Request | URL | string, init?: RequestInit) => Promise<Response>;
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`;
@@ -47,11 +49,39 @@ function isInputIdTooLongErrorBody(payload) {
47
49
  return false;
48
50
  const error = payload.error;
49
51
  const message = String(error?.message ?? "").toLowerCase();
50
- return message.includes("invalid 'input[") && message.includes(".id'") && message.includes("string too long");
52
+ return message.includes("string too long") && (message.includes("input id") || message.includes(".id'"));
51
53
  }
52
54
  function isInputIdTooLongMessage(text) {
53
55
  const message = text.toLowerCase();
54
- return message.includes("invalid 'input[") && message.includes(".id'") && message.includes("string too long");
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
+ });
55
85
  }
56
86
  function hasLongInputIds(payload) {
57
87
  const input = payload.input;
@@ -59,22 +89,43 @@ function hasLongInputIds(payload) {
59
89
  return false;
60
90
  return input.some((item) => typeof item?.id === "string" && (item.id?.length ?? 0) > 64);
61
91
  }
62
- function stripLongInputIds(payload) {
92
+ function getTargetedLongInputId(payload, reportedLength) {
63
93
  const input = payload.input;
64
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) {
115
+ const input = payload.input;
116
+ if (!Array.isArray(input))
117
+ return payload;
118
+ const target = getTargetedLongInputId(payload, reportedLength);
119
+ if (!target)
65
120
  return payload;
66
121
  let changed = false;
67
122
  const nextInput = input.map((item) => {
68
- if (!item || typeof item !== "object")
123
+ if (item !== target)
69
124
  return item;
70
- const id = item.id;
71
- if (typeof id === "string" && id.length > 64) {
72
- changed = true;
73
- const clone = { ...item };
74
- delete clone.id;
75
- return clone;
76
- }
77
- return item;
125
+ changed = true;
126
+ const clone = { ...item };
127
+ delete clone.id;
128
+ return clone;
78
129
  });
79
130
  if (!changed)
80
131
  return payload;
@@ -98,6 +149,7 @@ function parseJsonBody(init) {
98
149
  }
99
150
  function buildRetryInit(init, payload) {
100
151
  const headers = new Headers(init?.headers);
152
+ headers.delete(INTERNAL_SESSION_HEADER);
101
153
  if (!headers.has("content-type")) {
102
154
  headers.set("content-type", "application/json");
103
155
  }
@@ -107,13 +159,118 @@ function buildRetryInit(init, payload) {
107
159
  body: JSON.stringify(payload),
108
160
  };
109
161
  }
110
- async function maybeRetryInputIdTooLong(request, init, response, baseFetch) {
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) {
111
268
  if (response.status !== 400)
112
- return response;
269
+ return { response, retried: false, nextInit: init };
113
270
  const requestPayload = parseJsonBody(init);
114
271
  if (!requestPayload || !hasLongInputIds(requestPayload)) {
115
272
  debugLog("skip input-id retry: request has no long ids");
116
- return response;
273
+ return { response, retried: false, nextInit: init };
117
274
  }
118
275
  debugLog("input-id retry candidate", {
119
276
  status: response.status,
@@ -125,13 +282,16 @@ async function maybeRetryInputIdTooLong(request, init, response, baseFetch) {
125
282
  .catch(() => "");
126
283
  if (!responseText) {
127
284
  debugLog("skip input-id retry: empty response body");
128
- return response;
285
+ return { response, retried: false, nextInit: init };
129
286
  }
130
- let matched = isInputIdTooLongMessage(responseText);
287
+ let parsed = parseInputIdTooLongDetails(responseText);
288
+ let matched = parsed.matched;
131
289
  if (!matched) {
132
290
  try {
133
291
  const bodyPayload = JSON.parse(responseText);
134
- matched = isInputIdTooLongErrorBody(bodyPayload);
292
+ const error = bodyPayload.error;
293
+ parsed = parseInputIdTooLongDetails(String(error?.message ?? ""));
294
+ matched = parsed.matched || isInputIdTooLongErrorBody(bodyPayload);
135
295
  }
136
296
  catch {
137
297
  matched = false;
@@ -139,25 +299,56 @@ async function maybeRetryInputIdTooLong(request, init, response, baseFetch) {
139
299
  }
140
300
  debugLog("input-id retry detection", {
141
301
  matched,
302
+ serverReportedIndex: parsed.serverReportedIndex,
303
+ reportedLength: parsed.reportedLength,
142
304
  bodyPreview: responseText.slice(0, 200),
143
305
  });
306
+ debugLog("input-id retry parsed", {
307
+ serverReportedIndex: parsed.serverReportedIndex,
308
+ reportedLength: parsed.reportedLength,
309
+ });
144
310
  if (!matched)
145
- return response;
146
- const sanitized = stripLongInputIds(requestPayload);
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);
147
337
  if (sanitized === requestPayload) {
148
338
  debugLog("skip input-id retry: sanitize made no changes");
149
- return response;
339
+ return { response, retried: false, nextInit: init };
150
340
  }
151
341
  debugLog("input-id retry triggered", {
152
342
  removedLongIds: true,
153
343
  hadPreviousResponseId: typeof requestPayload.previous_response_id === "string",
154
344
  });
155
- const retried = await baseFetch(request, buildRetryInit(init, sanitized));
345
+ const nextInit = buildRetryInit(init, sanitized);
346
+ const retried = await baseFetch(request, nextInit);
156
347
  debugLog("input-id retry response", {
157
348
  status: retried.status,
158
349
  contentType: retried.headers.get("content-type") ?? undefined,
159
350
  });
160
- return retried;
351
+ return { response: retried, retried: true, nextInit };
161
352
  }
162
353
  function toRetryableSystemError(error) {
163
354
  const base = error instanceof Error ? error : new Error(String(error));
@@ -229,19 +420,37 @@ export function isRetryableCopilotFetchError(error) {
229
420
  export function createCopilotRetryingFetch(baseFetch, options) {
230
421
  void options;
231
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;
232
433
  debugLog("fetch start", {
233
- url: request instanceof Request ? request.url : request instanceof URL ? request.href : String(request),
234
- isCopilot: isCopilotUrl(request),
434
+ url: safeRequest instanceof Request ? safeRequest.url : safeRequest instanceof URL ? safeRequest.href : String(safeRequest),
435
+ isCopilot: isCopilotUrl(safeRequest),
235
436
  });
236
437
  try {
237
- const response = await baseFetch(request, init);
438
+ const response = await baseFetch(safeRequest, effectiveInit);
238
439
  debugLog("fetch resolved", {
239
440
  status: response.status,
240
441
  contentType: response.headers.get("content-type") ?? undefined,
241
442
  });
242
- if (isCopilotUrl(request)) {
243
- const retried = await maybeRetryInputIdTooLong(request, init, response, baseFetch);
244
- return withStreamDebugLogs(retried, request);
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);
245
454
  }
246
455
  return response;
247
456
  }
@@ -250,7 +459,7 @@ export function createCopilotRetryingFetch(baseFetch, options) {
250
459
  message: getErrorMessage(error),
251
460
  retryableByMessage: isRetryableCopilotFetchError(error),
252
461
  });
253
- if (!isCopilotUrl(request) || !isRetryableCopilotFetchError(error)) {
462
+ if (!isCopilotUrl(safeRequest) || !isRetryableCopilotFetchError(error)) {
254
463
  throw error;
255
464
  }
256
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.
@@ -1,7 +1,27 @@
1
1
  import { type CopilotPluginHooks } from "./loop-safety-plugin.js";
2
- import { createCopilotRetryingFetch } from "./copilot-network-retry.js";
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?: typeof createCopilotRetryingFetch;
13
- }): CopilotPluginHooks;
32
+ createRetryFetch?: (fetch: FetchLike, ctx?: CopilotRetryContext) => FetchLike;
33
+ client?: CopilotRetryContext["client"];
34
+ directory?: CopilotRetryContext["directory"];
35
+ serverUrl?: CopilotRetryContext["serverUrl"];
36
+ }): CopilotPluginHooksWithChatHeaders;
37
+ export {};
@@ -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 ({ client }) => {
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 !== true)
20
- data.loopSafetyEnabled = false;
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: "Actions", value: { type: "cancel" }, kind: "heading" },
32
- { label: "Add account", value: { type: "add" }, color: "cyan", hint: "device login or manual" },
33
- { label: "Import from auth.json", value: { type: "import" }, color: "cyan" },
34
- { label: "Check quotas", value: { type: "quota" }, color: "cyan", hint: quotaHint },
35
- { label: "Refresh identity", value: { type: "refresh-identity" }, color: "cyan" },
36
- { label: "Check models", value: { type: "check-models" }, color: "cyan" },
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 ? "Disable auto refresh" : "Enable auto refresh",
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: "Set refresh interval", value: { type: "set-interval" }, color: "cyan" },
97
+ { label: copy.setRefresh, value: { type: "set-interval" }, color: "cyan" },
44
98
  {
45
- label: input.loopSafetyEnabled ? "Disable guided loop safety" : "Enable guided loop safety",
99
+ label: input.loopSafetyEnabled ? copy.disableLoopSafety : copy.enableLoopSafety,
46
100
  value: { type: "toggle-loop-safety" },
47
101
  color: "cyan",
48
- hint: "Prompt-guided: fewer report interruptions, fewer unnecessary subagents",
102
+ hint: copy.loopSafetyHint,
49
103
  },
50
104
  {
51
- label: input.networkRetryEnabled ? "Disable Copilot network retry" : "Enable Copilot network retry",
105
+ label: input.networkRetryEnabled ? copy.disableRetry : copy.enableRetry,
52
106
  value: { type: "toggle-network-retry" },
53
107
  color: "cyan",
54
- hint: "Overrides official fetch path; may drift from upstream",
108
+ hint: copy.retryHint,
55
109
  },
56
110
  { label: "", value: { type: "cancel" }, separator: true },
57
- { label: "Accounts", value: { type: "cancel" }, kind: "heading" },
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: "Danger zone", value: { type: "cancel" }, kind: "heading" },
82
- { label: "Remove all accounts", value: { type: "remove-all" }, color: "red" },
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
- const items = buildMenuItems({
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: "GitHub Copilot accounts",
96
- subtitle: "Select an action or account",
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)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-copilot-account-switcher",
3
- "version": "0.2.6",
3
+ "version": "0.2.7",
4
4
  "description": "GitHub Copilot account switcher plugin for OpenCode",
5
5
  "main": "./dist/index.js",
6
6
  "types": "./dist/index.d.ts",