@goodandready/dsh-subscriptions 0.6.65 → 0.6.67

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
@@ -207,6 +207,16 @@ const res = await ctx.subscriptions.request('codex', '/backend-api/codex/images/
207
207
  * **In-App Toast Alerts (`enableToasts`)**: Lightweight floating toast alerts in the DSH chat interface notifying you when accounts enter quarantine, recover from cooldowns, or exceed quota safety thresholds.
208
208
  * **Automated Webhook Integration**: Instant event notifications dispatched via `notifyQuarantineEntered` and `notifyQuarantineReleased`.
209
209
 
210
+ ### 21. 🛡️ Per-Account Claude Billing Attribution Isolation (v0.6.67)
211
+ * **Per-Account `cc_turn_index` & `cch` Isolation (#530)**: Isolates Claude Code billing attribution state (`cch` session hash and `cc_turn_index` counter) per subscription account (`accountId`) in a bounded module Map instead of `globalThis`, preventing cross-account telemetry mixing in multi-account pools.
212
+ * **Dynamic `cc_prompt_index` & Kill-Switch**: Automatically derives 0-based `cc_prompt_index` from multi-turn `messages` history and supports suppression via `CLAUDE_CODE_ATTRIBUTION_HEADER=0` or `disableBillingHeader: true`.
213
+
214
+ ### 20. 🤖 Native Copilot CLI SDK Mode & Local Tool Calling (v0.6.66)
215
+ * **Direct Copilot CLI Integration (#508)**: Native driver `copilot-cli.js` supporting execution via local `copilot` / `github-copilot-cli` binaries with zero network token emulation risk.
216
+ * **Smart Mode Switching (`copilotMode`)**: Choose between `auto` (prefers local CLI with graceful web API fallback), `cli` (strict CLI SDK execution), and `web` (direct OAuth/API).
217
+ * **Multi-Source Credential Detection**: Automatically discovers tokens from GitHub CLI (`gh hosts.yml`), Copilot config files (`hosts.json`, `apps.json`), and environment variables (`GITHUB_COPILOT_TOKEN`, `GH_TOKEN`).
218
+ * **Full Tool-Calling & Streaming**: Seamless translation of CLI process output into real-time streaming chunks and multi-turn tool-call deltas.
219
+
210
220
  ### 19. 🧠 Session Reasoning Effort Management & Contract Matrix (v0.6.65)
211
221
  * **Session-Scoped Reasoning Effort Control (#505)**: Dynamically adjust reasoning effort (`low`, `medium`, `high`, `max`) or disable reasoning (`off`) per conversation session via REST endpoints (`GET`, `POST`, `DELETE /dsh-subscriptions/session-reasoning`).
212
222
  * **Model-Switch Invariant & Safety Guard**: Automatic suppression of reasoning parameters when switching from a reasoning model (e.g. `o1`) to a standard model (e.g. `gpt-4o`), avoiding upstream HTTP 400 parameter errors.
package/README.ru.md CHANGED
@@ -183,6 +183,16 @@ const res = await ctx.subscriptions.request('codex', '/backend-api/codex/images/
183
183
  * **Всплывающие уведомления (Toast Alerts)**: Легковесные всплывающие тосты в веб-интерфейсе DSH при событиях карантина (с отображением причины), восстановлении доступа и критическом расходе квоты. Включение управляется параметром `enableToasts` (по умолчанию `true`).
184
184
  * **Интеграция с вебхуками**: Автоматическая отправка уведомлений через `notifyQuarantineEntered` и `notifyQuarantineReleased`.
185
185
 
186
+ ### 17. 🛡️ Поаккаунтная изоляция атрибуции биллинга Claude (v0.6.67)
187
+ * **Изоляция `cc_turn_index` и `cch` по `accountId` (#530)**: Состояние атрибуции Claude Code (`cch` и счётчик `cc_turn_index`) изолировано по каждому аккаунту в ограниченной таблице модуля вместо `globalThis`, что исключает смешивание телеметрии между подписками в пуле.
188
+ * **Динамический `cc_prompt_index` и Kill-Switch**: Индекс `cc_prompt_index` автоматически вычисляется по числу пользовательских ходов в `messages`, а отключение заголовка поддерживается через `CLAUDE_CODE_ATTRIBUTION_HEADER=0` или `disableBillingHeader: true`.
189
+
190
+ ### 16. 🤖 Нативный режим Copilot CLI SDK и локальный Tool Calling (v0.6.66)
191
+ * **Прямая интеграция с Copilot CLI (#508)**: Драйвер `copilot-cli.js` для работы через локальный бинарник `copilot` / `github-copilot-cli` без риска сетевых блокировок при эмуляции токенов.
192
+ * **Гибкие режимы работы (`copilotMode`)**: Поддержка режимов `auto` (приоритет CLI с автоматическим fallback на web-API), `cli` (строгий режим CLI SDK) и `web` (прямое подключение по OAuth/API).
193
+ * **Многоканальное обнаружение учетных данных**: Чтение токенов из `gh hosts.yml`, `hosts.json`, `apps.json` и переменных окружения (`GITHUB_COPILOT_TOKEN`, `GH_TOKEN`).
194
+ * **Потоковая передача и вызовы инструментов**: Полная поддержка real-time стриминга и событий `tool-call-delta` при исполнении через CLI.
195
+
186
196
  ### 15. 🧠 Управление Reasoning Effort в сессии и матрица контрактов (v0.6.65)
187
197
  * **Управление глубиной рассуждений в сессии (#505)**: Динамическая настройка усилия рассуждений (`low`, `medium`, `high`, `max`) или отключение (`off`) в рамках диалога через REST API (`GET`, `POST`, `DELETE /dsh-subscriptions/session-reasoning`).
188
198
  * **Безопасность при смене модели**: Автоматическое подавление параметров рассуждений при переключении с reasoning-модели (напр. `o1`) на обычную (напр. `gpt-4o`), предотвращающее ошибки HTTP 400 от upstream API.
package/README.zh.md CHANGED
@@ -132,6 +132,10 @@ graph LR
132
132
  queueTimeoutMs: 30000 # v0.6.62: 队列最大等待超时时间(毫秒)
133
133
  * **自动化 Webhook 告警集成**:通过 `notifyQuarantineEntered` 与 `notifyQuarantineReleased` 自动向外部通知系统派发隔离生命周期事件。
134
134
 
135
+ ### Claude 账户级计费归因隔离与动态轮次索引 (v0.6.67)
136
+ * **按账户隔离 `cc_turn_index` 与 `cch` (#530)**:将 Claude Code 计费归因状态(会话哈希 `cch` 与递增轮次 `cc_turn_index`)按 `accountId` 隔离在有界模块级映射表中,彻底消除 `globalThis` 跨订阅账户状态混用问题。
137
+ * **动态计算 `cc_prompt_index` 与禁用开关**:根据多轮对话消息历史自动计算从 0 开始的 `cc_prompt_index`,并支持通过环境变量 `CLAUDE_CODE_ATTRIBUTION_HEADER=0` 或 `disableBillingHeader: true` 禁用归因头注入。
138
+
135
139
  ### Antigravity 客户端密钥架构与无感 CLI 令牌刷新 (v0.6.60)
136
140
  * **配置架构密钥字段 (`antigravityClientSecret`)**:支持用户在设置中自定义配置 Google Cloud OAuth 客户端密钥(Client Secret),公共接口自动脱敏掩码 (`••••••`),彻底解决机密型 OAuth 客户端刷新令牌被拒的问题。
137
141
  * **CLI 令牌零配置平滑刷新**:内置官方 Gemini / Antigravity CLI 客户端的公共应用凭据 (`Google Gemini CLI Client ID` / `Google Gemini CLI Client Secret`),使通过 `📥 从 CLI 导入` 的会话能够持久无限刷新,不再出现 1 小时后失效问题。
package/lib/adapter.js CHANGED
@@ -329,6 +329,7 @@ export class SubscriptionAdapter extends LlmAdapter {
329
329
  if (typeof deps.rememberRequest === 'function') deps.rememberRequest(account.ref)
330
330
  for await (const chunk of vendor.streamOnce({
331
331
  blob,
332
+ accountId: account.id || account.ref,
332
333
  options: opts,
333
334
  fetchImpl: quotaFetch,
334
335
  headers: attributionHeaders(),
package/lib/client.js CHANGED
@@ -395,6 +395,13 @@ const cssId = 'dsh-subscriptions/settings.module.css'
395
395
  "codexVerbosityLow": "Low",
396
396
  "codexVerbosityMedium": "Medium",
397
397
  "codexVerbosityHigh": "High",
398
+ "copilotMode": "Copilot execution mode",
399
+ "copilotModeHint": "Execution mode: auto (prefer local CLI with web fallback), cli (strict CLI SDK), or web (direct API).",
400
+ "copilotModeAuto": "Auto (CLI with Web fallback)",
401
+ "copilotModeCli": "CLI Only (Local copilot-cli)",
402
+ "copilotModeWeb": "Web Only (Direct API)",
403
+ "copilotCliPath": "Custom Copilot CLI path",
404
+ "copilotCliPathHint": "Optional path to copilot or github-copilot-cli binary.",
398
405
  "ollamaFallback": "Ollama local fallback",
399
406
  "ollamaFallbackHint": "Automatically route queries to a local Ollama instance when cloud quota is exhausted.",
400
407
  "ollamaBaseUrl": "Ollama Base URL",
@@ -660,6 +667,13 @@ const cssId = 'dsh-subscriptions/settings.module.css'
660
667
  "codexVerbosityLow": "低",
661
668
  "codexVerbosityMedium": "中",
662
669
  "codexVerbosityHigh": "高",
670
+ "copilotMode": "Copilot 执行模式",
671
+ "copilotModeHint": "执行模式:auto(优先本地 CLI,带 Web 回退)、cli(仅本地 CLI)、web(仅直连 API)。",
672
+ "copilotModeAuto": "自动(优先本地 CLI)",
673
+ "copilotModeCli": "仅本地 CLI",
674
+ "copilotModeWeb": "仅直连 API",
675
+ "copilotCliPath": "自定义 Copilot CLI 路径",
676
+ "copilotCliPathHint": "可选的 copilot 或 github-copilot-cli 二进制文件路径。",
663
677
  "ollamaFallback": "Ollama 本地模型自动兜底",
664
678
  "ollamaFallbackHint": "当云端配额耗尽或服务商故障时,自动将请求转发给本地 Ollama 实例。",
665
679
  "ollamaBaseUrl": "Ollama 服务基础 URL",
@@ -1904,6 +1918,32 @@ const cssId = 'dsh-subscriptions/settings.module.css'
1904
1918
  ),
1905
1919
  React.createElement('div', { className: 'dsub-sub' },
1906
1920
  t('codexVerbosityHint')),
1921
+ React.createElement('div', { className: 'dsub-row' },
1922
+ React.createElement('span', { className: 'dsub-h' }, t('copilotMode')),
1923
+ React.createElement('select', {
1924
+ className: 'dsub-select',
1925
+ value: draft.copilotMode || 'auto',
1926
+ onChange: (e) => setDraft((d) => Object.assign({}, d, { copilotMode: e.target.value })),
1927
+ },
1928
+ React.createElement('option', { value: 'auto' }, t('copilotModeAuto')),
1929
+ React.createElement('option', { value: 'cli' }, t('copilotModeCli')),
1930
+ React.createElement('option', { value: 'web' }, t('copilotModeWeb')),
1931
+ ),
1932
+ ),
1933
+ React.createElement('div', { className: 'dsub-sub' },
1934
+ t('copilotModeHint')),
1935
+ React.createElement('div', { className: 'dsub-row' },
1936
+ React.createElement('span', { className: 'dsub-h' }, t('copilotCliPath')),
1937
+ React.createElement('input', {
1938
+ className: 'dsub-input',
1939
+ type: 'text',
1940
+ value: draft.copilotCliPath || '',
1941
+ placeholder: 'copilot',
1942
+ onChange: (e) => setDraft((d) => Object.assign({}, d, { copilotCliPath: e.target.value })),
1943
+ }),
1944
+ ),
1945
+ React.createElement('div', { className: 'dsub-sub' },
1946
+ t('copilotCliPathHint')),
1907
1947
  ),
1908
1948
  React.createElement('details', { className: 'dsub-group', open: true },
1909
1949
  React.createElement('summary', { className: 'dsub-groupHead' }, t('groupFallback')),
@@ -96,6 +96,10 @@ export const Config = z.object({
96
96
  .description('Custom model catalog for Codex.'),
97
97
  claudeSystemPrefix: z.string().volatile().default('')
98
98
  .description('System prompt prefix for Claude.'),
99
+ copilotMode: z.string().volatile().default('auto')
100
+ .description('#508 Execution mode for GitHub Copilot: auto (prefer CLI if available), cli (require copilot CLI), or web (direct web/OAuth API).'),
101
+ copilotCliPath: z.string().volatile().default('')
102
+ .description('#508 Custom binary path for copilot-cli (empty detects in PATH and ~/.npm-global).'),
99
103
  claudeCcVersion: z.string().volatile().default('')
100
104
  .description('Claude Code version header for billing attribution (default: 2.1.289.7e2).'),
101
105
  claudeModels: z.array(z.any()).volatile().default([])
@@ -1,3 +1,4 @@
1
+ import { copilotCliInfo } from './vendors/copilot.js'
1
2
  import { getSessionReasoningStats } from './reasoning-effort.js'
2
3
  import { defaultConcurrencyQueue } from './concurrency-queue.js'
3
4
  import { calculateCacheMetrics } from './analytics.js'
@@ -54,6 +55,7 @@ export async function createDiagnosticsReport({ live, store, history, NS, pkgVer
54
55
  queue: defaultConcurrencyQueue.stats(),
55
56
  pins: getSessionPinStats(),
56
57
  reasoning: getSessionReasoningStats(),
58
+ copilotCli: copilotCliInfo(cfg),
57
59
  decisions: {
58
60
  recent: rows.filter((r) => r && r.decisionTrace).slice(0, 10).map((r) => ({
59
61
  ts: r.ts,
@@ -3,6 +3,7 @@ import { readFile } from 'node:fs/promises'
3
3
  import { homedir } from 'node:os'
4
4
  import { join } from 'node:path'
5
5
  import { readClaudeCredentials } from './vendors/claude-cli.js'
6
+ import { readCopilotCredentials } from './vendors/copilot-cli.js'
6
7
  import { normalizeExpiresAt } from './blob.js'
7
8
  import { isGoogleClientId, extractGoogleClientIdFromJwt, emailFromToken } from './jwt.js'
8
9
  import { DEFAULT_CLI_CLIENT_ID, DEFAULT_CLI_CLIENT_SECRET } from './vendors/antigravity.js'
@@ -264,6 +265,19 @@ async function discoverLocalCliSessionsInner() {
264
265
  detected['claude-cli'] = claudeEntry
265
266
  }
266
267
 
268
+ // 9. GitHub Copilot token and CLI credentials
269
+ const copilotCreds = await readCopilotCredentials(activeHome)
270
+ if (copilotCreds) {
271
+ const copilotEntry = {
272
+ provider: 'copilot',
273
+ source: copilotCreds.source || 'file',
274
+ path: copilotCreds.path || 'copilot-cli',
275
+ email: copilotCreds.email || 'GitHub Copilot User',
276
+ hasRefreshToken: !!copilotCreds.refreshToken,
277
+ }
278
+ detected.copilot = copilotEntry
279
+ detected['copilot-cli'] = copilotEntry
280
+ }
267
281
  // 9. GitHub Copilot token
268
282
  const copilotPaths = [
269
283
  homeFile('.config', 'github-copilot', 'hosts.json'),
@@ -317,6 +331,17 @@ export async function loadLocalCliBlob(provider, options = {}) {
317
331
  }
318
332
 
319
333
  if (canonical === 'copilot') {
334
+ const creds = await readCopilotCredentials(options && options.home)
335
+ if (creds && creds.accessToken) {
336
+ return {
337
+ accessToken: creds.accessToken,
338
+ refreshToken: creds.refreshToken || creds.accessToken,
339
+ githubToken: creds.githubToken || creds.accessToken,
340
+ expiresAt: creds.expiresAt || 0,
341
+ email: creds.email || 'GitHub Copilot User',
342
+ label: creds.label || creds.email || 'GitHub Copilot',
343
+ }
344
+ }
320
345
  let tok = process.env.GITHUB_COPILOT_TOKEN || process.env.GH_TOKEN || ''
321
346
  let email = 'GitHub Copilot User'
322
347
  if (info.source === 'file' && info.path) {
@@ -258,7 +258,7 @@ export function registerAccountsRoutes(ctx, state) {
258
258
  body = null
259
259
  }
260
260
  const rawProv = body && body.provider
261
- const prov = (rawProv === 'claude-cli') ? 'claude' : rawProv
261
+ const prov = (rawProv === 'claude-cli') ? 'claude' : (rawProv === 'copilot-cli') ? 'copilot' : rawProv
262
262
  if (!prov || !isProvider(prov)) {
263
263
  writeJson(res, 400, { ok: false, error: { code: 'bad_request', message: 'unknown or missing provider' } })
264
264
  return
@@ -222,20 +222,65 @@ export async function usage(blob, _cfg, fetchImpl) {
222
222
  }
223
223
  }
224
224
 
225
- export function makeClaudeBillingHeader(cfg) {
225
+ const billingAccountState = new Map()
226
+ const MAX_BILLING_ACCOUNTS = 256
227
+
228
+ function resolveAccountKey(cfg, context) {
229
+ const explicit = (context && context.accountId) || (cfg && cfg.accountId)
230
+ if (typeof explicit === 'string' && explicit.trim()) return explicit.trim()
231
+ const blob = (context && context.blob) || (cfg && cfg.blob)
232
+ if (blob && typeof blob === 'object') {
233
+ const id = blob.accountId || blob.id || blob.email
234
+ if (typeof id === 'string' && id.trim()) return id.trim()
235
+ if (typeof blob.accessToken === 'string' && blob.accessToken.length >= 8) {
236
+ return `token:${blob.accessToken.slice(-12)}`
237
+ }
238
+ }
239
+ return 'default'
240
+ }
241
+
242
+ function resolvePromptIndex(cfg, context) {
243
+ const explicit = (context && context.promptIndex !== undefined)
244
+ ? context.promptIndex
245
+ : (cfg && cfg.promptIndex !== undefined ? cfg.promptIndex : undefined)
246
+ if (typeof explicit === 'number' && Number.isFinite(explicit) && explicit >= 0) {
247
+ return Math.floor(explicit)
248
+ }
249
+ const messages = (context && context.options && Array.isArray(context.options.messages))
250
+ ? context.options.messages
251
+ : (context && Array.isArray(context.messages) ? context.messages : null)
252
+ if (messages) {
253
+ const userTurns = messages.filter((m) => m && m.role === 'user').length
254
+ return Math.max(0, userTurns - 1)
255
+ }
256
+ return 0
257
+ }
258
+
259
+ export function makeClaudeBillingHeader(cfg, context) {
226
260
  const ccVersion = (cfg && cfg.claudeCcVersion) || '2.1.289.7e2'
227
- const cch = (globalThis.__DSH_CC_H__ || (() => {
228
- globalThis.__DSH_CC_H__ = Math.random().toString(16).slice(2, 6)
229
- return globalThis.__DSH_CC_H__
230
- })())
231
- const turnIndex = (globalThis.__DSH_CC_TURN__ = (globalThis.__DSH_CC_TURN__ || 0) + 1)
232
- return `x-anthropic-billing-header: cc_version=${ccVersion}; cc_entrypoint=sdk-cli; cch=${cch}; cc_turn_origin=sdk; cc_prompt_index=0; cc_turn_index=${turnIndex};`
261
+ const accountKey = resolveAccountKey(cfg, context)
262
+ let state = billingAccountState.get(accountKey)
263
+ if (!state) {
264
+ if (billingAccountState.size >= MAX_BILLING_ACCOUNTS) {
265
+ const oldestKey = billingAccountState.keys().next().value
266
+ if (oldestKey !== undefined) billingAccountState.delete(oldestKey)
267
+ }
268
+ state = {
269
+ cch: Math.random().toString(16).slice(2, 6).padEnd(4, '0'),
270
+ turnIndex: 0,
271
+ }
272
+ billingAccountState.set(accountKey, state)
273
+ }
274
+ state.turnIndex += 1
275
+ const promptIndex = resolvePromptIndex(cfg, context)
276
+ return `x-anthropic-billing-header: cc_version=${ccVersion}; cc_entrypoint=sdk-cli; cch=${state.cch}; cc_turn_origin=sdk; cc_prompt_index=${promptIndex}; cc_turn_index=${state.turnIndex};`
233
277
  }
234
278
 
235
- export function applyClaudeBillingHeader(payload, cfg) {
279
+ export function applyClaudeBillingHeader(payload, cfg, context) {
236
280
  if (process.env.CLAUDE_CODE_ATTRIBUTION_HEADER === '0') return payload
281
+ if (cfg && cfg.disableBillingHeader === true) return payload
237
282
  if (!payload) return payload
238
- const headerText = makeClaudeBillingHeader(cfg)
283
+ const headerText = makeClaudeBillingHeader(cfg, context)
239
284
  const headerBlock = { type: 'text', text: headerText }
240
285
  if (Array.isArray(payload.system)) {
241
286
  if (!payload.system.some((b) => typeof b === 'object' && b && typeof b.text === 'string' && b.text.startsWith('x-anthropic-billing-header:'))) {
@@ -249,10 +294,10 @@ export function applyClaudeBillingHeader(payload, cfg) {
249
294
  return payload
250
295
  }
251
296
 
252
- export async function* streamOnce({ blob, options, fetchImpl, headers, config, signal }) {
297
+ export async function* streamOnce({ blob, options, fetchImpl, headers, config, signal, accountId }) {
253
298
  const payload = anthropicPayload(options, config.systemPrefix || IDENTITY)
254
299
  applyClaudeThinking(payload, options)
255
- applyClaudeBillingHeader(payload, config)
300
+ applyClaudeBillingHeader(payload, config, { accountId, blob, options })
256
301
  const res = await fetchImpl(API, {
257
302
  method: 'POST',
258
303
  headers: {
@@ -0,0 +1,400 @@
1
+ // Native copilot-cli SDK mode with local tool calling (#508).
2
+ // Enforces 0 Cyrillic characters.
3
+
4
+ import { readFile } from 'node:fs/promises'
5
+ import { existsSync, accessSync, constants } from 'node:fs'
6
+ import { homedir } from 'node:os'
7
+ import { join, delimiter } from 'node:path'
8
+ import { spawn } from 'node:child_process'
9
+ import { LlmError } from '@deepseek-ai/dsh-llm'
10
+
11
+ /**
12
+ * Standard candidate binary names for GitHub Copilot CLI.
13
+ */
14
+ export const COPILOT_CLI_BINARIES = [
15
+ 'copilot',
16
+ 'copilot.cmd',
17
+ 'copilot.exe',
18
+ 'github-copilot-cli',
19
+ 'github-copilot-cli.cmd',
20
+ 'copilot-cli',
21
+ 'gh-copilot',
22
+ ]
23
+
24
+ /**
25
+ * Read GitHub Copilot credentials from local environments or config files.
26
+ * @param {string} [home] Test seam for home directory
27
+ * @param {NodeJS.ProcessEnv} [env] Test seam for process environment
28
+ * @returns {Promise<{ accessToken: string, githubToken: string, refreshToken?: string, expiresAt?: number, email?: string, label?: string, source: string, path?: string } | null>}
29
+ */
30
+ export async function readCopilotCredentials(home, env = process.env) {
31
+ const activeHome = home || homedir()
32
+
33
+ // 1. Environment variables
34
+ const envToken = env.GITHUB_COPILOT_TOKEN || env.GH_TOKEN || env.GITHUB_TOKEN || env.COPILOT_API_KEY
35
+ if (envToken && typeof envToken === 'string' && envToken.trim()) {
36
+ const clean = envToken.trim()
37
+ return {
38
+ accessToken: clean,
39
+ githubToken: clean,
40
+ refreshToken: clean,
41
+ expiresAt: 0,
42
+ email: 'GitHub Copilot User',
43
+ label: 'copilot-env',
44
+ source: 'env',
45
+ path: env.GITHUB_COPILOT_TOKEN ? 'GITHUB_COPILOT_TOKEN' : (env.GH_TOKEN ? 'GH_TOKEN' : 'GITHUB_TOKEN'),
46
+ }
47
+ }
48
+
49
+ // 2. Known configuration files
50
+ const fileCandidates = [
51
+ join(activeHome, '.config', 'github-copilot', 'hosts.json'),
52
+ join(activeHome, '.config', 'github-copilot', 'apps.json'),
53
+ join(activeHome, '.copilot-cli', 'config.json'),
54
+ join(activeHome, '.config', 'gh', 'hosts.yml'),
55
+ ]
56
+
57
+ for (const filePath of fileCandidates) {
58
+ try {
59
+ if (!existsSync(filePath)) continue
60
+ const content = await readFile(filePath, 'utf8')
61
+ if (filePath.endsWith('.json')) {
62
+ const json = JSON.parse(content)
63
+ if (!json) continue
64
+ const gh = json['github.com'] || json
65
+ const tok = gh.oauth_token || gh.token || gh.access_token || gh.accessToken
66
+ if (tok) {
67
+ return {
68
+ accessToken: String(tok),
69
+ githubToken: String(tok),
70
+ refreshToken: String(tok),
71
+ expiresAt: 0,
72
+ email: gh.user || 'GitHub Copilot User',
73
+ label: gh.user || 'copilot-cli',
74
+ source: 'file',
75
+ path: filePath,
76
+ }
77
+ }
78
+ } else if (filePath.endsWith('.yml') || filePath.endsWith('.yaml')) {
79
+ const lines = content.split('\n')
80
+ let inGithub = false
81
+ let user = ''
82
+ let oauthToken = ''
83
+ for (const line of lines) {
84
+ const trimmed = line.trim()
85
+ if (/^github\.com:/.test(trimmed)) {
86
+ inGithub = true
87
+ continue
88
+ }
89
+ if (inGithub) {
90
+ if (/^[a-zA-Z0-9.-]+:/.test(trimmed) && !trimmed.startsWith('oauth_token') && !trimmed.startsWith('user')) {
91
+ break
92
+ }
93
+ const userMatch = /user:\s*([^\s#]+)/.exec(trimmed)
94
+ if (userMatch) user = userMatch[1]
95
+ const tokenMatch = /oauth_token:\s*([^\s#]+)/.exec(trimmed)
96
+ if (tokenMatch) oauthToken = tokenMatch[1]
97
+ }
98
+ }
99
+ if (oauthToken) {
100
+ return {
101
+ accessToken: oauthToken,
102
+ githubToken: oauthToken,
103
+ refreshToken: oauthToken,
104
+ expiresAt: 0,
105
+ email: user || 'GitHub Copilot User',
106
+ label: user || 'gh-hosts',
107
+ source: 'file',
108
+ path: filePath,
109
+ }
110
+ }
111
+ }
112
+ } catch {
113
+ // Continue to next candidate
114
+ }
115
+ }
116
+
117
+ return null
118
+ }
119
+
120
+ /**
121
+ * Locate Copilot CLI binary on system.
122
+ * @param {string} [customPath] Explicit user-configured path
123
+ * @param {NodeJS.ProcessEnv} [env] Process environment
124
+ * @param {string} [platform] Process platform
125
+ * @returns {string|null} Resolved executable path or null
126
+ */
127
+ export function findCopilotCli(customPath, env = process.env, platform = process.platform) {
128
+ if (customPath && typeof customPath === 'string' && customPath.trim()) {
129
+ const p = customPath.trim()
130
+ try {
131
+ if (existsSync(p)) return p
132
+ } catch { /* best-effort */ }
133
+ }
134
+
135
+ const pathVar = env.PATH || ''
136
+ const pathDirs = pathVar.split(delimiter).filter(Boolean)
137
+
138
+ const home = env.HOME || env.USERPROFILE || homedir()
139
+ if (home) {
140
+ pathDirs.push(
141
+ join(home, '.npm-global', 'bin'),
142
+ join(home, '.local', 'bin'),
143
+ join(home, 'AppData', 'Roaming', 'npm'),
144
+ )
145
+ }
146
+
147
+ for (const dir of pathDirs) {
148
+ for (const bin of COPILOT_CLI_BINARIES) {
149
+ const fullPath = join(dir, bin)
150
+ try {
151
+ if (existsSync(fullPath)) {
152
+ if (platform !== 'win32') {
153
+ try {
154
+ accessSync(fullPath, constants.X_OK)
155
+ } catch {
156
+ continue
157
+ }
158
+ }
159
+ return fullPath
160
+ }
161
+ } catch {
162
+ // Continue searching
163
+ }
164
+ }
165
+ }
166
+
167
+ return null
168
+ }
169
+
170
+ /**
171
+ * Verify Copilot CLI availability and return status.
172
+ * @param {{ customPath?: string, env?: NodeJS.ProcessEnv }} [options]
173
+ * @returns {{ available: boolean, path?: string, error?: string }}
174
+ */
175
+ export function isCopilotCliAvailable(options = {}) {
176
+ const binary = findCopilotCli(options.customPath, options.env)
177
+ if (binary) {
178
+ return { available: true, path: binary }
179
+ }
180
+ return { available: false, error: 'copilot_binary_not_found' }
181
+ }
182
+
183
+ /**
184
+ * Translate DSH turn options to Copilot CLI arguments and environment.
185
+ * @param {any} options
186
+ * @param {any} [blob]
187
+ * @param {string} [_binaryPath]
188
+ * @returns {{ args: string[], env: NodeJS.ProcessEnv, inputPayload: string }}
189
+ */
190
+ export function prepareCopilotCliInvocation(options, blob = {}, _binaryPath = 'copilot') {
191
+ const model = options.model || 'claude-3.7-sonnet'
192
+ const token = blob.accessToken || blob.githubToken || blob.refreshToken || ''
193
+
194
+ const args = [
195
+ '--model', model,
196
+ '--stream',
197
+ '--json',
198
+ ]
199
+
200
+ if (options.temperature != null) {
201
+ args.push('--temperature', String(options.temperature))
202
+ }
203
+ if (options.maxTokens != null) {
204
+ args.push('--max-tokens', String(options.maxTokens))
205
+ }
206
+
207
+ if (Array.isArray(options.tools) && options.tools.length) {
208
+ args.push('--tools', JSON.stringify(options.tools))
209
+ }
210
+
211
+ const env = {
212
+ ...process.env,
213
+ ...(token ? {
214
+ GITHUB_TOKEN: token,
215
+ GH_TOKEN: token,
216
+ GITHUB_COPILOT_TOKEN: token,
217
+ COPILOT_API_KEY: token,
218
+ } : {}),
219
+ }
220
+
221
+ const inputPayload = JSON.stringify({
222
+ model,
223
+ messages: options.messages || (options.prompt ? [{ role: 'user', content: options.prompt }] : []),
224
+ tools: options.tools || [],
225
+ })
226
+
227
+ return { args, env, inputPayload }
228
+ }
229
+
230
+ /**
231
+ * Stream execution from local Copilot CLI process.
232
+ * Yields canonical DSH events: block-start, text-delta, tool-call-delta, finish, usage.
233
+ * @param {object} params
234
+ * @param {string} params.binaryPath
235
+ * @param {any} params.options
236
+ * @param {any} [params.blob]
237
+ * @param {any} [params.config]
238
+ * @param {AbortSignal} [params.signal]
239
+ * @param {Function} [params.spawnImpl] Test seam for child_process.spawn
240
+ * @returns {AsyncGenerator<any, void, unknown>}
241
+ */
242
+ export async function* streamCopilotCli({ binaryPath, options, blob, config: _config, signal, spawnImpl }) {
243
+ if (signal && signal.aborted) {
244
+ throw new LlmError('Request aborted by client', 'ABORTED')
245
+ }
246
+
247
+ const { args, env, inputPayload } = prepareCopilotCliInvocation(options, blob, binaryPath)
248
+ const spawner = spawnImpl || spawn
249
+
250
+ let child
251
+ try {
252
+ child = spawner(binaryPath, args, {
253
+ env,
254
+ stdio: ['pipe', 'pipe', 'pipe'],
255
+ })
256
+ } catch (err) {
257
+ throw new LlmError(`Failed to spawn Copilot CLI: ${err && err.message ? err.message : err}`, 'INTERNAL_ERROR')
258
+ }
259
+
260
+ if (signal) {
261
+ const abortHandler = () => {
262
+ try { child.kill('SIGTERM') } catch { /* best-effort */ }
263
+ }
264
+ signal.addEventListener('abort', abortHandler, { once: true })
265
+ }
266
+
267
+ if (child.stdin) {
268
+ child.stdin.write(inputPayload)
269
+ child.stdin.end()
270
+ }
271
+
272
+ let textStarted = false
273
+ let toolIndex = 0
274
+ let sawTools = false
275
+ const toolCallMap = new Map()
276
+
277
+ let stdoutBuffer = ''
278
+ let stderrBuffer = ''
279
+
280
+ if (child.stderr) {
281
+ child.stderr.on('data', (d) => { stderrBuffer += d.toString() })
282
+ }
283
+
284
+ const chunkQueue = []
285
+ let resolveNext = null
286
+ let processEnded = false
287
+ let processError = null
288
+
289
+ child.stdout.on('data', (chunk) => {
290
+ stdoutBuffer += chunk.toString()
291
+ let newlineIndex
292
+ while ((newlineIndex = stdoutBuffer.indexOf('\n')) >= 0) {
293
+ const line = stdoutBuffer.slice(0, newlineIndex).trim()
294
+ stdoutBuffer = stdoutBuffer.slice(newlineIndex + 1)
295
+ if (line) {
296
+ chunkQueue.push(line)
297
+ if (resolveNext) {
298
+ const r = resolveNext
299
+ resolveNext = null
300
+ r()
301
+ }
302
+ }
303
+ }
304
+ })
305
+
306
+ child.on('error', (err) => {
307
+ processError = err
308
+ if (resolveNext) {
309
+ const r = resolveNext
310
+ resolveNext = null
311
+ r()
312
+ }
313
+ })
314
+
315
+ child.on('close', (code) => {
316
+ processEnded = true
317
+ if (stdoutBuffer.trim()) {
318
+ chunkQueue.push(stdoutBuffer.trim())
319
+ stdoutBuffer = ''
320
+ }
321
+ if (code !== 0 && !chunkQueue.length) {
322
+ processError = new LlmError(`Copilot CLI exited with code ${code}: ${stderrBuffer.trim() || 'unknown error'}`, 'VENDOR')
323
+ }
324
+ if (resolveNext) {
325
+ const r = resolveNext
326
+ resolveNext = null
327
+ r()
328
+ }
329
+ })
330
+
331
+ while (true) {
332
+ if (chunkQueue.length) {
333
+ const line = chunkQueue.shift()
334
+ const raw = line.startsWith('data:') ? line.slice(5).trim() : line
335
+ if (raw === '[DONE]') break
336
+
337
+ let parsed = null
338
+ try { parsed = JSON.parse(raw) } catch { /* text fragment */ }
339
+
340
+ if (parsed) {
341
+ // 1. Text delta
342
+ const text = parsed.text || parsed.delta?.content || parsed.content || (parsed.choices && parsed.choices[0]?.delta?.content)
343
+ if (typeof text === 'string' && text) {
344
+ if (!textStarted) {
345
+ yield { type: 'block-start', index: 0, blockType: 'text' }
346
+ textStarted = true
347
+ }
348
+ yield { type: 'text-delta', index: 0, text }
349
+ }
350
+
351
+ // 2. Tool calls
352
+ const toolCalls = parsed.tool_calls || parsed.delta?.tool_calls || (parsed.choices && parsed.choices[0]?.delta?.tool_calls) || (parsed.tool_call ? [parsed.tool_call] : null)
353
+ if (Array.isArray(toolCalls)) {
354
+ sawTools = true
355
+ for (const tc of toolCalls) {
356
+ const id = tc.id || `call_${toolIndex}`
357
+ const name = tc.name || (tc.function && tc.function.name)
358
+ const argsDelta = tc.argumentsDelta || tc.arguments || (tc.function && tc.function.arguments) || ''
359
+ let idx = toolCallMap.get(id)
360
+ if (idx === undefined) {
361
+ idx = ++toolIndex
362
+ toolCallMap.set(id, idx)
363
+ yield { type: 'block-start', index: idx, blockType: 'tool-call' }
364
+ }
365
+ yield {
366
+ type: 'tool-call-delta',
367
+ index: idx,
368
+ id,
369
+ ...(name ? { name } : {}),
370
+ argumentsDelta: typeof argsDelta === 'string' ? argsDelta : JSON.stringify(argsDelta),
371
+ }
372
+ }
373
+ }
374
+
375
+ // 3. Usage
376
+ if (parsed.usage) {
377
+ yield { type: 'usage', usage: parsed.usage }
378
+ }
379
+ } else if (raw) {
380
+ // Plain text stdout line
381
+ if (!textStarted) {
382
+ yield { type: 'block-start', index: 0, blockType: 'text' }
383
+ textStarted = true
384
+ }
385
+ yield { type: 'text-delta', index: 0, text: raw }
386
+ }
387
+ } else if (processEnded) {
388
+ if (processError) throw processError
389
+ break
390
+ } else {
391
+ await new Promise((res) => { resolveNext = res })
392
+ if (processError && !chunkQueue.length) throw processError
393
+ }
394
+ }
395
+
396
+ yield {
397
+ type: 'finish',
398
+ reason: { kind: sawTools ? 'tool-calls' : 'stop' },
399
+ }
400
+ }
@@ -2,6 +2,7 @@ import { LlmError } from "@deepseek-ai/dsh-llm"
2
2
  import { openaiMessages, openaiTools, modelCatalog } from "../messages.js"
3
3
  import { fetchWithTimeout } from "../http.js"
4
4
  import { openaiChatStream, readJson, httpError } from "../wire.js"
5
+ import { findCopilotCli, streamCopilotCli, isCopilotCliAvailable, COPILOT_CLI_BINARIES } from "./copilot-cli.js"
5
6
 
6
7
  export const id = "copilot"
7
8
 
@@ -53,6 +54,14 @@ const COPILOT_MODELS = [
53
54
  }
54
55
  ]
55
56
 
57
+ export function copilotCliInfo(config = {}) {
58
+ const status = isCopilotCliAvailable({ customPath: config && config.copilotCliPath })
59
+ return {
60
+ ...status,
61
+ supportedBinaries: COPILOT_CLI_BINARIES,
62
+ }
63
+ }
64
+
56
65
  export function providerInfo() {
57
66
  return { id, name: "GitHub Copilot" }
58
67
  }
@@ -280,7 +289,28 @@ export async function usage(blob, config, fetchImpl) {
280
289
  }
281
290
  }
282
291
 
283
- export async function* streamOnce({ blob, options, fetchImpl, headers, config, signal }) {
292
+ export async function* streamOnce({ blob, options, fetchImpl, headers, config, signal, spawnImpl }) {
293
+ const mode = (config && config.copilotMode) || (blob && blob.copilotMode) || 'auto'
294
+ const customPath = (config && config.copilotCliPath) || (blob && blob.copilotCliPath) || ''
295
+
296
+ if (mode !== 'web') {
297
+ const cliPath = findCopilotCli(customPath)
298
+ if (cliPath) {
299
+ try {
300
+ let yieldedAny = false
301
+ for await (const chunk of streamCopilotCli({ binaryPath: cliPath, options, blob, config, signal, spawnImpl })) {
302
+ yieldedAny = true
303
+ yield chunk
304
+ }
305
+ if (yieldedAny) return
306
+ } catch (err) {
307
+ if (mode === 'cli') throw err
308
+ // In auto mode, fallback seamlessly to web / OAuth execution
309
+ }
310
+ } else if (mode === 'cli') {
311
+ throw new LlmError('GitHub Copilot CLI binary not found on system', 'NOT_CONFIGURED')
312
+ }
313
+ }
284
314
  const impl = fetchImpl || fetch
285
315
  let copilotToken = blob && blob.accessToken
286
316
  const githubToken = blob && (blob.refreshToken || blob.githubToken)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-subscriptions",
3
- "version": "0.6.65",
3
+ "version": "0.6.67",
4
4
  "description": "Use ChatGPT Codex, Claude, Grok, Antigravity, Kimi, GLM, Cursor, Kiro, Copilot, Qwen, ERNIE, Spark, JetBrains, Perplexity, Replit, and Cody subscriptions as DeepSeek Harness LLM providers via OAuth.",
5
5
  "license": "MIT",
6
6
  "type": "module",