pi-ccswitch-auto-switch 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +14 -1
- package/README.zh-CN.md +12 -1
- package/index.ts +41 -1
- package/package.json +6 -2
- package/runner.mjs +283 -0
package/README.md
CHANGED
|
@@ -21,6 +21,7 @@ The extension observes real Pi requests, records sanitized health signals, and
|
|
|
21
21
|
- Persistent, atomic, cross-process health state with error redaction and bounded logs.
|
|
22
22
|
- Compact Pi status bar and interactive health panel.
|
|
23
23
|
- Non-interactive `--print` / JSON runs monitor and record failures but do not inject a competing retry.
|
|
24
|
+
- `pi-ccswitch-run` keeps one headless Pi RPC session alive and returns only the final successful answer after automatic model failover.
|
|
24
25
|
|
|
25
26
|
## Requirements
|
|
26
27
|
|
|
@@ -46,6 +47,18 @@ Restart Pi or run `/reload` after installing or updating. To update the Git inst
|
|
|
46
47
|
|
|
47
48
|
CC Switch should be configured normally. This extension deliberately does not write Pi model settings or CC Switch data.
|
|
48
49
|
|
|
50
|
+
### Headless / automation use
|
|
51
|
+
|
|
52
|
+
Install the package as an npm dependency, then run its binary (or invoke the packaged `runner.mjs` with Node):
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
pi-ccswitch-run --no-tools --no-context-files @prompt.md "Summarize the attached text"
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
The runner starts one `pi --mode rpc --no-session --no-extensions --extension <this package>/index.ts` process, so only CCSwitch is loaded. `PI_BIN` may point to the real Pi executable; it must not point to `pi-ccswitch-run`. The default deadline is ten minutes and can be changed with `--timeout-ms`.
|
|
59
|
+
|
|
60
|
+
Exit codes are `0` for success, `1` when candidates are exhausted, `2` for invocation/RPC configuration failures, `124` for timeout, and `130`/`143` for interruption. Text `@files` are supported in the first release; images and stdin input are intentionally not.
|
|
61
|
+
|
|
49
62
|
## Commands
|
|
50
63
|
|
|
51
64
|
| Command | What it does |
|
|
@@ -81,7 +94,7 @@ CCS ✓70/131 · ⏳61 · ⛔0 · 🔄3 · provider/model-id
|
|
|
81
94
|
|
|
82
95
|
四个状态(健康/冷却/禁用/切换)即使为 0 也始终显示,便于确认扩展处于监控中。All-healthy still shows zero counters, e.g. `CCS ✓131/131 · ⏳0 · ⛔0 · 🔄0 · provider/model-id`. Use `/ccswitch` or `/ccswitch-test` to see Pi's raw scope entry count, the deduplicated model count, affected model counts, and the underlying breaker-record count.
|
|
83
96
|
|
|
84
|
-
切换成功后扩展还会通过 `appendEntry` 向会话注入一条 `ccswitch-switch` custom entry(不参与 LLM 上下文),触发 TUI 底栏重绘——这样 Pi 右下角的模型名显示也会同步为切换后的模型。
|
|
97
|
+
切换成功后扩展还会通过 `appendEntry` 向会话注入一条 `ccswitch-switch` custom entry(不参与 LLM 上下文),触发 TUI 底栏重绘——这样 Pi 右下角的模型名显示也会同步为切换后的模型。RPC 模式还会发出协议版本为 `1` 的 `ccswitch-complete` 或 `ccswitch-exhausted` 终态 entry;runner 用它们区分中间失败轮和最终结果。
|
|
85
98
|
|
|
86
99
|
## Failover behavior
|
|
87
100
|
|
package/README.zh-CN.md
CHANGED
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
- 原子持久化状态、错误脱敏和有上限的日志。
|
|
20
20
|
- Pi 紧凑状态栏与健康管理面板。
|
|
21
21
|
- `--print` / JSON 非交互运行只监控和记录,不会注入可能与进程退出竞争的新请求。
|
|
22
|
+
- `pi-ccswitch-run` 使用单个 Pi RPC 会话完成无头自动故障转移,只输出最终成功模型的回答。
|
|
22
23
|
|
|
23
24
|
## 要求
|
|
24
25
|
|
|
@@ -42,6 +43,16 @@ pi install npm:pi-ccswitch-auto-switch
|
|
|
42
43
|
|
|
43
44
|
安装或更新后重启 Pi,或执行 `/reload`。后续更新 Git 版本可执行 `pi update --extensions`。
|
|
44
45
|
|
|
46
|
+
### 无头自动化调用
|
|
47
|
+
|
|
48
|
+
将本包作为 npm 依赖安装后,可调用:
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
pi-ccswitch-run --no-tools --no-context-files @prompt.md "请总结附件"
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
runner 内部只显式加载本扩展并启动一次 Pi RPC;`PI_BIN` 只能指定内部真实 `pi`,不能指向 runner。默认总超时为 10 分钟,可用 `--timeout-ms` 调整。退出码:成功 `0`、候选耗尽 `1`、参数/RPC 配置错误 `2`、超时 `124`、中断 `130/143`。首版只支持文本 `@文件`,不支持图片和 stdin。
|
|
55
|
+
|
|
45
56
|
正常使用 CC Switch 配置 Provider 即可;本插件不会修改 Pi 模型设置或 CC Switch 数据。
|
|
46
57
|
|
|
47
58
|
## 命令
|
|
@@ -71,7 +82,7 @@ CCS ✓70/131 · ⏳61 · ⛔0 · 🔄3 · provider/model-id
|
|
|
71
82
|
|
|
72
83
|
四个状态(健康/冷却/禁用/切换)即使为 0 也始终显示,便于确认扩展处于监控中;全部健康时仍显示零计数,例如 `CCS ✓131/131 · ⏳0 · ⛔0 · 🔄0 · provider/model-id`。通过 `/ccswitch` 或 `/ccswitch-test` 可同时查看 Pi 原始 scope 条目数、去重模型数、受影响模型数、底层熔断记录数、本 session 切换数与累计切换数。
|
|
73
84
|
|
|
74
|
-
切换成功后扩展还会通过 `appendEntry` 向会话注入一条 `ccswitch-switch` custom entry(不参与 LLM 上下文),触发 TUI 底栏重绘——这样 Pi 右下角的模型名显示也会同步为切换后的模型。
|
|
85
|
+
切换成功后扩展还会通过 `appendEntry` 向会话注入一条 `ccswitch-switch` custom entry(不参与 LLM 上下文),触发 TUI 底栏重绘——这样 Pi 右下角的模型名显示也会同步为切换后的模型。RPC 模式还会发出协议版本为 `1` 的 `ccswitch-complete` 或 `ccswitch-exhausted` 终态 entry,供 runner 判定最终结果。
|
|
75
86
|
|
|
76
87
|
## 故障转移逻辑
|
|
77
88
|
|
package/index.ts
CHANGED
|
@@ -7,6 +7,7 @@ const FIRST_RESPONSE_TIMEOUT = 90_000
|
|
|
7
7
|
const STREAM_IDLE_TIMEOUT = 120_000
|
|
8
8
|
const MAX_ATTEMPTS = 5
|
|
9
9
|
const ROUND_LIMIT = 8 * 60_000
|
|
10
|
+
const RPC_PROTOCOL_VERSION = 1
|
|
10
11
|
// 同端点(BaseURL 相同)连续失败达到该次数即隔离该端点,避免同一个平台的多个模型逐个试错耗尽本轮切换
|
|
11
12
|
const ENDPOINT_FAIL_THRESHOLD = 3
|
|
12
13
|
|
|
@@ -156,6 +157,23 @@ export default function (pi: ExtensionAPI) {
|
|
|
156
157
|
ctx.ui.setWorkingMessage()
|
|
157
158
|
await health.report(`# CCSwitch 自动故障转移失败\n\n时间:${new Date().toISOString()}\n原因:${reason}\n\n已尝试:\n${[...round.tried].map(item => `- ${item}`).join('\n')}\n\n可使用 /ccswitch status 查看状态,/ccswitch reactivate <provider/model> 重新激活。`)
|
|
158
159
|
await health.log(`round exhausted: ${reason}; tried=${[...round.tried].join(',')}`)
|
|
160
|
+
// RPC runner relies on an explicit terminal signal instead of guessing from
|
|
161
|
+
// agent_settled. Keep this RPC-only so ordinary TUI transcripts stay quiet.
|
|
162
|
+
if (ctx.mode === 'rpc') {
|
|
163
|
+
const lastFailure = round.observation ? classifyFailure(round.observation).kind : undefined
|
|
164
|
+
try {
|
|
165
|
+
pi.appendEntry?.('ccswitch-exhausted', {
|
|
166
|
+
protocolVersion: RPC_PROTOCOL_VERSION,
|
|
167
|
+
roundId: round.id,
|
|
168
|
+
reason,
|
|
169
|
+
lastFailure,
|
|
170
|
+
model: key(round.model),
|
|
171
|
+
attempts: round.attempts,
|
|
172
|
+
tried: [...round.tried],
|
|
173
|
+
sessionSwitches,
|
|
174
|
+
})
|
|
175
|
+
} catch { /* reporting must not prevent the regular exhausted path */ }
|
|
176
|
+
}
|
|
159
177
|
notify(ctx, `CCSwitch:自动切换停止(${reason}),请用 /ccswitch 查看详情`, 'error')
|
|
160
178
|
status(ctx)
|
|
161
179
|
}
|
|
@@ -223,7 +241,15 @@ export default function (pi: ExtensionAPI) {
|
|
|
223
241
|
// interactive-mode 收到后执行 footer.invalidate() + requestRender(),footer 从 session.state.model 重新读取,
|
|
224
242
|
// 从而让右下角模型名同步显示新模型(setModel 只改 state,不直接触发 footer 刷新)。
|
|
225
243
|
try {
|
|
226
|
-
pi.appendEntry?.('ccswitch-switch', {
|
|
244
|
+
pi.appendEntry?.('ccswitch-switch', {
|
|
245
|
+
protocolVersion: RPC_PROTOCOL_VERSION,
|
|
246
|
+
roundId: round.id,
|
|
247
|
+
from: fromKey ?? '',
|
|
248
|
+
to: modelKey(next),
|
|
249
|
+
reason: classification.kind,
|
|
250
|
+
attempts: round.attempts,
|
|
251
|
+
sessionSwitches,
|
|
252
|
+
})
|
|
227
253
|
} catch { /* appendEntry 失败不影响切换 */ }
|
|
228
254
|
const continuation = round.hadTool || round.hadOutput
|
|
229
255
|
? '请从当前会话状态继续完成上一条请求;不要重复已经完成的工具操作。'
|
|
@@ -281,6 +307,20 @@ export default function (pi: ExtensionAPI) {
|
|
|
281
307
|
} else {
|
|
282
308
|
health.recordSuccess(round.model); await health.flush()
|
|
283
309
|
round.phase = 'idle'; round.observation = undefined; ctx.ui.setWorkingMessage(); status(ctx)
|
|
310
|
+
// agent_settled is also emitted after intermediate failed turns. An
|
|
311
|
+
// explicit terminal entry lets the headless RPC runner return only after
|
|
312
|
+
// this final successful assistant turn has settled.
|
|
313
|
+
if (ctx.mode === 'rpc') {
|
|
314
|
+
try {
|
|
315
|
+
pi.appendEntry?.('ccswitch-complete', {
|
|
316
|
+
protocolVersion: RPC_PROTOCOL_VERSION,
|
|
317
|
+
roundId: round.id,
|
|
318
|
+
model: key(round.model),
|
|
319
|
+
attempts: round.attempts,
|
|
320
|
+
sessionSwitches,
|
|
321
|
+
})
|
|
322
|
+
} catch { /* completion reporting is best-effort */ }
|
|
323
|
+
}
|
|
284
324
|
}
|
|
285
325
|
})
|
|
286
326
|
pi.on('agent_settled', async (_event, ctx) => {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-ccswitch-auto-switch",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Provider-first automatic model failover extension for Pi and CC Switch",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"keywords": [
|
|
@@ -20,11 +20,15 @@
|
|
|
20
20
|
"homepage": "https://github.com/JunyWuuuu91/pi-ccswitch-auto-switch#readme",
|
|
21
21
|
"files": [
|
|
22
22
|
"index.ts",
|
|
23
|
+
"runner.mjs",
|
|
23
24
|
"types.ts",
|
|
24
25
|
"classify.ts",
|
|
25
26
|
"candidates.ts",
|
|
26
27
|
"health.ts"
|
|
27
28
|
],
|
|
29
|
+
"bin": {
|
|
30
|
+
"pi-ccswitch-run": "./runner.mjs"
|
|
31
|
+
},
|
|
28
32
|
"pi": {
|
|
29
33
|
"extensions": [
|
|
30
34
|
"./index.ts"
|
|
@@ -35,7 +39,7 @@
|
|
|
35
39
|
"node": ">=22.19.0"
|
|
36
40
|
},
|
|
37
41
|
"scripts": {
|
|
38
|
-
"test": "node --test test/*.test.ts",
|
|
42
|
+
"test": "node --test test/*.test.ts test/*.test.mjs",
|
|
39
43
|
"typecheck": "tsc --noEmit --allowImportingTsExtensions --module nodenext --moduleResolution nodenext --target es2022 --skipLibCheck *.ts"
|
|
40
44
|
},
|
|
41
45
|
"devDependencies": {
|
package/runner.mjs
ADDED
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Headless Pi runner that keeps one RPC session alive while the CCSwitch
|
|
4
|
+
* extension retries a failed turn on another model.
|
|
5
|
+
*/
|
|
6
|
+
import { spawn } from 'node:child_process'
|
|
7
|
+
import { access, readFile, stat } from 'node:fs/promises'
|
|
8
|
+
import { existsSync } from 'node:fs'
|
|
9
|
+
import { fileURLToPath } from 'node:url'
|
|
10
|
+
import { basename, delimiter, dirname, extname, isAbsolute, join, resolve } from 'node:path'
|
|
11
|
+
|
|
12
|
+
export const EXIT = Object.freeze({ SUCCESS: 0, EXHAUSTED: 1, CONFIG: 2, TIMEOUT: 124, INTERRUPTED: 130, TERMINATED: 143 })
|
|
13
|
+
export const DEFAULT_TIMEOUT_MS = 600_000
|
|
14
|
+
|
|
15
|
+
const usage = `Usage: pi-ccswitch-run [options] [--] [@text-file ...] <prompt>
|
|
16
|
+
|
|
17
|
+
Options:
|
|
18
|
+
--timeout-ms <ms> Total runner timeout (default: ${DEFAULT_TIMEOUT_MS})
|
|
19
|
+
--no-tools Disable Pi tools
|
|
20
|
+
--no-context-files Disable AGENTS.md / CLAUDE.md discovery
|
|
21
|
+
--provider <name> Initial Pi provider
|
|
22
|
+
--model <name> Initial Pi model
|
|
23
|
+
--thinking <level> Initial Pi thinking level
|
|
24
|
+
--models <patterns> Limit the failover candidate scope
|
|
25
|
+
-p, --print Accepted for migration compatibility
|
|
26
|
+
--no-session Accepted; sessions are always ephemeral
|
|
27
|
+
--no-extensions Accepted; external discovery is always disabled
|
|
28
|
+
-h, --help Show this help`
|
|
29
|
+
|
|
30
|
+
const passthroughWithValue = new Set(['--provider', '--model', '--thinking', '--models'])
|
|
31
|
+
const acceptedNoops = new Set(['-p', '--print', '--no-session', '--no-extensions'])
|
|
32
|
+
|
|
33
|
+
export function parseArgs(argv) {
|
|
34
|
+
const piArgs = []
|
|
35
|
+
const files = []
|
|
36
|
+
const prompt = []
|
|
37
|
+
let timeoutMs = DEFAULT_TIMEOUT_MS
|
|
38
|
+
let positional = false
|
|
39
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
40
|
+
const arg = argv[index]
|
|
41
|
+
if (arg === '--') { positional = true; continue }
|
|
42
|
+
if (!positional && (arg === '-h' || arg === '--help')) return { help: true }
|
|
43
|
+
if (!positional && arg === '--timeout-ms') {
|
|
44
|
+
const raw = argv[++index]
|
|
45
|
+
const value = Number(raw)
|
|
46
|
+
if (!Number.isSafeInteger(value) || value <= 0) throw new Error('--timeout-ms must be a positive integer')
|
|
47
|
+
timeoutMs = value
|
|
48
|
+
continue
|
|
49
|
+
}
|
|
50
|
+
if (!positional && (arg === '--no-tools' || arg === '--no-context-files')) { piArgs.push(arg); continue }
|
|
51
|
+
if (!positional && passthroughWithValue.has(arg)) {
|
|
52
|
+
const value = argv[++index]
|
|
53
|
+
if (!value || value.startsWith('--')) throw new Error(`${arg} requires a value`)
|
|
54
|
+
piArgs.push(arg, value)
|
|
55
|
+
continue
|
|
56
|
+
}
|
|
57
|
+
if (!positional && acceptedNoops.has(arg)) continue
|
|
58
|
+
if (!positional && arg.startsWith('-')) throw new Error(`unsupported option: ${arg}`)
|
|
59
|
+
if (!positional && arg.startsWith('@')) {
|
|
60
|
+
if (arg.length === 1) throw new Error('@file requires a path')
|
|
61
|
+
files.push(arg.slice(1))
|
|
62
|
+
continue
|
|
63
|
+
}
|
|
64
|
+
prompt.push(arg)
|
|
65
|
+
}
|
|
66
|
+
if (prompt.length === 0) throw new Error('a prompt is required')
|
|
67
|
+
return { timeoutMs, piArgs, files, prompt: prompt.join(' ') }
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export async function readTextFiles(fileArgs, cwd = process.cwd()) {
|
|
71
|
+
let text = ''
|
|
72
|
+
for (const raw of fileArgs) {
|
|
73
|
+
const path = resolve(cwd, raw)
|
|
74
|
+
try { await access(path) } catch { throw new Error(`file not found: ${path}`) }
|
|
75
|
+
const info = await stat(path)
|
|
76
|
+
if (!info.isFile()) throw new Error(`not a regular text file: ${path}`)
|
|
77
|
+
if (info.size === 0) continue
|
|
78
|
+
let content
|
|
79
|
+
try { content = await readFile(path, 'utf8') } catch { throw new Error(`not a UTF-8 text file: ${path}`) }
|
|
80
|
+
if (content.includes('\0')) throw new Error(`binary files are not supported: ${path}`)
|
|
81
|
+
text += `<file name="${path}">\n${content.replace(/^\uFEFF/, '')}\n</file>\n`
|
|
82
|
+
}
|
|
83
|
+
return text
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function buildMessage(fileText, prompt) {
|
|
87
|
+
return fileText ? `${fileText}\n${prompt}` : prompt
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function validPiBinary(value) {
|
|
91
|
+
const normalized = basename(value).toLowerCase().replace(/\.(cmd|exe|bat)$/, '')
|
|
92
|
+
return normalized === 'pi'
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
async function resolveWindowsShim(command) {
|
|
96
|
+
const candidates = []
|
|
97
|
+
if (isAbsolute(command) || command.includes('\\') || command.includes('/')) candidates.push(command)
|
|
98
|
+
else {
|
|
99
|
+
for (const dir of (process.env.PATH || '').split(delimiter).filter(Boolean)) {
|
|
100
|
+
candidates.push(join(dir, command))
|
|
101
|
+
if (!/\.(cmd|bat)$/i.test(command)) candidates.push(join(dir, `${command}.cmd`))
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
for (const candidate of candidates) {
|
|
105
|
+
if (!existsSync(candidate) || !/\.(cmd|bat)$/i.test(candidate)) continue
|
|
106
|
+
const source = await readFile(candidate, 'utf8').catch(() => '')
|
|
107
|
+
const match = source.match(/["']([^"']+\.js)["']/i)
|
|
108
|
+
if (!match) continue
|
|
109
|
+
const target = match[1].replace(/%~dp0/ig, `${dirname(candidate)}\\`)
|
|
110
|
+
const entry = resolve(target.replace(/\\/g, '/'))
|
|
111
|
+
if (existsSync(entry)) return { command: process.execPath, prefix: [entry] }
|
|
112
|
+
}
|
|
113
|
+
return { command, prefix: [] }
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
async function resolvePiCommand() {
|
|
117
|
+
const configured = String(process.env.PI_BIN || '').trim()
|
|
118
|
+
if (configured && !validPiBinary(configured)) throw new Error('PI_BIN must name the real pi executable, not an arbitrary command')
|
|
119
|
+
const command = configured || 'pi'
|
|
120
|
+
if (process.platform === 'win32') return resolveWindowsShim(command)
|
|
121
|
+
return { command, prefix: [] }
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function terminateTree(child, signal = 'SIGTERM') {
|
|
125
|
+
if (!child || child.exitCode !== null) return
|
|
126
|
+
if (process.platform === 'win32' && child.pid) {
|
|
127
|
+
const killer = spawn('taskkill', ['/pid', String(child.pid), '/T', '/F'], { stdio: 'ignore', windowsHide: true })
|
|
128
|
+
killer.on('error', () => { try { child.kill('SIGKILL') } catch {} })
|
|
129
|
+
return
|
|
130
|
+
}
|
|
131
|
+
try { child.kill(signal) } catch {}
|
|
132
|
+
setTimeout(() => { try { child.kill('SIGKILL') } catch {} }, 1_000).unref()
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function customEntry(event) {
|
|
136
|
+
if (event?.type !== 'entry_appended' || event.entry?.type !== 'custom') return undefined
|
|
137
|
+
return { type: event.entry.customType, data: event.entry.data || {} }
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export async function run(options, runtime = {}) {
|
|
141
|
+
const spawnImpl = runtime.spawn || spawn
|
|
142
|
+
const extensionPath = runtime.extensionPath || fileURLToPath(new URL('./index.ts', import.meta.url))
|
|
143
|
+
const resolvePi = runtime.resolvePiCommand || resolvePiCommand
|
|
144
|
+
const log = runtime.log || ((message) => process.stderr.write(`[ccswitch] ${message}\n`))
|
|
145
|
+
const resolved = await resolvePi()
|
|
146
|
+
const args = [...resolved.prefix, '--mode', 'rpc', '--no-session', '--no-extensions', '--extension', extensionPath, ...options.piArgs]
|
|
147
|
+
const child = spawnImpl(resolved.command, args, { stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true, env: process.env })
|
|
148
|
+
let buffer = ''
|
|
149
|
+
let closed = false
|
|
150
|
+
let terminal
|
|
151
|
+
let retryPending = false
|
|
152
|
+
let protocolError
|
|
153
|
+
let nextId = 1
|
|
154
|
+
const pending = new Map()
|
|
155
|
+
const closeStdin = () => { try { child.stdin?.end() } catch {} }
|
|
156
|
+
const request = (type, payload = {}) => new Promise((resolveRequest, rejectRequest) => {
|
|
157
|
+
const id = String(nextId++)
|
|
158
|
+
pending.set(id, { resolve: resolveRequest, reject: rejectRequest })
|
|
159
|
+
try { child.stdin.write(`${JSON.stringify({ id, type, ...payload })}\n`) } catch (error) { pending.delete(id); rejectRequest(error) }
|
|
160
|
+
})
|
|
161
|
+
const failPending = (error) => {
|
|
162
|
+
for (const item of pending.values()) item.reject(error)
|
|
163
|
+
pending.clear()
|
|
164
|
+
}
|
|
165
|
+
const handleEvent = (event) => {
|
|
166
|
+
if (event?.type === 'extension_error') { protocolError = `extension error: ${event.error}`; return }
|
|
167
|
+
if (event?.type === 'turn_start') retryPending = false
|
|
168
|
+
const entry = customEntry(event)
|
|
169
|
+
if (!entry) {
|
|
170
|
+
if (event?.type === 'agent_settled' && !terminal && !retryPending) {
|
|
171
|
+
protocolError = 'Pi settled without a CCSwitch terminal event'
|
|
172
|
+
}
|
|
173
|
+
return
|
|
174
|
+
}
|
|
175
|
+
if (entry.data?.protocolVersion !== 1) return
|
|
176
|
+
if (entry.type === 'ccswitch-switch') {
|
|
177
|
+
retryPending = true
|
|
178
|
+
log(`switch ${entry.data.from || '?'} → ${entry.data.to || '?'} (${entry.data.attempts ?? '?'}/5)`)
|
|
179
|
+
} else if (entry.type === 'ccswitch-complete') {
|
|
180
|
+
terminal = { kind: 'complete', data: entry.data }
|
|
181
|
+
} else if (entry.type === 'ccswitch-exhausted') {
|
|
182
|
+
terminal = { kind: 'exhausted', data: entry.data }
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
const processLine = (line) => {
|
|
186
|
+
if (!line.trim()) return
|
|
187
|
+
let event
|
|
188
|
+
try { event = JSON.parse(line) } catch { protocolError = 'Pi RPC emitted malformed JSONL'; return }
|
|
189
|
+
if (event?.type === 'response' && event.id !== undefined) {
|
|
190
|
+
const item = pending.get(String(event.id))
|
|
191
|
+
if (item) {
|
|
192
|
+
pending.delete(String(event.id))
|
|
193
|
+
if (event.success) item.resolve(event.data)
|
|
194
|
+
else item.reject(new Error(event.error || `${event.command || 'RPC'} failed`))
|
|
195
|
+
}
|
|
196
|
+
return
|
|
197
|
+
}
|
|
198
|
+
handleEvent(event)
|
|
199
|
+
}
|
|
200
|
+
child.stdout?.setEncoding('utf8')
|
|
201
|
+
child.stdout?.on('data', (chunk) => {
|
|
202
|
+
buffer += chunk
|
|
203
|
+
let newline
|
|
204
|
+
while ((newline = buffer.indexOf('\n')) !== -1) {
|
|
205
|
+
const line = buffer.slice(0, newline)
|
|
206
|
+
buffer = buffer.slice(newline + 1)
|
|
207
|
+
processLine(line)
|
|
208
|
+
}
|
|
209
|
+
})
|
|
210
|
+
child.stderr?.on('data', () => {})
|
|
211
|
+
const exit = new Promise((resolveExit, rejectExit) => {
|
|
212
|
+
child.once('error', rejectExit)
|
|
213
|
+
child.once('close', (code, signal) => { closed = true; resolveExit({ code, signal }) })
|
|
214
|
+
})
|
|
215
|
+
const timeout = setTimeout(() => { protocolError = 'runner timed out'; terminateTree(child) }, options.timeoutMs)
|
|
216
|
+
timeout.unref?.()
|
|
217
|
+
const onSignal = (signal, code) => {
|
|
218
|
+
protocolError = signal
|
|
219
|
+
terminateTree(child, signal)
|
|
220
|
+
process.exitCode = code
|
|
221
|
+
}
|
|
222
|
+
const onSigint = () => onSignal('SIGINT', EXIT.INTERRUPTED)
|
|
223
|
+
const onSigterm = () => onSignal('SIGTERM', EXIT.TERMINATED)
|
|
224
|
+
process.once('SIGINT', onSigint)
|
|
225
|
+
process.once('SIGTERM', onSigterm)
|
|
226
|
+
try {
|
|
227
|
+
const commands = await request('get_commands')
|
|
228
|
+
if (!Array.isArray(commands?.commands) || !commands.commands.some((entry) => entry.name === 'ccswitch')) {
|
|
229
|
+
throw new Error('CCSwitch extension did not load')
|
|
230
|
+
}
|
|
231
|
+
await request('prompt', { message: options.message })
|
|
232
|
+
while (!terminal && !protocolError && !closed) await new Promise(resolveWait => setTimeout(resolveWait, 10))
|
|
233
|
+
if (protocolError) {
|
|
234
|
+
closeStdin()
|
|
235
|
+
await exit.catch(() => {})
|
|
236
|
+
if (protocolError === 'runner timed out') return { code: EXIT.TIMEOUT, error: protocolError }
|
|
237
|
+
if (protocolError === 'SIGINT') return { code: EXIT.INTERRUPTED, error: protocolError }
|
|
238
|
+
if (protocolError === 'SIGTERM') return { code: EXIT.TERMINATED, error: protocolError }
|
|
239
|
+
return { code: EXIT.CONFIG, error: protocolError }
|
|
240
|
+
}
|
|
241
|
+
if (!terminal) return { code: EXIT.CONFIG, error: 'Pi exited before CCSwitch reached a terminal state' }
|
|
242
|
+
if (terminal.kind === 'exhausted') {
|
|
243
|
+
closeStdin()
|
|
244
|
+
await exit.catch(() => {})
|
|
245
|
+
return { code: EXIT.EXHAUSTED, error: terminal.data.reason || 'all candidate models were exhausted' }
|
|
246
|
+
}
|
|
247
|
+
const last = await request('get_last_assistant_text')
|
|
248
|
+
const text = String(last?.text || '').trim()
|
|
249
|
+
closeStdin()
|
|
250
|
+
await exit.catch(() => {})
|
|
251
|
+
if (!text) return { code: EXIT.CONFIG, error: 'CCSwitch completed but Pi returned no assistant text' }
|
|
252
|
+
return { code: EXIT.SUCCESS, text }
|
|
253
|
+
} catch (error) {
|
|
254
|
+
terminateTree(child)
|
|
255
|
+
closeStdin()
|
|
256
|
+
await exit.catch(() => {})
|
|
257
|
+
return { code: EXIT.CONFIG, error: error instanceof Error ? error.message : String(error) }
|
|
258
|
+
} finally {
|
|
259
|
+
clearTimeout(timeout)
|
|
260
|
+
process.off('SIGINT', onSigint)
|
|
261
|
+
process.off('SIGTERM', onSigterm)
|
|
262
|
+
if (!closed) { failPending(new Error('runner stopped')); terminateTree(child) }
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
export async function main(argv = process.argv.slice(2)) {
|
|
267
|
+
let options
|
|
268
|
+
try { options = parseArgs(argv) } catch (error) { process.stderr.write(`pi-ccswitch-run: ${error.message}\n${usage}\n`); return EXIT.CONFIG }
|
|
269
|
+
if (options.help) { process.stdout.write(`${usage}\n`); return EXIT.SUCCESS }
|
|
270
|
+
try {
|
|
271
|
+
const fileText = await readTextFiles(options.files)
|
|
272
|
+
const result = await run({ ...options, message: buildMessage(fileText, options.prompt) })
|
|
273
|
+
if (result.text) process.stdout.write(`${result.text}\n`)
|
|
274
|
+
if (result.error) process.stderr.write(`pi-ccswitch-run: ${result.error}\n`)
|
|
275
|
+
return result.code
|
|
276
|
+
} catch (error) {
|
|
277
|
+
process.stderr.write(`pi-ccswitch-run: ${error instanceof Error ? error.message : String(error)}\n`)
|
|
278
|
+
return EXIT.CONFIG
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
const invoked = process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)
|
|
283
|
+
if (invoked) main().then(code => { process.exitCode = code })
|