@menghuan1918/dsh-apollo 0.0.0-stage → 0.4.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.
Files changed (40) hide show
  1. package/LICENSE +674 -0
  2. package/README.md +253 -2
  3. package/cordis.patch.yml +78 -0
  4. package/docs/DEVELOPMENT.md +682 -0
  5. package/package.json +76 -4
  6. package/presets/eng/NOTICE +3 -0
  7. package/presets/eng/plugins/exit-guard/index.js +111 -0
  8. package/presets/eng/plugins/exit-guard/package.json +7 -0
  9. package/presets/eng/plugins/mode-instructions/index.js +124 -0
  10. package/presets/eng/plugins/mode-instructions/package.json +7 -0
  11. package/presets/eng/plugins/wait-subagent/index.js +233 -0
  12. package/presets/eng/plugins/wait-subagent/package.json +7 -0
  13. package/presets/eng-ptc.patch.yml +186 -0
  14. package/presets/eng.patch.yml +338 -0
  15. package/skills/brainstorming/SKILL.md +90 -0
  16. package/skills/brainstorming/spec-document-reviewer-prompt.md +50 -0
  17. package/skills/code-review/SKILL.md +90 -0
  18. package/skills/dev/SKILL.md +41 -0
  19. package/skills/diagnosing-bugs/SKILL.md +132 -0
  20. package/skills/diagnosing-bugs/scripts/hitl-loop.template.sh +55 -0
  21. package/skills/exec/SKILL.md +78 -0
  22. package/skills/handoff/SKILL.md +23 -0
  23. package/skills/main-agent-driven-development/SKILL.md +64 -0
  24. package/skills/tdd/SKILL.md +34 -0
  25. package/skills/tdd/mocking.md +59 -0
  26. package/skills/tdd/tests.md +77 -0
  27. package/src/cli/claude.js +166 -0
  28. package/src/cli/codex.js +164 -0
  29. package/src/cli/jsonl.js +216 -0
  30. package/src/cli-adapter.js +198 -0
  31. package/src/cli-sessions.js +69 -0
  32. package/src/client.js +1128 -0
  33. package/src/config.js +327 -0
  34. package/src/dsh-home.js +38 -0
  35. package/src/index.js +181 -0
  36. package/src/panel.js +844 -0
  37. package/src/settings.js +191 -0
  38. package/src/subagents.js +198 -0
  39. package/third-party/LICENSE-MIT-dsh-standard-preset +21 -0
  40. package/third-party/NOTICE-mattpocock-skills +21 -0
