@menghuan1918/dsh-apollo 0.4.0 → 0.5.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 +135 -131
- package/cordis.patch.yml +75 -42
- package/docs/DEVELOPMENT.md +281 -470
- package/package.json +8 -5
- package/presets/eng/plugins/exit-guard/index.js +39 -51
- package/presets/eng/plugins/mode-instructions/index.js +23 -59
- package/presets/eng-ptc.patch.yml +5 -9
- package/presets/eng.patch.yml +29 -36
- package/src/client.js +63 -912
- package/src/config.js +31 -10
- package/src/index.js +13 -7
- package/src/migrate.js +602 -0
- package/src/owns-host.js +66 -0
- package/src/settings.js +39 -35
- package/presets/eng/plugins/wait-subagent/index.js +0 -233
- package/presets/eng/plugins/wait-subagent/package.json +0 -7
- package/src/cli/claude.js +0 -166
- package/src/cli/codex.js +0 -164
- package/src/cli/jsonl.js +0 -216
- package/src/cli-adapter.js +0 -198
- package/src/cli-sessions.js +0 -69
- package/src/panel.js +0 -844
- package/src/subagents.js +0 -198
package/src/cli/codex.js
DELETED
|
@@ -1,164 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Codex CLI 适配:纯命令构造 + `codex exec --json` 事件流解析。
|
|
3
|
-
* 只产出 argv 与 stdin 正文并翻译事件;spawn/管道/stderr/中断由父 adapter 的
|
|
4
|
-
* `ctx.subprocess` 掌握(stdin 取 `{data: input}`)。不 import 任何 DSH 包。
|
|
5
|
-
*
|
|
6
|
-
* 实测依据(codex-cli 0.162.1,新建 + 续接各一轮真实跑通):
|
|
7
|
-
* - 根选项 `-p/-s/-a/-C` 必须在 `exec` **之前**:`codex exec resume` 自己不接受
|
|
8
|
-
* 这四项,续接时只有根位置能让权限与 cwd 生效。
|
|
9
|
-
* - `resume <SESSION_ID>` 紧跟 `exec`,其余选项随后(clap 允许选项交错)。
|
|
10
|
-
* - 正文走 stdin:末尾 `-` 表示从 stdin 读;argv 不经 shell 解释,所以
|
|
11
|
-
* `-c model_reasoning_effort="high"` 里的双引号是**字面量**。
|
|
12
|
-
*
|
|
13
|
-
* 事件语义:
|
|
14
|
-
* - `thread.started` 的 `thread_id` 即 remoteId,立刻回报给父落 KV;
|
|
15
|
-
* - `item.completed` 且 `item.type === 'agent_message'` → 最终助手文本(覆盖式);
|
|
16
|
-
* - `item.type === 'reasoning'` / 工具类 item → **绝不**当作正文;
|
|
17
|
-
* - `item.type === 'error'` → 实测是配置告警("ignoring N unrecognized configuration
|
|
18
|
-
* settings"),**非致命**,只作信息回报;
|
|
19
|
-
* - `turn.completed` → 唯一成功完成标志,带 turn 用量;
|
|
20
|
-
* - `turn.failed` / 顶层 `error` → 致命,`finish()` 抛错。
|
|
21
|
-
*/
|
|
22
|
-
|
|
23
|
-
import { normalizeUsage, requireEffort, requireEnum, requireId, requirePrompt, requireText, sanitizeError } from './jsonl.js'
|
|
24
|
-
|
|
25
|
-
/** `codex -s/--sandbox` 的实测取值(`codex --help`)。 */
|
|
26
|
-
const SANDBOX_MODES = ['read-only', 'workspace-write', 'danger-full-access']
|
|
27
|
-
|
|
28
|
-
/**
|
|
29
|
-
* 构造一轮 `codex exec` 调用。
|
|
30
|
-
*
|
|
31
|
-
* @param {object} spec
|
|
32
|
-
* @param {string} [spec.executable='codex'] - 可执行名或绝对路径。
|
|
33
|
-
* @param {string} [spec.cwd] - 工作根,落到根选项 `-C`。
|
|
34
|
-
* @param {string} [spec.model] - `-m` 模型 id。
|
|
35
|
-
* @param {string} [spec.effort] - 拼进 `-c model_reasoning_effort="…"`。
|
|
36
|
-
* @param {string} [spec.profile] - 根选项 `-p`;留空请传 undefined。
|
|
37
|
-
* @param {string} [spec.permissionMode] - 根选项 `-s`,只接受三种 sandbox 模式。
|
|
38
|
-
* @param {string} [spec.remoteId] - 已有 thread id 时走 `exec resume <id>`。
|
|
39
|
-
* @param {string} spec.prompt - 正文,经 stdin 送入。
|
|
40
|
-
* @returns {{argv: string[], input: string}}
|
|
41
|
-
*/
|
|
42
|
-
export function buildCommand({ executable = 'codex', cwd, model, effort, profile, permissionMode, remoteId, prompt } = {}) {
|
|
43
|
-
const argv = [requireText(executable, 'executable')]
|
|
44
|
-
// —— 根选项:必须在 `exec` 之前,否则续接时 `exec resume` 会拒绝它们 ——
|
|
45
|
-
if (profile !== undefined) argv.push('-p', requireText(profile, 'profile'))
|
|
46
|
-
if (permissionMode !== undefined) argv.push('-s', requireEnum(permissionMode, SANDBOX_MODES, 'permissionMode'))
|
|
47
|
-
// 非交互:任何审批请求都会挂住管道,所以恒定 never。
|
|
48
|
-
argv.push('-a', 'never')
|
|
49
|
-
if (cwd !== undefined) argv.push('-C', requireText(cwd, 'cwd'))
|
|
50
|
-
argv.push('exec')
|
|
51
|
-
if (remoteId !== undefined) argv.push('resume', requireId(remoteId, 'remoteId'))
|
|
52
|
-
argv.push('--json', '--skip-git-repo-check')
|
|
53
|
-
if (model !== undefined) argv.push('-m', requireText(model, 'model'))
|
|
54
|
-
if (effort !== undefined) argv.push('-c', `model_reasoning_effort="${requireEffort(effort)}"`)
|
|
55
|
-
// 位置参数 `-`:正文从 stdin 读,避免超长 prompt 撑爆 argv/命令行长度上限。
|
|
56
|
-
argv.push('-')
|
|
57
|
-
return { argv, input: requirePrompt(prompt) }
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
/**
|
|
61
|
-
* 创建一个有状态的 codex 事件解析器(一轮子进程一个)。
|
|
62
|
-
*
|
|
63
|
-
* @returns {{push: (event: unknown) => ({remoteId?: string, text?: string, usage?: object, completed?: boolean, error?: string}|undefined), finish: () => {text: string, remoteId?: string, usage?: object}}}
|
|
64
|
-
*/
|
|
65
|
-
export function createParser() {
|
|
66
|
-
let remoteId
|
|
67
|
-
let text = ''
|
|
68
|
-
let usage
|
|
69
|
-
let completed = false
|
|
70
|
-
let failure
|
|
71
|
-
|
|
72
|
-
return {
|
|
73
|
-
/**
|
|
74
|
-
* 投喂一个已解析的事件。
|
|
75
|
-
* @param {unknown} event - `readJsonLines` 产出的单个事件。
|
|
76
|
-
* @returns {{remoteId?: string, text?: string, usage?: object, completed?: boolean, error?: string}|undefined}
|
|
77
|
-
* 本次事件产生的动作;无动作返回 undefined。
|
|
78
|
-
* `error` 是**信息性**的:只有 `finish()` 抛错才代表这一轮失败。
|
|
79
|
-
*/
|
|
80
|
-
push(event) {
|
|
81
|
-
if (event === null || typeof event !== 'object') return undefined
|
|
82
|
-
switch (event.type) {
|
|
83
|
-
case 'thread.started': {
|
|
84
|
-
if (typeof event.thread_id !== 'string' || event.thread_id === '') return undefined
|
|
85
|
-
// 只回报一次:父 adapter 在第一次拿到时就落到自己的 KV。
|
|
86
|
-
if (remoteId !== undefined) return undefined
|
|
87
|
-
remoteId = event.thread_id
|
|
88
|
-
return { remoteId }
|
|
89
|
-
}
|
|
90
|
-
case 'item.completed':
|
|
91
|
-
case 'item.updated': {
|
|
92
|
-
const item = event.item
|
|
93
|
-
if (item === null || typeof item !== 'object') return undefined
|
|
94
|
-
if (item.type === 'agent_message') {
|
|
95
|
-
if (typeof item.text !== 'string' || item.text === '') return undefined
|
|
96
|
-
// 覆盖式:一轮里可能有多条 agent_message,最后一条才是最终答复。
|
|
97
|
-
text = item.text
|
|
98
|
-
return { text }
|
|
99
|
-
}
|
|
100
|
-
if (item.type === 'error') {
|
|
101
|
-
// 实测这里是配置告警,不是回合失败,所以不设置 failure。
|
|
102
|
-
return { error: sanitizeError(item.message) }
|
|
103
|
-
}
|
|
104
|
-
// reasoning / command_execution / file_change / mcp_tool_call … 都不是正文。
|
|
105
|
-
return undefined
|
|
106
|
-
}
|
|
107
|
-
case 'turn.completed': {
|
|
108
|
-
completed = true
|
|
109
|
-
usage = mapCodexUsage(event.usage)
|
|
110
|
-
return usage === undefined ? { completed: true } : { usage, completed: true }
|
|
111
|
-
}
|
|
112
|
-
case 'turn.failed': {
|
|
113
|
-
failure ??= sanitizeError(event.error ?? event)
|
|
114
|
-
return { error: failure }
|
|
115
|
-
}
|
|
116
|
-
case 'error': {
|
|
117
|
-
failure ??= sanitizeError(event.message ?? event)
|
|
118
|
-
return { error: failure }
|
|
119
|
-
}
|
|
120
|
-
default:
|
|
121
|
-
// turn.started / item.started / 未来新增事件:无语义动作。
|
|
122
|
-
return undefined
|
|
123
|
-
}
|
|
124
|
-
},
|
|
125
|
-
|
|
126
|
-
/**
|
|
127
|
-
* 收流。失败或缺少完成标志都抛错——「流结束了」不等于「回合成功了」。
|
|
128
|
-
* 幂等:可重复调用,不改变状态。
|
|
129
|
-
* @returns {{text: string, remoteId?: string, usage?: object}}
|
|
130
|
-
*/
|
|
131
|
-
finish() {
|
|
132
|
-
if (failure !== undefined) throw new Error(`codex 回合失败:${failure}`)
|
|
133
|
-
if (!completed) throw new Error('codex 流在收到 turn.completed 之前结束')
|
|
134
|
-
return {
|
|
135
|
-
text,
|
|
136
|
-
...(remoteId === undefined ? {} : { remoteId }),
|
|
137
|
-
...(usage === undefined ? {} : { usage }),
|
|
138
|
-
}
|
|
139
|
-
},
|
|
140
|
-
}
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
/**
|
|
144
|
-
* 把 codex 的 turn 用量映射成 DSH TokenUsage。
|
|
145
|
-
*
|
|
146
|
-
* codex 沿用 OpenAI 语义:`input_tokens` **已含**缓存命中的 prompt token,而 DSH 约定
|
|
147
|
-
* `inputTokens` 不含缓存(见 dsh-llm-pi-ai),故这里做同样扣减;`totalTokens` 由
|
|
148
|
-
* normalizeUsage 按 `input + output + cacheRead + cacheWrite` 还原成完整调用量。
|
|
149
|
-
*/
|
|
150
|
-
function mapCodexUsage(raw) {
|
|
151
|
-
if (raw === null || typeof raw !== 'object') return undefined
|
|
152
|
-
const cacheRead = raw.cached_input_tokens
|
|
153
|
-
const cacheWrite = raw.cache_write_input_tokens
|
|
154
|
-
const prompt = typeof raw.input_tokens === 'number' && Number.isFinite(raw.input_tokens) ? raw.input_tokens : 0
|
|
155
|
-
const cacheReadCount = typeof cacheRead === 'number' && Number.isFinite(cacheRead) && cacheRead > 0 ? cacheRead : 0
|
|
156
|
-
const cacheWriteCount = typeof cacheWrite === 'number' && Number.isFinite(cacheWrite) && cacheWrite > 0 ? cacheWrite : 0
|
|
157
|
-
return normalizeUsage({
|
|
158
|
-
inputTokens: Math.max(0, prompt - cacheReadCount - cacheWriteCount),
|
|
159
|
-
outputTokens: raw.output_tokens,
|
|
160
|
-
cacheReadTokens: cacheReadCount,
|
|
161
|
-
cacheWriteTokens: cacheWriteCount,
|
|
162
|
-
reasoningTokens: raw.reasoning_output_tokens,
|
|
163
|
-
})
|
|
164
|
-
}
|
package/src/cli/jsonl.js
DELETED
|
@@ -1,216 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* CLI 适配层的公共底座:JSONL 切行 + 事件校验/去敏/用量归一。
|
|
3
|
-
*
|
|
4
|
-
* 为什么需要:两个 CLI 都按「一行一个 JSON 事件」输出,而 stdout 的分块边界与行边界
|
|
5
|
-
* 毫无关系——一个 chunk 可能含半行、多行,或正好切在某个 UTF-8 多字节字符中间。
|
|
6
|
-
* 切行只在这里做一次,两个 backend 就只需关心事件语义。
|
|
7
|
-
*
|
|
8
|
-
* 契约:
|
|
9
|
-
* - 只跳过**空白行**(CLI 收尾换行必然切出一个空串);非空但非法 JSON 一律抛
|
|
10
|
-
* SyntaxError——静默丢弃会让调用方把「畸形流」误判成「回合正常结束」。
|
|
11
|
-
* - Buffer 走 StringDecoder,跨 chunk 多字节字符不乱码;单行超 `maxLineBytes` 抛 RangeError。
|
|
12
|
-
* - 只读一个流:stderr 由父 adapter 自己收集。
|
|
13
|
-
*
|
|
14
|
-
* 顶层只依赖 node 内置模块,不 import 任何 DSH 包——子进程生命周期归父 adapter。
|
|
15
|
-
*/
|
|
16
|
-
|
|
17
|
-
import { StringDecoder } from 'node:string_decoder'
|
|
18
|
-
|
|
19
|
-
/** 单行上限 1 MiB:CLI 事件远小于此,超过即视为协议被破坏。 */
|
|
20
|
-
export const DEFAULT_MAX_LINE_BYTES = 1048576
|
|
21
|
-
|
|
22
|
-
/** 错误文本上限:既保证日志有界,也避免把大段原始输出回灌进会话。 */
|
|
23
|
-
export const MAX_ERROR_CHARS = 400
|
|
24
|
-
|
|
25
|
-
/** 空白行没有 JSON 值可产出,用哨兵与「合法的 null 事件」区分。 */
|
|
26
|
-
const SKIP = Symbol('jsonl.skip')
|
|
27
|
-
|
|
28
|
-
/**
|
|
29
|
-
* 把可读流切成 JSON 行并逐个产出解析后的值。
|
|
30
|
-
*
|
|
31
|
-
* @param {AsyncIterable<Buffer|Uint8Array|string>} readable - 子进程 stdout(或任意异步可迭代)。
|
|
32
|
-
* @param {{maxLineBytes?: number}} [options] - 单行字节上限。
|
|
33
|
-
* @returns {AsyncGenerator<unknown>} 解析后的事件,按流序产出。
|
|
34
|
-
*/
|
|
35
|
-
export async function* readJsonLines(readable, { maxLineBytes = DEFAULT_MAX_LINE_BYTES } = {}) {
|
|
36
|
-
if (!Number.isSafeInteger(maxLineBytes) || maxLineBytes <= 0) {
|
|
37
|
-
throw new TypeError(`readJsonLines: maxLineBytes 必须是正的安全整数,收到 ${maxLineBytes}`)
|
|
38
|
-
}
|
|
39
|
-
if (readable === null || typeof readable !== 'object' || typeof readable[Symbol.asyncIterator] !== 'function') {
|
|
40
|
-
throw new TypeError('readJsonLines: readable 必须是产出 Buffer 或 string 的异步可迭代对象')
|
|
41
|
-
}
|
|
42
|
-
const decoder = new StringDecoder('utf8')
|
|
43
|
-
let pending = ''
|
|
44
|
-
let lineNumber = 0
|
|
45
|
-
for await (const chunk of readable) {
|
|
46
|
-
// string chunk 说明上游已解码:先冲掉解码器里的残余字节,再直接拼接。
|
|
47
|
-
pending += typeof chunk === 'string' ? decoder.end() + chunk : decoder.write(chunk)
|
|
48
|
-
let index = pending.indexOf('\n')
|
|
49
|
-
while (index !== -1) {
|
|
50
|
-
const line = pending.slice(0, index)
|
|
51
|
-
pending = pending.slice(index + 1)
|
|
52
|
-
lineNumber += 1
|
|
53
|
-
const value = parseLine(line, lineNumber, maxLineBytes)
|
|
54
|
-
if (value !== SKIP) yield value
|
|
55
|
-
index = pending.indexOf('\n')
|
|
56
|
-
}
|
|
57
|
-
// 还没等到换行就已经超限:不必等到整行结束才报错。
|
|
58
|
-
if (Buffer.byteLength(pending) > maxLineBytes) {
|
|
59
|
-
throw new RangeError(`readJsonLines: 第 ${lineNumber + 1} 行超过 maxLineBytes(${maxLineBytes})`)
|
|
60
|
-
}
|
|
61
|
-
}
|
|
62
|
-
// 尾行:流结束时最后一行可能没有换行符,仍需产出。
|
|
63
|
-
pending += decoder.end()
|
|
64
|
-
if (pending !== '') {
|
|
65
|
-
lineNumber += 1
|
|
66
|
-
const value = parseLine(pending, lineNumber, maxLineBytes)
|
|
67
|
-
if (value !== SKIP) yield value
|
|
68
|
-
}
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
/** 解析一行:空白行返回哨兵,非法 JSON 抛 SyntaxError(带行号)。 */
|
|
72
|
-
function parseLine(line, lineNumber, maxLineBytes) {
|
|
73
|
-
const text = line.endsWith('\r') ? line.slice(0, -1) : line
|
|
74
|
-
if (text.trim() === '') return SKIP
|
|
75
|
-
if (Buffer.byteLength(text) > maxLineBytes) {
|
|
76
|
-
throw new RangeError(`readJsonLines: 第 ${lineNumber} 行超过 maxLineBytes(${maxLineBytes})`)
|
|
77
|
-
}
|
|
78
|
-
try {
|
|
79
|
-
return JSON.parse(text)
|
|
80
|
-
} catch (error) {
|
|
81
|
-
throw new SyntaxError(`readJsonLines: 第 ${lineNumber} 行不是合法 JSON:${error.message}`)
|
|
82
|
-
}
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
/**
|
|
86
|
-
* 归一成 DSH TokenUsage(契约见 dsh-llm 的 `TokenUsage`:`inputTokens` / `outputTokens` /
|
|
87
|
-
* `totalTokens?` / `cacheReadTokens?` / `cacheWriteTokens?` / `reasoningTokens?`)。
|
|
88
|
-
*
|
|
89
|
-
* 与 dsh-llm-pi-ai 同约定:`inputTokens` **不含**缓存命中/写入;
|
|
90
|
-
* `totalTokens = input + output + cacheRead + cacheWrite`;为 0 的可选字段省略。
|
|
91
|
-
* 非法数字按 0 处理而不抛错——用量异常不该毁掉一整轮。
|
|
92
|
-
*
|
|
93
|
-
* @param {{inputTokens?: unknown, outputTokens?: unknown, cacheReadTokens?: unknown, cacheWriteTokens?: unknown, reasoningTokens?: unknown}} raw
|
|
94
|
-
* @returns {{inputTokens: number, outputTokens: number, totalTokens: number, cacheReadTokens?: number, cacheWriteTokens?: number, reasoningTokens?: number}}
|
|
95
|
-
*/
|
|
96
|
-
export function normalizeUsage({ inputTokens, outputTokens, cacheReadTokens, cacheWriteTokens, reasoningTokens } = {}) {
|
|
97
|
-
const input = count(inputTokens)
|
|
98
|
-
const output = count(outputTokens)
|
|
99
|
-
const cacheRead = count(cacheReadTokens)
|
|
100
|
-
const cacheWrite = count(cacheWriteTokens)
|
|
101
|
-
const reasoning = count(reasoningTokens)
|
|
102
|
-
const usage = {
|
|
103
|
-
inputTokens: input,
|
|
104
|
-
outputTokens: output,
|
|
105
|
-
totalTokens: input + output + cacheRead + cacheWrite,
|
|
106
|
-
}
|
|
107
|
-
if (cacheRead > 0) usage.cacheReadTokens = cacheRead
|
|
108
|
-
if (cacheWrite > 0) usage.cacheWriteTokens = cacheWrite
|
|
109
|
-
if (reasoning > 0) usage.reasoningTokens = reasoning
|
|
110
|
-
return usage
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
/** 取一个非负有限数;其余(undefined/NaN/负数/字符串)一律折成 0。 */
|
|
114
|
-
function count(value) {
|
|
115
|
-
return typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : 0
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
/** 去敏 + 有界的错误文本:抹掉凭据形状、压掉控制字符/换行、按字符数截断。 */
|
|
119
|
-
const REDACTIONS = [
|
|
120
|
-
// Bearer / Authorization 头
|
|
121
|
-
[/\b(Bearer|Basic)\s+[A-Za-z0-9._~+/=-]{8,}/gi, '$1 [redacted]'],
|
|
122
|
-
// 显式键名后的取值(api_key=、token:、password 等)
|
|
123
|
-
[/\b(api[_-]?key|apikey|access[_-]?token|refresh[_-]?token|auth[_-]?token|secret|password|passwd|token)(\s*[":=]\s*)["']?[A-Za-z0-9._~+/=-]{8,}["']?/gi, '$1$2[redacted]'],
|
|
124
|
-
// 常见前缀凭据
|
|
125
|
-
[/\b(sk|rk|pk|ghp|gho|ghu|ghs|ghr|xoxb|xoxp|xapp|glpat|AIza|AKIA)[-_][A-Za-z0-9_-]{8,}/g, '[redacted]'],
|
|
126
|
-
// 通用长随机串(JWT / 裸密钥 / 长 base64)
|
|
127
|
-
[/[A-Za-z0-9+/=_-]{64,}/g, '[redacted]'],
|
|
128
|
-
]
|
|
129
|
-
|
|
130
|
-
/** 去掉 NUL 与其余 C0/C1 控制字符(含换行),保证错误是单行文本。 */
|
|
131
|
-
const CONTROL_CHARS = /[\u0000-\u001f\u007f-\u009f]+/g
|
|
132
|
-
|
|
133
|
-
/**
|
|
134
|
-
* 把任意错误载荷压成一条可直接进日志/会话文本的安全字符串。
|
|
135
|
-
* @param {unknown} value - 原始错误(字符串、Error、事件字段……)。
|
|
136
|
-
* @returns {string} 去敏且不超过 {@link MAX_ERROR_CHARS} 的单行文本。
|
|
137
|
-
*/
|
|
138
|
-
export function sanitizeError(value) {
|
|
139
|
-
let text = typeof value === 'string' ? value : value instanceof Error ? value.message : safeStringify(value)
|
|
140
|
-
for (const [pattern, replacement] of REDACTIONS) text = text.replace(pattern, replacement)
|
|
141
|
-
text = text.replace(CONTROL_CHARS, ' ').replace(/\s+/g, ' ').trim()
|
|
142
|
-
if (text === '') return '未知错误'
|
|
143
|
-
return text.length > MAX_ERROR_CHARS ? `${text.slice(0, MAX_ERROR_CHARS)}…` : text
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
/**
|
|
147
|
-
* 兜底序列化:循环引用、undefined、函数等都不该让解析器崩掉,
|
|
148
|
-
* 也绝不能让 `undefined` 变成字面量字符串 "undefined" 混进错误文本。
|
|
149
|
-
*/
|
|
150
|
-
function safeStringify(value) {
|
|
151
|
-
if (value === undefined || value === null) return ''
|
|
152
|
-
try {
|
|
153
|
-
const text = JSON.stringify(value)
|
|
154
|
-
return typeof text === 'string' ? text : ''
|
|
155
|
-
} catch {
|
|
156
|
-
return ''
|
|
157
|
-
}
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
/** 校验「非空字符串且不含控制字符」的 argv 取值。argv 不经 shell 解释,只需拦空值与换行。 */
|
|
161
|
-
export function requireText(value, name) {
|
|
162
|
-
if (typeof value !== 'string' || value.trim() === '') {
|
|
163
|
-
throw new TypeError(`${name} 必须是非空字符串,收到 ${describe(value)}`)
|
|
164
|
-
}
|
|
165
|
-
if (/[\u0000-\u001f\u007f]/.test(value)) {
|
|
166
|
-
throw new TypeError(`${name} 不能包含控制字符`)
|
|
167
|
-
}
|
|
168
|
-
return value
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
/**
|
|
172
|
-
* 校验**位置参数**取值(如 `codex exec resume <id>`)。
|
|
173
|
-
* 额外拒绝前导 `-`:否则一个形如 `--json` 的 id 会被 CLI 当成选项解析。
|
|
174
|
-
*/
|
|
175
|
-
export function requireId(value, name) {
|
|
176
|
-
const text = requireText(value, name)
|
|
177
|
-
if (text.startsWith('-')) {
|
|
178
|
-
throw new TypeError(`${name} 不能以 "-" 开头(会被 CLI 当作选项),收到 ${JSON.stringify(text)}`)
|
|
179
|
-
}
|
|
180
|
-
return text
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
/**
|
|
184
|
-
* 校验 reasoning effort 档位。刻意用严格 token 正则:codex 侧它会被拼进
|
|
185
|
-
* `-c model_reasoning_effort="…"`,绝不能含 `"`、`\` 或空白(等于往 TOML 注入)。
|
|
186
|
-
* 实测取值 codex = minimal/low/medium/high,claude = low/medium/high/xhigh/max;
|
|
187
|
-
* 不做枚举硬编码,以免上游加档位就炸。
|
|
188
|
-
*/
|
|
189
|
-
export function requireEffort(value, name = 'effort') {
|
|
190
|
-
if (typeof value !== 'string' || !/^[A-Za-z][A-Za-z0-9_-]*$/.test(value)) {
|
|
191
|
-
throw new TypeError(`${name} 必须是形如 low/medium/high/xhigh/max 的档位标识,收到 ${describe(value)}`)
|
|
192
|
-
}
|
|
193
|
-
return value
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
/** 校验枚举型取值,把「CLI 后期才报错」提前成构造期的清晰 TypeError。 */
|
|
197
|
-
export function requireEnum(value, allowed, name) {
|
|
198
|
-
if (!allowed.includes(value)) {
|
|
199
|
-
throw new TypeError(`${name} 必须是 ${allowed.join(' / ')} 之一,收到 ${describe(value)}`)
|
|
200
|
-
}
|
|
201
|
-
return value
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
/** 校验 stdin 正文:只要求是字符串,内容原样透传(可以含换行,空串也合法)。 */
|
|
205
|
-
export function requirePrompt(value) {
|
|
206
|
-
if (typeof value !== 'string') {
|
|
207
|
-
throw new TypeError(`prompt 必须是字符串(作为子进程 stdin 正文),收到 ${describe(value)}`)
|
|
208
|
-
}
|
|
209
|
-
return value
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
/** 给报错用的短描述,避免把整个超长 prompt 打进错误里。 */
|
|
213
|
-
function describe(value) {
|
|
214
|
-
if (typeof value === 'string') return JSON.stringify(value.length > 60 ? `${value.slice(0, 60)}…` : value)
|
|
215
|
-
return String(value)
|
|
216
|
-
}
|
package/src/cli-adapter.js
DELETED
|
@@ -1,198 +0,0 @@
|
|
|
1
|
-
import { createHash } from 'node:crypto'
|
|
2
|
-
import { dirname } from 'node:path'
|
|
3
|
-
import { normalizeConfig } from './config.js'
|
|
4
|
-
import * as codex from './cli/codex.js'
|
|
5
|
-
import * as claude from './cli/claude.js'
|
|
6
|
-
import { readJsonLines, sanitizeError } from './cli/jsonl.js'
|
|
7
|
-
|
|
8
|
-
export const CLI_ROUTES = Object.freeze({ codex: 'apollo-cli-codex', claude: 'apollo-cli-claude' })
|
|
9
|
-
const protocols = { codex, claude }
|
|
10
|
-
const NO_RETRIES = Object.freeze({ mode: 'normal', maxRetries: 0, retryableCodes: [], initialDelayMs: 500, maxDelayMs: 500, jitterRatio: 0 })
|
|
11
|
-
|
|
12
|
-
export function enabledIn(config, agent) {
|
|
13
|
-
const scope = normalizeConfig(config).subagents
|
|
14
|
-
let preset = agent?.session.header.agentPreset
|
|
15
|
-
try {
|
|
16
|
-
const projections = agent?.ctx?.get?.('sessionProjections')
|
|
17
|
-
if (projections) preset = projections.stateOf(agent.session, 'agentPreset') ?? preset
|
|
18
|
-
} catch { /* Compositions without the optional preset registry use the header. */ }
|
|
19
|
-
return scope.global || (scope.eng && ['eng', 'eng-ptc'].includes(preset))
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
/** Exact checks are repeated before every CLI turn, including cold resume. */
|
|
23
|
-
export function selectCli(config, cli, model, effort) {
|
|
24
|
-
const backend = normalizeConfig(config).subagents[cli]
|
|
25
|
-
if (!backend?.enabled) throw new Error(`Apollo ${cli} backend is disabled; enable it in Plugins → Apollo`)
|
|
26
|
-
const modelId = model ?? backend.defaultModel
|
|
27
|
-
const selected = backend.models.find((candidate) => candidate.id === modelId)
|
|
28
|
-
if (!selected) throw new Error(`Apollo ${cli} model "${modelId}" is not allowed; check the backend model list`)
|
|
29
|
-
const selectedEffort = effort ?? selected.defaultEffort
|
|
30
|
-
if (!selectedEffort || !selected.efforts.includes(selectedEffort)) {
|
|
31
|
-
throw new Error(`Apollo ${cli} effort "${selectedEffort ?? ''}" is not allowed for model "${modelId}"`)
|
|
32
|
-
}
|
|
33
|
-
return { ...backend, model: modelId, effort: selectedEffort }
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
/** Only new user deliveries go to a native CLI that already owns its history. */
|
|
37
|
-
export function pendingMessages(messages, delivered) {
|
|
38
|
-
const seen = new Set(delivered)
|
|
39
|
-
const pending = []
|
|
40
|
-
for (const message of messages) {
|
|
41
|
-
if (message.role !== 'user') continue
|
|
42
|
-
const id = message.id ?? createHash('sha256').update(JSON.stringify(message.content)).digest('hex')
|
|
43
|
-
if (seen.has(id)) continue
|
|
44
|
-
const blocks = typeof message.content === 'string' ? [{ type: 'text', text: message.content }] : message.content
|
|
45
|
-
if (!Array.isArray(blocks) || blocks.some((block) => block.type !== 'text')) {
|
|
46
|
-
throw new Error('Apollo CLI subagents currently accept text messages only')
|
|
47
|
-
}
|
|
48
|
-
const text = blocks.map((block) => block.text).join('\n')
|
|
49
|
-
pending.push({ id, text })
|
|
50
|
-
seen.add(id)
|
|
51
|
-
}
|
|
52
|
-
return pending
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
/** One adapter instance owns only one CLI; DSH owns the containing Agent loop. */
|
|
56
|
-
export function createCliAdapter(ctx, config, sessions, cli) {
|
|
57
|
-
const route = CLI_ROUTES[cli]
|
|
58
|
-
const active = new Set()
|
|
59
|
-
const running = new Set()
|
|
60
|
-
const settling = new Set()
|
|
61
|
-
const lifetime = new AbortController()
|
|
62
|
-
let disposed = false
|
|
63
|
-
const adapter = {
|
|
64
|
-
providerInfo: () => ({ id: route, name: `Apollo ${cli === 'codex' ? 'Codex' : 'Claude'} CLI (subagents)` }),
|
|
65
|
-
providerRetryPolicy: () => NO_RETRIES,
|
|
66
|
-
imageRequestPricing: () => undefined,
|
|
67
|
-
async listModels() {
|
|
68
|
-
const backend = normalizeConfig(config).subagents[cli]
|
|
69
|
-
return backend.enabled ? backend.models.map((model) => ({ provider: route, id: model.id, name: model.id, inputModalities: ['text'] })) : []
|
|
70
|
-
},
|
|
71
|
-
async resolveModel(provider, model) {
|
|
72
|
-
const backend = normalizeConfig(config).subagents[cli]
|
|
73
|
-
const selected = backend.models.find((candidate) => candidate.id === model)
|
|
74
|
-
if (provider !== route || !backend.enabled || !selected) throw new Error(`Apollo ${cli} model "${model}" is unavailable`)
|
|
75
|
-
if (selected.efforts.length === 0) throw new Error(`Apollo ${cli} model "${model}" has no allowed reasoning efforts; configure them in Plugins → Apollo`)
|
|
76
|
-
return {
|
|
77
|
-
provider: route, id: model, name: model, inputModalities: ['text'],
|
|
78
|
-
reasoning: { efforts: selected.efforts.map((id) => ({ id, name: id })), ...(selected.defaultEffort ? { defaultEffort: selected.defaultEffort } : {}) },
|
|
79
|
-
}
|
|
80
|
-
},
|
|
81
|
-
async prepareCall(provider, model, signal) {
|
|
82
|
-
signal?.throwIfAborted()
|
|
83
|
-
return { model: await adapter.resolveModel(provider, model), stream: (options) => adapter.stream(options) }
|
|
84
|
-
},
|
|
85
|
-
async *stream(options) {
|
|
86
|
-
if (disposed) throw new Error('Apollo CLI adapter has been unloaded')
|
|
87
|
-
const id = options.sessionId
|
|
88
|
-
const agent = id === undefined ? undefined : ctx.agents.get(id)
|
|
89
|
-
let state = id === undefined ? undefined : sessions.get(id)
|
|
90
|
-
if (!agent || !state || state.cli !== cli || agent.session.header.parentSession !== state.parentId
|
|
91
|
-
|| agent.session.header.origin !== 'subagent' || agent.session.header.isSeeded) {
|
|
92
|
-
throw new Error('Apollo CLI routes are reserved for subagent-created continuable sessions')
|
|
93
|
-
}
|
|
94
|
-
if (options.provider !== route || options.model !== state.model || options.reasoningEffort !== state.effort) {
|
|
95
|
-
throw new Error('Apollo CLI session model and effort are fixed at creation')
|
|
96
|
-
}
|
|
97
|
-
if (running.has(id)) throw new Error('Apollo CLI session already has an active turn')
|
|
98
|
-
const current = selectCli(config, cli, state.model, state.effort)
|
|
99
|
-
const pending = pendingMessages(options.messages, state.delivered)
|
|
100
|
-
if (pending.length === 0) throw new Error('Apollo CLI turn has no new user message to deliver')
|
|
101
|
-
if (state.started && !state.remoteId) throw new Error('Apollo CLI session creation was interrupted before its identity was saved; create a new subagent')
|
|
102
|
-
const signal = AbortSignal.any([lifetime.signal, ...options.signal ? [options.signal] : []])
|
|
103
|
-
signal.throwIfAborted()
|
|
104
|
-
running.add(id)
|
|
105
|
-
let settled
|
|
106
|
-
const completion = new Promise((resolve) => { settled = resolve })
|
|
107
|
-
settling.add(completion)
|
|
108
|
-
const prior = structuredClone(state)
|
|
109
|
-
let stateWritten = false
|
|
110
|
-
let handle
|
|
111
|
-
let done
|
|
112
|
-
try {
|
|
113
|
-
const prompt = [
|
|
114
|
-
...pending.map((message) => message.text),
|
|
115
|
-
`Apollo CLI bridge: return your answer as text; DSH forwards the final output automatically. Harness-only messaging tools are unavailable here. This turn uses ${cli} permission mode "${current.permissionMode}" from Apollo settings. Embedded DSH runtime snapshots describe the host tools; CLI permissions are controlled by these CLI settings.`,
|
|
116
|
-
].join('\n\n')
|
|
117
|
-
const { argv, input } = protocols[cli].buildCommand({
|
|
118
|
-
...state, profile: state.profile || undefined, remoteId: state.remoteId, prompt, permissionMode: current.permissionMode,
|
|
119
|
-
})
|
|
120
|
-
// Record attempted delivery before I/O. CLI turns are never retried automatically.
|
|
121
|
-
state = { ...state, started: true, delivered: [...state.delivered, ...pending.map((message) => message.id)] }
|
|
122
|
-
await sessions.put(state)
|
|
123
|
-
stateWritten = true
|
|
124
|
-
signal.throwIfAborted()
|
|
125
|
-
const subprocess = agent.ctx.get('subprocess')
|
|
126
|
-
if (!subprocess) throw new Error('Apollo CLI subagents require the managed subprocess service')
|
|
127
|
-
const env = state.cliHome ? { [cli === 'codex' ? 'CODEX_HOME' : 'CLAUDE_CONFIG_DIR']: state.cliHome } : undefined
|
|
128
|
-
if (cli === 'claude' && state.claudeDefaultHome && env) {
|
|
129
|
-
env[process.platform === 'win32' ? 'USERPROFILE' : 'HOME'] = dirname(state.cliHome)
|
|
130
|
-
env.CLAUDE_CONFIG_DIR = undefined // Managed subprocess tombstone preserves native default/keychain lookup.
|
|
131
|
-
}
|
|
132
|
-
handle = subprocess.spawn({
|
|
133
|
-
argv, cwd: state.cwd,
|
|
134
|
-
stdio: { stdin: { data: input }, stdout: 'pipe', stderr: { maxBytes: 4096 } },
|
|
135
|
-
graceMs: 1500, signal,
|
|
136
|
-
...(env === undefined ? {} : { env }),
|
|
137
|
-
})
|
|
138
|
-
active.add(handle)
|
|
139
|
-
// Observe spawn errors immediately while stdout is being consumed.
|
|
140
|
-
done = handle.done.then((outcome) => ({ outcome }), (error) => ({ error }))
|
|
141
|
-
if (!handle.stdout) throw new Error('Apollo CLI did not expose its JSON event stream')
|
|
142
|
-
const parser = protocols[cli].createParser()
|
|
143
|
-
let protocolError
|
|
144
|
-
for await (const event of readJsonLines(handle.stdout)) {
|
|
145
|
-
const update = parser.push(event)
|
|
146
|
-
if (update?.error) protocolError = update.error
|
|
147
|
-
if (update?.remoteId) {
|
|
148
|
-
if (state.remoteId && state.remoteId !== update.remoteId) throw new Error('CLI resumed a different session; refusing to replace its identity')
|
|
149
|
-
if (!state.remoteId) {
|
|
150
|
-
state = { ...state, remoteId: update.remoteId }
|
|
151
|
-
await sessions.put(state)
|
|
152
|
-
}
|
|
153
|
-
}
|
|
154
|
-
signal.throwIfAborted()
|
|
155
|
-
}
|
|
156
|
-
const result = await done
|
|
157
|
-
signal.throwIfAborted()
|
|
158
|
-
if (result.error) throw new Error(`Apollo ${cli} process could not start: ${result.error.code ?? result.error.name ?? 'spawn error'}`)
|
|
159
|
-
if (result.outcome.exitCode !== 0) {
|
|
160
|
-
const detail = protocolError || handle.collected?.stderr?.readFrom(0).text
|
|
161
|
-
throw new Error(`Apollo ${cli} exited with ${result.outcome.exitCode ?? result.outcome.signal}; check CLI authentication, profile and permissions${detail ? `: ${sanitizeError(detail)}` : ''}`)
|
|
162
|
-
}
|
|
163
|
-
const output = parser.finish()
|
|
164
|
-
if (!state.remoteId) throw new Error(`Apollo ${cli} completed without a resumable session id`)
|
|
165
|
-
if (output.text) {
|
|
166
|
-
yield { type: 'block-start', index: 0, blockType: 'text' }
|
|
167
|
-
yield { type: 'text-delta', index: 0, text: output.text }
|
|
168
|
-
yield { type: 'block-end', index: 0, block: { type: 'text', text: output.text } }
|
|
169
|
-
}
|
|
170
|
-
if (output.usage) yield { type: 'usage', usage: output.usage }
|
|
171
|
-
yield { type: 'finish', reason: { kind: 'stop' } }
|
|
172
|
-
} finally {
|
|
173
|
-
try {
|
|
174
|
-
if (handle) {
|
|
175
|
-
handle.terminate()
|
|
176
|
-
await done
|
|
177
|
-
await handle.waitForExit()
|
|
178
|
-
} else if (stateWritten) {
|
|
179
|
-
// No process was published, so no native CLI consumed this delivery.
|
|
180
|
-
await sessions.put(prior)
|
|
181
|
-
}
|
|
182
|
-
} finally {
|
|
183
|
-
if (handle) active.delete(handle)
|
|
184
|
-
running.delete(id)
|
|
185
|
-
settling.delete(completion)
|
|
186
|
-
settled()
|
|
187
|
-
}
|
|
188
|
-
}
|
|
189
|
-
},
|
|
190
|
-
async dispose() {
|
|
191
|
-
disposed = true
|
|
192
|
-
lifetime.abort(new Error('Apollo CLI adapter unloaded'))
|
|
193
|
-
for (const handle of active) handle.terminate()
|
|
194
|
-
await Promise.allSettled([...settling])
|
|
195
|
-
},
|
|
196
|
-
}
|
|
197
|
-
return adapter
|
|
198
|
-
}
|
package/src/cli-sessions.js
DELETED
|
@@ -1,69 +0,0 @@
|
|
|
1
|
-
import { createHash } from 'node:crypto'
|
|
2
|
-
|
|
3
|
-
/** Durable CLI identities live outside the compactable conversation surface. */
|
|
4
|
-
export async function openCliSessions(ctx) {
|
|
5
|
-
const profile = ctx.get('profileContext')
|
|
6
|
-
const namespace = createHash('sha256').update(profile?.dir ?? 'default').digest('hex').slice(0, 16)
|
|
7
|
-
const backend = ctx.storage.backend.get('json')
|
|
8
|
-
if (!backend?.kv) throw new Error('Apollo CLI subagents require the JSON storage backend')
|
|
9
|
-
const unit = await backend.kv.open({
|
|
10
|
-
name: `apollo_cli_${namespace}`, version: 1, tables: ['sessions'], hasGlobal: false, layout: 'per-record',
|
|
11
|
-
})
|
|
12
|
-
try {
|
|
13
|
-
const snapshot = await unit.loadAll()
|
|
14
|
-
return new CliSessions(unit, snapshot.tables.sessions ?? {})
|
|
15
|
-
} catch (error) {
|
|
16
|
-
await unit.close()
|
|
17
|
-
throw error
|
|
18
|
-
}
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
export class CliSessions {
|
|
22
|
-
constructor(unit, records = {}) {
|
|
23
|
-
this.unit = unit
|
|
24
|
-
this.records = new Map(Object.entries(records))
|
|
25
|
-
this.writes = Promise.resolve()
|
|
26
|
-
this.closed = false
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
get(id) {
|
|
30
|
-
const value = this.records.get(id)
|
|
31
|
-
if (value === undefined) return undefined
|
|
32
|
-
if (value?.version !== 1 || !['codex', 'claude'].includes(value.cli) || value.id !== id
|
|
33
|
-
|| typeof value.parentId !== 'string' || typeof value.cwd !== 'string'
|
|
34
|
-
|| typeof value.model !== 'string' || typeof value.effort !== 'string'
|
|
35
|
-
|| typeof value.executable !== 'string' || !Array.isArray(value.delivered)) {
|
|
36
|
-
throw new Error(`Apollo CLI session ${id} has invalid stored state`)
|
|
37
|
-
}
|
|
38
|
-
return structuredClone(value)
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
async put(record) {
|
|
42
|
-
if (!/^[a-zA-Z0-9_-]+$/.test(record.id)) throw new Error('Invalid Apollo CLI session id')
|
|
43
|
-
const detached = structuredClone(record)
|
|
44
|
-
await this.enqueue(async () => {
|
|
45
|
-
await this.unit.putRecord('sessions', detached.id, detached)
|
|
46
|
-
this.records.set(detached.id, detached)
|
|
47
|
-
})
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
async delete(id) {
|
|
51
|
-
await this.enqueue(async () => {
|
|
52
|
-
await this.unit.deleteRecord('sessions', id)
|
|
53
|
-
this.records.delete(id)
|
|
54
|
-
})
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
enqueue(write) {
|
|
58
|
-
if (this.closed) return Promise.reject(new Error('Apollo CLI session store is closed'))
|
|
59
|
-
const pending = this.writes.then(write)
|
|
60
|
-
this.writes = pending.catch(() => {})
|
|
61
|
-
return pending
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
async close() {
|
|
65
|
-
this.closed = true
|
|
66
|
-
await this.writes
|
|
67
|
-
await this.unit.close()
|
|
68
|
-
}
|
|
69
|
-
}
|