magic-agent 2.4.3 → 2.6.3
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/CLIENT-CONTRACT.md +467 -41
- package/README.md +15 -14
- package/client-ui/pc/app.css +74 -0
- package/client-ui/pc/js/04-task-list.js +5 -1
- package/client-ui/pc/js/07-chat-stream-render.js +3 -3
- package/client-ui/pc/js/90-glue.js +40 -2
- package/client-ui/pc/js/92-composer.js +19 -4
- package/client-ui/pc/js/93-rail.js +16 -5
- package/client-ui/shared/conv/02-media-kinds.js +2 -2
- package/client-ui/shared/conv/03-chat-message.js +247 -23
- package/client-ui/shared/conv/06-ask-answer.js +75 -1
- package/client-ui/shared/conv/09-stream-apply.js +72 -7
- package/client-ui/shared/conv/19-attachments.js +157 -6
- package/client-ui/shared/conv/27-conv-ledger.js +5 -2
- package/client-ui/shared/conv/29-msg-variants.js +217 -0
- package/client-ui/shared/conv/conv.css +329 -174
- package/client-ui/shared/conv/dom-map.md +1 -0
- package/client-ui/shared/conversation.css +13 -2
- package/client-ui/shared/conversation.js +373 -50
- package/client-ui/shared/transport-http.js +14 -0
- package/client-ui-mobile/apps.css +16 -0
- package/client-ui-mobile/apps.js +406 -0
- package/client-ui-mobile/index.html +5554 -639
- package/client-ui-mobile/reqs.css +110 -1
- package/client-ui-mobile/reqs.js +533 -74
- package/client-ui-mobile/reqshot.js +505 -0
- package/client-ui-mobile/scripts.css +67 -0
- package/client-ui-mobile/scripts.js +599 -0
- package/client-ui-mobile/vendor/cose-base.js +3214 -0
- package/client-ui-mobile/vendor/cytoscape-fcose.js +1549 -0
- package/client-ui-mobile/vendor/cytoscape.min.js +32 -0
- package/client-ui-mobile/vendor/layout-base.js +5230 -0
- package/cmd/magic-client/browsectl.go +199 -0
- package/cmd/magic-client/clientctl.go +69 -10
- package/cmd/magic-client/main.go +210 -32
- package/internal/agent/ask.go +45 -0
- package/internal/agent/ask_test.go +39 -0
- package/internal/agent/attachments_test.go +6 -2
- package/internal/agent/capability.go +7 -1
- package/internal/agent/claude.go +20 -15
- package/internal/agent/codebuddy.go +125 -34
- package/internal/agent/codebuddy_account_test.go +54 -0
- package/internal/agent/codebuddy_stream_test.go +103 -0
- package/internal/agent/credits.go +713 -0
- package/internal/agent/credits_minimax_test.go +97 -0
- package/internal/agent/credits_test.go +211 -0
- package/internal/agent/custom_models.go +155 -0
- package/internal/agent/custom_models_test.go +125 -0
- package/internal/agent/dsh.go +123 -49
- package/internal/agent/dsh_caps_cache_test.go +184 -0
- package/internal/agent/dsh_resume.go +157 -7
- package/internal/agent/dsh_test.go +108 -6
- package/internal/agent/engine.go +37 -1
- package/internal/agent/engine_base.go +21 -17
- package/internal/agent/engine_base_test.go +2 -2
- package/internal/agent/engine_test.go +47 -86
- package/internal/agent/env.go +173 -1
- package/internal/agent/env_test.go +114 -0
- package/internal/agent/fallback.go +313 -0
- package/internal/agent/fallback_test.go +340 -0
- package/internal/agent/launch.go +43 -1
- package/internal/agent/limitnotice.go +207 -1
- package/internal/agent/limitnotice_test.go +140 -0
- package/internal/agent/llmhttp.go +46 -4
- package/internal/agent/llmhttp_test.go +157 -0
- package/internal/agent/models.go +53 -1
- package/internal/agent/models_test.go +20 -5
- package/internal/agent/pidalive_windows_test.go +40 -0
- package/internal/agent/runcmd.go +26 -0
- package/internal/agent/runcmd_windows.go +36 -5
- package/internal/agent/stream.go +166 -6
- package/internal/agent/stream_test.go +25 -38
- package/internal/agent/stream_timeline_test.go +2 -37
- package/internal/agent/streamjson.go +119 -7
- package/internal/agent/tools_suffix_test.go +6 -3
- package/internal/agent/trae.go +50 -243
- package/internal/agent/trae_exec.go +433 -0
- package/internal/agent/trae_exec_test.go +181 -0
- package/internal/agent/wraplaunch_test.go +113 -0
- package/internal/cli/ask.go +168 -37
- package/internal/cli/ask_test.go +1 -1
- package/internal/cli/checkin.go +77 -0
- package/internal/cli/clientcmd.go +3 -2
- package/internal/cli/control.go +46 -5
- package/internal/cli/control_test.go +65 -0
- package/internal/cli/engines_test.go +103 -0
- package/internal/cli/events.go +15 -0
- package/internal/cli/events_test.go +50 -0
- package/internal/cli/judge.go +104 -0
- package/internal/cli/quotas.go +125 -0
- package/internal/cli/requirements.go +51 -0
- package/internal/cli/root.go +157 -11
- package/internal/cli/session.go +75 -3
- package/internal/cli/session_test.go +82 -3
- package/internal/cli/sessionlog.go +6 -0
- package/internal/cli/sessionlog_test.go +22 -2
- package/internal/cli/suggest.go +7 -5
- package/internal/cli/team.go +37 -9
- package/internal/cli/team_test.go +39 -0
- package/internal/client/ab.go +372 -0
- package/internal/client/ab_test.go +287 -0
- package/internal/client/apk.go +8 -0
- package/internal/client/apk_test.go +59 -0
- package/internal/client/apps.go +1078 -0
- package/internal/client/apps_test.go +516 -0
- package/internal/client/browse.go +84 -36
- package/internal/client/browse_test.go +107 -9
- package/internal/client/browseinput.go +481 -0
- package/internal/client/browseinput_test.go +216 -0
- package/internal/client/corecmd.go +23 -0
- package/internal/client/corecmd_other.go +8 -0
- package/internal/client/corecmd_windows.go +24 -0
- package/internal/client/coreexe.go +196 -0
- package/internal/client/coreexe_test.go +161 -0
- package/internal/client/coreupgrade.go +866 -0
- package/internal/client/coreupgrade_test.go +561 -0
- package/internal/client/daemon.go +297 -33
- package/internal/client/daemon_test.go +33 -0
- package/internal/client/engines_checkin_test.go +57 -0
- package/internal/client/fallback_flags_test.go +64 -0
- package/internal/client/feishu.go +1259 -0
- package/internal/client/feishu_test.go +649 -0
- package/internal/client/health_identity_test.go +84 -0
- package/internal/client/hostenv.go +68 -0
- package/internal/client/hostenv_test.go +37 -0
- package/internal/client/jobs.go +518 -24
- package/internal/client/jobs_auto.go +571 -0
- package/internal/client/jobs_auto_test.go +431 -0
- package/internal/client/jobs_test.go +299 -2
- package/internal/client/knowledge.go +854 -0
- package/internal/client/knowledge_test.go +510 -0
- package/internal/client/lan.go +3 -3
- package/internal/client/plugins.go +5 -1
- package/internal/client/prefs.go +67 -0
- package/internal/client/procstart_linux.go +59 -0
- package/internal/client/publicurl.go +82 -0
- package/internal/client/quotas_merge_test.go +107 -0
- package/internal/client/requirements.go +207 -2
- package/internal/client/requirements_shots_test.go +200 -0
- package/internal/client/requirements_test.go +103 -0
- package/internal/client/restart.go +288 -0
- package/internal/client/restart_handoff_test.go +230 -0
- package/internal/client/restart_test.go +290 -0
- package/internal/client/scripts.go +1047 -0
- package/internal/client/scripts_decode_implausible.go +38 -0
- package/internal/client/scripts_decode_other.go +31 -0
- package/internal/client/scripts_decode_test.go +130 -0
- package/internal/client/scripts_decode_windows.go +182 -0
- package/internal/client/scripts_test.go +739 -0
- package/internal/client/service.go +717 -97
- package/internal/client/service_test.go +217 -1
- package/internal/client/sessions_cache.go +148 -0
- package/internal/client/sessions_cache_test.go +163 -0
- package/internal/client/sessions_fold.go +165 -0
- package/internal/client/sessions_fold_test.go +204 -0
- package/internal/client/singleinstance.go +470 -0
- package/internal/client/singleinstance_test.go +607 -0
- package/internal/client/suggest.go +105 -2
- package/internal/client/suggest_test.go +86 -0
- package/internal/client/team_flags.go +25 -11
- package/internal/client/team_flags_test.go +39 -0
- package/internal/client/testmain_test.go +88 -0
- package/internal/client/workspace_default_test.go +158 -0
- package/internal/client/workspacelockenv_test.go +54 -0
- package/internal/config/config.go +48 -0
- package/internal/judge/judge.go +315 -0
- package/internal/requirements/bootstrap.go +16 -0
- package/internal/requirements/consolidate.go +100 -9
- package/internal/requirements/doc.go +154 -3
- package/internal/requirements/graph.go +138 -0
- package/internal/requirements/memory.go +7 -0
- package/internal/requirements/modules.go +312 -0
- package/internal/requirements/modules_test.go +373 -0
- package/internal/requirements/relations.go +155 -0
- package/internal/requirements/relations_test.go +231 -0
- package/internal/requirements/requirements.go +10 -0
- package/internal/requirements/shots.go +418 -0
- package/internal/requirements/shots_test.go +387 -0
- package/internal/session/multiclient_test.go +368 -0
- package/internal/session/session.go +175 -11
- package/internal/session/session_test.go +7 -0
- package/internal/session/status.go +21 -0
- package/internal/session/status_test.go +28 -0
- package/internal/session/title.go +16 -0
- package/internal/session/title_test.go +26 -0
- package/internal/session/workspace_lock.go +265 -28
- package/internal/suggest/suggest.go +23 -1
- package/internal/team/event.go +3 -1
- package/internal/team/execute.go +21 -6
- package/internal/team/fallback_route_test.go +80 -0
- package/internal/team/planner.go +15 -3
- package/internal/team/route.go +89 -17
- package/internal/team/team.go +18 -4
- package/npm/apk/magic-agent-mobile-release.apk +0 -0
- package/npm/apk/version.json +6 -3
- package/npm/dist/darwin-arm64/magic-agent +0 -0
- package/npm/dist/darwin-arm64/magic-client +0 -0
- package/npm/dist/darwin-x64/magic-agent +0 -0
- package/npm/dist/darwin-x64/magic-client +0 -0
- package/npm/dist/linux-arm64/magic-agent +0 -0
- package/npm/dist/linux-arm64/magic-client +0 -0
- package/npm/dist/linux-x64/magic-agent +0 -0
- package/npm/dist/linux-x64/magic-client +0 -0
- package/npm/dist/win32-x64/magic-agent.exe +0 -0
- package/npm/dist/win32-x64/magic-client.exe +0 -0
- package/npm/install.js +11 -0
- package/package.json +14 -3
package/internal/agent/dsh.go
CHANGED
|
@@ -84,6 +84,7 @@ import (
|
|
|
84
84
|
"fmt"
|
|
85
85
|
"os"
|
|
86
86
|
"path/filepath"
|
|
87
|
+
"strconv"
|
|
87
88
|
"strings"
|
|
88
89
|
"time"
|
|
89
90
|
)
|
|
@@ -170,16 +171,17 @@ func (e *DshEngine) Detect() (bool, string) {
|
|
|
170
171
|
// 什么的东西」—— 网关列出的上百个模型并不都能用,只有配置里声明过的才会被路由接受。
|
|
171
172
|
// 故**配置层才是权威**,也顺带避免了每次探测都发网络请求 + 需要解密凭据。
|
|
172
173
|
//
|
|
173
|
-
// 配置位置随 dsh 版本变过(见 dshConfigDocs):0.1.7
|
|
174
|
-
//
|
|
174
|
+
// 配置位置随 dsh 版本变过(见 dshConfigDocs):0.1.7 起是 cordis.patch.yml
|
|
175
|
+
// (profile 级,0.2.1 起也会写在 home 级 $DSH_HOME/cordis.patch.yml),之前是
|
|
176
|
+
// $DSH_HOME/settings.yaml。各处都读、取并集 ——
|
|
175
177
|
// 前者是 SDK 通道,后者是 headless 回退通道,magic-agent 两条都会用到。
|
|
176
178
|
//
|
|
177
179
|
// 输出形态 `provider/model`(与 openclaw 的 `provider/model` 同构):dsh 的模型只在
|
|
178
180
|
// 所属路由内有意义,同名模型可能出现在两条路由上,裸 id 会歧义。
|
|
179
181
|
//
|
|
180
|
-
// 这份清单**就是** `-m`
|
|
181
|
-
//
|
|
182
|
-
//
|
|
182
|
+
// 这份清单**就是** `-m` 的可取值:SDK 通道转成 initialize 的 provider/model
|
|
183
|
+
// (见 dshResolveRoute);headless 通道没有 --model,改由每轮临时 `--patch` 覆盖配置层的
|
|
184
|
+
// agent-default-model(见 dshHeadlessPatchFor)—— 两条通道都能按 -m 换模型。
|
|
183
185
|
func (e *DshEngine) ListModels(_ context.Context) ([]string, error) {
|
|
184
186
|
docs := dshConfigDocs()
|
|
185
187
|
var models []string
|
|
@@ -202,7 +204,7 @@ func (e *DshEngine) ListModels(_ context.Context) ([]string, error) {
|
|
|
202
204
|
tried = strings.Join(paths, "、")
|
|
203
205
|
}
|
|
204
206
|
return nil, fmt.Errorf("%w: 没有读到已配置的模型;找过 %s。"+
|
|
205
|
-
"dsh 0.1.7 起 provider 声明在
|
|
207
|
+
"dsh 0.1.7 起 provider 声明在 cordis.patch.yml(profile 级或 home 级,可用 dsh 的「设置 → 模型」写入),"+
|
|
206
208
|
"更早的版本在 settings.yaml 的 llm-pi-ai.providers.<id>.models 段",
|
|
207
209
|
ErrNoModelSource, tried)
|
|
208
210
|
}
|
|
@@ -343,27 +345,24 @@ func dshNativeImageMIME(mime string) bool {
|
|
|
343
345
|
// 形态:`dsh --profile headless [--session-id <id>] "<prompt>"`,stdout 即最终正文。
|
|
344
346
|
// --session-id 是 dsh 自己的续接入口(见 dsh_resume.go),只在续接轮带上。
|
|
345
347
|
func (e *DshEngine) completeHeadless(ctx context.Context, req Request, bin, sessionID, prompt string, start time.Time) (Response, error) {
|
|
346
|
-
// headless 没有 --model 参数:-m
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
if req.Model != "" && sessionID == "" {
|
|
351
|
-
fmt.Fprintf(stderr, " ⚠ dsh: headless 通道下 --model %s 不生效(无 --model 参数);"+
|
|
352
|
-
"换模型请改 $DSH_HOME/settings.yaml 的 agent-default-model 段,或用 dsh 的 Settings → Models\n", req.Model)
|
|
353
|
-
}
|
|
348
|
+
// headless 没有 --model 参数:-m 只能经 `--patch` 覆盖配置层的 agent-default-model
|
|
349
|
+
//(见 dshHeadlessPatchFor)。拿不到 --patch 才退回「配置默认模型」,并告警一次。
|
|
350
|
+
patchPath, cleanup := dshHeadlessPatchFor(ctx, bin, req.Model)
|
|
351
|
+
defer cleanup()
|
|
354
352
|
|
|
355
|
-
|
|
353
|
+
promptArg, stdinText := dshHeadlessPromptArg(prompt)
|
|
354
|
+
args := dshHeadlessArgs(e.profile(), sessionID, patchPath, promptArg, false)
|
|
356
355
|
|
|
357
356
|
// workspace:dsh 的「调用时所在目录即默认 workspace 根」正是子进程 cwd 的语义。
|
|
358
357
|
// 档位同 SDK 通道:走 DSH_PERMISSION_MODE(dsh 没有逐调用的权限 flag)。
|
|
359
|
-
stdout, stderrText, runErr :=
|
|
358
|
+
stdout, stderrText, runErr := runCLIEnvInStdin(ctx, req.Workspace, dshPermissionEnv(req.Permission), stdinText, bin, args...)
|
|
360
359
|
|
|
361
360
|
resp := Response{
|
|
362
361
|
Engine: e.Name(),
|
|
363
362
|
// headless 的 stdout 就是最终助手正文(推理增量走 stderr,不混入)。
|
|
364
363
|
Text: strings.TrimSpace(stdout),
|
|
365
|
-
//
|
|
366
|
-
Model:
|
|
364
|
+
// 模型标签:本轮真的用哪个(有 -m 且 patch 生效 → -m;否则配置默认)。
|
|
365
|
+
Model: dshHeadlessModelLabel(req, patchPath),
|
|
367
366
|
Latency: time.Since(start),
|
|
368
367
|
}
|
|
369
368
|
if resp.Model == "" {
|
|
@@ -386,18 +385,19 @@ func (e *DshEngine) completeHeadless(ctx context.Context, req Request, bin, sess
|
|
|
386
385
|
return resp, nil
|
|
387
386
|
}
|
|
388
387
|
|
|
389
|
-
//
|
|
388
|
+
// dshHeadlessModelLabel headless 轮次对外报的模型标签(= 本轮真正用的那个)。
|
|
390
389
|
//
|
|
391
|
-
//
|
|
392
|
-
//
|
|
393
|
-
//
|
|
394
|
-
//
|
|
395
|
-
//
|
|
396
|
-
//
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
390
|
+
// patch 生效(有 -m 且 dsh 支持 --patch)→ 就是 -m 解析出的 `provider/model`
|
|
391
|
+
// 没有 -m → 读 $DSH_HOME 的 agent-default-model
|
|
392
|
+
// (读不到返回空串,调用方回落 "config-default")
|
|
393
|
+
//
|
|
394
|
+
// 旧实现里续接轮直接回 `req.Model` 当「最佳近似」,但实际跑的是配置默认模型 ——
|
|
395
|
+
// 2026-10-25 用户报障就是被这个假标签误导(界面写 volcengine/…,真实是 minimax/…)。
|
|
396
|
+
// 现在模型真的经 --patch 落到配置层,标签才跟事实一致。
|
|
397
|
+
func dshHeadlessModelLabel(req Request, patchPath string) string {
|
|
398
|
+
if patchPath != "" {
|
|
399
|
+
if p, m := dshResolveRoute(req.Model); p != "" && m != "" {
|
|
400
|
+
return p + "/" + m
|
|
401
401
|
}
|
|
402
402
|
}
|
|
403
403
|
return DshConfiguredModel()
|
|
@@ -405,17 +405,26 @@ func dshHeadlessModel(req Request, sessionID string) string {
|
|
|
405
405
|
|
|
406
406
|
// dshHeadlessArgs 构造 headless 的参数:**启动器 flag 在前,app 参数在后**。
|
|
407
407
|
//
|
|
408
|
-
// ["--profile", <profile>] [+ "--session-id", <id>] [+ "--json"] + ["<prompt>"]
|
|
408
|
+
// [--patch <file>] ["--profile", <profile>] [+ "--session-id", <id>] [+ "--json"] + ["<prompt>"]
|
|
409
|
+
//
|
|
410
|
+
// ⚠️ 三类 flag 的归属不同(顺序不能错):
|
|
411
|
+
// - `--patch` 是**启动器 flag**(LauncherOptions),必须在 --profile **之前** —— 放在
|
|
412
|
+
// 后面会被 headless 当成未知 app 参数(实测 `dsh --profile headless --patch` → unknown option)。
|
|
413
|
+
// - --session-id / --json 是 **headless 自己的 flag**,排在 --profile 之后、任务文本之前。
|
|
414
|
+
// - 启动器在第一个它不认识的 token 处结束解析,剩下的全交给 app。
|
|
409
415
|
//
|
|
410
|
-
//
|
|
411
|
-
//
|
|
412
|
-
//
|
|
413
|
-
// 超长 prompt 会撞 ARG_MAX:需要喂超长文本时先落成文件,让 dsh 用读文件工具看。
|
|
416
|
+
// prompt 默认走位置参数(headless 唯一的 app 参数,无 --prompt)。Windows 上改走 stdin
|
|
417
|
+
// 的 `-`(见 dshHeadlessPromptArg);位置参数形态下超长 prompt 会撞 ARG_MAX:需要喂超长
|
|
418
|
+
// 文本时先落成文件,让 dsh 用读文件工具看。
|
|
414
419
|
//
|
|
415
420
|
// ⚠️ 只有 headless 用得到它:SDK 通道的 prompt 走 session/prompt 的 contentBlocks
|
|
416
421
|
// (见 dsh_sdk.go),既不占命令行也不受 ARG_MAX 限制。
|
|
417
|
-
func dshHeadlessArgs(profile, sessionID, prompt string, jsonEvents bool) []string {
|
|
418
|
-
args := []string
|
|
422
|
+
func dshHeadlessArgs(profile, sessionID, patchPath, prompt string, jsonEvents bool) []string {
|
|
423
|
+
args := make([]string, 0, 8)
|
|
424
|
+
if patchPath != "" {
|
|
425
|
+
args = append(args, "--patch", patchPath)
|
|
426
|
+
}
|
|
427
|
+
args = append(args, "--profile", profile)
|
|
419
428
|
if sessionID != "" {
|
|
420
429
|
args = append(args, "--session-id", sessionID)
|
|
421
430
|
}
|
|
@@ -425,6 +434,67 @@ func dshHeadlessArgs(profile, sessionID, prompt string, jsonEvents bool) []strin
|
|
|
425
434
|
return append(args, prompt)
|
|
426
435
|
}
|
|
427
436
|
|
|
437
|
+
// dshHeadlessPromptArg 决定 headless 的提示词怎么进 dsh:位置参数,还是 stdin("-")。
|
|
438
|
+
//
|
|
439
|
+
// Windows 上必须走 stdin:dsh 在 PATH 上解析到 npm 的 `dsh.cmd`,launchWrap 会以
|
|
440
|
+
// `powershell -Command "& 'dsh.cmd' …"` 启动,参数还要再过一层 cmd.exe 的 `%*` 重解析 ——
|
|
441
|
+
// **多行位置参数会被整段丢掉**(2026-10-25 实测:单行正常,带换行的提示词模型收到的是空消息)。
|
|
442
|
+
// dsh 的 headless 原生支持 `-` 读 stdin(help 原文:task `-` reads stdin),走 stdin 既绕开
|
|
443
|
+
// cmd 解析层,也顺带避开 ARG_MAX。unix 无此问题,保持原来的位置参数形态。
|
|
444
|
+
func dshHeadlessPromptArg(prompt string) (arg, stdinText string) {
|
|
445
|
+
if platformIsWindows() {
|
|
446
|
+
return "-", prompt
|
|
447
|
+
}
|
|
448
|
+
return prompt, ""
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
// dshHeadlessPatchFor 把本轮 `-m` 落成一份临时 `--patch`(只覆盖 agent-default-model)。
|
|
452
|
+
//
|
|
453
|
+
// 为什么需要:headless 通道**没有 --model**(`dsh --profile headless --help` 只有
|
|
454
|
+
// --json / --session-id),模型只由配置层 agent-default-model 决定;而续接轮又**必须**
|
|
455
|
+
// 走 headless(SDK 没有 resume,见 dsh_resume.go)。2026-10-25 用户报障就是这条缝:
|
|
456
|
+
// 界面选 volcengine/deepseek-v4.1-flash-1017,续接轮实际跑配置默认的 minimax/MiniMax-M3.1-Flash,
|
|
457
|
+
// 报的是 MiniMax 的「Token Plan 用量上限」,看上去像模型选错了。
|
|
458
|
+
//
|
|
459
|
+
// 顶层启动器支持 `dsh --patch <path>`(profile 之上再叠一层),所以每轮按 -m 写一份临时
|
|
460
|
+
// patch,进程结束后删掉 —— 模型选择在续接轮 / 回退轮同样生效。
|
|
461
|
+
//
|
|
462
|
+
// 返回 (路径, 清理函数);没有 -m / 拆不出 provider/model / dsh 不认 --patch 时返回 "",
|
|
463
|
+
// 此时沿用配置默认模型并告警一次(旧行为,但不再谎报模型)。
|
|
464
|
+
func dshHeadlessPatchFor(ctx context.Context, bin, reqModel string) (string, func()) {
|
|
465
|
+
model := strings.TrimSpace(reqModel)
|
|
466
|
+
if model == "" {
|
|
467
|
+
return "", func() {}
|
|
468
|
+
}
|
|
469
|
+
if !dshHeadlessCapabilities(ctx, bin).Patch {
|
|
470
|
+
fmt.Fprintf(stderr, " ⚠ dsh: 这台 dsh 不认 --patch,headless 通道无法按 -m %s 换模型"+
|
|
471
|
+
"(实跑配置默认模型);请升级 dsh,或改 $DSH_HOME 的 agent-default-model\n", model)
|
|
472
|
+
return "", func() {}
|
|
473
|
+
}
|
|
474
|
+
provider, id := dshResolveRoute(model)
|
|
475
|
+
if provider == "" || id == "" {
|
|
476
|
+
fmt.Fprintf(stderr, " ⚠ dsh: 无法从 -m %s 拆出 provider/model,本轮沿用配置默认模型\n", model)
|
|
477
|
+
return "", func() {}
|
|
478
|
+
}
|
|
479
|
+
f, err := os.CreateTemp("", "magic-agent-dsh-model-*.yml")
|
|
480
|
+
if err != nil {
|
|
481
|
+
fmt.Fprintf(stderr, " ⚠ dsh: 写临时 patch 失败(%v),本轮沿用配置默认模型\n", err)
|
|
482
|
+
return "", func() {}
|
|
483
|
+
}
|
|
484
|
+
// YAML 双引号标量 = JSON 字符串,用 strconv.Quote 免得模型 id 里的特殊字符破坏结构。
|
|
485
|
+
content := "- id: agent-default-model\n config:\n provider: " + strconv.Quote(provider) +
|
|
486
|
+
"\n model: " + strconv.Quote(id) + "\n"
|
|
487
|
+
_, werr := f.WriteString(content)
|
|
488
|
+
cerr := f.Close()
|
|
489
|
+
if werr != nil || cerr != nil {
|
|
490
|
+
_ = os.Remove(f.Name())
|
|
491
|
+
fmt.Fprintf(stderr, " ⚠ dsh: 写临时 patch 失败(%v),本轮沿用配置默认模型\n", errors.Join(werr, cerr))
|
|
492
|
+
return "", func() {}
|
|
493
|
+
}
|
|
494
|
+
path := f.Name()
|
|
495
|
+
return path, func() { _ = os.Remove(path) }
|
|
496
|
+
}
|
|
497
|
+
|
|
428
498
|
// ── 流式(--stream)────────────────────────────────────────────
|
|
429
499
|
//
|
|
430
500
|
// dsh 的流式形态与通道有关,如实接线:
|
|
@@ -532,14 +602,12 @@ func (e *DshEngine) Stream(ctx context.Context, req Request, onEvent func(Stream
|
|
|
532
602
|
// 有 --json 时按事件流逐条上报(推理 / 正文 / 工具调用,能力与 SDK 通道同构);
|
|
533
603
|
// 老版本没有 --json 就退回「stderr 推理增量 + stdout 收尾正文」,工具调用拿不到 —— 如实说明。
|
|
534
604
|
func (e *DshEngine) streamHeadless(ctx context.Context, req Request, bin, sessionID, prompt string, onEvent func(StreamEvent), start time.Time) (StreamResult, error) {
|
|
535
|
-
// 同 completeHeadless
|
|
536
|
-
if req.Model != "" && sessionID == "" {
|
|
537
|
-
fmt.Fprintf(stderr, " ⚠ dsh: headless 通道下 --model %s 不生效(无 --model 参数);"+
|
|
538
|
-
"换模型请改 $DSH_HOME/settings.yaml 的 agent-default-model 段,或用 dsh 的 Settings → Models\n", req.Model)
|
|
539
|
-
}
|
|
540
|
-
|
|
605
|
+
// 同 completeHeadless:-m 经 --patch 覆盖配置层(headless 没有 --model)。
|
|
541
606
|
caps := dshHeadlessCapabilities(ctx, bin)
|
|
542
|
-
|
|
607
|
+
patchPath, cleanup := dshHeadlessPatchFor(ctx, bin, req.Model)
|
|
608
|
+
defer cleanup()
|
|
609
|
+
promptArg, stdinText := dshHeadlessPromptArg(prompt)
|
|
610
|
+
args := dshHeadlessArgs(e.profile(), sessionID, patchPath, promptArg, caps.JSON)
|
|
543
611
|
|
|
544
612
|
emit := func(ev StreamEvent) {
|
|
545
613
|
if onEvent != nil {
|
|
@@ -607,11 +675,11 @@ func (e *DshEngine) streamHeadless(ctx context.Context, req Request, bin, sessio
|
|
|
607
675
|
runErr error
|
|
608
676
|
)
|
|
609
677
|
if caps.JSON {
|
|
610
|
-
stdout, stderrText, runErr =
|
|
678
|
+
stdout, stderrText, runErr = runStreamLinesInStdin(ctx, req.Workspace, dshPermissionEnv(req.Permission), stdinText, bin, args, true, onLine)
|
|
611
679
|
} else {
|
|
612
680
|
// 老版本:推理增量在 stderr("dsh: reasoning:" 起头),正文在 stdout 收尾时一次给出。
|
|
613
681
|
inReasoning := false
|
|
614
|
-
stdout, stderrText, runErr =
|
|
682
|
+
stdout, stderrText, runErr = runStreamLinesInStdin(ctx, req.Workspace, dshPermissionEnv(req.Permission), stdinText, bin, args, false, func(line string) error {
|
|
615
683
|
// 标题行本身不是内容;它出现即进入推理区段。
|
|
616
684
|
if strings.HasPrefix(line, dshReasoningPrefix) {
|
|
617
685
|
inReasoning = true
|
|
@@ -650,7 +718,7 @@ func (e *DshEngine) streamHeadless(ctx context.Context, req Request, bin, sessio
|
|
|
650
718
|
resp := Response{
|
|
651
719
|
Engine: e.Name(),
|
|
652
720
|
Text: text,
|
|
653
|
-
Model:
|
|
721
|
+
Model: dshHeadlessModelLabel(req, patchPath),
|
|
654
722
|
SessionID: sid,
|
|
655
723
|
Latency: time.Since(start),
|
|
656
724
|
}
|
|
@@ -711,6 +779,9 @@ func DshHome() string {
|
|
|
711
779
|
// ≤ 0.1.6 $DSH_HOME/settings.yaml(顶层段 agent-default-model / llm-pi-ai)
|
|
712
780
|
// ≥ 0.1.7 各 profile 的 $DSH_HOME/profiles/<name>/cordis.patch.yml
|
|
713
781
|
// (settings 体系重构后,providers 由「设置 → 模型」页面写进这份 patch)
|
|
782
|
+
// ≥ 0.2.1 也会写在 home 级 $DSH_HOME/cordis.patch.yml(对所有 profile 生效;
|
|
783
|
+
// 2026-10-23 实测 0.2.1-rc.2 重写配置后 profile 级 patch 清成空 [],
|
|
784
|
+
// provider 只剩 home 级这一份 —— 不读它就整条链空手而归)
|
|
714
785
|
//
|
|
715
786
|
// ⚠️ 0.1.7 首次启动会把旧的 settings.yaml 改名为 settings.yaml.imported
|
|
716
787
|
//(官方 dsh-settings:改名先于首次写入,被组合拒绝的 section 只留在改名后的文件里)。
|
|
@@ -740,7 +811,8 @@ func (d dshConfigDoc) legacyText() string {
|
|
|
740
811
|
// (MAGIC_AGENT_DSH_PROFILE 可改;headless 回退通道见 ②)
|
|
741
812
|
// ② headless profile 的 cordis.patch.yml —— SDK 不可用时的回退通道,
|
|
742
813
|
// 列模型时不能漏(两条通道 magic-agent 都会用到)
|
|
743
|
-
// ③ $DSH_HOME/
|
|
814
|
+
// ③ $DSH_HOME/cordis.patch.yml —— home 级 patch,对所有 profile 生效
|
|
815
|
+
// ④ $DSH_HOME/settings.yaml —— 0.1.7 之前的位置,兼容未升级的装机
|
|
744
816
|
func dshConfigDocs() []dshConfigDoc {
|
|
745
817
|
home := DshHome()
|
|
746
818
|
if home == "" {
|
|
@@ -750,12 +822,14 @@ func dshConfigDocs() []dshConfigDoc {
|
|
|
750
822
|
if names[0] != dshDefaultProfile {
|
|
751
823
|
names = append(names, dshDefaultProfile)
|
|
752
824
|
}
|
|
753
|
-
docs := make([]dshConfigDoc, 0, len(names)+
|
|
825
|
+
docs := make([]dshConfigDoc, 0, len(names)+2)
|
|
754
826
|
for _, name := range names {
|
|
755
827
|
p := filepath.Join(home, "profiles", name, "cordis.patch.yml")
|
|
756
828
|
docs = append(docs, dshConfigDoc{Path: p, Patch: true, Text: readFileOrEmpty(p)})
|
|
757
829
|
}
|
|
758
|
-
p := filepath.Join(home, "
|
|
830
|
+
p := filepath.Join(home, "cordis.patch.yml")
|
|
831
|
+
docs = append(docs, dshConfigDoc{Path: p, Patch: true, Text: readFileOrEmpty(p)})
|
|
832
|
+
p = filepath.Join(home, "settings.yaml")
|
|
759
833
|
return append(docs, dshConfigDoc{Path: p, Text: readFileOrEmpty(p)})
|
|
760
834
|
}
|
|
761
835
|
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
package agent
|
|
2
|
+
|
|
3
|
+
// dsh_caps_cache_test.go - dsh 能力探测的**磁盘**缓存(2026-10-26)。
|
|
4
|
+
//
|
|
5
|
+
// 为什么单测必须钉住这件事:dshCapsCache 是进程内 sync.Map,而 magic-agent
|
|
6
|
+
// 每轮 ask 都是新进程 —— 没有磁盘那层,「一进程一次」实际是「一轮一次」,
|
|
7
|
+
// 每轮续接白等 7~9 秒(实测 dsh 0.2.0-rc.2 的 --profile headless --help)。
|
|
8
|
+
// 这类「看着有缓存、其实每轮都在重探」的回归用眼睛看不出来,只能靠计数。
|
|
9
|
+
|
|
10
|
+
import (
|
|
11
|
+
"context"
|
|
12
|
+
"os"
|
|
13
|
+
"path/filepath"
|
|
14
|
+
"strings"
|
|
15
|
+
"sync"
|
|
16
|
+
"testing"
|
|
17
|
+
"time"
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
// fakeDshCapsBin 造一个假 dsh:把每次被调用的参数记进 calls 文件,并回 help 文本。
|
|
21
|
+
//
|
|
22
|
+
// helpPatch=false 模拟老版本(顶层 --help 里没有 --patch)。
|
|
23
|
+
func fakeDshCapsBin(t *testing.T, helpPatch bool) (bin, callsPath string) {
|
|
24
|
+
t.Helper()
|
|
25
|
+
dir := t.TempDir()
|
|
26
|
+
bin = filepath.Join(dir, "dsh")
|
|
27
|
+
callsPath = filepath.Join(dir, "calls.txt")
|
|
28
|
+
patchLine := ""
|
|
29
|
+
if helpPatch {
|
|
30
|
+
patchLine = `echo " --patch <path> apply a patch file"`
|
|
31
|
+
}
|
|
32
|
+
script := `#!/bin/sh
|
|
33
|
+
echo "$@" >> ` + callsPath + `
|
|
34
|
+
case "$1" in
|
|
35
|
+
--help)
|
|
36
|
+
echo "Usage: dsh [--profile <name>] [--patch <path>] <prompt>"
|
|
37
|
+
` + patchLine + `
|
|
38
|
+
;;
|
|
39
|
+
--profile)
|
|
40
|
+
echo "Usage: dsh-headless [--session-id <id>] [--json] <prompt>"
|
|
41
|
+
;;
|
|
42
|
+
esac
|
|
43
|
+
exit 0
|
|
44
|
+
`
|
|
45
|
+
if err := os.WriteFile(bin, []byte(script), 0o755); err != nil {
|
|
46
|
+
t.Fatalf("write fake dsh: %v", err)
|
|
47
|
+
}
|
|
48
|
+
return bin, callsPath
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// countCalls 读假 dsh 被调用的次数。
|
|
52
|
+
func countCalls(t *testing.T, callsPath string) int {
|
|
53
|
+
t.Helper()
|
|
54
|
+
b, err := os.ReadFile(callsPath)
|
|
55
|
+
if err != nil {
|
|
56
|
+
return 0
|
|
57
|
+
}
|
|
58
|
+
return len(strings.Split(strings.TrimSpace(string(b)), "\n"))
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// resetDshCapsCache 清掉进程内缓存(每个用例都从「冷进程」起)。
|
|
62
|
+
func resetDshCapsCache() {
|
|
63
|
+
dshCapsCache = sync.Map{}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// TestDshCapsDiskCacheAvoidsSecondProbe 第二轮(新进程语义)不该再起子进程。
|
|
67
|
+
//
|
|
68
|
+
// 这是本改动的**核心断言**:把进程内缓存清掉再问一次,如果还是去起进程,
|
|
69
|
+
// 说明磁盘那层没生效 —— 用户侧表现就是每轮续接多等 7~9 秒。
|
|
70
|
+
func TestDshCapsDiskCacheAvoidsSecondProbe(t *testing.T) {
|
|
71
|
+
cachePath := filepath.Join(t.TempDir(), "dsh-caps.json")
|
|
72
|
+
t.Setenv("MAGIC_AGENT_DSH_CAPS_CACHE", cachePath)
|
|
73
|
+
|
|
74
|
+
bin, callsPath := fakeDshCapsBin(t, true)
|
|
75
|
+
resetDshCapsCache()
|
|
76
|
+
|
|
77
|
+
ctx := context.Background()
|
|
78
|
+
first := dshHeadlessCapabilities(ctx, bin)
|
|
79
|
+
if got := countCalls(t, callsPath); got != 2 {
|
|
80
|
+
t.Fatalf("首轮应探测 2 次(headless help + 顶层 help),实际 %d 次", got)
|
|
81
|
+
}
|
|
82
|
+
if !first.SessionID || !first.JSON || !first.Patch {
|
|
83
|
+
t.Fatalf("首轮 caps = %+v,期望三项都为 true", first)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// 模拟「下一个 core 进程」:进程内缓存全清,只剩磁盘。
|
|
87
|
+
resetDshCapsCache()
|
|
88
|
+
second := dshHeadlessCapabilities(ctx, bin)
|
|
89
|
+
if got := countCalls(t, callsPath); got != 2 {
|
|
90
|
+
t.Errorf("第二轮不该再起子进程,调用次数仍是 %d(磁盘缓存没命中)", got)
|
|
91
|
+
}
|
|
92
|
+
if second != first {
|
|
93
|
+
t.Errorf("磁盘缓存读回 %+v,与首次探测 %+v 不一致", second, first)
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// TestDshCapsDiskCacheInvalidatedByBinChange dsh 换了二进制(指纹变)就必须重探。
|
|
98
|
+
//
|
|
99
|
+
// 反过来的风险更严重:升级 dsh 后还拿老结论,续接会被判成「不支持 --session-id」
|
|
100
|
+
// 而直接报错,或者更糟 —— 静默按老形态拼参数。
|
|
101
|
+
func TestDshCapsDiskCacheInvalidatedByBinChange(t *testing.T) {
|
|
102
|
+
cachePath := filepath.Join(t.TempDir(), "dsh-caps.json")
|
|
103
|
+
t.Setenv("MAGIC_AGENT_DSH_CAPS_CACHE", cachePath)
|
|
104
|
+
|
|
105
|
+
// 第一版:顶层 --help 里有 --patch。
|
|
106
|
+
bin, callsPath := fakeDshCapsBin(t, true)
|
|
107
|
+
resetDshCapsCache()
|
|
108
|
+
if !dshHeadlessCapabilities(context.Background(), bin).Patch {
|
|
109
|
+
t.Fatal("第一版应探测到 --patch")
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// 换内容并确保 mtime 确实变化(同一纳秒内重写会让指纹看不出差别)。
|
|
113
|
+
time.Sleep(20 * time.Millisecond)
|
|
114
|
+
if err := os.WriteFile(bin, []byte(`#!/bin/sh
|
|
115
|
+
echo "$@" >> `+callsPath+`
|
|
116
|
+
case "$1" in
|
|
117
|
+
--help) echo "Usage: dsh [--profile <name>] <prompt>" ;;
|
|
118
|
+
--profile) echo "Usage: dsh-headless [--session-id <id>] [--json] <prompt>" ;;
|
|
119
|
+
esac
|
|
120
|
+
exit 0
|
|
121
|
+
`), 0o755); err != nil {
|
|
122
|
+
t.Fatalf("rewrite fake dsh: %v", err)
|
|
123
|
+
}
|
|
124
|
+
before := countCalls(t, callsPath)
|
|
125
|
+
resetDshCapsCache()
|
|
126
|
+
|
|
127
|
+
caps := dshHeadlessCapabilities(context.Background(), bin)
|
|
128
|
+
if countCalls(t, callsPath) == before {
|
|
129
|
+
t.Error("二进制变了却没重探(指纹判据失效)")
|
|
130
|
+
}
|
|
131
|
+
if caps.Patch {
|
|
132
|
+
t.Error("新版本没有 --patch,重探后不该还是 true")
|
|
133
|
+
}
|
|
134
|
+
if !caps.SessionID || !caps.JSON {
|
|
135
|
+
t.Errorf("caps = %+v,--session-id / --json 应仍为 true", caps)
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// TestDshCapsCanceledProbeNotCached 探测被取消时不落盘。
|
|
140
|
+
//
|
|
141
|
+
// 为什么值得一条用例:探测中途超时只拿到半截输出,按「探测不到不等于不支持」
|
|
142
|
+
// 的约定会保留乐观默认值。那本来是对的 —— 但**不能把它写进磁盘**,否则
|
|
143
|
+
// 一次抖动会让后续每一轮都跳过真正的探测。
|
|
144
|
+
func TestDshCapsCanceledProbeNotCached(t *testing.T) {
|
|
145
|
+
cachePath := filepath.Join(t.TempDir(), "dsh-caps.json")
|
|
146
|
+
t.Setenv("MAGIC_AGENT_DSH_CAPS_CACHE", cachePath)
|
|
147
|
+
|
|
148
|
+
dir := t.TempDir()
|
|
149
|
+
bin := filepath.Join(dir, "dsh-slow")
|
|
150
|
+
// 故意慢:探测的 ctx 已经取消了,这个脚本还在睡。
|
|
151
|
+
if err := os.WriteFile(bin, []byte("#!/bin/sh\nsleep 5\nexit 0\n"), 0o755); err != nil {
|
|
152
|
+
t.Fatalf("write slow fake dsh: %v", err)
|
|
153
|
+
}
|
|
154
|
+
resetDshCapsCache()
|
|
155
|
+
|
|
156
|
+
ctx, cancel := context.WithCancel(context.Background())
|
|
157
|
+
cancel() // 进函数时就是取消态
|
|
158
|
+
_ = dshHeadlessCapabilities(ctx, bin)
|
|
159
|
+
|
|
160
|
+
if b, err := os.ReadFile(cachePath); err == nil && len(b) > 0 {
|
|
161
|
+
t.Errorf("取消态下不该落盘,却写入了:%s", string(b))
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// TestDshBinFingerprintChangesWithSize 指纹同时看 mtime 和大小。
|
|
166
|
+
func TestDshBinFingerprintChangesWithSize(t *testing.T) {
|
|
167
|
+
p := filepath.Join(t.TempDir(), "f")
|
|
168
|
+
if err := os.WriteFile(p, []byte("a"), 0o644); err != nil {
|
|
169
|
+
t.Fatal(err)
|
|
170
|
+
}
|
|
171
|
+
fp1 := dshBinFingerprint(p)
|
|
172
|
+
if fp1 == "" {
|
|
173
|
+
t.Fatal("指纹为空")
|
|
174
|
+
}
|
|
175
|
+
if err := os.WriteFile(p, []byte("abcdef"), 0o644); err != nil {
|
|
176
|
+
t.Fatal(err)
|
|
177
|
+
}
|
|
178
|
+
if fp2 := dshBinFingerprint(p); fp2 == fp1 {
|
|
179
|
+
t.Error("文件大小变了,指纹却没变")
|
|
180
|
+
}
|
|
181
|
+
if got := dshBinFingerprint(filepath.Join(t.TempDir(), "missing")); got != "" {
|
|
182
|
+
t.Errorf("不存在的文件指纹应为空,得到 %q", got)
|
|
183
|
+
}
|
|
184
|
+
}
|
|
@@ -155,28 +155,164 @@ func dshSessionDir() string {
|
|
|
155
155
|
|
|
156
156
|
// ── 能力探测:这台 dsh 的 headless 认不认 --session-id / --json ──
|
|
157
157
|
|
|
158
|
-
// dshHeadlessCaps headless 支持的两个 flag
|
|
158
|
+
// dshHeadlessCaps headless 支持的两个 flag(外加顶层启动器的 --patch)。
|
|
159
159
|
type dshHeadlessCaps struct {
|
|
160
160
|
SessionID bool // --session-id(0.1.7+ 才有;没有就当不了真续接)
|
|
161
161
|
JSON bool // --json(事件流;没有就走旧版 stderr 推理增量)
|
|
162
|
+
// Patch 顶层启动器的 `--patch <path>`(在 profile 之上再叠一层配置)。
|
|
163
|
+
// headless 没有 --model,续接轮要按 -m 换模型只能靠它(见 dshHeadlessModelPatch)。
|
|
164
|
+
Patch bool
|
|
162
165
|
}
|
|
163
166
|
|
|
164
|
-
var dshCapsCache sync.Map //
|
|
167
|
+
var dshCapsCache sync.Map // 缓存 key → dshHeadlessCaps
|
|
165
168
|
|
|
166
|
-
//
|
|
169
|
+
// dshCapsCachePath 能力探测结果的**磁盘**缓存(2026-10-26)。
|
|
170
|
+
//
|
|
171
|
+
// 为什么必须有磁盘这一层:dshCapsCache 是进程内的 sync.Map,而 magic-agent 每次
|
|
172
|
+
// ask 都是**新进程**(客户端 spawn 一个 core 跑一轮)。于是「一进程一次」实际是
|
|
173
|
+
// 「一轮一次」—— 每轮续接都要重跑两条 --help。
|
|
174
|
+
//
|
|
175
|
+
// 实测代价(2026-10-26,dsh 0.2.0-rc.2):
|
|
176
|
+
//
|
|
177
|
+
// dsh --profile headless --help 7.09s / 9.01s / 7.66s(本机环境变量齐全时)
|
|
178
|
+
// 1.31s(干净 env)
|
|
179
|
+
// dsh --help 0.28s
|
|
180
|
+
//
|
|
181
|
+
// 也就是说**每一轮续接白等 7~9 秒**,而且这段是在 turn/start 之前,用户看到的就是
|
|
182
|
+
// 「发出去半天没动静」。本机实测一轮的 7.09s 空窗与此**完全吻合**。
|
|
183
|
+
//
|
|
184
|
+
// 缓存失效判据是 dsh 二进制的指纹(mtime + size):升级/替换 dsh 后指纹变,自动重探。
|
|
185
|
+
// 不用 `--version` 取版本号当 key —— 那要额外起一次进程(0.3s),而 stat 是零成本,
|
|
186
|
+
// 且「换了二进制」正是唯一需要重探的时机。
|
|
187
|
+
func dshCapsCachePath() string {
|
|
188
|
+
if p := strings.TrimSpace(os.Getenv("MAGIC_AGENT_DSH_CAPS_CACHE")); p != "" {
|
|
189
|
+
return p
|
|
190
|
+
}
|
|
191
|
+
home, err := os.UserHomeDir()
|
|
192
|
+
if err != nil || home == "" {
|
|
193
|
+
return ""
|
|
194
|
+
}
|
|
195
|
+
return filepath.Join(home, ".magic-agent", "dsh-caps.json")
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// dshBinFingerprint dsh 二进制的身份(mtime 纳秒 + 大小)。取不到 → 空串(不缓存)。
|
|
199
|
+
func dshBinFingerprint(bin string) string {
|
|
200
|
+
st, err := os.Stat(bin)
|
|
201
|
+
if err != nil || st.IsDir() {
|
|
202
|
+
return ""
|
|
203
|
+
}
|
|
204
|
+
return fmt.Sprintf("%d-%d", st.ModTime().UnixNano(), st.Size())
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// dshCapsDiskEntry 磁盘缓存的一条记录。
|
|
208
|
+
type dshCapsDiskEntry struct {
|
|
209
|
+
Bin string `json:"bin"`
|
|
210
|
+
Fingerprint string `json:"fingerprint"`
|
|
211
|
+
SessionID bool `json:"sessionId"`
|
|
212
|
+
JSON bool `json:"json"`
|
|
213
|
+
Patch bool `json:"patch"`
|
|
214
|
+
ProbedAt string `json:"probedAt,omitempty"`
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// dshCapsDiskFile 磁盘缓存文件(按 bin 路径分条,多份 dsh 并存也各自有效)。
|
|
218
|
+
type dshCapsDiskFile struct {
|
|
219
|
+
Version int `json:"version"`
|
|
220
|
+
Entries map[string]dshCapsDiskEntry `json:"entries"`
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
func dshLoadCapsFromDisk(bin, fp string) (dshHeadlessCaps, bool) {
|
|
224
|
+
path := dshCapsCachePath()
|
|
225
|
+
if path == "" || fp == "" {
|
|
226
|
+
return dshHeadlessCaps{}, false
|
|
227
|
+
}
|
|
228
|
+
b, err := os.ReadFile(path)
|
|
229
|
+
if err != nil || len(b) == 0 {
|
|
230
|
+
return dshHeadlessCaps{}, false
|
|
231
|
+
}
|
|
232
|
+
var f dshCapsDiskFile
|
|
233
|
+
if err := json.Unmarshal(b, &f); err != nil {
|
|
234
|
+
return dshHeadlessCaps{}, false
|
|
235
|
+
}
|
|
236
|
+
e, ok := f.Entries[bin]
|
|
237
|
+
if !ok || e.Fingerprint != fp {
|
|
238
|
+
return dshHeadlessCaps{}, false
|
|
239
|
+
}
|
|
240
|
+
return dshHeadlessCaps{SessionID: e.SessionID, JSON: e.JSON, Patch: e.Patch}, true
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// dshStoreCapsToDisk 写回一条。失败静默 —— 缓存只是提速手段,写不动不能让调用失败。
|
|
244
|
+
func dshStoreCapsToDisk(bin, fp string, caps dshHeadlessCaps) {
|
|
245
|
+
path := dshCapsCachePath()
|
|
246
|
+
if path == "" || fp == "" {
|
|
247
|
+
return
|
|
248
|
+
}
|
|
249
|
+
var f dshCapsDiskFile
|
|
250
|
+
if b, err := os.ReadFile(path); err == nil && len(b) > 0 {
|
|
251
|
+
_ = json.Unmarshal(b, &f)
|
|
252
|
+
}
|
|
253
|
+
if f.Entries == nil {
|
|
254
|
+
f.Entries = map[string]dshCapsDiskEntry{}
|
|
255
|
+
}
|
|
256
|
+
f.Version = 1
|
|
257
|
+
/* 顺手清掉**已经不存在的二进制**留下的条目。
|
|
258
|
+
不清理的话这个文件会只增不减 —— 测试里的临时 dsh、卸载过的旧版本、换过的
|
|
259
|
+
node 版本路径都会一直留着(实测跑一轮全量测试就塞进十几条 /var/folders 下的
|
|
260
|
+
临时路径)。每条都要 stat 一次,所以放在写回时才做,读路径上不碰。 */
|
|
261
|
+
for k := range f.Entries {
|
|
262
|
+
if strings.TrimSpace(k) == "" {
|
|
263
|
+
delete(f.Entries, k)
|
|
264
|
+
continue
|
|
265
|
+
}
|
|
266
|
+
if st, err := os.Stat(k); err != nil || st.IsDir() {
|
|
267
|
+
delete(f.Entries, k)
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
f.Entries[bin] = dshCapsDiskEntry{
|
|
271
|
+
Bin: bin,
|
|
272
|
+
Fingerprint: fp,
|
|
273
|
+
SessionID: caps.SessionID,
|
|
274
|
+
JSON: caps.JSON,
|
|
275
|
+
Patch: caps.Patch,
|
|
276
|
+
ProbedAt: time.Now().Format(time.RFC3339),
|
|
277
|
+
}
|
|
278
|
+
if dir := filepath.Dir(path); dir != "" {
|
|
279
|
+
_ = os.MkdirAll(dir, 0o700)
|
|
280
|
+
}
|
|
281
|
+
out, err := json.MarshalIndent(f, "", " ")
|
|
282
|
+
if err != nil {
|
|
283
|
+
return
|
|
284
|
+
}
|
|
285
|
+
tmp := path + ".tmp"
|
|
286
|
+
if err := os.WriteFile(tmp, append(out, '\n'), 0o600); err != nil {
|
|
287
|
+
return
|
|
288
|
+
}
|
|
289
|
+
_ = os.Rename(tmp, path)
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// dshHeadlessCapabilities 探测这台 dsh 支持哪些 headless flag。
|
|
293
|
+
//
|
|
294
|
+
// 三级缓存:进程内 sync.Map → 磁盘(按二进制指纹校验)→ 真起进程探。
|
|
295
|
+
// 见 dshCapsCachePath 的注释:没有磁盘这层,等于每轮续接白等 7~9 秒。
|
|
167
296
|
//
|
|
168
297
|
// 为什么必须探测:dsh 启动器在**第一个它不认识的 token 处结束**解析,剩下的都当
|
|
169
298
|
// app 参数交给 headless —— 老版本不认这俩 flag 时,它们会被拼进**任务文本**
|
|
170
|
-
//
|
|
299
|
+
// (静默改写提示词),比直接报错难查得多。
|
|
171
300
|
//
|
|
172
301
|
// ⚠️ 只有在输出**确实是 headless 的 help**(同时含 "Usage:" 与 "--profile")时才据它判断;
|
|
173
302
|
// 否则(--help 跑不起来、被包装脚本吞掉、输出被截断)一律按「都支持」处理 ——
|
|
174
303
|
// 「探测不到」不等于「不支持」,宁可让现代 dsh 正常工作,也不因一次探测抖动把续接判死。
|
|
175
304
|
func dshHeadlessCapabilities(ctx context.Context, bin string) dshHeadlessCaps {
|
|
176
|
-
|
|
305
|
+
fp := dshBinFingerprint(bin)
|
|
306
|
+
key := bin + "|" + fp
|
|
307
|
+
if v, ok := dshCapsCache.Load(key); ok {
|
|
177
308
|
return v.(dshHeadlessCaps)
|
|
178
309
|
}
|
|
179
|
-
|
|
310
|
+
// 磁盘命中:连一次子进程都不用起。
|
|
311
|
+
if caps, ok := dshLoadCapsFromDisk(bin, fp); ok {
|
|
312
|
+
dshCapsCache.Store(key, caps)
|
|
313
|
+
return caps
|
|
314
|
+
}
|
|
315
|
+
caps := dshHeadlessCaps{SessionID: true, JSON: true, Patch: true}
|
|
180
316
|
if stdout, stderr, err := runCLI(ctx, bin, "--profile", "headless", "--help"); err == nil {
|
|
181
317
|
help := stdout + stderr
|
|
182
318
|
if strings.Contains(help, "Usage:") && strings.Contains(help, "--profile") {
|
|
@@ -184,7 +320,21 @@ func dshHeadlessCapabilities(ctx context.Context, bin string) dshHeadlessCaps {
|
|
|
184
320
|
caps.JSON = strings.Contains(help, "--json")
|
|
185
321
|
}
|
|
186
322
|
}
|
|
187
|
-
|
|
323
|
+
// --patch 是**顶层启动器**的 flag,不在 headless 的 app help 里 —— 单独探一次。
|
|
324
|
+
// 同样只在输出确实像 help 时才据它判断(否则保留乐观默认 true)。
|
|
325
|
+
if stdout, stderr, err := runCLI(ctx, bin, "--help"); err == nil {
|
|
326
|
+
help := stdout + stderr
|
|
327
|
+
if strings.Contains(help, "Usage:") {
|
|
328
|
+
caps.Patch = strings.Contains(help, "--patch")
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
// ⚠️ 探测**中途被取消 / 超时**时不落盘:那两秒可能连 help 都没跑完,
|
|
332
|
+
// 存下去等于把「没探到」永久固化成「都支持」—— 后续每轮都照着错的判断走,
|
|
333
|
+
// 而用户再也不会遇到一次会纠正它的探测。落盘的必须是完整探完的结果。
|
|
334
|
+
if ctx.Err() == nil && fp != "" {
|
|
335
|
+
dshStoreCapsToDisk(bin, fp, caps)
|
|
336
|
+
}
|
|
337
|
+
dshCapsCache.Store(key, caps)
|
|
188
338
|
return caps
|
|
189
339
|
}
|
|
190
340
|
|