magic-agent 1.0.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 (126) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +392 -0
  3. package/cmd/magic-agent/main.go +12 -0
  4. package/cmd/magic-client/main.go +144 -0
  5. package/go.mod +11 -0
  6. package/go.sum +12 -0
  7. package/internal/agent/arkclaw.go +587 -0
  8. package/internal/agent/arkclaw_stream.go +312 -0
  9. package/internal/agent/arkclaw_stream_test.go +428 -0
  10. package/internal/agent/arkclaw_test.go +827 -0
  11. package/internal/agent/ask.go +694 -0
  12. package/internal/agent/ask_test.go +587 -0
  13. package/internal/agent/attachments.go +200 -0
  14. package/internal/agent/attachments_test.go +481 -0
  15. package/internal/agent/capability.go +154 -0
  16. package/internal/agent/capability_test.go +190 -0
  17. package/internal/agent/claude.go +406 -0
  18. package/internal/agent/codebuddy.go +1044 -0
  19. package/internal/agent/codebuddy_account_test.go +128 -0
  20. package/internal/agent/codebuddy_gateway.go +479 -0
  21. package/internal/agent/codebuddy_gateway_stream.go +358 -0
  22. package/internal/agent/codebuddy_gateway_test.go +673 -0
  23. package/internal/agent/codex.go +599 -0
  24. package/internal/agent/codex_home.go +206 -0
  25. package/internal/agent/codex_home_test.go +130 -0
  26. package/internal/agent/codex_stream_test.go +97 -0
  27. package/internal/agent/codex_test.go +280 -0
  28. package/internal/agent/custom_models.go +197 -0
  29. package/internal/agent/custom_models_test.go +283 -0
  30. package/internal/agent/dsh.go +754 -0
  31. package/internal/agent/dsh_sdk.go +679 -0
  32. package/internal/agent/dsh_sdk_test.go +726 -0
  33. package/internal/agent/dsh_test.go +759 -0
  34. package/internal/agent/engine.go +469 -0
  35. package/internal/agent/engine_base.go +497 -0
  36. package/internal/agent/engine_base_test.go +308 -0
  37. package/internal/agent/engine_test.go +858 -0
  38. package/internal/agent/env.go +88 -0
  39. package/internal/agent/env_test.go +106 -0
  40. package/internal/agent/jsonextract.go +128 -0
  41. package/internal/agent/jsonextract_test.go +110 -0
  42. package/internal/agent/keepalive_test.go +216 -0
  43. package/internal/agent/llmengine.go +470 -0
  44. package/internal/agent/llmengine_test.go +551 -0
  45. package/internal/agent/llmhttp.go +437 -0
  46. package/internal/agent/llmhttp_test.go +304 -0
  47. package/internal/agent/llmmodels.go +205 -0
  48. package/internal/agent/login.go +27 -0
  49. package/internal/agent/main_test.go +23 -0
  50. package/internal/agent/models.go +686 -0
  51. package/internal/agent/models_test.go +877 -0
  52. package/internal/agent/openclaw.go +543 -0
  53. package/internal/agent/openclaw_acp.go +949 -0
  54. package/internal/agent/openclaw_acp_test.go +406 -0
  55. package/internal/agent/openclaw_test.go +451 -0
  56. package/internal/agent/output.go +191 -0
  57. package/internal/agent/permission.go +362 -0
  58. package/internal/agent/permission_test.go +526 -0
  59. package/internal/agent/prompt.go +65 -0
  60. package/internal/agent/runcmd.go +259 -0
  61. package/internal/agent/runcmd_test.go +81 -0
  62. package/internal/agent/runcmd_unix.go +56 -0
  63. package/internal/agent/runcmd_windows.go +66 -0
  64. package/internal/agent/runner.go +180 -0
  65. package/internal/agent/runner_test.go +263 -0
  66. package/internal/agent/spawnhook_test.go +96 -0
  67. package/internal/agent/stream.go +882 -0
  68. package/internal/agent/stream_test.go +565 -0
  69. package/internal/agent/streamjson.go +185 -0
  70. package/internal/agent/tags.go +52 -0
  71. package/internal/agent/toolkind.go +96 -0
  72. package/internal/agent/toolkind_test.go +70 -0
  73. package/internal/agent/tools_suffix_test.go +261 -0
  74. package/internal/agent/trae.go +342 -0
  75. package/internal/agent/workspace_test.go +166 -0
  76. package/internal/boundary/boundary_test.go +165 -0
  77. package/internal/cli/ask.go +1113 -0
  78. package/internal/cli/ask_test.go +1088 -0
  79. package/internal/cli/attach_test.go +239 -0
  80. package/internal/cli/contract_test.go +157 -0
  81. package/internal/cli/control.go +149 -0
  82. package/internal/cli/control_test.go +192 -0
  83. package/internal/cli/engines_test.go +205 -0
  84. package/internal/cli/events.go +198 -0
  85. package/internal/cli/events_test.go +166 -0
  86. package/internal/cli/keepalive_test.go +391 -0
  87. package/internal/cli/login.go +78 -0
  88. package/internal/cli/permission_test.go +295 -0
  89. package/internal/cli/prompt_source_test.go +222 -0
  90. package/internal/cli/root.go +752 -0
  91. package/internal/cli/session.go +486 -0
  92. package/internal/cli/session_test.go +202 -0
  93. package/internal/cli/sessionlog.go +325 -0
  94. package/internal/cli/sessionlog_command_test.go +204 -0
  95. package/internal/cli/sessionlog_test.go +262 -0
  96. package/internal/cli/sessionlog_user_test.go +129 -0
  97. package/internal/cli/toolkind_wire_test.go +84 -0
  98. package/internal/cli/workspace_test.go +188 -0
  99. package/internal/client/cors_test.go +136 -0
  100. package/internal/client/lan.go +68 -0
  101. package/internal/client/lan_test.go +53 -0
  102. package/internal/client/pair.go +163 -0
  103. package/internal/client/pair_test.go +109 -0
  104. package/internal/client/profile.go +184 -0
  105. package/internal/client/service.go +888 -0
  106. package/internal/client/service_test.go +654 -0
  107. package/internal/client/session_events_test.go +186 -0
  108. package/internal/client/session_messages_test.go +150 -0
  109. package/internal/config/config.go +439 -0
  110. package/internal/config/config_test.go +448 -0
  111. package/internal/session/session.go +447 -0
  112. package/internal/session/session_append.go +241 -0
  113. package/internal/session/session_append_test.go +161 -0
  114. package/internal/session/session_kill_unix_test.go +90 -0
  115. package/internal/session/session_mark_test.go +95 -0
  116. package/internal/session/session_test.go +237 -0
  117. package/internal/session/sessionlog.go +128 -0
  118. package/internal/session/sessionlog_read_test.go +157 -0
  119. package/npm/dist/darwin-arm64/magic-agent +0 -0
  120. package/npm/dist/darwin-x64/magic-agent +0 -0
  121. package/npm/dist/linux-arm64/magic-agent +0 -0
  122. package/npm/dist/linux-x64/magic-agent +0 -0
  123. package/npm/dist/win32-x64/magic-agent.exe +0 -0
  124. package/npm/install.js +121 -0
  125. package/npm/lib/platform.js +78 -0
  126. package/package.json +36 -0
