@margay/ccl-core 1.4.0-beta.0 → 1.4.5-rc.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/sdk/API.md ADDED
@@ -0,0 +1,374 @@
1
+ # ccl-sdk 接口文档(对接版)
2
+
3
+ > 交付对象:margay(宿主平台)与 CoS(诸葛喵/军师)集成方。
4
+ > 分支:`ccl-agent-sdk`;包:`@margay/ccl-core`,子路径导出 `@margay/ccl-core/sdk`。
5
+ > 本文档是对接契约;内部实现与 issue 需求对照见同目录 `README.md`。
6
+
7
+ ---
8
+
9
+ ## 1. 引入与运行要求
10
+
11
+ ```ts
12
+ import {
13
+ query,
14
+ resumeWithDecision,
15
+ getSessionUsage,
16
+ fileSessionStore,
17
+ } from '@margay/ccl-core/sdk'
18
+ ```
19
+
20
+ - **运行时**:Node ≥ 20(ESM)。SDK 本体零第三方依赖,只用 Node 内置模块。
21
+ - **CLI 定位**:SDK 以受管子进程方式运行 CCL CLI(全管道 stdio,无 TTY)。CLI 路径解析顺序:
22
+ 1. `options.cliPath`
23
+ 2. 环境变量 `CCL_SDK_CLI_PATH`
24
+ 3. 包内相邻的 `dist/cli.js`(npm 安装的默认布局)
25
+ 找不到时抛出显式错误,不静默降级。
26
+ - **无 TTY 保证**:库形态下没有任何读 stdin/终端交互的代码路径;所有需要人确认的交互点都转为事件流出(见 §3)。
27
+
28
+ ---
29
+
30
+ ## 2. 核心入口
31
+
32
+ ### 2.1 `query(prompt, options?) → AsyncGenerator<SDKEvent>`
33
+
34
+ 发起一轮对话(一次 agentic 任务),异步迭代产出事件,直到终态事件(`result` / `suspended` / fatal `error`)后结束。
35
+
36
+ ```ts
37
+ for await (const event of query('检查收件箱并起草回复', {
38
+ cwd: '/srv/cos-workspace',
39
+ systemPrompt: junshiPrompt,
40
+ model: 'deepseek-v4-pro',
41
+ sessionId: crypto.randomUUID(), // 固定会话 ID,便于后续 resume
42
+ maxTurns: 30,
43
+ canUseTool: async (toolName, input, ctx) => {
44
+ if (isOutboundTool(toolName)) return { behavior: 'pending' } // 走审批
45
+ return { behavior: 'allow' }
46
+ },
47
+ })) {
48
+ // 见 §3 事件参考
49
+ }
50
+ ```
51
+
52
+ ### 2.2 `resumeWithDecision(sessionId, decision, options?) → AsyncGenerator<SDKEvent>`
53
+
54
+ 对因 `pending` 挂起的会话注入审批结果并继续执行(可在**另一个进程**调用)。
55
+
56
+ ```ts
57
+ // decision:
58
+ // { behavior: 'allow', updatedInput?: {...} } 批准;updatedInput 可选改写
59
+ // { behavior: 'deny', message?: string } 否决;message 会反馈给模型
60
+ for await (const event of resumeWithDecision(sessionId, { behavior: 'allow' })) { ... }
61
+ ```
62
+
63
+ - 若该 session 没有挂起记录,**首次迭代时抛错** `no pending approval recorded`。
64
+ - 批准时工具执行的输入 = **挂起时记录的原始 input**(或 `updatedInput`),不是模型恢复后重新生成的版本(红线保证,见 §5)。
65
+ - 记录一次性消费:应用后即从 store 删除。
66
+ - `options` 与 query 相同(store、hooks、canUseTool 等都生效;本次运行中其余工具调用照常走 `canUseTool`)。
67
+
68
+ ### 2.3 `getSessionUsage(sessionId, store?) → Promise<AccumulatedUsage | null>`
69
+
70
+ 查询某 session 跨多次 query 的累计用量(计费依据,见 §8)。
71
+
72
+ ### 2.4 续聊
73
+
74
+ `query(newPrompt, { resume: sessionId })` —— 在已有会话上继续新一轮对话(非审批场景的普通 resume)。
75
+
76
+ ---
77
+
78
+ ## 3. 事件参考(SDKEvent)
79
+
80
+ 所有事件都是 **JSON 可序列化** 的平面对象,按 `type` 区分:
81
+
82
+ | type | 字段 | 说明 |
83
+ |---|---|---|
84
+ | `assistant_text` | `text`, `sessionId?` | 模型文本输出(每个文本块一条) |
85
+ | `tool_use` | `toolName`, `input`, `toolUseId`, `sessionId?` | 模型发起工具调用 |
86
+ | `tool_result` | `toolUseId`, `content`, `isError`, `sessionId?` | 工具执行结果(含被拒时的错误结果) |
87
+ | `permission_request` | `requestId`, `toolName`, `input`, `toolUseId`, `agentId?` | 一次工具调用进入权限裁决(先于回调触发,可用于审计/展示) |
88
+ | `permission_resolved` | `requestId`, `toolUseId`, `behavior('allow'\|'deny')`, `source?('callback'\|'pending_decision')`, `failClosedReason?` | 裁决结果。`failClosedReason` 出现时表示是 fail-closed 兜底(见 §4) |
89
+ | `suspended` | `sessionId`, `requestId`, `toolUseId`, `toolName`, `input` | **终态**。会话因 pending 挂起;用这些字段渲染审批卡;之后进程可退出 |
90
+ | `result` | `subtype`, `usage?`, `totalCostUsd?`, `numTurns?`, `durationMs?`, `isError`, `sessionId?`, `result?` | **终态**。本次 query 结束;`usage` 为 provider 原始 token 计数 |
91
+ | `error` | `message`, `fatal`, `code?` | 错误。`fatal:true` 为终态(如子进程异常退出);`fatal:false` 为可继续的告警 |
92
+ | `raw` | `message` | 透传子进程原始消息(仅 `includeRaw:true` 时产出,调试用) |
93
+
94
+ **终态约定**:每次迭代**恰好一个**终态——`result`、`suspended`、或 fatal `error`。宿主状态机据此收敛。
95
+
96
+ `error.code` 取值:`subprocess_exit`(子进程无 result 退出,含 exit code)、`aborted`(宿主 signal 中止)、`initialize_error`、`pending_without_session`。
97
+
98
+ ---
99
+
100
+ ## 4. 权限回调契约(canUseTool)
101
+
102
+ ```ts
103
+ type CanUseToolFn = (
104
+ toolName: string,
105
+ input: Record<string, unknown>,
106
+ context: { toolUseId: string; agentId?: string },
107
+ ) => Promise<CanUseToolResult> | CanUseToolResult
108
+
109
+ type CanUseToolResult =
110
+ | { behavior: 'allow'; updatedInput?: Record<string, unknown> } // 放行;updatedInput=改写输入(modify)
111
+ | { behavior: 'deny'; message?: string; interrupt?: boolean } // 拒绝;interrupt=true 同时中断本回合
112
+ | { behavior: 'pending' } // 挂起走异步审批(§5)
113
+ ```
114
+
115
+ - 每次进入 'ask' 路径的工具调用,**执行前**都会经过该回调;回调不返回,工具就不执行。
116
+ - **fail-closed(硬保证)**:回调抛异常、超时(默认 300 000 ms,`canUseToolTimeoutMs` 可配,0=不限时)、返回不可识别值 → 一律按 deny 处理,工具不执行,`permission_resolved` 事件携带 `failClosedReason`(`callback_error` / `unrecognized_result` / `no_callback`)。
117
+ - `allow` 未提供 `updatedInput`(或提供空对象)时按原始 input 执行。
118
+ - 未配置 `canUseTool` 时 SDK 不启用权限转发(工具按 CLI 自身权限规则走);若此时 CLI 仍发来请求,按 deny 兜底。
119
+
120
+ ---
121
+
122
+ ## 5. pending 挂起 / 跨进程恢复(审批闭环)
123
+
124
+ 时序(margay 对接的核心流程):
125
+
126
+ ```
127
+ 进程 A 进程 B(可以是几小时后)
128
+ ────── ──────
129
+ query(...)
130
+ ├─ 模型调用外发工具
131
+ ├─ canUseTool 返回 {behavior:'pending'}
132
+ ├─ SDK: 审批记录落盘(SessionStore)
133
+ ├─ SDK: 不应答该工具调用(不执行、也不算拒绝)
134
+ ├─ SDK: 终止子进程
135
+ └─ yield {type:'suspended', sessionId, resumeWithDecision(sessionId,
136
+ toolName, input, ...} {behavior:'allow'})
137
+ → 宿主渲染飞书审批卡,进程退出 ├─ --resume 重建会话上下文
138
+ ├─ 模型重新发起该工具调用
139
+ ├─ SDK 拦截,应用记录的决定
140
+ │ (不咨询实时回调)
141
+ ├─ 工具以【记录的 input】真正执行
142
+ ├─ 审批记录消费删除
143
+ └─ yield permission_resolved
144
+ {source:'pending_decision'}
145
+ ... 正常事件流至 result(含 usage)
146
+ ```
147
+
148
+ **红线保证**:
149
+ 1. 挂起期间工具从未执行(SDK 不应答该请求,transcript 停在未解决的 tool_use)。
150
+ 2. 批准后执行的输入=挂起时记录的输入(或 owner 显式的 `updatedInput`),模型恢复后重新生成的文案**不会**被采用。
151
+ 3. 审批记录不会泄漏:若恢复后模型没有重发同名工具调用,记录保持未消费,任何其他调用照常走实时回调,不会被误放行。
152
+ 4. deny 对称:模型收到拒绝理由,会话正常继续。
153
+
154
+ **宿主须知**:
155
+ - 挂起→恢复之间,宿主只需保存 `sessionId`(以及审批 UI 需要的展示字段);对话在 CLI transcript,审批记录在 SessionStore,均已落盘。
156
+ - 同一 session 同时只支持**一条**挂起记录(覆盖写)。CoS 四闸场景下外发是串行审批,符合;如需并发多审批,升级 store key 即可(接口不变)。
157
+
158
+ ---
159
+
160
+ ## 6. QueryOptions 字段表
161
+
162
+ | 字段 | 类型 | 默认 | 说明 |
163
+ |---|---|---|---|
164
+ | `cwd` | string | 继承宿主 | 会话工作目录 |
165
+ | `systemPrompt` | string | — | 完整替换系统提示(经 initialize 注入,无 ARG_MAX 限制) |
166
+ | `appendSystemPrompt` | string | — | 追加到默认系统提示 |
167
+ | `model` | string | CLI 配置 | 模型名或路由别名(如 `deepseek-v4-pro`) |
168
+ | `provider` | ProviderConfig | CLI 配置 | 见 §7 |
169
+ | `allowedTools` | string[] | — | 工具白名单(支持 `Bash(git:*)` 形式) |
170
+ | `disallowedTools` | string[] | — | 工具黑名单 |
171
+ | `canUseTool` | CanUseToolFn | — | 权限回调(§4);设置后自动启用 stdio 权限转发 |
172
+ | `canUseToolTimeoutMs` | number | 300000 | 回调超时(fail-closed);0=不限时 |
173
+ | `sessionId` | string(UUID) | 自动生成 | 固定会话 ID |
174
+ | `resume` | string | — | 恢复既有会话 |
175
+ | `forkSession` | boolean | false | resume 时分叉新会话 ID |
176
+ | `maxTurns` | number | — | 单次 query 轮数上限(防失控,建议必设) |
177
+ | `maxBudgetUsd` | number | — | 单次 query 花费上限 |
178
+ | `permissionMode` | string | default | CLI 权限模式 |
179
+ | `skillsDirs` | string[] | — | 显式技能目录(cwd 无关);目录内为 `<name>/SKILL.md`;不存在则抛错 |
180
+ | `pluginDirs` | string[] | — | 插件目录(等价 `--plugin-dir`,可重复) |
181
+ | `addDirs` | string[] | — | 额外可访问目录 |
182
+ | `settings` | string | — | settings 文件路径或 JSON 字符串 |
183
+ | `agents` | string(JSON) | — | 内联子代理定义 |
184
+ | `persistSession` | boolean | true | false 时不落盘 transcript(不可 resume) |
185
+ | `store` | SessionStore | 本地文件 store | 挂起记录与用量累计的存储(§8) |
186
+ | `hooks` | LifecycleHooks | — | 生命周期钩子(§9) |
187
+ | `env` | Record<string,string> | — | 子进程额外环境变量 |
188
+ | `signal` | AbortSignal | — | 中止整个 query(杀子进程,产出 fatal error 事件) |
189
+ | `cliPath` / `execPath` | string | 自动解析 / process.execPath | CLI 入口 / Node 可执行文件 |
190
+ | `includeRaw` | boolean | false | 透传原始消息事件 |
191
+ | `extraArgs` | string[] | — | 逃生舱:追加原样 argv |
192
+
193
+ ---
194
+
195
+ ## 7. Provider 配置(双引擎)
196
+
197
+ ```ts
198
+ // Anthropic 协议端点(Claude 客户 / 任何 Anthropic-protocol 网关)
199
+ provider: { type: 'anthropic', baseUrl?: string, apiKey?: string }
200
+ // → 映射 --base-url / --api-key
201
+
202
+ // OpenAI 兼容(国产模型客户,经 CCL 网关通道)
203
+ provider: { type: 'openai-compatible', baseUrl: string, apiKey?: string }
204
+ // → 映射 CCL_GATEWAY_URL / CCL_GATEWAY_KEY 环境变量
205
+ ```
206
+
207
+ - `openai-compatible` 的 `baseUrl` 指向 **CCL/margay 网关**(litellm 类,前置 OpenAI 兼容上游);网关负责 tool-calling 归一化,对不支持原生 function calling 的模型**显式报错**而非静默劣化。
208
+ - 省略 `provider` 时继承 CLI 自身配置(`~/.ccl/gateway.json` 等)。
209
+ - margay 双引擎互换:同一调用 seam 下,ccl-sdk 与 Anthropic Agent SDK 的 `query()`/`canUseTool`/resume 形状对齐,切换引擎不改业务代码结构。
210
+
211
+ ---
212
+
213
+ ## 8. SessionStore 与计量
214
+
215
+ ```ts
216
+ type SessionStore = {
217
+ savePendingApproval(record: PendingApproval): Promise<void>
218
+ loadPendingApproval(sessionId: string): Promise<PendingApproval | null>
219
+ deletePendingApproval(sessionId: string): Promise<void>
220
+ addUsage(sessionId: string, usage: Usage, costUsd?: number): Promise<AccumulatedUsage>
221
+ getUsage(sessionId: string): Promise<AccumulatedUsage | null>
222
+ }
223
+ ```
224
+
225
+ - 默认实现 `fileSessionStore(dir?)`:本地文件,原子写;默认目录 `~/.ccl/sdk-store`(env `CCL_SDK_STORE_DIR` 覆盖)。
226
+ - **Redis/DB 对接**:实现上述 6 个方法即可,记录均为 JSON 可序列化;margay 多租户按租户注入不同 store 实例。
227
+ - **计量(P1-7)**:每个 `result` 事件的 `usage`(provider 原始 input/output/cache token 数)+ `totalCostUsd` 自动累计到 store;`getSessionUsage(sessionId)` 返回:
228
+
229
+ ```ts
230
+ type AccumulatedUsage = {
231
+ queries: number
232
+ inputTokens: number; outputTokens: number
233
+ cacheCreationInputTokens: number; cacheReadInputTokens: number
234
+ totalCostUsd: number
235
+ }
236
+ ```
237
+
238
+ 计量写入失败不影响会话流(只丢该次累计,事件里的 usage 仍在)。
239
+
240
+ ---
241
+
242
+ ## 9. 生命周期钩子(审计/计量侧车)
243
+
244
+ ```ts
245
+ hooks: {
246
+ onToolUse?: (e: { toolUseId, toolName?, content, isError, sessionId? }) => void|Promise<void>
247
+ onTurnEnd?: (e: { turn: number, sessionId? }) => void|Promise<void>
248
+ onSessionEnd?: (e: { sessionId?, subtype, isError, usage? }) => void|Promise<void>
249
+ }
250
+ ```
251
+
252
+ - 触发点:`onToolUse` 在每次工具执行后(tool_result 时);`onTurnEnd` 每个模型回合后;`onSessionEnd` 每次 query 终态时(`subtype` 含 `suspended` / `subprocess_exit` 等)。
253
+ - **纯观测契约**:钩子抛异常被吞掉,永不阻塞、永不影响裁决与事件流。需要阻断语义的一律走 `canUseTool`(权限回调独立于 hooks,正是 issue P1-6 的要求)。
254
+
255
+ ---
256
+
257
+ ## 10. 环境变量一览
258
+
259
+ | 变量 | 作用 |
260
+ |---|---|
261
+ | `CCL_SDK_CLI_PATH` | 覆盖 CLI 入口路径 |
262
+ | `CCL_SDK_STORE_DIR` | 覆盖默认文件 store 目录 |
263
+ | `CCL_GATEWAY_URL` / `CCL_GATEWAY_KEY` | OpenAI 兼容通道(provider 设置时由 SDK 注入子进程) |
264
+ | `CLAUDE_CODE_RESUME_INTERRUPTED_TURN` | (内部)resume 自动续跑;`resumeWithDecision` 自动设置,宿主不要手动碰 |
265
+
266
+ ---
267
+
268
+ ## 11. CoS DoD 切片对照(验收前自查)
269
+
270
+ issue 总验收四步 ↔ SDK 接口:
271
+
272
+ 1. 宿主脚本起会话注入军师 skill + 上下文 → `query(bossMessage, { skillsDirs, systemPrompt, cwd, sessionId, canUseTool })`
273
+ 2. 答复包含外发工具调用 → 事件流出现 `tool_use` + `permission_request`
274
+ 3. 回调返回 pending → 收到终态 `suspended` 事件 → 宿主进程退出(挂起态全部在盘上)
275
+ 4. 新进程 `resumeWithDecision(sessionId, {behavior:'allow'})` → 外发工具真正执行(打到 mock 端点)→ 收到含 `usage` 的 `result`
276
+
277
+ 全程无 TTY、无人工键盘介入、跨进程。SDK 侧 31 个单测已覆盖上述每个接缝(含"批准后执行记录的 input 而非模型重生成版本"的红线断言);活体切片脚本与 mock 端点按 issue 分工由 CoS 侧提供。
278
+
279
+ ## 12. 已知边界
280
+
281
+ - 同一 session 单条挂起记录(见 §5 宿主须知)。
282
+ - `resumeWithDecision` 依赖模型恢复后重发该工具调用(CLI 自动续跑机制驱动);若模型改做他事,审批记录保持未消费、绝不误放行——宿主可对 `suspended` 后长期未消费的记录做超时清理(`deletePendingApproval`)。
283
+ - P2-8 按 issue 明确不承诺:subagents/并行工具/MCP 无新能力,已有行为不回归。
284
+
285
+ ---
286
+
287
+ ## 13. 新增能力(ccl-agent-sdk 完整版,G1-G7)
288
+
289
+ 以下能力已随 G1-G7 批次落地,均**真子进程验证**、向后兼容(不设即旧行为)。
290
+
291
+ ### 13.1 In-process MCP 工具(host 用 JS 定义工具,G1)
292
+ host 在同进程用 JS 定义工具,agent(子进程)可调用,且仍走 `canUseTool` 审核。
293
+
294
+ ```ts
295
+ import { query, createSdkMcpServer, tool } from '@margay/ccl-core/sdk'
296
+ import { z } from 'zod/v4'
297
+
298
+ const dataTools = createSdkMcpServer({
299
+ name: 'data-tools',
300
+ tools: [
301
+ tool('load_csv', '读取 CSV 返回行数与列名', { path: z.string() },
302
+ async (args) => {
303
+ const text = require('fs').readFileSync(args.path, 'utf8')
304
+ const lines = text.trim().split('\n')
305
+ return { rows: lines.length - 1, columns: lines[0].split(',') }
306
+ }),
307
+ ],
308
+ })
309
+
310
+ for await (const ev of query('用 load_csv 读 /data/x.csv 并报告行列', {
311
+ sdkMcpServers: [dataTools],
312
+ canUseTool: async (toolName) => ({ behavior: 'allow' }), // host 在此 gate/审核
313
+ })) { /* ... */ }
314
+ ```
315
+ - 工具名对 agent 呈现为 `mcp__data-tools__load_csv`。
316
+ - handler 在 **host 进程**执行(可访问 host 的文件/网络/状态)。
317
+ - 每次调用先过 `canUseTool`(可 allow/deny/pending);deny → 工具不跑。
318
+
319
+ ### 13.2 Token 级流式(G3)
320
+ ```ts
321
+ for await (const ev of query('写一段', { includePartialMessages: true })) {
322
+ if (ev.type === 'stream_event' && ev.event.type === 'content_block_delta') {
323
+ process.stdout.write(ev.event.delta.text ?? '') // 逐 token
324
+ }
325
+ }
326
+ ```
327
+ - 默认关。开后收 `{ type:'stream_event', event }`(Anthropic raw:content_block_delta/text_delta 等);整块 `assistant_text` 仍并存。
328
+
329
+ ### 13.3 结构化输出 jsonSchema(G4)
330
+ ```ts
331
+ for await (const ev of query('分析并返回 {pm25, trend}', {
332
+ jsonSchema: { type:'object', properties:{ pm25:{type:'number'}, trend:{type:'string'} }, required:['pm25','trend'] },
333
+ })) {
334
+ if (ev.type === 'result') console.log(ev.structuredOutput) // schema-valid JSON
335
+ }
336
+ ```
337
+ - CLI 对最终结果按 schema 校验(失败重试);`result.structuredOutput` 露出;subtype `error_max_structured_output_retries` = 校验失败。默认不设 → `structuredOutput` undefined。
338
+
339
+ ### 13.4 观测钩子 + 逐轮 usage(G5+G7)
340
+ ```ts
341
+ query(prompt, { hooks: {
342
+ onSessionStart: (e) => log('session', e.sessionId), // 首个 session_id 一次
343
+ onUserPromptSubmit: (e) => log('prompt', e.prompt), // 提交时
344
+ onTurnEnd: (e) => log('turn', e.turn, 'usage', e.usage), // 每轮 + 逐轮 token usage
345
+ onToolUse: (e) => log('tool', e.toolName), // 既有
346
+ onSessionEnd: (e) => log('end', e.usage), // 既有
347
+ }})
348
+ ```
349
+ - 观测永不影响控制流(错误被吞)。新 hook 全可选。
350
+
351
+ ### 13.5 外部 MCP server + 多模态输入(G2+G6)
352
+ ```ts
353
+ query('用 filesystem server 列目录', {
354
+ mcpServers: { fs: { command: 'npx', args: ['-y', '@modelcontextprotocol/server-filesystem', '/data'] } }, // 外部 MCP,经 --mcp-config
355
+ promptContentBlocks: [ // 多模态:替换首条 user message 的 content(text+image);vision 需 vision 模型
356
+ { type: 'text', text: '看这张图' },
357
+ { type: 'image', source: { type: 'base64', media_type: 'image/png', data: '<b64>' } },
358
+ ],
359
+ })
360
+ ```
361
+ - `mcpServers`(外部 stdio/http)区别于 `sdkMcpServers`(in-process host 工具)。
362
+ - 两字段默认无 → 行为不变。
363
+
364
+ ### 13.6 QueryOptions 新增字段速查
365
+ | 字段 | 类型 | 能力 |
366
+ |---|---|---|
367
+ | `sdkMcpServers` | `McpSdkServerConfigWithInstance[]` | G1 in-process 工具 |
368
+ | `includePartialMessages` | `boolean` | G3 token 流式 |
369
+ | `jsonSchema` | `Record<string,unknown>` | G4 结构化输出 |
370
+ | `mcpServers` | `Record<string,unknown>` | G2 外部 MCP server |
371
+ | `promptContentBlocks` | `Array<{type,...}>` | G6 多模态输入 |
372
+
373
+ 新增事件:`{ type:'stream_event', event, sessionId? }`;result 事件新增 `structuredOutput?`。
374
+ 新增 hooks:`onSessionStart` / `onUserPromptSubmit`;`onTurnEnd` 事件新增 `usage?`。
package/sdk/README.md ADDED
@@ -0,0 +1,132 @@
1
+ # ccl-sdk — embeddable SDK form of CCL
2
+
3
+ Programmatic, headless, no-TTY entry to a CCL agent session, for hosts like
4
+ margay that need a resident (7×24) agent brain with code-level tool
5
+ interception. The interface shape mirrors the Anthropic Claude Agent SDK
6
+ (`query()` + `canUseTool` + session resume) so a host can keep one call seam
7
+ and swap engines.
8
+
9
+ ## Usage
10
+
11
+ ```ts
12
+ import { query } from '@margay/ccl-core/sdk'
13
+
14
+ for await (const event of query('检查今天的收件箱并起草回复', {
15
+ cwd: '/srv/cos-workspace',
16
+ systemPrompt: junshiSystemPrompt,
17
+ model: 'deepseek-v4-pro',
18
+ sessionId: crypto.randomUUID(),
19
+ maxTurns: 30,
20
+ canUseTool: async (toolName, input, { toolUseId }) => {
21
+ if (isOutboundTool(toolName)) {
22
+ return { behavior: 'deny', message: '外发需要 owner 审批' }
23
+ }
24
+ return { behavior: 'allow' }
25
+ },
26
+ })) {
27
+ switch (event.type) {
28
+ case 'assistant_text': /* render */ break
29
+ case 'permission_request': /* audit */ break
30
+ case 'result': /* event.usage → metering */ break
31
+ }
32
+ }
33
+ ```
34
+
35
+ Resume an existing session: `query(prompt, { resume: sessionId })`.
36
+
37
+ Pending approval (7×24 resident CoS, owner approves via Feishu card):
38
+
39
+ ```ts
40
+ // Process A: outbound tool → pending → suspend, then exit.
41
+ canUseTool: async (toolName) =>
42
+ isOutboundTool(toolName) ? { behavior: 'pending' } : { behavior: 'allow' }
43
+ // query() ends with {type:'suspended', sessionId, toolName, input} —
44
+ // render the approval card from it, persist nothing else, exit.
45
+
46
+ // Process B (hours later, after the owner taps 同意):
47
+ import { resumeWithDecision } from '@margay/ccl-core/sdk'
48
+ for await (const event of resumeWithDecision(sessionId, { behavior: 'allow' })) {
49
+ // the suspended tool now executes with exactly the recorded input
50
+ }
51
+ ```
52
+
53
+ ## Architecture
54
+
55
+ The SDK is the **host side** of the stream-json control protocol that
56
+ `cli/print.ts` + `cli/structuredIO.ts` already implement. `query()` runs the
57
+ CLI as a **managed subprocess** (`--print --input-format stream-json
58
+ --output-format stream-json`, all-pipe stdio, no TTY), which keeps every CLI
59
+ behavior — tools, skills, hooks, session persistence, gateway/model routing —
60
+ byte-identical between CLI and SDK use. This matches how the Anthropic Agent
61
+ SDK wraps Claude Code.
62
+
63
+ Permission flow (`canUseTool` set ⇒ `--permission-prompt-tool stdio`):
64
+
65
+ ```
66
+ CLI ──control_request{can_use_tool, tool_name, input, tool_use_id}──▶ SDK
67
+ SDK ──(host callback: allow/deny/modify; fail-closed on throw/timeout)──▶
68
+ SDK ──control_response{success, {behavior, updatedInput?/message}}──▶ CLI
69
+ ```
70
+
71
+ The CLI blocks that tool call until the response arrives, dedupes duplicate
72
+ responses by `tool_use_id`, and treats deny as a normal tool denial (the
73
+ session continues). Wire schemas live in
74
+ `utils/permissions/PermissionPromptToolResultSchema.ts` and
75
+ `entrypoints/sdk/controlSchemas.ts` — keep `sdk/types.ts` in sync.
76
+
77
+ ## Issue coverage (升级改造 issue → milestones)
78
+
79
+ | Requirement | Status |
80
+ |---|---|
81
+ | P0-1 `query()` + JSON-serializable event stream (`assistant_text`/`tool_use`/`tool_result`/`permission_request`/`result`/`error`) | **done** |
82
+ | P0-1 options: cwd / system_prompt / skills_dirs / model / allowed_tools / can_use_tool / session_id+resume / max_turns | **done** |
83
+ | P0-2 canUseTool allow / deny(reason) / modify(new_input) | **done** (CLI-side protocol pre-existed; SDK exposes it) |
84
+ | P0-2 fail-closed (throw / timeout / unrecognized ⇒ deny, tool never runs) | **done**, unit-tested |
85
+ | P0-2 `pending` + suspend + cross-process resume | **done** — see mechanism below; `resumeWithDecision(sessionId, {behavior})` |
86
+ | P0-3 session persistence + `resume(session_id)` | **done** — transcripts via the CLI's store (`--session-id`/`--resume`); SDK-level state (pending approvals, usage) via the pluggable `SessionStore` (file reference impl; Redis/DB implement the same 6-method interface) |
87
+ | P0-4 provider layer (Anthropic + OpenAI-compatible) | **done at the SDK seam** — `provider: {type:'anthropic'}` → `--base-url`/`--api-key`; `{type:'openai-compatible'}` → CCL gateway channel (`CCL_GATEWAY_URL`/`KEY`), which normalizes tool-calling and rejects non-function-calling models explicitly. Mapping unit-tested; live two-provider round-trip belongs to the DoD slice (CoS side provides mock endpoints per the issue). |
88
+ | P1-5 explicit `skills_dirs` | **done** — each dir is wrapped as an ephemeral plugin (skills symlink) and loaded via `--plugin-dir`, the CLI's cwd-independent skill channel; missing dirs fail loudly |
89
+ | P1-6 hooks (`on_tool_use`/`on_turn_end`/`on_session_end`) | **done** — observability-only contract: hook errors are swallowed, never affect control flow |
90
+ | P1-7 usage in `result` event + per-session accumulation | **done** — provider-raw `usage` + `total_cost_usd` on every result; accumulated per session in the store, queryable via `getSessionUsage(sessionId)` |
91
+
92
+ ## Pending / suspend / cross-process resume (P0-2) — how it works
93
+
94
+ Zero CLI changes; the mechanism rides two existing CLI behaviors:
95
+
96
+ 1. **Suspend** — when the callback returns `{behavior:'pending'}` the SDK
97
+ persists `{sessionId, requestId, toolUseId, toolName, input}` to the
98
+ store, deliberately does NOT answer the `can_use_tool` request (the tool
99
+ neither runs nor gets denied), terminates the subprocess, and ends the
100
+ stream with a terminal `suspended` event. The transcript on disk ends at
101
+ the unresolved assistant `tool_use`. The host process may now exit.
102
+ 2. **Resume** — `resumeWithDecision(sessionId, decision)` relaunches with
103
+ `--resume <sessionId>` + `CLAUDE_CODE_RESUME_INTERRUPTED_TURN=1`. The
104
+ CLI's resume path drops the unresolved `tool_use`
105
+ (`utils/conversationRecovery.ts filterUnresolvedToolUses`) and
106
+ auto-continues the interrupted turn (`cli/print.ts:1195`), so the model
107
+ re-issues the tool call — no new user prompt is sent. The SDK intercepts
108
+ the matching `can_use_tool` request (matched by tool name) and applies the
109
+ recorded decision **without consulting the live callback**.
110
+
111
+ **The red line guarantee**: on approve, the tool executes with exactly the
112
+ recorded pending input (or the owner's explicit `updatedInput`) — never the
113
+ model's re-generated input. What the owner approved is what runs. The record
114
+ is one-shot: consumed on first application. Denial is symmetric and the
115
+ session continues normally.
116
+
117
+ Caveat (by design): the resumed model could in principle take a different
118
+ action instead of re-issuing the suspended call. In that case the approval
119
+ record simply stays unconsumed — nothing executes without passing the live
120
+ callback again. Approval can never leak to a different tool.
121
+
122
+ ## Tests
123
+
124
+ `__tests__/unit/ccl-sdk/` — transport-injected unit tests, no network/model
125
+ (31 tests): event mapping, deny acceptance (P0-2 验收: deny ⇒ tool never
126
+ executes ⇒ session continues), suspend/resume acceptance across two query
127
+ instances sharing only the on-disk store (approve executes the RECORDED
128
+ input; deny; one-shot consumption; missing-record error), fail-closed matrix,
129
+ provider mapping, skills-dir wrapping, hooks, usage accumulation, file-store
130
+ round-trip. The issue's live end-to-end DoD slice (real model + mock outbound
131
+ endpoint + real process exit) is CoS-side per the issue's division of labor —
132
+ the SDK-side seams it needs are all covered above.