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,1113 @@
1
+ package cli
2
+
3
+ // ask.go - 提问主流程(根命令直用,无子命令)。
4
+ //
5
+ // 输入来源(优先级:--prompt > args > --file > stdin):
6
+ // 1. -p/--prompt <text>:命令行直接给提示词
7
+ // 2. 位置参数拼接:magic-agent "问题"
8
+ // 3. --file <path>:文件全文("-" = stdin)
9
+ // 4. stdin 管道(stdin 非 TTY 且无其他输入时自动读取)
10
+ // --file 与 -p/args 可组合:文件内容在前,-p/args 在后(附加上下文)。
11
+ //
12
+ // **提示词可以是文件路径**(用户 2026-09-17:「传了提示词需要支持传入文件路径」):
13
+ // -p / 位置参数 / -s 的值若命中一个已存在的普通文件,就按文件内容用(并打一行 stderr 提示);
14
+ // 想强制按文件读、读不到就报错,写 "@<path>"(-p、位置参数、-s、配置的 systemPrompt 都支持)。
15
+ // 判据保守(单行、≤4096 字节、stat 是普通文件),所以 `-p "解释一下 README.md"` 这类
16
+ // 正常提示词不受影响;详见 expandPromptSource。
17
+ //
18
+ // 工具开关(--tools):
19
+ // off(默认) 禁用全部工具 —— 纯 chat 一次成型,输出可解析
20
+ // on 保持引擎默认工具集 + 权限旁路(agent 模式)
21
+ // <白名单> 逗号分隔工具名(如 Bash,Read),仅允许这些工具
22
+ // + 权限旁路;引擎不支持的白名单成员忽略。
23
+ //
24
+ // 会话续接(--session):
25
+ // 默认新会话;把上次输出 envelope 里的 session_id 传回 --session,
26
+ // 即可继续同一会话(引擎侧透传各自的 --resume 参数)。
27
+
28
+ import (
29
+ "context"
30
+ "encoding/json"
31
+ "fmt"
32
+ "io"
33
+ "os"
34
+ "path/filepath"
35
+ "strings"
36
+ "time"
37
+
38
+ "github.com/spf13/cobra"
39
+
40
+ "github.com/darren/magic-agent/internal/agent"
41
+ "github.com/darren/magic-agent/internal/config"
42
+ "github.com/darren/magic-agent/internal/session"
43
+ )
44
+
45
+ // askOptions 根命令的全部参数。
46
+ type askOptions struct {
47
+ engine string
48
+ model string
49
+ system string
50
+ prompt string
51
+ file string
52
+ attach []string // 附件(截图/图片)路径;与提示词一起发给引擎
53
+ tools string
54
+ timeout time.Duration
55
+ retries int
56
+ backoff time.Duration
57
+ output string
58
+ verbose bool
59
+ engines bool
60
+ contract bool // 输出带版本的契约 envelope(--contract;客户端启动校验用)
61
+ noModels bool
62
+ login string // 拉起某引擎自己的交互式登录会话(--login <engine>)
63
+ events bool // --stream 的事件流带契约版本与行号(--events;客户端消费用)
64
+ control bool // 从 stdin 读 NDJSON 控制命令(--control;打断 / 收工 / 回审批)
65
+ jsonOut bool
66
+ stream bool
67
+ noThinking bool
68
+ maxTokens int
69
+ temperature float64
70
+ tempSet bool
71
+ jsonSchema string // 内联 JSON Schema(仅 llm 引擎走 --schema)
72
+ session string // 会话续接 id(空 = 新会话,默认)
73
+ continueF bool // 续接最近一次会话(-c/--continue;不需要 id)
74
+ workspace string // 工作目录(-w/--workspace;空 = 用调用方 cwd)
75
+ stop string // 停止指定会话/运行(--stop <session_id|run_id>)
76
+ listSessions bool // 列出会话登记表(--sessions)
77
+ // sessionLog 读一段会话的**事件历史**(--session-log <session_id|run_id>)。
78
+ // 为什么要有它:会话的「查询」能力原先只在 HTTP 插件里(/desk/session/{id}/messages),
79
+ // 而 magic-agent 自己不跑服务、由调用方(如掌天瓶)直接 exec CLI —— 所以读历史
80
+ // 必须也是一条 CLI 命令,否则调用方拿不到历史,只能自己解析磁盘格式。
81
+ sessionLog string
82
+ // logAfter 增量续读游标(--after <seq>,仅配合 --session-log):
83
+ // 调用方报「我已看到 seq=N」,只回 seq > N 的部分。断线重连时不必重传整段历史。
84
+ logAfter uint64
85
+ keepAlive bool // 常驻会话:首轮结束后不退出,等 --append 追加(需配合 --stream)
86
+ appendTo string // 向常驻会话追加消息(--append <session_id|run_id>)
87
+ idle time.Duration // 常驻会话空闲收工时长(--idle,仅 --keep-alive 有效)
88
+
89
+ // 四档权限模型(见 internal/agent/permission.go)。仅 claude / codebuddy 接线。
90
+ permission string // 档位:manual | accept-edits | auto | full
91
+ sandboxExclude []string // 始终在沙箱外执行的命令(sandbox.excludedCommands)
92
+ sandboxDomain []string // 沙箱网络白名单(sandbox.network.allowedDomains)
93
+ autoModeEnv []string // 第 3 档分类器的受信边界描述(autoMode.environment)
94
+ permDeny []string // 追加 deny 规则(permissions.deny,所有档位生效)
95
+ permAsk []string // 追加 ask 规则(permissions.ask,强制人工审批)
96
+ }
97
+
98
+ // newAskOptions 返回带默认值的选项集。
99
+ // - 默认输出 json:固定单行 envelope,程序解析最友好;人看用 -o text。
100
+ // - 默认引擎 codebuddy:hy3 免费模型开箱即用。
101
+ // - 默认超时 600s:10 分钟,覆盖 codebuddy 慢响应场景。
102
+ // - 默认常驻(keepAlive):claude/codebuddy 的流式调用自动成为常驻会话,
103
+ // 任务跑着的时候就能 `--append` 追加需求(用户 2026-09-18:「keep-alive要是默认的」)。
104
+ // 用 --keep-alive=false 关掉;非流式 / 不支持追加的引擎会自动忽略它。
105
+ func newAskOptions() *askOptions {
106
+ return &askOptions{
107
+ engine: "codebuddy",
108
+ output: "json",
109
+ timeout: 600 * time.Second,
110
+ keepAlive: true,
111
+ }
112
+ }
113
+
114
+ // bindAskFlags 把全部 flags 注册为根命令的 persistent flags。
115
+ func bindAskFlags(cmd *cobra.Command, opts *askOptions) {
116
+ f := cmd.PersistentFlags()
117
+ f.StringVarP(&opts.engine, "engine", "e", "codebuddy", "引擎: codebuddy | codebuddy-ai | claude | trae | llm | codex | openclaw | dsh | arkclaw(默认 codebuddy)")
118
+ f.StringVarP(&opts.model, "model", "m", "", "模型(空 = 引擎默认:codebuddy=hy3;codebuddy-ai=CLI 自选默认;llm 引擎传 llm CLI 注册名,如 minimax-m3;dsh/arkclaw 不透传)")
119
+ f.StringVarP(&opts.system, "system", "s", "", "系统提示词(空 = 用配置文件 ~/.config/magic-agent/config.json 的 systemPrompt;两者都为空则不注入)。值若是文件路径或 @文件 → 读该文件内容")
120
+ f.StringVarP(&opts.prompt, "prompt", "p", "", "提示词(值若是文件路径或 @文件 → 读该文件内容作为提示词)")
121
+ f.StringVarP(&opts.workspace, "workspace", "w", "", "工作目录(workspace):在该目录里执行引擎;codex 走原生 -C,claude/codebuddy/trae/dsh 用子进程 cwd,llm/arkclaw/openclaw 不支持(忽略并提示)")
122
+ f.StringVarP(&opts.file, "file", "f", "", "从文件读 prompt(\"-\" = stdin)")
123
+ f.StringArrayVarP(&opts.attach, "attach", "a", nil, "附件路径(截图/图片等),可重复或逗号分隔;与提示词一起发给引擎(各引擎落地方式见 --engines 的 attachments 字段)")
124
+ f.StringVar(&opts.tools, "tools", "off", "工具开关: off | on | 逗号分隔白名单(如 Bash,Read)")
125
+ f.StringVar(&opts.session, "session", "", "会话续接 id(空=新会话;传入上次输出里的 session_id 继续同一会话;arkclaw 传 contextId;dsh 不支持续接)")
126
+ f.BoolVarP(&opts.continueF, "continue", "c", false, "续接当前目录最近一次会话(不需要 session id;与 --session 同时给时 --session 优先)")
127
+ f.StringVar(&opts.stop, "stop", "", "停止指定会话/运行:传 session_id 或 run_id(--sessions 可见),杀掉它的引擎进程组")
128
+ f.BoolVar(&opts.listSessions, "sessions", false, "列出会话登记表(JSON 数组:run_id / session_id / pid / engine / state / 起止时间)")
129
+ /* 会话历史的**读取**命令。与 --sessions(列表)、--stop、--append 同一族:
130
+ 调用方(掌天瓶等)exec CLI 就能拿到会话数据,不需要 magic-agent 跑任何服务。 */
131
+ f.StringVar(&opts.sessionLog, "session-log", "", "读一段会话的事件历史(JSON:{session,events})。传 session_id 或 run_id(--sessions 可见)")
132
+ f.Uint64Var(&opts.logAfter, "after", 0, "配合 --session-log:增量续读,只回 seq > N 的事件(另附 count/after/lastSeq/snapshotRequired)")
133
+ f.BoolVar(&opts.keepAlive, "keep-alive", true, "常驻会话:首轮结束后不退出、等 --append 追加(需 --stream)。claude/codebuddy 默认开;dsh 默认关(显式传 --keep-alive 开启);其余引擎不支持")
134
+ f.StringVar(&opts.appendTo, "append", "", "向常驻会话追加一条消息:传 session_id 或 run_id,内容用 -p/位置参数给")
135
+ f.DurationVar(&opts.idle, "idle", 5*time.Minute, "常驻会话空闲收工时长(默认 5m;0 = 本轮结束就收工,追加窗口只在任务运行期间)")
136
+ f.IntVar(&opts.maxTokens, "max-tokens", 0, "输出 token 上限(0=不指定;claude/codebuddy 经 --settings 注入,llm 透传,trae/dsh 忽略)")
137
+ f.Float64Var(&opts.temperature, "temperature", -1, "采样温度(-1=不指定;仅 llm 引擎透传,其余引擎忽略)")
138
+ f.StringVar(&opts.jsonSchema, "json-schema", "", "JSON Schema 内联字符串(llm / openclaw / dsh / arkclaw 引擎;启用结构化输出与 JSON 后处理)")
139
+ f.DurationVarP(&opts.timeout, "timeout", "t", 600*time.Second, "单次尝试超时(默认 600s=10m)")
140
+ f.IntVarP(&opts.retries, "retries", "r", 0, "失败重试次数(默认 0)")
141
+ f.DurationVar(&opts.backoff, "backoff", 2*time.Second, "首次重试退避间隔(指数翻倍,上限 30s)")
142
+ f.StringVarP(&opts.output, "output", "o", "json", "输出格式: json | text")
143
+ f.BoolVarP(&opts.verbose, "verbose", "v", false, "重试过程打印到 stderr")
144
+ f.BoolVar(&opts.engines, "engines", false, "列出支持的引擎、本机 CLI 可用性与各引擎当前支持的模型(JSON 数组;每行含 capabilities / models / workspace / streaming 能力字段;不可用的引擎带 install 一键安装命令)")
145
+ f.BoolVar(&opts.contract, "contract", false, "输出桌面/移动客户端契约:{\"contractVersion\":N,\"engines\":[...]}(engines 与 --engines 同构,另含 capabilities 静态能力字段)。默认不探测模型(快);要模型写 --no-models=false")
146
+ f.BoolVar(&opts.noModels, "no-models", false, "配合 --engines / --contract:跳过各引擎的模型探测(只列引擎与可用性,不启动 CLI)")
147
+ f.StringVar(&opts.login, "login", "", "拉起指定引擎自己的交互式登录会话(如 codebuddy / codebuddy-ai):自动带上该引擎的账号环境,进去执行 /login 即可;登录态落在该引擎自己的票据上,两个账号互不顶号")
148
+ f.BoolVar(&opts.jsonOut, "json", false, "兼容保留:--engines 已默认 JSON,本 flag 不再需要")
149
+ f.BoolVar(&opts.stream, "stream", false, "流式输出:正文/思考增量实时打到 stdout(text 模式思考走 stderr)")
150
+ f.BoolVar(&opts.events, "events", false, "配合 --stream:事件流带契约版本与行号(每行加 v / seq,并先发一行 ready),供客户端消费;老消费者不要开(形状与 --stream 不同)")
151
+ f.BoolVar(&opts.control, "control", false, "配合 --stream:从 stdin 读 NDJSON 控制命令(ping / interrupt / stop / answer),让调用方能打断长任务、回审批")
152
+ f.BoolVar(&opts.noThinking, "no-thinking", false, "流式模式下不转发思考过程增量")
153
+ bindPermissionFlags(cmd, opts)
154
+ }
155
+
156
+ // bindPermissionFlags 注册四档权限模型的 flags(仅 claude / codebuddy 生效)。
157
+ //
158
+ // 默认档 full 是**刻意**的:改造前 claude/codebuddy 在 --tools 非 off 时恒传
159
+ // --dangerously-skip-permissions / -y,语义正是第 4 档。把默认值改成别的档位
160
+ // 会是一次静默的行为变更(既有调用方的 agent 会突然开始弹审批 / 被沙箱拦)。
161
+ // 推荐需要 agent 能力的调用方显式传 `--permission auto`。
162
+ func bindPermissionFlags(cmd *cobra.Command, opts *askOptions) {
163
+ f := cmd.PersistentFlags()
164
+ f.StringVar(&opts.permission, "permission", string(agent.DefaultPermissionTier),
165
+ "权限档位: manual | accept-edits | auto | full(默认 full=保持既有行为;仅 claude/codebuddy/codebuddy-ai 生效)\n"+
166
+ " manual 沙箱开启,只读放行,其余逐项由用户确认\n"+
167
+ " accept-edits 沙箱开启,编辑放行,命令仍逐条确认\n"+
168
+ " auto 沙箱开启,越界由内置 LLM Guardian 判定(推荐)\n"+
169
+ " full 沙箱关闭,命令直接在宿主机执行,无审批")
170
+ f.StringArrayVar(&opts.sandboxExclude, "sandbox-exclude", nil,
171
+ "始终在沙箱外执行的命令(如 docker,watchman);可重复或逗号分隔(manual/accept-edits/auto 有效)")
172
+ f.StringArrayVar(&opts.sandboxDomain, "sandbox-domain", nil,
173
+ "沙箱网络白名单域名;可重复或逗号分隔(留空 = 不改动引擎自身网络策略)")
174
+ f.StringArrayVar(&opts.autoModeEnv, "auto-mode-env", nil,
175
+ "第 3 档分类器的受信边界(自然语言,可重复)。如 \"Source control: github.example.com/acme-corp\"")
176
+ f.StringArrayVar(&opts.permDeny, "permission-deny", nil,
177
+ "追加 deny 规则,如 'Bash(rm -rf *)';在所有档位(含 full)都生效且不可被白名单覆盖;可重复")
178
+ f.StringArrayVar(&opts.permAsk, "permission-ask", nil,
179
+ "追加 ask 规则,如 'Bash(git push *)';命中即强制人工审批(第 3 档下分类器也无法自动放行);可重复")
180
+ }
181
+
182
+ // workspaceUnsupportedWarn 引擎没有文件系统语义(llm / arkclaw)时的提示文案;
183
+ // 支持的引擎返回空串(无需提示)。抽成函数便于单测,也把「不支持要说明白」变成可断言的行为。
184
+ func workspaceUnsupportedWarn(engine string) string {
185
+ if agent.WorkspaceSupportOf(engine) != "none" {
186
+ return ""
187
+ }
188
+ return fmt.Sprintf("magic-agent: 警告:%s 引擎不支持指定工作目录,-w/--workspace 已忽略"+
189
+ "(各引擎能力见 --engines 的 workspace 字段)\n", engine)
190
+ }
191
+
192
+ // resolveWorkspaceDir 解析 -w/--workspace:展开 ~、转绝对路径、校验存在且是目录。
193
+ // 空串原样返回(= 不指定,用调用方 cwd)。
194
+ // 路径不存在时提前报错 —— 否则会在 spawn 阶段以 "chdir ...: no such file or directory"
195
+ // 的形式冒出来,指向性差(尤其 `-o json` 下只看到一句 fork/exec 错误)。
196
+ func resolveWorkspaceDir(p string) (string, error) {
197
+ p = strings.TrimSpace(p)
198
+ if p == "" {
199
+ return "", nil
200
+ }
201
+ abs, err := filepath.Abs(agent.ExpandHome(p))
202
+ if err != nil {
203
+ return "", fmt.Errorf("--workspace: %w", err)
204
+ }
205
+ st, err := os.Stat(abs)
206
+ if err != nil {
207
+ return "", fmt.Errorf("--workspace: %s 不可访问: %w", abs, err)
208
+ }
209
+ if !st.IsDir() {
210
+ return "", fmt.Errorf("--workspace: %s 不是目录", abs)
211
+ }
212
+ return abs, nil
213
+ }
214
+
215
+ // resolveSystemPrompt 决定本轮生效的 system prompt:`-s/--system` 优先
216
+ // (其值已由调用方过过 expandPromptSource),未给时回落到配置文件里的全局默认值
217
+ // (`~/.config/magic-agent/config.json` 的 `systemPrompt`,路径可用 MAGIC_AGENT_CONFIG 覆盖,
218
+ // 也可用 MAGIC_AGENT_SYSTEM_PROMPT 覆盖取值)。两者都为空 → 空串(不注入)。
219
+ //
220
+ // 配置里的默认值同样支持「文件路径 / @路径」(系统提示词常单独存一个文件),
221
+ // 故这里也过一遍 expandPromptSource;返回的 note 非空时调用方应写到 stderr。
222
+ //
223
+ // 第二个返回值(旧签名的 err)只在「配置文件读不了 / JSON 语法错 / @路径读不到」时非空 ——
224
+ // 调用方按「不注入 + 提示」处理,绝不因为一份坏配置让整个调用失败。
225
+ func resolveSystemPrompt(flagValue string) (text, note string, err error) {
226
+ if s := strings.TrimSpace(flagValue); s != "" {
227
+ return s, "", nil
228
+ }
229
+ cfg, cerr := config.Load()
230
+ if cerr != nil {
231
+ return "", "", cerr
232
+ }
233
+ v := strings.TrimSpace(cfg.SystemPrompt)
234
+ if v == "" {
235
+ return "", "", nil
236
+ }
237
+ return expandPromptSource(v)
238
+ }
239
+
240
+ // maxPromptPathLen 自动识别「提示词其实是个文件路径」时的长度上限。
241
+ // 超过这个长度不可能是路径,直接按文本用(也免去一次 stat)。
242
+ const maxPromptPathLen = 4096
243
+
244
+ // expandPromptSource 把「可能是文件路径」的提示词输入展开成文本。
245
+ //
246
+ // 用户 2026-09-17 的要求:「传了提示词需要支持传入文件路径」。支持的写法:
247
+ //
248
+ // "@<path>" 显式按文件读取 —— 读不到/不是普通文件直接报错(调用方明确要的就是文件)
249
+ // "<path>" 值恰好命中一个**已存在的普通文件** → 按文件内容读取,并给一行 stderr 提示
250
+ // 其余 原样文本
251
+ //
252
+ // 自动识别刻意保守,避免把正常提示词误当路径:必须不含换行、长度 ≤ maxPromptPathLen、
253
+ // stat 出来是普通文件(目录不算)、可读。命中时**一定**在 stderr 说明「已按文件读取」,
254
+ // 不静默 —— 否则用户会奇怪「模型怎么收到了文件内容」。
255
+ //
256
+ // 返回的 note 非空时,调用方应把它原样写到 stderr。
257
+ func expandPromptSource(s string) (text, note string, err error) {
258
+ raw := strings.TrimSpace(s)
259
+ if raw == "" {
260
+ return s, "", nil
261
+ }
262
+ forced := strings.HasPrefix(raw, "@")
263
+ p := raw
264
+ if forced {
265
+ p = strings.TrimSpace(strings.TrimPrefix(raw, "@"))
266
+ if p == "" {
267
+ return "", "", fmt.Errorf("@ 后缺少文件路径")
268
+ }
269
+ } else if strings.ContainsAny(raw, "\r\n") || len(raw) > maxPromptPathLen {
270
+ return s, "", nil // 多行 / 超长:一定是提示词正文,不做路径识别
271
+ }
272
+
273
+ abs, aerr := filepath.Abs(agent.ExpandHome(p))
274
+ if aerr != nil {
275
+ if forced {
276
+ return "", "", fmt.Errorf("解析提示词文件路径 %s: %w", p, aerr)
277
+ }
278
+ return s, "", nil
279
+ }
280
+ st, serr := os.Stat(abs)
281
+ switch {
282
+ case serr != nil:
283
+ if forced {
284
+ return "", "", fmt.Errorf("读取提示词文件 %s: %w", abs, serr)
285
+ }
286
+ return s, "", nil
287
+ case !st.Mode().IsRegular():
288
+ if forced {
289
+ return "", "", fmt.Errorf("提示词文件 %s 不是普通文件(目录?)", abs)
290
+ }
291
+ return s, "", nil
292
+ }
293
+ data, rerr := os.ReadFile(abs)
294
+ if rerr != nil {
295
+ if forced {
296
+ return "", "", fmt.Errorf("读取提示词文件 %s: %w", abs, rerr)
297
+ }
298
+ return s, "", nil
299
+ }
300
+ note = fmt.Sprintf("magic-agent: 提示:提示词输入命中文件,已按文件内容使用:%s(%d 字节)\n", abs, len(data))
301
+ return strings.TrimSpace(string(data)), note, nil
302
+ }
303
+
304
+ // maxAttachBytes 单个附件大小上限 —— base64 之后还要胖 1/3,且要整条进 HTTP 请求体,
305
+ // 32MB 已远超「截图」量级,超过基本是误把大文件当附件。
306
+ const maxAttachBytes = 32 << 20
307
+
308
+ // resolveAttachments 展开 -a/--attach:拆逗号 → 展开 ~ → 绝对路径 → 校验(普通文件 + 体积)。
309
+ //
310
+ // 为什么在这里校验:路径错误要尽早以 exit 2 报出来(而不是等到引擎层变成
311
+ // 一句含糊的 "open ...: no such file"),且引擎实现因此可以信任 Path 是干净的绝对路径。
312
+ // 重复路径去重(同一张图手滑写两遍不必发两遍)。
313
+ func resolveAttachments(values []string) ([]agent.Attachment, error) {
314
+ var out []agent.Attachment
315
+ seen := make(map[string]bool)
316
+ for _, v := range values {
317
+ for _, raw := range strings.Split(v, ",") {
318
+ p := strings.TrimSpace(raw)
319
+ if p == "" {
320
+ continue
321
+ }
322
+ abs, err := filepath.Abs(agent.ExpandHome(p))
323
+ if err != nil {
324
+ return nil, fmt.Errorf("--attach: %w", err)
325
+ }
326
+ st, err := os.Stat(abs)
327
+ if err != nil {
328
+ return nil, fmt.Errorf("--attach: %s 不可访问: %w", abs, err)
329
+ }
330
+ if !st.Mode().IsRegular() {
331
+ return nil, fmt.Errorf("--attach: %s 不是普通文件", abs)
332
+ }
333
+ if st.Size() > maxAttachBytes {
334
+ return nil, fmt.Errorf("--attach: %s 超过 %dMB 上限", abs, maxAttachBytes>>20)
335
+ }
336
+ if seen[abs] {
337
+ continue
338
+ }
339
+ seen[abs] = true
340
+ out = append(out, agent.NewAttachment(abs))
341
+ }
342
+ }
343
+ return out, nil
344
+ }
345
+
346
+ // attachmentPromptFallbackWarn 引擎没有附件输入通道(AttachmentSupportOf == "prompt")时的提示:
347
+ // 附件改成「把路径写进提示词」,需要引擎自己能读文件 —— 因此 --tools off 时必须点明。
348
+ // 抽成函数便于单测,也把「不静默降级」变成可断言的行为。
349
+ //
350
+ // 「请改用 --tools on」这句只对**真的有 --tools 落地通道**的引擎说(ToolsSwitchableOf):
351
+ // dsh / openclaw 这类自带工具循环的引擎传 --tools 也不改变行为,说了等于误导。
352
+ func attachmentPromptFallbackWarn(engine string, count int, toolsOff bool) string {
353
+ if count == 0 || agent.AttachmentSupportOf(engine) != "prompt" {
354
+ return ""
355
+ }
356
+ msg := fmt.Sprintf("magic-agent: 警告:%s 引擎没有附件输入通道,%d 个附件已改为「把路径写进提示词」", engine, count)
357
+ if toolsOff && agent.ToolsSwitchableOf(engine) {
358
+ msg += ";当前 --tools off,引擎读不到这些文件,请改用 --tools on"
359
+ }
360
+ return msg + "(各引擎能力见 --engines 的 attachments 字段)\n"
361
+ }
362
+
363
+ // parseToolsMode 解析 --tools 参数为结构化模式。
364
+ func parseToolsMode(s string) (agent.ToolsMode, error) {
365
+ switch strings.TrimSpace(strings.ToLower(s)) {
366
+ case "", "off", "none", "false":
367
+ return agent.ToolsOff, nil
368
+ case "on", "true", "default":
369
+ return agent.ToolsOn, nil
370
+ default:
371
+ // 白名单:逗号/空格分隔。
372
+ names := strings.FieldsFunc(s, func(r rune) bool { return r == ',' || r == ' ' || r == '\t' })
373
+ if len(names) == 0 {
374
+ return agent.ToolsOff, &usageError{fmt.Errorf("invalid --tools value %q (want off | on | comma-separated tool names)", s)}
375
+ }
376
+ clean := make([]string, 0, len(names))
377
+ for _, n := range names {
378
+ if n = strings.TrimSpace(n); n != "" {
379
+ clean = append(clean, n)
380
+ }
381
+ }
382
+ if len(clean) == 0 {
383
+ return agent.ToolsOff, &usageError{fmt.Errorf("invalid --tools value %q (no tool names parsed)", s)}
384
+ }
385
+ return agent.ToolsAllowlist(clean), nil
386
+ }
387
+ }
388
+
389
+ // resolvePermissionTier 解析并校验 --permission(四档权限模型,见 agent/permission.go)。
390
+ //
391
+ // 两道校验:
392
+ // 1. 取值必须是四档之一(含别名与数字档位);
393
+ // 2. 引擎必须已接线 —— 未接线时**报错**(exit 2),不静默忽略。
394
+ // 静默忽略一个安全设置是最坏的结果:用户以为自己被保护着,实际没有。
395
+ // 注意只在「显式传过 --permission」时才做第 2 道校验,这样默认值不会
396
+ // 让 `-e codex` 这类既有调用突然失败。
397
+ func resolvePermissionTier(engineName, raw string, explicit bool) (agent.PermissionTier, error) {
398
+ tier, ok := agent.ParsePermissionTier(raw)
399
+ if !ok {
400
+ return "", &usageError{fmt.Errorf("invalid --permission %q (want one of: %s)",
401
+ raw, strings.Join(agent.PermissionTiers(), " | "))}
402
+ }
403
+ if explicit && !agent.PermissionSupported(engineName) {
404
+ return "", &usageError{fmt.Errorf(
405
+ "--permission 暂不支持 %s 引擎(当前仅 claude、codebuddy、codebuddy-ai;各引擎能力见 --engines 的 permission 字段)",
406
+ engineName)}
407
+ }
408
+ return tier, nil
409
+ }
410
+
411
+ // runAsk ask 主流程(非流式)。
412
+ func runAsk(cmd *cobra.Command, args []string, opts *askOptions) error {
413
+ engine, format, req, err := prepareAsk(cmd, args, opts)
414
+ if err != nil {
415
+ return err
416
+ }
417
+
418
+ // 会话登记 + spawn 钩子:把引擎子进程 pid 记下来,之后任何进程都能
419
+ // `magic-agent --stop <session_id>` 精确停掉它(见 internal/session)。
420
+ h := beginSession(engine.Name(), req)
421
+ ctx := withSessionHook(cmd.Context(), h)
422
+
423
+ runner := &agent.Runner{
424
+ Engine: engine,
425
+ Timeout: opts.timeout,
426
+ Retries: opts.retries,
427
+ Backoff: opts.backoff,
428
+ Verbose: opts.verbose,
429
+ }
430
+ resp, err := runner.Run(ctx, req)
431
+ if err != nil {
432
+ finishSession(h, "", session.StateFailed)
433
+ attempts := 1 + opts.retries
434
+ // WriteError 已把失败信息(json envelope / text 行)写到 stderr,
435
+ // 标记已输出,Execute 不再重复打印。
436
+ if werr := agent.WriteError(cmd.ErrOrStderr(), format, engine.Name(), attempts, err); werr == nil {
437
+ return &reportedError{err}
438
+ }
439
+ return err
440
+ }
441
+ finishSession(h, resp.SessionID, session.StateDone)
442
+ return agent.WriteOutput(cmd.OutOrStdout(), format, resp)
443
+ }
444
+
445
+ // beginSession 落一条 running 记录(失败不阻断调用:登记表只是可运维性)。
446
+ // PID 先填自身 pid 兜底 —— HTTP 直连这类没有子进程的引擎也能被 --stop 停掉
447
+ // (杀 magic-agent 自己即中止该次调用);子进程起来后 spawn 钩子会覆盖成引擎 pid。
448
+ func beginSession(engine string, req agent.Request) *session.Handle {
449
+ h, err := session.Begin(session.BeginOptions{
450
+ Engine: engine,
451
+ Model: req.Model,
452
+ Workspace: req.Workspace,
453
+ SessionID: req.SessionID,
454
+ PromptHead: firstUserText(req.Messages),
455
+ PID: os.Getpid(),
456
+ })
457
+ if err != nil {
458
+ return nil
459
+ }
460
+ setActiveSession(h)
461
+ return h
462
+ }
463
+
464
+ // withSessionHook 给 ctx 挂上「子进程已启动」回调(h 为空时原样返回)。
465
+ func withSessionHook(ctx context.Context, h *session.Handle) context.Context {
466
+ if h == nil {
467
+ return ctx
468
+ }
469
+ return agent.WithSpawnHook(ctx, func(pid int) { h.SetPID(pid, true) })
470
+ }
471
+
472
+ // finishSession 收尾:写最终状态 + 回填会话 id,并清掉「当前活动会话」。
473
+ func finishSession(h *session.Handle, sessionID, state string) {
474
+ if h == nil {
475
+ return
476
+ }
477
+ h.Finish(sessionID, state)
478
+ clearActiveSession(h)
479
+ }
480
+
481
+ // firstUserText 取第一条 user 消息的开头(记录里给人看)。
482
+ func firstUserText(msgs []agent.Message) string {
483
+ for _, m := range msgs {
484
+ if m.Role == "user" || m.Role == "" {
485
+ return m.Content
486
+ }
487
+ }
488
+ return ""
489
+ }
490
+
491
+ // runStreamAsk 流式主流程。
492
+ //
493
+ // 输出分流(增量实时、无缓冲):
494
+ //
495
+ // text 模式(-o text):
496
+ // 正文增量 → stdout(拼成连续正文)
497
+ // 思考增量 → stderr(前置 "… " 每行,供 2>/dev/null 静音或 tee 保留)
498
+ // 结尾 stdout 不再重复全文
499
+ //
500
+ // json 模式(默认):
501
+ // 每条增量一行 NDJSON:{"type":"text","text":"…"} / {"type":"thinking","text":"…"}
502
+ // 结尾一行汇总 envelope(含 thinking 全文 + attempts + latency),
503
+ // jq 逐事件消费或整体 tail 取 result。
504
+ //
505
+ // 语义差异(相对非流式):
506
+ // - 不做自动重试(增量已实时发出,重放会重复消费);-r 被忽略并提示。
507
+ // - 超时照常生效(杀整个 CLI 进程组)。
508
+ func runStreamAsk(cmd *cobra.Command, args []string, opts *askOptions) error {
509
+ engine, format, req, err := prepareAsk(cmd, args, opts)
510
+ if err != nil {
511
+ return err
512
+ }
513
+ streamer := agent.AsStreamer(engine)
514
+ if streamer == nil {
515
+ return &usageError{fmt.Errorf("engine %q does not support streaming", engine.Name())}
516
+ }
517
+
518
+ if opts.retries > 0 {
519
+ fmt.Fprintln(cmd.ErrOrStderr(), "magic-agent: --stream 不支持自动重试,-r 已忽略")
520
+ }
521
+
522
+ /* 把调用方**显式**给的 -t 透传给引擎(2026-09-24 修)。
523
+ 以前流式路径从不设 req.Timeout,引擎只能用自己的默认值 —— arkclaw 那个默认是 3 分钟,
524
+ 于是 `-t 600s` 被静默忽略:网关侧要跑 5 分钟的任务(实测「生成周报」304s 才回)必在
525
+ 3 分钟被砍,桌面壳上就是「什么都没显示 · 调用失败 context deadline exceeded」。
526
+ 只在 flagChanged 时透传:没给 -t 就别去覆盖引擎自己的默认(如 llm 的条目级 timeout)。 */
527
+ if flagChanged(cmd, "timeout") {
528
+ req.Timeout = opts.timeout
529
+ }
530
+
531
+ stdout, stderr := cmd.OutOrStdout(), cmd.ErrOrStderr()
532
+ sink := newEventSink(stdout, engine.Name(), opts.events)
533
+
534
+ // 「看历史」持久化:每条事件落盘到 <dir>/<session_id>.jsonl,
535
+ // 移动端进入会话详情时先读这个文件渲染历史,再开 SSE 接续。
536
+ // 不写盘也不会让流失败;所有写盘错误都进 stderr 警告、流继续(写盘是个独立 goroutine)。
537
+ sessionLog, err := newSessionWriterAuto(sessionLogDir)
538
+ if err != nil {
539
+ fmt.Fprintf(stderr, "magic-agent: 准备会话持久化目录失败:%v(流继续)\n", err)
540
+ sessionLog = nil
541
+ } else {
542
+ defer func() { sessionLog.close() }()
543
+ }
544
+ // 把 session_id 推到 writer:CLI 这边的 OnSessionID 回调(引擎流里见到 init 行就调)
545
+ // 写新 id,writer 自己的写盘 goroutine 读出来开文件。
546
+ req.OnSessionID = func(id string) {
547
+ if sessionLog != nil {
548
+ sessionLog.setSessionID(id)
549
+ }
550
+ }
551
+
552
+ /* 用户自己的提问也落盘(2026-09-28)。
553
+ 在此之前落盘的**只有引擎事件** —— 于是从列表点进会话,历史里只有 agent 的回复,
554
+ 没有「你问的那句话」(实时发消息时气泡正常,因为那条是客户端自己本地渲染的,
555
+ 不依赖落盘)。移动端渲染器早就预留了 `kind:"user"` 分支,这里一补历史就完整。
556
+ ⚠️ 位置很关键:放在 OnSessionID **之后**、流开始**之前**。此刻还不知道 session_id,
557
+ writer 会把它收进 pending;等 id 出现时 pending 会**先于**当前事件被写出 ——
558
+ 所以顺序天然正确,不必在这里自己等 id(等就是把流路径堵住,违反「绝不断流」)。
559
+ ⚠️ 只落 `user` 角色:system prompt 是调用参数、不是会话内容,落了会让历史里出现
560
+ 用户从没说过的话;assistant 正文已由引擎的 text / turn_end 事件覆盖。
561
+ ⚠️ 一条 user 消息一条事件:UI 侧一个气泡对应一条,合并成一坨就没法分开渲染。 */
562
+ if sessionLog != nil {
563
+ for _, m := range req.Messages {
564
+ if m.Role != "user" || strings.TrimSpace(m.Content) == "" {
565
+ continue
566
+ }
567
+ sessionLog.push(sessionEvent{Kind: "user", Text: m.Content})
568
+ }
569
+ }
570
+
571
+ var onEvent func(agent.StreamEvent)
572
+ wireLogger := func(ev agent.StreamEvent) {
573
+ if sessionLog == nil {
574
+ return
575
+ }
576
+ // 全部 StreamEventKind 都该落盘:thinking / text / tool_use / tool_result /
577
+ // ask / turn_end。turn_end 是「这一轮到底了」的最重要标记(flush 到 UI 的
578
+ // 「一轮结束」线);历史视图上少一条 turn_end 就看不出「这轮已完」。
579
+ // ⚠️ 这里只走**引擎事件**。用户提问(kind:"user")与失败(kind:"error")
580
+ // 不由流事件触发,见上面 runStreamAsk 里的 push 与下面的 error 分支。
581
+ /* ToolKind 与 SSE 流同源(同一个 toolKindOfEvent):历史回放与实时渲染
582
+ 因此画出同一种卡,而不是各按工具名猜一套(见 agent/toolkind.go)。 */
583
+ sessionLog.push(sessionEvent{
584
+ Kind: string(ev.Kind), Text: ev.Text, Name: ev.Name, ID: ev.ID,
585
+ ToolKind: toolKindOfEvent(ev), Ask: ev.Ask,
586
+ })
587
+ }
588
+ if format == agent.FormatJSON {
589
+ onEvent = func(ev agent.StreamEvent) {
590
+ // --no-thinking 只压制思考过程;工具事件保持透传。
591
+ if ev.Kind == agent.KindThinking && opts.noThinking {
592
+ return
593
+ }
594
+ _ = sink.stream(ev)
595
+ wireLogger(ev)
596
+ }
597
+ } else {
598
+ onEvent = func(ev agent.StreamEvent) {
599
+ switch ev.Kind {
600
+ case agent.KindThinking:
601
+ if opts.noThinking {
602
+ return
603
+ }
604
+ fmt.Fprint(stderr, "… "+ev.Text)
605
+ case agent.KindText:
606
+ fmt.Fprint(stdout, ev.Text)
607
+ case agent.KindToolUse:
608
+ // 与 thinking 对称:工具事件走 stderr,前缀 🔧。
609
+ // 2>/dev/null 可静音;stdout 正文保持干净。
610
+ prefix := "🔧 " + ev.Name + "(" + ev.ID + ") "
611
+ fmt.Fprint(stderr, prefix+ev.Text+"\n")
612
+ case agent.KindToolResult:
613
+ fmt.Fprint(stderr, " ↳ "+ev.Text+"\n")
614
+ case agent.KindAsk:
615
+ // 「需要用户选择」:一行摘要打到 stderr(与工具事件同一条通道,
616
+ // 2>/dev/null 可静音),stdout 正文保持干净。
617
+ //
618
+ // ⚠️ headless 下 claude 会在模型提问后**自行拒绝**(模型拿不到答案),
619
+ // 要真正作答得把答案作为后续 user 消息补进去(agent.EncodeAskFollowUp)。
620
+ fmt.Fprint(stderr, "❓ "+ev.Text+"\n")
621
+ }
622
+ wireLogger(ev)
623
+ }
624
+ }
625
+
626
+ // 超时:常驻会话的存活由 --idle(空闲收工)决定,所以常驻生效时只有调用方**显式**给 -t
627
+ // 才套总超时;否则不设上限(长任务 + 追加不受 600s 默认值限制)。
628
+ ctx := cmd.Context()
629
+ /* --control 要能主动打断:在(可能的)超时之上再套一层可取消的 ctx。
630
+ 打断与超时走同一条路径 —— 引擎进程组由 Stream 侧按 ctx 收拾。 */
631
+ var cancelAll context.CancelFunc
632
+ if opts.control {
633
+ ctx, cancelAll = context.WithCancel(ctx)
634
+ defer cancelAll()
635
+ }
636
+ if !kaEnabled(cmd, opts, engine.Name()) || flagChanged(cmd, "timeout") {
637
+ var cancel context.CancelFunc
638
+ ctx, cancel = context.WithTimeout(ctx, opts.timeout)
639
+ defer cancel()
640
+ }
641
+
642
+ // 会话登记 + spawn 钩子(与 runAsk 同一套:--stop 靠它拿到引擎 pid)。
643
+ h := beginSession(engine.Name(), req)
644
+ ctx = withSessionHook(ctx, h)
645
+
646
+ // 常驻会话(默认开,claude/codebuddy 的流式调用):开追加入口等 --append,
647
+ // 空闲 --idle 后优雅收工(关闭通道 → 引擎 stdin EOF → 正常收尾)。
648
+ var ka *keepAlive
649
+ if kaEnabled(cmd, opts, engine.Name()) {
650
+ var kerr error
651
+ ka, kerr = startKeepAlive(cmd, opts, engine.Name(), h)
652
+ if kerr != nil {
653
+ finishSession(h, "", session.StateFailed)
654
+ return kerr
655
+ }
656
+ defer ka.Close()
657
+ req.Append = ka.appendCh
658
+ onEvent = ka.wrapOnEvent(onEvent)
659
+ }
660
+
661
+ /* 控制通道:给调用方一条回传通道(打断 / 收工 / 回审批)。
662
+ 起在常驻会话之后 —— answer 依赖 ka。 */
663
+ var ctl *controlSession
664
+ if opts.control {
665
+ ctl = startControlReader(cmd.InOrStdin(), sink, controlHooks{
666
+ interrupt: func(reason string) { cancelAll() },
667
+ stop: func() {
668
+ if ka != nil {
669
+ ka.Close() // 关追加入口 → 引擎 stdin EOF → 本轮优雅收尾
670
+ return
671
+ }
672
+ cancelAll()
673
+ },
674
+ answer: func(text string) bool { return ka != nil && ka.Push(text) },
675
+ })
676
+ defer ctl.Close()
677
+ }
678
+
679
+ // ready 握手(仅 --events):客户端据此确认「谁在跑、契约版本是多少」。
680
+ _ = sink.ready(req.Model)
681
+
682
+ /* ── 会话状态的中途写入(2026-09-29,对齐 anywhere 的 TimelineStatus)──
683
+ 引擎**停下来等用户**(提问 / 工具待授权)时,这条会话仍然活着,但没在跑 ——
684
+ 界面上该显示「等审批」而不是「生成中」。用户作答、或这一轮的任何后续事件到达时
685
+ 回到 running。落点选在这里,是因为它同时罩住两条 onEvent(--stream 与 --events),
686
+ 不必在两个分支里各写一遍(漏一个就会有一条通道的状态是假的)。
687
+ 写入很快(状态没变时 Mark 直接返回,一轮最多两次落盘),见 session.Handle.Mark。 */
688
+ if h != nil {
689
+ inner := onEvent
690
+ onEvent = func(ev agent.StreamEvent) {
691
+ if ev.Kind == agent.KindAsk {
692
+ h.Mark(session.StateWaitingApproval)
693
+ } else {
694
+ h.Mark(session.StateRunning)
695
+ }
696
+ inner(ev)
697
+ }
698
+ }
699
+
700
+ res, err := streamer.Stream(ctx, req, onEvent)
701
+ interrupted := ctl != nil && ctl.WasInterrupted()
702
+ if interrupted {
703
+ /* 打断的应答放在这里、而不是命令到达的那一刻:interrupt 的真实含义是
704
+ 「这一轮真的结束了」。失败链本身就是 context canceled,不必再包一层。 */
705
+ _ = sink.notice("interrupted", map[string]any{"reason": "control"})
706
+ }
707
+ if err != nil {
708
+ /* 被打断 ≠ 失败(2026-09-29):这里的 err 就是 context canceled,但「用户喊停」
709
+ 与「引擎报错」是两件事 —— 混成 failed 之后左栏只能显示「失败」,
710
+ 用户会以为是自己哪里没接好。 */
711
+ if interrupted {
712
+ finishSession(h, "", session.StateInterrupted)
713
+ } else {
714
+ finishSession(h, "", session.StateFailed)
715
+ }
716
+ /* ⚠️ 失败必须留下**调用方读得到**的说明(2026-09-22 修)。
717
+ 以前这里 json 模式一个字都不写:stdout 空、stderr 只剩前面那些提示行
718
+ (如 openclaw 的「指定了模型,本轮改走非流式」)—— 调用方(桌面壳)看到的就是
719
+ 「退出码 1 + 一行事件都没有」,界面上画成「空白 + 已完成」,看起来像
720
+ 「引擎没对接好 / 不显示」(用户 2026-09-22 报障)。
721
+ 两处都写,各有分工(与 output.go 的契约、runAsk 的做法对齐):
722
+ · stdout 一条 `{"type":"error",...}` 事件 —— 流式消费者不必回头解析 stderr,
723
+ 事件流自己就是完整的(每轮以 result 或 error 收尾,不会两样都没有);
724
+ · stderr 一份 WriteError —— json = 带 reason 根因的 envelope,text = 一行;
725
+ 老调用方(只读 stderr / 只读 envelope)照旧能拿到原因。
726
+ ⚠️ 别把这条只写进 stderr:桌面壳的**换模型重试**判据吃的是事件流与 stderr 两处,
727
+ 但只认事件的消费方(第三方 jq 管道)会因此永远看不到失败。 */
728
+ if interrupted {
729
+ /* 打断这一支**不发 error 事件**(2026-09-29):事件流上已经发过 `interrupted`,
730
+ 再叠一条 error 会让界面把「用户喊停」画成「运行失败」(消费方的 onError 分支),
731
+ 词表就白分了。stderr 仍留一份 WriteError,排障时看清是哪一个 signal 结束的。
732
+ ⚠️ 退出码不动(这里照旧返回 reportedError)—— 改退出码是另一件事,
733
+ 会连带所有调用方的判错逻辑,不夹带在这次改动里。 */
734
+ _ = agent.WriteError(stderr, format, engine.Name(), 1, err)
735
+ } else if format == agent.FormatJSON {
736
+ _ = sink.event(streamErrorPayload(engine.Name(), err))
737
+ _ = agent.WriteError(stderr, format, engine.Name(), 1, err)
738
+ } else {
739
+ fmt.Fprintf(stderr, "\nmagic-agent: %v\n", err)
740
+ }
741
+ return &reportedError{err}
742
+ }
743
+ finishSession(h, res.SessionID, session.StateDone)
744
+
745
+ if format == agent.FormatJSON {
746
+ // 结尾汇总行(jq 可 tail -1 取全文 + 思考过程 + 工具调用列表)。
747
+ out := struct {
748
+ Type string `json:"type"`
749
+ Engine string `json:"engine"`
750
+ Model string `json:"model"`
751
+ SessionID string `json:"session_id,omitempty"`
752
+ Attempts int `json:"attempts"`
753
+ LatencyMS int64 `json:"latency_ms"`
754
+ Thinking string `json:"thinking,omitempty"`
755
+ Text string `json:"text"`
756
+ Tools []agent.ToolCall `json:"tools,omitempty"`
757
+ }{
758
+ Type: "result",
759
+ Engine: res.Engine,
760
+ Model: res.Model,
761
+ SessionID: res.SessionID,
762
+ Attempts: 1,
763
+ LatencyMS: res.Latency.Milliseconds(),
764
+ Thinking: res.Thinking,
765
+ Text: res.Text,
766
+ Tools: res.Tools,
767
+ }
768
+ if opts.noThinking {
769
+ out.Thinking = ""
770
+ }
771
+ if err := sink.event(out); err != nil {
772
+ return err
773
+ }
774
+ } else {
775
+ // text 模式:正文已实时打完,补尾换行即可。
776
+ fmt.Fprintln(stdout)
777
+ }
778
+ return nil
779
+ }
780
+
781
+ // writeStreamEventJSON 输出一行流式事件 NDJSON。
782
+ //
783
+ // 输出 schema(所有事件共享 {type,text}):
784
+ //
785
+ // {"type":"thinking","text":"..."} 模型思考增量
786
+ // {"type":"text","text":"..."} 正文增量
787
+ // {"type":"tool_use","text":"<args JSON>","name":"Bash","id":"toolu_xxx"}
788
+ // {"type":"tool_result","text":"<output>","name":"","id":"toolu_xxx"}
789
+ // {"type":"turn_end","text":"<该轮正文>","session_id":"..."} 一轮结束(常驻会话)
790
+ // {"type":"error","error":"<完整错误链>","reason":"<根因>","engine":"..."} 本轮失败(收尾)
791
+ //
792
+ // 工具事件多带 name / id 字段;turn_end 带 session_id(调用方据此绑定会话锚点);
793
+ // thinking / text 事件的 name / id / session_id 省略(取零值)。
794
+ //
795
+ // ⚠️ **每轮一定以 result 或 error 收尾**(2026-09-22 起):失败那条由 writeStreamErrorJSON
796
+ // 发出(见 runStreamAsk 的失败分支)。以前失败什么都不发,消费方只能靠退出码猜 ——
797
+ // 「一行事件都没有」被读成「引擎没输出」,界面上就是空白。
798
+ func writeStreamEventJSON(w io.Writer, ev agent.StreamEvent) error {
799
+ data, err := json.Marshal(streamEventPayload(ev))
800
+ if err != nil {
801
+ return err
802
+ }
803
+ _, err = fmt.Fprintln(w, string(data))
804
+ return err
805
+ }
806
+
807
+ /* writeStreamErrorJSON 输出一行「本轮失败」事件(流式形态的失败收尾)。
808
+ *
809
+ * schema: {"type":"error","engine":"openclaw","attempts":1,
810
+ * "error":"<完整错误链>","reason":"<最内层根因>"}
811
+ *
812
+ * 字段与 `-o json` 的失败 envelope(agent.WriteError)**逐字同源**(都走 agent.ReasonOf),
813
+ * 只是多一个 `type` 便于与事件流混排 —— 同一次失败在事件流与 stderr 里写出的原因必然一致。
814
+ * 为什么要它:流式调用方按行读 stdout,**失败不发声**就等于这一轮凭空消失(见 runStreamAsk)。 */
815
+ func writeStreamErrorJSON(w io.Writer, engine string, err error) error {
816
+ data, jerr := json.Marshal(streamErrorPayload(engine, err))
817
+ if jerr != nil {
818
+ return jerr
819
+ }
820
+ _, werr := fmt.Fprintln(w, string(data))
821
+ return werr
822
+ }
823
+
824
+ // streamErrorPayload 「本轮失败」事件的 wire 形状。
825
+ // 与 `-o json` 的失败 envelope(agent.WriteError)**逐字同源**(都走 agent.ReasonOf),
826
+ // 只是多一个 `type` 便于与事件流混排 —— 同一次失败在事件流与 stderr 里写出的原因必然一致。
827
+ func streamErrorPayload(engine string, err error) any {
828
+ return struct {
829
+ Type string `json:"type"`
830
+ Engine string `json:"engine"`
831
+ Attempts int `json:"attempts"`
832
+ Error string `json:"error"`
833
+ Reason string `json:"reason"`
834
+ }{Type: "error", Engine: engine, Attempts: 1, Error: err.Error(), Reason: agent.ReasonOf(err)}
835
+ }
836
+
837
+ // prepareAsk 校验参数并组装 Request(流式 / 非流式共用)。
838
+ func prepareAsk(cmd *cobra.Command, args []string, opts *askOptions) (agent.Engine, agent.OutputFormat, agent.Request, error) {
839
+ // 1. 校验引擎与输出格式。
840
+ engine := agent.Lookup(opts.engine)
841
+ if engine == nil {
842
+ names := make([]string, 0)
843
+ for _, e := range agent.Engines() {
844
+ names = append(names, e.Name())
845
+ }
846
+ return nil, "", agent.Request{}, &usageError{fmt.Errorf("unknown engine %q (available: %s)", opts.engine, strings.Join(names, ", "))}
847
+ }
848
+ format, err := agent.ParseFormat(opts.output)
849
+ if err != nil {
850
+ return nil, "", agent.Request{}, &usageError{err}
851
+ }
852
+ if opts.retries < 0 {
853
+ return nil, "", agent.Request{}, &usageError{fmt.Errorf("--retries must be >= 0, got %d", opts.retries)}
854
+ }
855
+ if opts.maxTokens < 0 {
856
+ return nil, "", agent.Request{}, &usageError{fmt.Errorf("--max-tokens must be >= 0, got %d", opts.maxTokens)}
857
+ }
858
+ if opts.temperature < -1 {
859
+ return nil, "", agent.Request{}, &usageError{fmt.Errorf("--temperature must be >= 0 or omitted, got %v", opts.temperature)}
860
+ }
861
+ toolsMode, err := parseToolsMode(opts.tools)
862
+ if err != nil {
863
+ return nil, "", agent.Request{}, err
864
+ }
865
+ // 四档权限档位:解析 + 能力校验。
866
+ tier, err := resolvePermissionTier(engine.Name(), opts.permission, flagChanged(cmd, "permission"))
867
+ if err != nil {
868
+ return nil, "", agent.Request{}, err
869
+ }
870
+ // --tools off 下不调用任何工具,档位无处生效 —— 显式传了非默认档就说明白(不静默)。
871
+ if flagChanged(cmd, "permission") && toolsMode.IsOff() && tier != agent.DefaultPermissionTier {
872
+ fmt.Fprintf(cmd.ErrOrStderr(),
873
+ "magic-agent: 提示:当前 --tools off(不调用任何工具),--permission %s 不会生效;如需工具请加 --tools on\n", tier)
874
+ }
875
+ // 常驻会话默认值按引擎区分(见 kaEnabled / agent.AppendDefaultOn),但只在
876
+ // 「流式 + 引擎支持追加」时才有意义。显式 `--keep-alive` 却没满足条件 → 明确报错;
877
+ // 默认值不满足条件 → 静默忽略(不影响原有调用)。
878
+ if flagChanged(cmd, "keep-alive") && opts.keepAlive {
879
+ if !opts.stream {
880
+ return nil, "", agent.Request{}, &usageError{fmt.Errorf(
881
+ "--keep-alive 是常驻会话(首轮结束后等 --append 追加),必须配合 --stream:追加轮次的输出要靠事件流送出")}
882
+ }
883
+ if !agent.AppendSupportOf(engine.Name()) {
884
+ return nil, "", agent.Request{}, &usageError{fmt.Errorf(
885
+ "--keep-alive 暂不支持 %s 引擎(当前支持 claude、codebuddy、codebuddy-ai:靠 stream-json 输入持续收 user 消息;"+
886
+ "dsh:靠 SDK 通道对同一会话继续 prompt)", engine.Name())}
887
+ }
888
+ }
889
+ if opts.idle < 0 {
890
+ return nil, "", agent.Request{}, &usageError{fmt.Errorf("--idle 不能为负(收到 %v;0 = 本轮结束就收工)", opts.idle)}
891
+ }
892
+ // 工作目录(workspace):展开 ~、转绝对路径、校验存在且是目录。
893
+ workspace, err := resolveWorkspaceDir(opts.workspace)
894
+ if err != nil {
895
+ return nil, "", agent.Request{}, &usageError{err}
896
+ }
897
+ if workspace != "" {
898
+ // 引擎不支持时**明确提示**(参数会被忽略),而不是静默不生效。
899
+ if warn := workspaceUnsupportedWarn(engine.Name()); warn != "" {
900
+ fmt.Fprint(cmd.ErrOrStderr(), warn)
901
+ }
902
+ }
903
+ // 附件(截图/图片等):与提示词并列的第二份输入,校验后交给引擎按各自原生方式落地。
904
+ attachments, err := resolveAttachments(opts.attach)
905
+ if err != nil {
906
+ return nil, "", agent.Request{}, &usageError{err}
907
+ }
908
+ if len(attachments) > 0 {
909
+ // 没有原生附件通道的引擎会降级成「路径写进提示词」——明确说清楚(不静默)。
910
+ if warn := attachmentPromptFallbackWarn(engine.Name(), len(attachments), toolsMode.IsOff()); warn != "" {
911
+ fmt.Fprint(cmd.ErrOrStderr(), warn)
912
+ }
913
+ }
914
+
915
+ // 2. 组装 prompt:-p > --file/stdin 内容 + 位置参数。
916
+ // 每个输入先过 expandPromptSource:值是文件路径(或 @路径)时按文件内容用,
917
+ // 命中提示写 stderr(不静默)。
918
+ var notes []string
919
+ expandArg := func(label, v string) (string, error) {
920
+ text, note, e := expandPromptSource(v)
921
+ if e != nil {
922
+ return "", fmt.Errorf("%s: %w", label, e)
923
+ }
924
+ if note != "" {
925
+ notes = append(notes, note)
926
+ }
927
+ return text, nil
928
+ }
929
+ expandedArgs := make([]string, 0, len(args))
930
+ for _, a := range args {
931
+ text, e := expandArg("参数", a)
932
+ if e != nil {
933
+ return nil, "", agent.Request{}, &usageError{e}
934
+ }
935
+ expandedArgs = append(expandedArgs, text)
936
+ }
937
+ /* ⚠️ --control 时 stdin 归控制通道,**不能**再让 collectPrompt 去读它:
938
+ 它会把控制命令当成提示词读走(真机实测:`-p "..." --control` 下 ping / stop
939
+ 全被吞掉,控制通道形同虚设,而 stdout 上一条异常都没有 —— 极难定位)。
940
+ 传 nil 让 collectPrompt 跳过 stdin 兜底;提示词必须由 -p / 位置参数 / --file 给。 */
941
+ promptStdin := cmd.InOrStdin()
942
+ if opts.control {
943
+ promptStdin = nil
944
+ }
945
+ promptParts, err := collectPrompt(promptStdin, expandedArgs, opts.file)
946
+ if err != nil {
947
+ return nil, "", agent.Request{}, &usageError{err}
948
+ }
949
+ if opts.control && opts.file == "" && len(promptParts) == 0 && strings.TrimSpace(opts.prompt) == "" {
950
+ return nil, "", agent.Request{}, &usageError{fmt.Errorf(
951
+ "--control 占用了 stdin(控制命令走它),所以提示词不能从 stdin 取:请用 -p \"...\" 或位置参数给出")}
952
+ }
953
+ if p := strings.TrimSpace(opts.prompt); p != "" {
954
+ text, e := expandArg("-p/--prompt", p)
955
+ if e != nil {
956
+ return nil, "", agent.Request{}, &usageError{e}
957
+ }
958
+ // --prompt 优先级最高:放到最前(同 --file 语义,附加内容跟后面)。
959
+ if text != "" {
960
+ promptParts = append([]string{text}, promptParts...)
961
+ }
962
+ }
963
+ for _, n := range notes {
964
+ fmt.Fprint(cmd.ErrOrStderr(), n)
965
+ }
966
+ prompt := strings.TrimSpace(strings.Join(promptParts, "\n\n"))
967
+ if prompt == "" {
968
+ return nil, "", agent.Request{}, &usageError{fmt.Errorf("empty prompt: use -p, pass args, --file, or pipe stdin")}
969
+ }
970
+
971
+ // 3. 引擎 CLI 预检(快速失败,给出可操作提示)。
972
+ if ok, note := engine.Detect(); !ok {
973
+ return nil, "", agent.Request{}, fmt.Errorf("engine %q unavailable: %s", engine.Name(), note)
974
+ }
975
+
976
+ // 4. 组装 Request。
977
+ // system prompt:-s 优先,其次配置文件里的默认值(空 = 不注入)。
978
+ // -s 的值同样支持「文件路径 / @路径」(系统提示词常存在单独的文件里)。
979
+ systemFlag, sysNote, sysFlagErr := expandPromptSource(opts.system)
980
+ if sysFlagErr != nil {
981
+ return nil, "", agent.Request{}, &usageError{fmt.Errorf("-s/--system: %w", sysFlagErr)}
982
+ }
983
+ if sysNote != "" {
984
+ fmt.Fprint(cmd.ErrOrStderr(), sysNote)
985
+ }
986
+ systemPrompt, cfgNote, sysErr := resolveSystemPrompt(systemFlag)
987
+ if cfgNote != "" {
988
+ // 配置里的默认值指向文件 → 同样明说一句(不静默)。
989
+ fmt.Fprint(cmd.ErrOrStderr(), cfgNote)
990
+ }
991
+ if sysErr != nil && opts.verbose {
992
+ // 配置坏掉不阻断调用(默认值只是「锦上添花」),但要让 -v 用户看见原因。
993
+ fmt.Fprintf(cmd.ErrOrStderr(), "magic-agent: 警告:读取默认系统提示词失败(%v),本次不注入\n", sysErr)
994
+ }
995
+ req := agent.Request{
996
+ Engine: engine.Name(),
997
+ Model: opts.model,
998
+ SystemPrompt: systemPrompt,
999
+ Tools: toolsMode,
1000
+ Attachments: attachments,
1001
+ MaxTokens: opts.maxTokens,
1002
+ SessionID: strings.TrimSpace(opts.session),
1003
+ Continue: opts.continueF,
1004
+ Workspace: workspace,
1005
+ Permission: tier,
1006
+ PermissionOptions: agent.PermissionOptions{
1007
+ ExcludedCommands: opts.sandboxExclude,
1008
+ AllowedDomains: opts.sandboxDomain,
1009
+ AutoModeEnvironment: opts.autoModeEnv,
1010
+ Deny: opts.permDeny,
1011
+ Ask: opts.permAsk,
1012
+ },
1013
+ Messages: []agent.Message{
1014
+ {Role: "user", Content: prompt},
1015
+ },
1016
+ }
1017
+ if opts.temperature >= 0 {
1018
+ t := opts.temperature
1019
+ req.Temperature = &t
1020
+ }
1021
+ if s := strings.TrimSpace(opts.jsonSchema); s != "" {
1022
+ schema, err := parseJSONSchemaInline(s)
1023
+ if err != nil {
1024
+ return nil, "", agent.Request{}, &usageError{fmt.Errorf("--json-schema: %w", err)}
1025
+ }
1026
+ req.JSONSchema = schema
1027
+ }
1028
+ return engine, format, req, nil
1029
+ }
1030
+
1031
+ // parseJSONSchemaInline 把 --json-schema 字符串解析成 agent.JSONSchema。
1032
+ //
1033
+ // 支持的形态(按宽松顺序):
1034
+ //
1035
+ // 1. 完整 JSON Schema 对象:{"type":"object","properties":{...},"required":[...]}
1036
+ // 2. 已经预解析的 JSON(任意合法 JSON Schema 顶层对象)
1037
+ //
1038
+ // 仅当 type=="object" 时返回;其他顶层 type 一律视为不适用并报错。
1039
+ func parseJSONSchemaInline(s string) (*agent.JSONSchema, error) {
1040
+ var raw struct {
1041
+ Type string `json:"type"`
1042
+ Properties map[string]map[string]any `json:"properties"`
1043
+ Required []string `json:"required"`
1044
+ }
1045
+ if err := json.Unmarshal([]byte(s), &raw); err != nil {
1046
+ return nil, fmt.Errorf("invalid JSON: %w", err)
1047
+ }
1048
+ if !strings.EqualFold(raw.Type, "object") {
1049
+ return nil, fmt.Errorf("only type=object supported (got %q)", raw.Type)
1050
+ }
1051
+ if len(raw.Required) == 0 && len(raw.Properties) == 0 {
1052
+ return nil, fmt.Errorf("schema must declare at least properties or required")
1053
+ }
1054
+ return &agent.JSONSchema{
1055
+ Type: "object",
1056
+ Properties: raw.Properties,
1057
+ Required: raw.Required,
1058
+ }, nil
1059
+ }
1060
+
1061
+ // collectPrompt 收集 prompt 输入。返回 (parts):
1062
+ // --file 内容在前(若有),位置参数拼接在后(若有)。
1063
+ func collectPrompt(stdin io.Reader, args []string, file string) ([]string, error) {
1064
+ var parts []string
1065
+ if file != "" {
1066
+ var (
1067
+ data []byte
1068
+ err error
1069
+ )
1070
+ if file == "-" {
1071
+ data, err = io.ReadAll(stdin)
1072
+ } else {
1073
+ data, err = os.ReadFile(file)
1074
+ }
1075
+ if err != nil {
1076
+ return nil, fmt.Errorf("read prompt file: %w", err)
1077
+ }
1078
+ if s := strings.TrimSpace(string(data)); s != "" {
1079
+ parts = append(parts, s)
1080
+ }
1081
+ }
1082
+ if len(args) > 0 {
1083
+ parts = append(parts, strings.Join(args, " "))
1084
+ }
1085
+ // 无参数无文件但 stdin 有内容 → 读 stdin。
1086
+ // *os.File 且是 TTY 时跳过(交互式误用保护);其余 reader
1087
+ //(真实管道 / 测试注入)在无其他输入时直接读取。
1088
+ if len(parts) == 0 && file == "" && stdin != nil {
1089
+ if f, ok := stdin.(*os.File); ok {
1090
+ if fi, err := f.Stat(); err == nil && fi.Mode()&os.ModeCharDevice != 0 {
1091
+ return parts, nil // TTY,不读
1092
+ }
1093
+ }
1094
+ data, err := io.ReadAll(stdin)
1095
+ if err != nil {
1096
+ return nil, fmt.Errorf("read stdin: %w", err)
1097
+ }
1098
+ if s := strings.TrimSpace(string(data)); s != "" {
1099
+ parts = append(parts, s)
1100
+ }
1101
+ }
1102
+ return parts, nil
1103
+ }
1104
+
1105
+ // printJSON 单行 JSON 输出辅助。
1106
+ func printJSON(w io.Writer, v any) error {
1107
+ data, err := json.Marshal(v)
1108
+ if err != nil {
1109
+ return err
1110
+ }
1111
+ _, err = fmt.Fprintln(w, string(data))
1112
+ return err
1113
+ }