@@ -0,0 +1,694 @@
1
+ package agent
2
+
3
+ // ask.go - 「需要用户选择」的统一格式与跨引擎兼容映射层。
4
+ //
5
+ // 背景(2026-09-18 真机实测,claude 2.1.146 / 本机 WorkBuddy 内置 codebuddy):
6
+ //
7
+ // claude 在 -p(headless)下调用 AskUserQuestion 时**没有** control_request,
8
+ // 而是普通 assistant 消息里的 tool_use 块,紧接着 CLI 自己回一条 is_error 的
9
+ // tool_result 把提问**自动拒绝**(实测内容 "Answer questions?"):
10
+ //
11
+ // {"type":"assistant","message":{"content":[{"type":"tool_use",
12
+ // "id":"call_01a0…","name":"AskUserQuestion",
13
+ // "input":{"questions":[{"header":"午餐选择","multiSelect":false,
14
+ // "options":[{"description":"选拉面","label":"拉面"},{"description":"选盖饭","label":"盖饭"}],
15
+ // "question":"午餐吃拉面还是盖饭?"}]}}]}}
16
+ // {"type":"user","message":{"content":[{"type":"tool_result",
17
+ // "content":"Answer questions?","is_error":true,"tool_use_id":"call_01a0…"}]},
18
+ // "tool_use_result":"Error: Answer questions?"}
19
+ //
20
+ // codebuddy(独立 CLI v2.159.0)**模型根本看不到 AskUserQuestion**:官方文档把它列为
21
+ // 内置工具,但可用性有宿主门槛(只给第一方宿主)。2026-09-28 用七种配置实测全部拿不到
22
+ // (-p / --tools default / 白名单 / --allowedTools / -y / auto / SDK 声明 elicitation.form /
23
+ // CODEBUDDY_HOST_CAPABILITIES / --acp ± elicitation.form),模型一律 ToolSearch 搜不到、
24
+ // 退化成文字提问 —— 详见 AskSupportOf 的注释。故能力表如实报 "none";
25
+ // 下面的答案编码格式仍保留(官方 SDK 文档与 claude 同构,将来若通道打开可直接复用)。
26
+ //
27
+ // 于是「需要用户选择」在 wire 上只有两种形状,本文件把它们归一化成一份统一格式:
28
+ //
29
+ // tool_use 模型发起提问(claude / codebuddy 族)。宿主只能**观察**到:
30
+ // CLI 已自行拒绝,想真正作答只能靠 EncodeAskFollowUp 把答案作为
31
+ // 后续 user 消息补进去(正好复用常驻会话的 --append 通道)。
32
+ // control_request SDK 层 can_use_tool 请求(宿主自己实现了 canUseTool 回调时才有)。
33
+ // 此时宿主是决策方,用 EncodeAskAnswer / EncodeAskControlResponse 作答。
34
+ //
35
+ // 两族的**答案格式**是同一套(官方文档 claude / codebuddy 一致):
36
+ //
37
+ // allow {"behavior":"allow","updatedInput":{<原 input 原样>,"answers":{<问题原文>:<label>}}}
38
+ // deny {"behavior":"deny","message":"…"}
39
+ //
40
+ // 最容易写错的三点,本文件用代码把它们钉死(不靠调用方自觉):
41
+ //
42
+ // 1. answers 的 key 是**问题原文**(不是 header、不是 id)—— 见 askAnswersMap;
43
+ // 问题原文重复时直接报错(文本 key 无法区分,静默合并会答错题)。
44
+ // 2. 多选把多个 label 用 ", " 连接(官方示例的写法)。
45
+ // 3. allow 时 updatedInput **必填**,且必须原样回传 questions 数组 —— EncodeAskAnswer
46
+ // 用「原始 input + answers」的方式保证这一点(不做字段级重建,免得漏字段)。
47
+ //
48
+ // 其余引擎(codebuddy / codebuddy-ai / trae / llm / codex / openclaw / dsh / arkclaw)实测均无
49
+ // AskUserQuestion 可用(codebuddy 族是"工具存在但模型看不到",见上),能力表 AskSupportOf
50
+ // 返回 "none"(ACP 系的 session/request_permission 是另一族协议,本项目尚未接入,
51
+ // 不在本文件冒充支持)。
52
+
53
+ import (
54
+ "encoding/json"
55
+ "fmt"
56
+ "strings"
57
+ "unicode/utf8"
58
+ )
59
+
60
+ // askMultiSelectSep 多选答案的连接符。官方文档示例即 ", "(逗号 + 空格),
61
+ // 换成别的(如 "、" 或 ",")会让 CLI 侧解析不到已选项。
62
+ const askMultiSelectSep = ", "
63
+
64
+ // AskAnswerPrefix 兜底通道(EncodeAskFollowUp)输出的固定前缀。
65
+ //
66
+ // 带上它是为了让模型能一眼认出「这段是用户对刚才那个提问的选择」,
67
+ // 而不是把它当成新的自由文本需求。调用方(观物台等)也可用它做解析锚点。
68
+ const AskAnswerPrefix = "【用户选择】"
69
+
70
+ // AskSource 说明这次「需要用户选择」是从哪种 wire 形状识别出来的。
71
+ type AskSource string
72
+
73
+ const (
74
+ // AskSourceToolUse assistant 消息里的 tool_use 块(claude / codebuddy 实测形态)。
75
+ AskSourceToolUse AskSource = "tool_use"
76
+ // AskSourceControlRequest SDK 层 control_request(subtype=can_use_tool)。
77
+ AskSourceControlRequest AskSource = "control_request"
78
+ )
79
+
80
+ // AskKind 这次「需要用户选择」的性质。
81
+ type AskKind string
82
+
83
+ const (
84
+ // AskKindQuestion 模型主动提问(AskUserQuestion),答案是选项 label。
85
+ AskKindQuestion AskKind = "question"
86
+ // AskKindPermission 工具调用待授权(can_use_tool 且工具不是 AskUserQuestion),
87
+ // 答案是 allow / deny。Questions 为空,待授权工具的入参在 ToolInput 里。
88
+ AskKindPermission AskKind = "permission"
89
+ )
90
+
91
+ // AskOption 一个候选项。
92
+ type AskOption struct {
93
+ Label string `json:"label"`
94
+ Description string `json:"description,omitempty"`
95
+ // Preview 选项预览(claude TS SDK 专有:由 toolConfig.askUserQuestion.previewFormat
96
+ // 决定是否生成,markdown / html 两种)。codebuddy 无此字段,归一化后留空。
97
+ Preview string `json:"preview,omitempty"`
98
+ }
99
+
100
+ // AskQuestion 一个归一化后的问题。
101
+ type AskQuestion struct {
102
+ // ID 规范化 id("q0" / "q1" …,按出现顺序)。**不是** wire 上的字段:
103
+ // 两家 CLI 都不给问题 id,answers 只能用问题原文做 key,所以本 id 只用于
104
+ // 调用方(UI / 程序)稳定地回填答案 —— 见 EncodeAskAnswer 的匹配顺序。
105
+ ID string `json:"id"`
106
+ // Text 问题正文(answers 的 key,必须原样)。
107
+ Text string `json:"text"`
108
+ // Header 短标签。claude 侧要求 ≤12 字符(见 NormalizeAskHeader),codebuddy 无限制。
109
+ Header string `json:"header,omitempty"`
110
+ // Options 候选项(claude 侧 2–4 个)。
111
+ Options []AskOption `json:"options,omitempty"`
112
+ // MultiSelect 是否多选(wire 字段名就是 camelCase 的 multiSelect)。
113
+ MultiSelect bool `json:"multi_select"`
114
+ }
115
+
116
+ // AskRequest 归一化后的「需要用户选择」请求(跨引擎统一格式)。
117
+ //
118
+ // 这是本层对外的**唯一**格式:调用方只认它,不必知道底下是 claude 还是 codebuddy、
119
+ // 也不必知道这次走的是 tool_use 还是 control_request。
120
+ type AskRequest struct {
121
+ // Engine 产出该请求的引擎名(由调用方 / 流解析器填入,可能为空)。
122
+ Engine string `json:"engine,omitempty"`
123
+ // Kind 提问 / 待授权。
124
+ Kind AskKind `json:"kind"`
125
+ // Source 识别来源。
126
+ Source AskSource `json:"source"`
127
+ // ToolName 原始工具名(提问恒为 "AskUserQuestion")。
128
+ ToolName string `json:"tool_name,omitempty"`
129
+ // ToolUseID 引擎分配的工具调用 id(tool_use 形态必有)。
130
+ ToolUseID string `json:"tool_use_id,omitempty"`
131
+ // RequestID control_request 的 request_id(回 control_response 时必须原样带回)。
132
+ RequestID string `json:"request_id,omitempty"`
133
+ // Questions 归一化后的问题列表(Kind=question 时非空)。
134
+ Questions []AskQuestion `json:"questions,omitempty"`
135
+ // ToolInput 原始工具入参 JSON(allow 时作为 updatedInput 的基底原样回传)。
136
+ ToolInput json.RawMessage `json:"tool_input,omitempty"`
137
+ // Raw 原始整行 NDJSON(排查用,不对外序列化)。
138
+ Raw json.RawMessage `json:"-"`
139
+ }
140
+
141
+ // AskChoice 调用方对**一个问题**的选择。
142
+ type AskChoice struct {
143
+ // QuestionID 优先按归一化 id 匹配("q0")。
144
+ QuestionID string `json:"question_id,omitempty"`
145
+ // Question 次选:按问题原文精确匹配(与 CLI 侧的 key 语义一致)。
146
+ Question string `json:"question,omitempty"`
147
+ // Labels 命中的选项 label(多选可多个)。
148
+ Labels []string `json:"labels,omitempty"`
149
+ // FreeText 自由文本(用户自填,wire 上直接把文本当答案,不要写 "Other")。
150
+ FreeText string `json:"free_text,omitempty"`
151
+ }
152
+
153
+ // AskAnswer 调用方给出的统一答案。
154
+ type AskAnswer struct {
155
+ // Denied 拒绝(提问:拒绝回答;待授权:不允许执行)。
156
+ Denied bool `json:"denied,omitempty"`
157
+ // Message 拒绝理由 / 想回给模型的话。为空时回 "User declined"。
158
+ Message string `json:"message,omitempty"`
159
+ // Interrupt 拒绝的同时中断整个会话(阻止模型继续尝试)。
160
+ // 只有声明支持的引擎会渲染该字段(见 AskInterruptSupportOf)。
161
+ Interrupt bool `json:"interrupt,omitempty"`
162
+ // Choices 每个问题的选择(Kind=question 时必填;Kind=permission 时忽略)。
163
+ Choices []AskChoice `json:"choices,omitempty"`
164
+ }
165
+
166
+ // ---------------------------------------------------------------- 解析(引擎原生 → 统一)
167
+
168
+ // askQuestionWire 是 wire 上单个问题的原始形状(两家一致)。
169
+ type askQuestionWire struct {
170
+ Question string `json:"question"`
171
+ Header string `json:"header"`
172
+ MultiSelect bool `json:"multiSelect"`
173
+ Options []struct {
174
+ Label string `json:"label"`
175
+ Description string `json:"description"`
176
+ Preview string `json:"preview"`
177
+ } `json:"options"`
178
+ }
179
+
180
+ // ParseAskLine 从一行 stream-json NDJSON 里识别「需要用户选择」并归一化。
181
+ //
182
+ // 命中返回 (统一请求, true);非该形态(普通正文 / 工具事件 / 杂讯)返回 (nil, false)。
183
+ // 只认两种形状,与真机实测一致:
184
+ //
185
+ // {"type":"assistant", …content[].type=tool_use,name=AskUserQuestion…}
186
+ // {"type":"control_request","request":{"subtype":"can_use_tool",…}}
187
+ //
188
+ // engine 只用于回填 AskRequest.Engine(可为空);解析本身与引擎无关。
189
+ func ParseAskLine(engine, line string) (*AskRequest, bool) {
190
+ var probe struct {
191
+ Type string `json:"type"`
192
+ }
193
+ if err := json.Unmarshal([]byte(line), &probe); err != nil {
194
+ return nil, false
195
+ }
196
+ switch probe.Type {
197
+ case "control_request":
198
+ return parseAskControlRequest(engine, line)
199
+ case "assistant":
200
+ return parseAskAssistantLine(engine, line)
201
+ }
202
+ return nil, false
203
+ }
204
+
205
+ // parseAskControlRequest 解析 SDK 层 control_request(宿主自己实现 canUseTool 时才会出现)。
206
+ func parseAskControlRequest(engine, line string) (*AskRequest, bool) {
207
+ var cr struct {
208
+ RequestID string `json:"request_id"`
209
+ Request struct {
210
+ Subtype string `json:"subtype"`
211
+ ToolName string `json:"tool_name"`
212
+ ToolUseID string `json:"tool_use_id"`
213
+ Input json.RawMessage `json:"input"`
214
+ } `json:"request"`
215
+ }
216
+ if err := json.Unmarshal([]byte(line), &cr); err != nil {
217
+ return nil, false
218
+ }
219
+ // 只有 can_use_tool 才是「需要用户选择」;同层的 rewind / interrupt 等子类型不是。
220
+ if cr.Request.Subtype != "can_use_tool" {
221
+ return nil, false
222
+ }
223
+ return normalizeAsk(engine, AskSourceControlRequest, cr.Request.ToolName,
224
+ cr.Request.Input, cr.Request.ToolUseID, cr.RequestID, json.RawMessage(line))
225
+ }
226
+
227
+ // parseAskAssistantLine 解析 assistant 消息里的 AskUserQuestion tool_use 块。
228
+ //
229
+ // 一条 assistant 消息可能带多个 content 块,这里只取**第一条** AskUserQuestion ——
230
+ // 同一条消息里出现多个提问块的情形在真机上未观察到(模型一轮只问一次)。
231
+ func parseAskAssistantLine(engine, line string) (*AskRequest, bool) {
232
+ var am struct {
233
+ Message struct {
234
+ Content []struct {
235
+ Type string `json:"type"`
236
+ ID string `json:"id"`
237
+ Name string `json:"name"`
238
+ Input json.RawMessage `json:"input"`
239
+ } `json:"content"`
240
+ } `json:"message"`
241
+ }
242
+ if err := json.Unmarshal([]byte(line), &am); err != nil {
243
+ return nil, false
244
+ }
245
+ for _, b := range am.Message.Content {
246
+ if b.Type != "tool_use" || !isAskUserQuestion(b.Name) {
247
+ continue
248
+ }
249
+ if req, ok := normalizeAsk(engine, AskSourceToolUse, b.Name, b.Input, b.ID, "", json.RawMessage(line)); ok {
250
+ return req, true
251
+ }
252
+ }
253
+ return nil, false
254
+ }
255
+
256
+ // NormalizeAskToolUse 把一个已知的 tool_use 块归一化成统一格式(流解析器累积完参数后调用)。
257
+ //
258
+ // 与 ParseAskLine 的区别:这里不重新解析整行,直接吃「工具名 + 入参 JSON」——
259
+ // claude/codebuddy 的流式协议里参数是 input_json_delta 拼出来的,收尾时才有完整 JSON。
260
+ //
261
+ // 工具名不是 AskUserQuestion(或 questions 为空)时返回 (nil, false):
262
+ // 普通工具调用的授权不在这里冒充成用户选择(待授权请走 control_request 那条路)。
263
+ func NormalizeAskToolUse(engine, toolName string, input json.RawMessage, toolUseID string) (*AskRequest, bool) {
264
+ return normalizeAsk(engine, AskSourceToolUse, toolName, input, toolUseID, "", nil)
265
+ }
266
+
267
+ // normalizeAsk 是两条解析路径的公共归一点。
268
+ //
269
+ // AskUserQuestion → Kind=question(解析 questions;**一个都没有就不算命中**,
270
+ // 免得把空入参的工具调用误报成提问)。
271
+ // 其它工具 → 仅 control_request 形态算命中:Kind=permission(待授权;Questions 为空,
272
+ // 入参留在 ToolInput);tool_use 形态下非 AskUserQuestion 一律不命中(见下)。
273
+ func normalizeAsk(engine string, src AskSource, toolName string, input json.RawMessage, toolUseID, requestID string, raw json.RawMessage) (*AskRequest, bool) {
274
+ req := &AskRequest{
275
+ Engine: engine,
276
+ Source: src,
277
+ ToolName: toolName,
278
+ ToolUseID: toolUseID,
279
+ RequestID: requestID,
280
+ ToolInput: input,
281
+ Raw: raw,
282
+ }
283
+ if !isAskUserQuestion(toolName) {
284
+ // tool_use 形态下**只有** AskUserQuestion 才算「需要用户选择」:普通工具调用
285
+ //(Bash / Read…)的授权是 CLI 自己的事,不该在这里冒充成一次用户选择。
286
+ // control_request 形态则相反 —— can_use_tool 本身就是「这条工具调用待授权」。
287
+ if src == AskSourceToolUse {
288
+ return nil, false
289
+ }
290
+ req.Kind = AskKindPermission
291
+ return req, true
292
+ }
293
+ req.Kind = AskKindQuestion
294
+
295
+ var wire struct {
296
+ Questions []askQuestionWire `json:"questions"`
297
+ }
298
+ if len(input) > 0 {
299
+ _ = json.Unmarshal(input, &wire) // 形状不合就落到下面的「空问题」分支
300
+ }
301
+ for i, q := range wire.Questions {
302
+ nq := AskQuestion{
303
+ ID: fmt.Sprintf("q%d", i),
304
+ Text: q.Question,
305
+ Header: q.Header,
306
+ MultiSelect: q.MultiSelect,
307
+ }
308
+ for _, o := range q.Options {
309
+ nq.Options = append(nq.Options, AskOption{
310
+ Label: o.Label,
311
+ Description: o.Description,
312
+ Preview: o.Preview,
313
+ })
314
+ }
315
+ req.Questions = append(req.Questions, nq)
316
+ }
317
+ if len(req.Questions) == 0 {
318
+ return nil, false
319
+ }
320
+ return req, true
321
+ }
322
+
323
+ // isAskUserQuestion 工具名判定。容忍大小写与下划线差异
324
+ // ("AskUserQuestion" / "ask_user_question" / "askuserquestion" 都认)。
325
+ func isAskUserQuestion(name string) bool {
326
+ if name == "" {
327
+ return false
328
+ }
329
+ var b strings.Builder
330
+ for _, r := range name {
331
+ if r == '_' || r == '-' || r == ' ' {
332
+ continue
333
+ }
334
+ if 'A' <= r && r <= 'Z' {
335
+ r += 'a' - 'A'
336
+ }
337
+ b.WriteRune(r)
338
+ }
339
+ return b.String() == "askuserquestion"
340
+ }
341
+
342
+ // ---------------------------------------------------------------- 渲染(统一 → 引擎原生)
343
+
344
+ // EncodeAskAnswer 把统一答案渲染成引擎的**原生返回格式**(SDK 层 PermissionResult):
345
+ //
346
+ // 允许 {"behavior":"allow","updatedInput":{…原 input…,"answers":{"<问题原文>":"<label>"}}}
347
+ // 拒绝 {"behavior":"deny","message":"…"[,"interrupt":true]}
348
+ //
349
+ // 适用场景:宿主自己实现了 canUseTool 回调(即这次请求来自 control_request)。
350
+ // 若这次请求是 tool_use 形态(CLI 已自行拒绝),请改用 EncodeAskFollowUp。
351
+ //
352
+ // 门槛用的是 askAnswerShapeKnown("答案该长什么样")而不是 AskSupportOf("能不能收到提问"):
353
+ // 两者在 codebuddy 上分道 —— 它收不到提问,但答案形状官方文档写了,编码保持可用,
354
+ // 将来它的提问通道打开(第一方宿主 / 官方补齐)时直接复用。见 AskSupportOf 的 ⚠️ 段。
355
+ func EncodeAskAnswer(engine string, req *AskRequest, ans AskAnswer) ([]byte, error) {
356
+ if req == nil {
357
+ return nil, fmt.Errorf("ask: nil request")
358
+ }
359
+ if !askAnswerShapeKnown(engine) {
360
+ return nil, fmt.Errorf("ask: engine %q has no known ask-answer protocol shape", engine)
361
+ }
362
+
363
+ if ans.Denied {
364
+ out := map[string]any{"behavior": "deny"}
365
+ msg := ans.Message
366
+ if msg == "" {
367
+ msg = "User declined"
368
+ }
369
+ out["message"] = msg
370
+ // interrupt 只有声明支持的引擎才渲染:claude 的官方 SDK 文档没有该字段,
371
+ // 多传可能被严格校验的实现拒绝(不静默降级,也不冒险多塞)。
372
+ if ans.Interrupt && AskInterruptSupportOf(engine) {
373
+ out["interrupt"] = true
374
+ }
375
+ return json.Marshal(out)
376
+ }
377
+
378
+ // allow:updatedInput 必填(claude 侧省略会因校验失败被拒),且必须原样回传
379
+ // questions —— 所以拿**原始入参**当基底,只加/改 answers 一个键。
380
+ input := map[string]any{}
381
+ if len(req.ToolInput) > 0 {
382
+ if err := json.Unmarshal(req.ToolInput, &input); err != nil {
383
+ return nil, fmt.Errorf("ask: decode tool input: %w", err)
384
+ }
385
+ }
386
+ if req.Kind == AskKindQuestion {
387
+ answers, err := askAnswersMap(req, ans)
388
+ if err != nil {
389
+ return nil, err
390
+ }
391
+ input["answers"] = answers
392
+ }
393
+ return json.Marshal(map[string]any{"behavior": "allow", "updatedInput": input})
394
+ }
395
+
396
+ // EncodeAskControlResponse 在 EncodeAskAnswer 外面再包一层 wire 信封(control_response)。
397
+ //
398
+ // ⚠️ 信封的嵌套层级**官方未公开逐字段规范**(社区逆向对层级描述也不一致,见
399
+ // WEBSOCKET_PROTOCOL_REVERSED 一类资料)。这里按最常见的三层写法输出:
400
+ //
401
+ // {"type":"control_response","response":{"subtype":"success","request_id":"…",
402
+ // "response":{<EncodeAskAnswer 的结果>}}}
403
+ //
404
+ // 真要对接某个宿主前,务必对该宿主的真实 CLI 抓包核对一次。
405
+ func EncodeAskControlResponse(engine string, req *AskRequest, ans AskAnswer) ([]byte, error) {
406
+ if req == nil {
407
+ return nil, fmt.Errorf("ask: nil request")
408
+ }
409
+ if req.RequestID == "" {
410
+ return nil, fmt.Errorf("ask: request has no request_id (source=%s); control_response cannot be built", req.Source)
411
+ }
412
+ result, err := EncodeAskAnswer(engine, req, ans)
413
+ if err != nil {
414
+ return nil, err
415
+ }
416
+ return json.Marshal(map[string]any{
417
+ "type": "control_response",
418
+ "response": map[string]any{
419
+ "subtype": "success",
420
+ "request_id": req.RequestID,
421
+ "response": json.RawMessage(result),
422
+ },
423
+ })
424
+ }
425
+
426
+ // EncodeAskFollowUp 把统一答案渲染成一段**普通用户消息文本**(兜底通道)。
427
+ //
428
+ // 为什么需要它:headless 下 claude 在模型调用 AskUserQuestion 后**立刻自行拒绝**
429
+ // (实测 tool_result 内容 "Answer questions?",is_error=true),宿主没有回填
430
+ // tool_result 的窗口。此时唯一还能把用户选择送进模型的办法,就是把它作为
431
+ // **后续一条 user 消息**追加进去 —— 正好复用常驻会话的 --append 通道。
432
+ //
433
+ // 输出是纯文本(不依赖任何引擎的 JSON 协议),对全部引擎可用。
434
+ func EncodeAskFollowUp(req *AskRequest, ans AskAnswer) string {
435
+ var b strings.Builder
436
+ b.WriteString(AskAnswerPrefix)
437
+ b.WriteString("\n")
438
+ if req == nil {
439
+ b.WriteString("- (请求缺失)\n")
440
+ return b.String()
441
+ }
442
+ if req.Kind == AskKindPermission {
443
+ verdict := "允许"
444
+ if ans.Denied {
445
+ verdict = "拒绝"
446
+ }
447
+ name := req.ToolName
448
+ if name == "" {
449
+ name = "工具调用"
450
+ }
451
+ b.WriteString("- " + name + " → " + verdict)
452
+ if ans.Message != "" {
453
+ b.WriteString(":" + ans.Message)
454
+ }
455
+ b.WriteString("\n")
456
+ return b.String()
457
+ }
458
+ if ans.Denied {
459
+ b.WriteString("- (用户拒绝回答本次提问)")
460
+ if ans.Message != "" {
461
+ b.WriteString(":" + ans.Message)
462
+ }
463
+ b.WriteString("\n")
464
+ return b.String()
465
+ }
466
+ for _, q := range req.Questions {
467
+ v := "(未回答)"
468
+ for _, c := range ans.Choices {
469
+ if !choiceMatches(c, q) {
470
+ continue
471
+ }
472
+ if c.FreeText != "" {
473
+ v = c.FreeText
474
+ } else {
475
+ v = strings.Join(c.Labels, askMultiSelectSep)
476
+ }
477
+ break
478
+ }
479
+ b.WriteString("- " + q.Text + " → " + v + "\n")
480
+ }
481
+ return b.String()
482
+ }
483
+
484
+ // ---------------------------------------------------------------- 能力表
485
+
486
+ // AskSupportOf 返回某引擎「需要用户选择」的落地方式(机器可读):
487
+ //
488
+ // "tool:AskUserQuestion" 有该工具 + can_use_tool 协议(**仅 claude**)
489
+ // "none" 无该能力(其余引擎均无 —— 含 codebuddy 族,见下)
490
+ //
491
+ // 与 WorkspaceSupportOf / AttachmentSupportOf 同一约定:能力值同时可进 `--engines` 输出,
492
+ // 供调用方决定要不要走 Ask 流程。
493
+ //
494
+ // ⚠️ codebuddy / codebuddy-ai 为什么是 "none"(2026-09-28 实测更正):
495
+ // 官方文档把 AskUserQuestion 列为内置工具(Requires Permission: Yes),但**它的可用性有宿主门槛** ——
496
+ // CLI 只把该工具交给第一方宿主(官方 App / IDE),第三方宿主驱动的通道下模型根本看不到它。
497
+ // 实测七种配置,全部拿不到(模型一律 ToolSearch 搜不到 → 自述"注册表里没有" → 退化成文字提问):
498
+ //
499
+ // -p 文本 / --print json ❌
500
+ // --tools default / 白名单 / --allowedTools ❌
501
+ // -y(bypassPermissions) / auto / default ❌
502
+ // SDK initialize 声明 capabilities.elicitation.form ❌
503
+ // CODEBUDDY_HOST_CAPABILITIES=elicitation.form ❌
504
+ // --acp(initialize+session/new+session/prompt) ❌
505
+ // --acp + clientCapabilities.elicitation.form=true ❌
506
+ //
507
+ // 佐证:该工具只在 CLI init 行的 75 个工具**注册表**里出现,不进模型的可用工具列表,
508
+ // ToolSearch 也索引不到;而 elicitation.form 解锁的是 AskUserForStructuredInput(文档明说
509
+ // "否则该工具不暴露给模型"),不是 AskUserQuestion。
510
+ // 故此前那句 "tool:AskUserQuestion" 是**按 claude/codebuddy 同族协议推断**的失实声明:
511
+ // 上层 UI 会因此承诺决策卡,而它永远不会出现 —— 比"不支持"更糟。
512
+ //
513
+ // 注:trae / cursor / iflow / qwen 等走的是 ACP 的 session/request_permission(另一族
514
+ // 协议:选项带 optionId + kind=allow_once/reject_once…),本项目尚未接入,因此如实报 none。
515
+ func AskSupportOf(engine string) string {
516
+ // 具名 agent(如 MagicAI)先归到它协议的家族名,再查表 —— 见 CapabilityFamilyOf。
517
+ engine = CapabilityFamilyOf(engine)
518
+ if equalFold(engine, "claude") {
519
+ return "tool:AskUserQuestion"
520
+ }
521
+ return "none"
522
+ }
523
+
524
+ // AskSupportsEngine 引擎是否支持 AskUserQuestion 族协议。
525
+ func AskSupportsEngine(engine string) bool { return AskSupportOf(engine) != "none" }
526
+
527
+ // askAnswerShapeKnown 该引擎是否有**已知的答案编码形状**(EncodeAskAnswer 的门槛)。
528
+ //
529
+ // 与 AskSupportOf 是两件事,别合并:AskSupportOf 回答"这台机器上能不能**收到**提问"
530
+ // (面向调用方/UI,codebuddy 族为 none),这里回答"若拿到提问,**答案**该渲染成什么形状"。
531
+ // codebuddy 族的形状官方 SDK 文档写了(含 interrupt),故保留编码能力 ——
532
+ // 沿用同一套问答格式的将来(第一方宿主 / 官方补齐第三方通道)不必再改编码层。
533
+ func askAnswerShapeKnown(engine string) bool {
534
+ // 具名 agent 先归到它协议的家族名,再查表 —— 见 CapabilityFamilyOf。
535
+ engine = CapabilityFamilyOf(engine)
536
+ return equalFold(engine, "claude") || equalFold(engine, "codebuddy") || equalFold(engine, "codebuddy-ai")
537
+ }
538
+
539
+ // AskInterruptSupportOf 拒绝时是否支持 interrupt(拒绝并中断整个会话)。
540
+ //
541
+ // 只有 codebuddy 的官方文档写了该字段;claude 的 SDK 文档只给 behavior/message,
542
+ // 故对 claude 不渲染(见 EncodeAskAnswer)。codebuddy-ai 与 codebuddy 同族同协议,同样支持。
543
+ //
544
+ // ⚠️ 现实注脚(2026-09-28):codebuddy 族**产生不了提问**(见 AskSupportOf),所以这个分支
545
+ // 在当前真机上走不到 —— 保留的是**协议形状**:将来若 codebuddy 的提问通道打开(第一方宿主
546
+ // 或官方补齐第三方通道),编码答案时该字段仍然正确。
547
+ func AskInterruptSupportOf(engine string) bool {
548
+ // 具名 agent 先归到它协议的家族名,再查表 —— 见 CapabilityFamilyOf。
549
+ engine = CapabilityFamilyOf(engine)
550
+ return equalFold(engine, "codebuddy") || equalFold(engine, "codebuddy-ai")
551
+ }
552
+
553
+ // askHeaderLimit claude 侧 header 的字符上限(官方 SDK 文档:最多 12 字符)。
554
+ // codebuddy 无此限制,返回 0 表示不限。
555
+ func askHeaderLimit(engine string) int {
556
+ // 具名 agent 先归到它协议的家族名,再查表 —— 见 CapabilityFamilyOf。
557
+ engine = CapabilityFamilyOf(engine)
558
+ if equalFold(engine, "claude") {
559
+ return 12
560
+ }
561
+ return 0
562
+ }
563
+
564
+ // NormalizeAskHeader 按目标引擎的限制归一化 header(超长按**字符**截断,不是字节)。
565
+ //
566
+ // 给「自己生成问题」的调用方用(本层的解析方向只做原样透传,不改模型给的内容)。
567
+ func NormalizeAskHeader(engine, header string) string {
568
+ limit := askHeaderLimit(engine)
569
+ if limit <= 0 || utf8.RuneCountInString(header) <= limit {
570
+ return header
571
+ }
572
+ runes := []rune(header)
573
+ return string(runes[:limit])
574
+ }
575
+
576
+ // ---------------------------------------------------------------- 内部工具
577
+
578
+ // askAnswersMap 把统一答案编译成 wire 需要的 answers 映射。
579
+ //
580
+ // 三条硬约束(对应文件头注释里「最容易写错的三点」):
581
+ // 1. key 必须是**问题原文**;
582
+ // 2. 每个问题都必须有答案(CLI 侧按「全部问题都答了」处理 updatedInput);
583
+ // 3. 问题原文不能重复 —— 重复时文本 key 无法区分,直接报错而不是静默合并。
584
+ func askAnswersMap(req *AskRequest, ans AskAnswer) (map[string]string, error) {
585
+ if len(req.Questions) == 0 {
586
+ return nil, fmt.Errorf("ask: no questions to answer")
587
+ }
588
+ byText := map[string]int{}
589
+ for i, q := range req.Questions {
590
+ if _, dup := byText[q.Text]; dup {
591
+ return nil, fmt.Errorf("ask: question text %q appears more than once; the wire format keys answers by question text, so duplicates cannot be answered reliably", q.Text)
592
+ }
593
+ byText[q.Text] = i
594
+ }
595
+
596
+ out := make(map[string]string, len(req.Questions))
597
+ for _, c := range ans.Choices {
598
+ idx, err := matchChoice(req, byText, c)
599
+ if err != nil {
600
+ return nil, err
601
+ }
602
+ if _, dup := out[req.Questions[idx].Text]; dup {
603
+ return nil, fmt.Errorf("ask: question %q answered twice", req.Questions[idx].Text)
604
+ }
605
+ v, err := askChoiceValue(req.Questions[idx], c)
606
+ if err != nil {
607
+ return nil, err
608
+ }
609
+ out[req.Questions[idx].Text] = v
610
+ }
611
+ for _, q := range req.Questions {
612
+ if _, ok := out[q.Text]; !ok {
613
+ return nil, fmt.Errorf("ask: question %q has no answer", q.Text)
614
+ }
615
+ }
616
+ return out, nil
617
+ }
618
+
619
+ // matchChoice 定位一个选择对应的问题下标:先按归一化 id,再按问题原文。
620
+ func matchChoice(req *AskRequest, byText map[string]int, c AskChoice) (int, error) {
621
+ if c.QuestionID != "" {
622
+ for i, q := range req.Questions {
623
+ if q.ID == c.QuestionID {
624
+ return i, nil
625
+ }
626
+ }
627
+ return -1, fmt.Errorf("ask: unknown question id %q", c.QuestionID)
628
+ }
629
+ if c.Question != "" {
630
+ if i, ok := byText[c.Question]; ok {
631
+ return i, nil
632
+ }
633
+ return -1, fmt.Errorf("ask: unknown question %q", c.Question)
634
+ }
635
+ return -1, fmt.Errorf("ask: choice needs question_id or question")
636
+ }
637
+
638
+ // askChoiceValue 校验并渲染单个问题的答案值。
639
+ //
640
+ // 不静默接受「不在候选里的 label」:wire 上 answers 的值必须是所选项的 label,
641
+ // 幻觉 label 到了模型侧会被当成无效答案,不如在这里就报错。
642
+ func askChoiceValue(q AskQuestion, c AskChoice) (string, error) {
643
+ if c.FreeText != "" {
644
+ if len(c.Labels) > 0 {
645
+ return "", fmt.Errorf("ask: question %q: give either labels or free_text, not both", q.Text)
646
+ }
647
+ return c.FreeText, nil
648
+ }
649
+ if len(c.Labels) == 0 {
650
+ return "", fmt.Errorf("ask: question %q: empty answer", q.Text)
651
+ }
652
+ if len(c.Labels) > 1 && !q.MultiSelect {
653
+ return "", fmt.Errorf("ask: question %q is single-select but got %d labels", q.Text, len(c.Labels))
654
+ }
655
+ known := make(map[string]bool, len(q.Options))
656
+ for _, o := range q.Options {
657
+ known[o.Label] = true
658
+ }
659
+ for _, l := range c.Labels {
660
+ if !known[l] {
661
+ return "", fmt.Errorf("ask: question %q: label %q is not one of its options", q.Text, l)
662
+ }
663
+ }
664
+ return strings.Join(c.Labels, askMultiSelectSep), nil
665
+ }
666
+
667
+ // choiceMatches 宽松匹配(仅用于兜底文本渲染,不做校验):
668
+ // 按 id 或问题原文任一命中即可。
669
+ func choiceMatches(c AskChoice, q AskQuestion) bool {
670
+ if c.QuestionID != "" {
671
+ return c.QuestionID == q.ID
672
+ }
673
+ return c.Question != "" && c.Question == q.Text
674
+ }
675
+
676
+ // askSummaryText 把一次「需要用户选择」压成一行人类可读文本
677
+ // (流事件的 Text 字段用它,text 模式打到 stderr 即可看懂)。
678
+ func askSummaryText(req *AskRequest) string {
679
+ if req == nil {
680
+ return ""
681
+ }
682
+ if req.Kind == AskKindPermission {
683
+ return fmt.Sprintf("需要授权:%s(等待用户选择)", req.ToolName)
684
+ }
685
+ parts := make([]string, 0, len(req.Questions))
686
+ for _, q := range req.Questions {
687
+ labels := make([]string, 0, len(q.Options))
688
+ for _, o := range q.Options {
689
+ labels = append(labels, o.Label)
690
+ }
691
+ parts = append(parts, fmt.Sprintf("%s [%s]", q.Text, strings.Join(labels, " | ")))
692
+ }
693
+ return "需要用户选择:" + strings.Join(parts, ";")
694
+ }