@@ -0,0 +1,77 @@
1
+ # Good and Bad Tests
2
+
3
+ ## Good Tests
4
+
5
+ **Integration-style**: Test through real interfaces, not mocks of internal parts.
6
+
7
+ ```typescript
8
+ // GOOD: Tests observable behavior
9
+ test("user can checkout with valid cart", async () => {
10
+ const cart = createCart();
11
+ cart.add(product);
12
+ const result = await checkout(cart, paymentMethod);
13
+ expect(result.status).toBe("confirmed");
14
+ });
15
+ ```
16
+
17
+ Characteristics:
18
+
19
+ - Tests behavior users/callers care about
20
+ - Uses public API only
21
+ - Survives internal refactors
22
+ - Describes WHAT, not HOW
23
+ - One logical assertion per test
24
+
25
+ ## Bad Tests
26
+
27
+ **Implementation-detail tests**: Coupled to internal structure.
28
+
29
+ ```typescript
30
+ // BAD: Tests implementation details
31
+ test("checkout calls paymentService.process", async () => {
32
+ const mockPayment = jest.mock(paymentService);
33
+ await checkout(cart, payment);
34
+ expect(mockPayment.process).toHaveBeenCalledWith(cart.total);
35
+ });
36
+ ```
37
+
38
+ Red flags:
39
+
40
+ - Mocking internal collaborators
41
+ - Testing private methods
42
+ - Asserting on call counts/order
43
+ - Test breaks when refactoring without behavior change
44
+ - Test name describes HOW not WHAT
45
+ - Verifying through external means instead of interface
46
+
47
+ ```typescript
48
+ // BAD: Bypasses interface to verify
49
+ test("createUser saves to database", async () => {
50
+ await createUser({ name: "Alice" });
51
+ const row = await db.query("SELECT * FROM users WHERE name = ?", ["Alice"]);
52
+ expect(row).toBeDefined();
53
+ });
54
+
55
+ // GOOD: Verifies through interface
56
+ test("createUser makes user retrievable", async () => {
57
+ const user = await createUser({ name: "Alice" });
58
+ const retrieved = await getUser(user.id);
59
+ expect(retrieved.name).toBe("Alice");
60
+ });
61
+ ```
62
+
63
+ **Tautological tests**: Expected value restates the implementation, so the test passes by construction.
64
+
65
+ ```typescript
66
+ // BAD: Expected value is recomputed the way the code computes it
67
+ test("calculateTotal sums line items", () => {
68
+ const items = [{ price: 10 }, { price: 5 }];
69
+ const expected = items.reduce((sum, i) => sum + i.price, 0);
70
+ expect(calculateTotal(items)).toBe(expected);
71
+ });
72
+
73
+ // GOOD: Expected value is an independent, known literal
74
+ test("calculateTotal sums line items", () => {
75
+ expect(calculateTotal([{ price: 10 }, { price: 5 }])).toBe(15);
76
+ });
77
+ ```
@@ -0,0 +1,166 @@
1
+ /**
2
+ * Claude Code CLI 适配:纯命令构造 + `--print --output-format stream-json` 事件流解析。
3
+ * 只产出 argv 与 stdin 正文并翻译事件;spawn/管道/stderr/中断由父 adapter 的
4
+ * `ctx.subprocess` 掌握(stdin 取 `{data: input}`)。不 import 任何 DSH 包。
5
+ *
6
+ * 实测依据(Claude Code 2.1.300,新建 + `--resume` 各一轮真实跑通):
7
+ * - `--print` 不给位置参数 prompt 时正文从 stdin 读,所以 argv 里没有 prompt;
8
+ * - `stream-json` 必须配 `--verbose` 才是完整事件流;
9
+ * - `--permission-prompts none` = 没人回答权限提问、会被提问的一律自动拒绝,
10
+ * 正是无人值守管道要的语义(`--permission-mode` 仍决定其余判定);
11
+ * - 续接用 `--resume <SESSION_ID>`;**不传** `--no-session-persistence`,否则会话
12
+ * 不落盘、下一轮无法续接;
13
+ * - claude **没有** cwd 选项(`--add-dir` 只是追加可写目录),工作目录由进程 cwd
14
+ * 决定,故本模块接受并忽略 `cwd`,交给父 adapter 的 spawn `cwd`;`profile` 是
15
+ * codex 专属概念,同样忽略。
16
+ *
17
+ * 事件语义:
18
+ * - `system`(hook_started / hook_response / init)都带 `session_id`,且前两者出现在
19
+ * `init` **之前**——故 remoteId 在「第一个带 session_id 的 system 事件」就回报;
20
+ * - `assistant` → `message.content[]` 里 `type === 'text'` 的块拼成正文;`tool_use` /
21
+ * `thinking` 块**绝不**当作正文;带 `parent_tool_use_id` 的是子代理消息,也不计入;
22
+ * - `result` → 唯一权威收尾:`subtype === 'success' && is_error !== true` 才算成功;
23
+ * `result` 字段是最终答复文本(覆盖累积值),`usage` 是整轮用量。
24
+ */
25
+
26
+ import { normalizeUsage, requireEnum, requireEffort, requireId, requirePrompt, requireText, sanitizeError } from './jsonl.js'
27
+
28
+ /** `claude --permission-mode` 的实测取值(`claude --help`)。 */
29
+ const PERMISSION_MODES = ['acceptEdits', 'auto', 'bypassPermissions', 'manual', 'dontAsk', 'plan']
30
+
31
+ /**
32
+ * 构造一轮 `claude --print` 调用。
33
+ *
34
+ * @param {object} spec
35
+ * @param {string} [spec.executable='claude'] - 可执行名或绝对路径。
36
+ * @param {string} [spec.cwd] - 接受但忽略:claude 无 cwd 选项,请用 spawn 的 cwd。
37
+ * @param {string} [spec.model] - `--model`,别名(sonnet/opus…)或全名。
38
+ * @param {string} [spec.effort] - `--effort`,实测取值 low/medium/high/xhigh/max。
39
+ * @param {string} [spec.profile] - 接受但忽略:codex 专属概念。
40
+ * @param {string} [spec.permissionMode] - `--permission-mode`。
41
+ * @param {string} [spec.remoteId] - 已有 session id 时走 `--resume <id>`。
42
+ * @param {string} spec.prompt - 正文,经 stdin 送入。
43
+ * @returns {{argv: string[], input: string}}
44
+ */
45
+ export function buildCommand({ executable = 'claude', model, effort, permissionMode, remoteId, prompt } = {}) {
46
+ const argv = [requireText(executable, 'executable'), '--print', '--output-format', 'stream-json', '--verbose']
47
+ if (model !== undefined) argv.push('--model', requireText(model, 'model'))
48
+ if (effort !== undefined) argv.push('--effort', requireEffort(effort))
49
+ if (permissionMode !== undefined) argv.push('--permission-mode', requireEnum(permissionMode, PERMISSION_MODES, 'permissionMode'))
50
+ // 无人值守:任何权限提问都自动拒绝,而不是挂起等一个永远不会来的回答。
51
+ argv.push('--permission-prompts', 'none')
52
+ if (remoteId !== undefined) argv.push('--resume', requireId(remoteId, 'remoteId'))
53
+ // 正文走 stdin(`--print` 无位置参数时读 stdin),argv 里不出现 prompt。
54
+ return { argv, input: requirePrompt(prompt) }
55
+ }
56
+
57
+ /**
58
+ * 创建一个有状态的 claude 事件解析器(一轮子进程一个)。
59
+ *
60
+ * @returns {{push: (event: unknown) => ({remoteId?: string, text?: string, usage?: object, completed?: boolean, error?: string}|undefined), finish: () => {text: string, remoteId?: string, usage?: object}}}
61
+ */
62
+ export function createParser() {
63
+ let remoteId
64
+ let text = ''
65
+ let usage
66
+ let completed = false
67
+ let failure
68
+
69
+ /** 失败时优先用 provider 给的错误正文,否则退化成 subtype/api_error_status。 */
70
+ function describeResultFailure(event) {
71
+ const label = typeof event.subtype === 'string' && event.subtype !== '' ? event.subtype : 'error'
72
+ const detail = typeof event.result === 'string' && event.result !== '' ? event.result
73
+ : Array.isArray(event.errors) ? event.errors.filter((item) => typeof item === 'string').slice(0, 3).join('; ') : ''
74
+ const status = event.api_error_status === undefined ? '' : ` (api_error_status=${event.api_error_status})`
75
+ return sanitizeError(detail === '' ? `claude result ${label}${status}` : `claude result ${label}${status}: ${detail}`)
76
+ }
77
+
78
+ return {
79
+ /**
80
+ * 投喂一个已解析的事件。
81
+ * @param {unknown} event - `readJsonLines` 产出的单个事件。
82
+ * @returns {{remoteId?: string, text?: string, usage?: object, completed?: boolean, error?: string}|undefined}
83
+ * 本次事件产生的动作;无动作返回 undefined。
84
+ * `error` 是**信息性**的:只有 `finish()` 抛错才代表这一轮失败。
85
+ */
86
+ push(event) {
87
+ if (event === null || typeof event !== 'object') return undefined
88
+ switch (event.type) {
89
+ case 'system': {
90
+ if (typeof event.session_id !== 'string' || event.session_id === '') return undefined
91
+ // 只回报一次;实测 hook_started 早于 init,因此这里通常是最早的时机。
92
+ if (remoteId !== undefined) return undefined
93
+ remoteId = event.session_id
94
+ return { remoteId }
95
+ }
96
+ case 'assistant': {
97
+ // 子代理(Task 工具)的消息带 parent_tool_use_id,不能混进主答复。
98
+ if (typeof event.parent_tool_use_id === 'string' && event.parent_tool_use_id !== '') return undefined
99
+ const message = event.message
100
+ if (message === null || typeof message !== 'object' || !Array.isArray(message.content)) return undefined
101
+ const chunk = message.content
102
+ .filter((block) => block !== null && typeof block === 'object' && block.type === 'text' && typeof block.text === 'string')
103
+ .map((block) => block.text)
104
+ .join('')
105
+ if (chunk === '') return undefined
106
+ // 覆盖式:一条 assistant 事件就是一条完整消息,最后一条才是最终答复。
107
+ text = chunk
108
+ return { text }
109
+ }
110
+ case 'result': {
111
+ const mapped = mapClaudeUsage(event.usage)
112
+ if (mapped !== undefined) usage = mapped
113
+ const succeeded = event.subtype === 'success' && event.is_error !== true
114
+ if (succeeded) {
115
+ completed = true
116
+ if (typeof event.result === 'string' && event.result !== '') text = event.result
117
+ } else {
118
+ failure ??= describeResultFailure(event)
119
+ }
120
+ return {
121
+ ...(mapped === undefined ? {} : { usage: mapped }),
122
+ ...(succeeded ? { completed: true } : { error: failure }),
123
+ ...(succeeded && typeof event.result === 'string' && event.result !== '' ? { text: event.result } : {}),
124
+ }
125
+ }
126
+ default:
127
+ // user(工具结果)/ stream_event / 未来新增事件:无语义动作。
128
+ return undefined
129
+ }
130
+ },
131
+
132
+ /**
133
+ * 收流。失败或缺少 `result` 都抛错——「流结束了」不等于「回合成功了」。
134
+ * 幂等:可重复调用,不改变状态。
135
+ * @returns {{text: string, remoteId?: string, usage?: object}}
136
+ */
137
+ finish() {
138
+ if (failure !== undefined) throw new Error(`claude 回合失败:${failure}`)
139
+ if (!completed) throw new Error('claude 流在收到成功的 result 事件之前结束')
140
+ return {
141
+ text,
142
+ ...(remoteId === undefined ? {} : { remoteId }),
143
+ ...(usage === undefined ? {} : { usage }),
144
+ }
145
+ },
146
+ }
147
+ }
148
+
149
+ /**
150
+ * 把 claude 的整轮用量映射成 DSH TokenUsage。
151
+ *
152
+ * Anthropic 的 `input_tokens` 本来就**不含**缓存(另给 `cache_read_input_tokens` /
153
+ * `cache_creation_input_tokens`),与 DSH 约定一致,无需扣减;
154
+ * `output_tokens_details.thinking_tokens` 映射为 reasoning。
155
+ */
156
+ function mapClaudeUsage(raw) {
157
+ if (raw === null || typeof raw !== 'object') return undefined
158
+ const details = raw.output_tokens_details
159
+ return normalizeUsage({
160
+ inputTokens: raw.input_tokens,
161
+ outputTokens: raw.output_tokens,
162
+ cacheReadTokens: raw.cache_read_input_tokens,
163
+ cacheWriteTokens: raw.cache_creation_input_tokens,
164
+ reasoningTokens: details !== null && typeof details === 'object' ? details.thinking_tokens : undefined,
165
+ })
166
+ }
@@ -0,0 +1,164 @@
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
+ }
@@ -0,0 +1,216 @@
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
+ }