magic-agent 2.6.0 → 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.
Files changed (102) hide show
  1. package/CLIENT-CONTRACT.md +188 -16
  2. package/client-ui/pc/app.css +42 -0
  3. package/client-ui/pc/js/90-glue.js +3 -0
  4. package/client-ui/shared/conv/03-chat-message.js +25 -19
  5. package/client-ui/shared/conv/09-stream-apply.js +9 -0
  6. package/client-ui/shared/conv/29-msg-variants.js +136 -0
  7. package/client-ui/shared/conv/conv.css +253 -158
  8. package/client-ui/shared/conversation.js +39 -2
  9. package/client-ui-mobile/apps.css +16 -0
  10. package/client-ui-mobile/apps.js +406 -0
  11. package/client-ui-mobile/index.html +2695 -379
  12. package/client-ui-mobile/reqs.css +11 -4
  13. package/client-ui-mobile/reqs.js +49 -5
  14. package/client-ui-mobile/scripts.css +46 -0
  15. package/client-ui-mobile/scripts.js +66 -6
  16. package/cmd/magic-client/browsectl.go +1 -1
  17. package/cmd/magic-client/clientctl.go +37 -2
  18. package/cmd/magic-client/main.go +159 -12
  19. package/internal/agent/dsh_caps_cache_test.go +184 -0
  20. package/internal/agent/dsh_resume.go +144 -5
  21. package/internal/agent/env.go +173 -1
  22. package/internal/agent/env_test.go +114 -0
  23. package/internal/agent/limitnotice.go +207 -1
  24. package/internal/agent/limitnotice_test.go +140 -0
  25. package/internal/agent/stream.go +30 -0
  26. package/internal/agent/streamjson.go +11 -1
  27. package/internal/cli/ask.go +8 -0
  28. package/internal/cli/engines_test.go +103 -0
  29. package/internal/cli/events.go +11 -0
  30. package/internal/cli/events_test.go +50 -0
  31. package/internal/cli/judge.go +104 -0
  32. package/internal/cli/root.go +35 -6
  33. package/internal/cli/session.go +69 -3
  34. package/internal/cli/session_test.go +82 -3
  35. package/internal/cli/sessionlog.go +6 -0
  36. package/internal/cli/sessionlog_test.go +22 -2
  37. package/internal/client/apk.go +8 -0
  38. package/internal/client/apk_test.go +59 -0
  39. package/internal/client/apps.go +1078 -0
  40. package/internal/client/apps_test.go +516 -0
  41. package/internal/client/coreexe.go +196 -0
  42. package/internal/client/coreexe_test.go +161 -0
  43. package/internal/client/coreupgrade.go +866 -0
  44. package/internal/client/coreupgrade_test.go +561 -0
  45. package/internal/client/daemon.go +209 -18
  46. package/internal/client/feishu.go +1259 -0
  47. package/internal/client/feishu_test.go +649 -0
  48. package/internal/client/health_identity_test.go +84 -0
  49. package/internal/client/hostenv.go +68 -0
  50. package/internal/client/hostenv_test.go +37 -0
  51. package/internal/client/jobs.go +233 -12
  52. package/internal/client/jobs_auto.go +571 -0
  53. package/internal/client/jobs_auto_test.go +431 -0
  54. package/internal/client/plugins.go +5 -1
  55. package/internal/client/prefs.go +67 -0
  56. package/internal/client/procstart_linux.go +59 -0
  57. package/internal/client/publicurl.go +82 -0
  58. package/internal/client/requirements.go +9 -1
  59. package/internal/client/requirements_test.go +103 -0
  60. package/internal/client/restart.go +167 -13
  61. package/internal/client/restart_handoff_test.go +230 -0
  62. package/internal/client/restart_test.go +140 -1
  63. package/internal/client/scripts_decode_implausible.go +38 -0
  64. package/internal/client/scripts_decode_other.go +4 -0
  65. package/internal/client/scripts_decode_test.go +16 -11
  66. package/internal/client/scripts_decode_windows.go +0 -24
  67. package/internal/client/scripts_test.go +14 -1
  68. package/internal/client/service.go +403 -87
  69. package/internal/client/service_test.go +166 -1
  70. package/internal/client/sessions_cache.go +148 -0
  71. package/internal/client/sessions_cache_test.go +163 -0
  72. package/internal/client/sessions_fold.go +165 -0
  73. package/internal/client/sessions_fold_test.go +204 -0
  74. package/internal/client/singleinstance.go +470 -0
  75. package/internal/client/singleinstance_test.go +607 -0
  76. package/internal/client/testmain_test.go +88 -0
  77. package/internal/client/workspace_default_test.go +158 -0
  78. package/internal/client/workspacelockenv_test.go +54 -0
  79. package/internal/config/config.go +48 -0
  80. package/internal/judge/judge.go +315 -0
  81. package/internal/session/multiclient_test.go +368 -0
  82. package/internal/session/session.go +83 -11
  83. package/internal/session/session_test.go +7 -0
  84. package/internal/session/status.go +21 -0
  85. package/internal/session/status_test.go +28 -0
  86. package/internal/session/title.go +16 -0
  87. package/internal/session/title_test.go +26 -0
  88. package/internal/session/workspace_lock.go +265 -28
  89. package/npm/apk/magic-agent-mobile-release.apk +0 -0
  90. package/npm/apk/version.json +8 -1
  91. package/npm/dist/darwin-arm64/magic-agent +0 -0
  92. package/npm/dist/darwin-arm64/magic-client +0 -0
  93. package/npm/dist/darwin-x64/magic-agent +0 -0
  94. package/npm/dist/darwin-x64/magic-client +0 -0
  95. package/npm/dist/linux-arm64/magic-agent +0 -0
  96. package/npm/dist/linux-arm64/magic-client +0 -0
  97. package/npm/dist/linux-x64/magic-agent +0 -0
  98. package/npm/dist/linux-x64/magic-client +0 -0
  99. package/npm/dist/win32-x64/magic-agent.exe +0 -0
  100. package/npm/dist/win32-x64/magic-client.exe +0 -0
  101. package/npm/install.js +11 -0
  102. package/package.json +8 -3
@@ -17,8 +17,11 @@
17
17
  .wsrow__sync:active{opacity:.7}
18
18
  .wsrow__sync:disabled{color:var(--muted);opacity:.6}
19
19
  /* 「未初始化」与「待整理」同形不同色:前者是「还没按目录生成过基线」,
20
- 后者是「有对话等着归纳」,两种提示不能混成一个数字。 */
20
+ 后者是「有对话等着归纳」,两种提示不能混成一个数字。
21
+ 深色下「待整理」那枚走 --badge-accent(警告琥珀,压在页面底上 5.8),别用 --warning 直接当字色:
22
+ 它和排队金只差 1.08,11px 上分不出来(2026-10-25 第二轮)。 */
21
23
  .wsrow__badge--init{color:var(--warning);background:var(--tint-wait)}
24
+ .wsrow__badge--pending{color:var(--badge-accent,var(--queued));background:var(--tint-queued)}
22
25
 
23
26
  /* ══════════ 需求页:整理失败的提示卡 ══════════
24
27
  服务端给的是引擎原始报错(`HTTP 402: {…JSON…}`,还常被截断),原样铺开会是一屏乱码。
@@ -26,7 +29,9 @@
26
29
  配色走 warning(淡底 + 左侧竖条),与「待整理 / 未初始化」同一套语义色,不做大红报错块 ——
27
30
  整理失败不损坏数据,它是「这次没跑成」,不是页面出错。 */
28
31
  .reqerr{margin:2px 10px 10px;padding:9px 12px;border-radius:var(--r);text-align:left;
29
- background:color-mix(in srgb,var(--warning) 9%,var(--raised));
32
+ /* 底色走令牌 --warn-surface / --warn-badge(**必须按主题分给**,理由见 index.html 的 :root):
33
+ 深色压 --canvas 20% 才保住琥珀族(H64),浅色仍压 --raised 9%(原样暖白)。 */
34
+ background:var(--warn-surface);
30
35
  border:1px solid color-mix(in srgb,var(--warning) 26%,transparent);
31
36
  border-left:3px solid var(--warning)}
32
37
  .reqerr__head{display:flex;align-items:center;gap:6px}
@@ -34,9 +39,11 @@
34
39
  .reqerr__t{flex:1;min-width:0;font-size:13px;font-weight:600;color:var(--ink);
35
40
  overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
36
41
  /* 类别徽标(额度不足 / 未授权 / 超时…):认不出类别时不出,不硬套标签。
37
- 底色比卡片再浓一档、文字用 --ink —— 黄字压黄底在暗色主题下几乎读不出来(实测)。 */
42
+ 文字色走 --warn-badge-text —— 黄字压黄底在暗色主题下几乎读不出来(实测)。
43
+ 深色(2026-10-25 第二轮)底色提到实心琥珀、文字换成 --canvas 墨字(8.5+);
44
+ 浅色钉住「--ink 压 24% 琥珀」(与改动前逐像素一致)。 */
38
45
  .reqerr__badge{flex:0 0 auto;padding:1px 7px;border-radius:var(--r-full);font-size:11px;line-height:16px;
39
- color:var(--ink);background:color-mix(in srgb,var(--warning) 24%,transparent)}
46
+ color:var(--warn-badge-text);background:var(--warn-badge)}
40
47
  .reqerr__msg{margin-top:5px;font-size:13px;line-height:1.55;color:var(--ink-soft);word-break:break-word}
41
48
  .reqerr__hint{margin-top:3px;font-size:12px;line-height:1.5;color:var(--muted)}
42
49
  .reqerr__more{display:flex;align-items:center;gap:3px;margin-top:5px;padding:0;background:none;border:0;
@@ -96,6 +96,33 @@
96
96
  return alias || w.name || baseName(w.dir || '') || w.id;
97
97
  }
98
98
 
99
+ /* 空间列表按**显示名**去重(2026-10-26,用户截图:「magic-stock 出现了两行」)。
100
+ *
101
+ * 为什么路径去重不够:core 的 Workspaces() 按 ws id(sha1(归一路径))去重 ——
102
+ * 那只认**同一个目录**。但「别名」是客户端这一层的概念:两个**不同目录**可以起
103
+ * 同一个显示名(自定义空间 D:/workspace/chan-chart-py 起了别名「magic-stock」,
104
+ * 而根目录下真有一个 magic-stock 目录)—— 路径各不相干,用户看到的却是两行一模一样的条目。
105
+ *
106
+ * 留谁:留**显示名 == 目录真名**的那条(真名是客观事实,别名是用户随手起的)。
107
+ * 两条都是别名时留先出现的那条(不靠猜)。
108
+ *
109
+ * ⚠️ 与任务页 collectSpaceKeys 是同一套口径 —— 两页列的是同一批空间,
110
+ * 去重规则不一样就会出现「任务页 3 个空间、需求页 4 个空间」。 */
111
+ function dedupeByLabel(list) {
112
+ var byLabel = {}, out = [];
113
+ (list || []).forEach(function (w) {
114
+ var label = reqSpaceLabel(w);
115
+ var isRealName = (label === baseName(w && w.dir));
116
+ var at = byLabel[label];
117
+ if (at === undefined) { byLabel[label] = out.length; out.push(w); return; }
118
+ if (!isRealName) return;
119
+ var prev = out[at];
120
+ if (reqSpaceLabel(prev) === baseName(prev && prev.dir)) return;
121
+ out[at] = w;
122
+ });
123
+ return out;
124
+ }
125
+
99
126
  /* 相机徽标的判定(**纯函数**:DOM 之外的逻辑单独拎出来,好测)。
100
127
  *
101
128
  * 触发条件 = 归纳时从对话里提取到了页面地址(page_url):
@@ -294,8 +321,10 @@
294
321
  空间规模不是状态,套上会让人误以为这个空间在排队。 */
295
322
  var mods = (w.modules || []).filter(function (m) { return (m.features || []).length; });
296
323
  tog.appendChild(elSpan('wsrow__meta', mods.length + ' 模块 · ' + count + ' 条'));
297
- /* 待整理条数做成徽标(与「等待中」同款):折叠起来也看得见这个空间在长新需求。 */
298
- if (w.pending > 0) tog.appendChild(elSpan('wsrow__badge', w.pending + ' 待整理'));
324
+ /* 待整理条数做成徽标(与「等待中」同款形状):折叠起来也看得见这个空间在长新需求。
325
+ 主色挂 --badge-accent(= 警告琥珀):11px 的「待整理」若直接用排队金,深色下和上方
326
+ 模块计数、和空间名几乎同亮度,「有活等着」读不出来(2026-10-25 第二轮)。 */
327
+ if (w.pending > 0) tog.appendChild(elSpan('wsrow__badge wsrow__badge--pending', w.pending + ' 待整理'));
299
328
  /* 未初始化 = 还没按空间目录生成过基线。老 core 不带 initialized 字段(undefined),
300
329
  此时不提示 —— 不能把「不知道」画成「未初始化」。 */
301
330
  if (w.initialized === false) tog.appendChild(elSpan('wsrow__badge wsrow__badge--init', '未初始化'));
@@ -419,6 +448,9 @@
419
448
  需求页于是出现一个叫「/」的假空间(用户 2026-10-22:「没有空间的不需要」)。
420
449
  这里先卡一道:不是真空间目录的直接不画。 */
421
450
  if (!H.isRealSpaceDir(dir)) return false;
451
+ /* 被用户移除过的空间(2026-10-26):需求页整块不画 —— 与任务页「空间」分段
452
+ 同一口径,否则用户删了空间、需求页还留着它(用户:「空间都可以修改和删除」)。 */
453
+ if (H.isHiddenSpace && H.isHiddenSpace(w && w.dir)) return false;
422
454
  return !REQ_ALIVE || !dir || !!REQ_ALIVE[dir];
423
455
  });
424
456
  /* 排序:**需求条数多的排前面**(用户 2026-10-22)。
@@ -431,6 +463,13 @@
431
463
  if (ca !== cb) return cb - ca;
432
464
  return String((a && a.name) || '').localeCompare(String((b && b.name) || ''));
433
465
  });
466
+ /* ⚠️ 按**显示名**去重(2026-10-26,用户截图:「magic-stock 出现了两行」):
467
+ core 的 Workspaces() 按 ws id(sha1(归一路径))去重,但**别名**不在 core 那层 ——
468
+ 两个不同目录可以起同一个显示名(自定义空间 D:/workspace/chan-chart-py 起了别名
469
+ 「magic-stock」,而根目录下真有一个 magic-stock 目录),需求页就会画两个同名空间组。
470
+ 与任务页 collectSpaceKeys 同一口径:留**显示名 == 目录真名**的那条,
471
+ 都是别名时留先出现的那条。 */
472
+ list = dedupeByLabel(list);
434
473
  /* 二级视图:只画这个模块的需求列表。空间可能已被会话删除联动掉
435
474
  (list 里没有它)—— 那就退回一级目录,不要留一页空白。 */
436
475
  if (REQ_DETAIL) {
@@ -505,8 +544,7 @@
505
544
  H.topLevelSessions(sess).forEach(function (s) {
506
545
  if (s && s.workspace && H.isRealSpaceDir(s.workspace)) alive[spaceKey(s.workspace)] = 1;
507
546
  });
508
- REQ_ALIVE = alive;
509
- } else {
547
+ REQ_ALIVE = alive; } else {
510
548
  REQ_ALIVE = null; // 清单没拿到:不筛,宁可多显示
511
549
  }
512
550
  REQ_DATA = a[0] || { workspaces: [] };
@@ -746,6 +784,9 @@
746
784
  return all.filter(function (w) {
747
785
  var dir = spaceKey(w && w.dir);
748
786
  if (!H.isRealSpaceDir(dir)) return false;
787
+ /* 被用户移除过的空间(2026-10-26):图谱顶部 chips 与需求列表同一口径 ——
788
+ 删了的空间不该又从图谱入口冒出来。 */
789
+ if (H.isHiddenSpace && H.isHiddenSpace(w && w.dir)) return false;
749
790
  return !REQ_ALIVE || !dir || !!REQ_ALIVE[dir];
750
791
  });
751
792
  }
@@ -753,7 +794,10 @@
753
794
  function renderGraphPick() {
754
795
  var pick = els.detail.querySelector('#reqGraphPick');
755
796
  if (!pick) return;
756
- var list = graphWsList();
797
+ /* ⚠️ 按**显示名**去重(2026-10-26,用户截图:「magic-stock 出现了两行」):
798
+ 与需求列表同一套口径(dedupeByLabel)—— 列表里只留一条,图谱里也不能冒出两枚
799
+ 一模一样的 chip(否则用户点哪枚都一样,又是一处「重复」)。 */
800
+ var list = dedupeByLabel(graphWsList());
757
801
  pick.replaceChildren.apply(pick, list.map(function (w) {
758
802
  var b = hEl('button', 'reqgraph__chip' + (REQ_GRAPH_WS && REQ_GRAPH_WS.id === w.id ? ' is-on' : ''));
759
803
  b.type = 'button';
@@ -19,3 +19,49 @@
19
19
  /* 详情页的日志区:比插件页那条略高一点 —— 脚本日志是要盯着看的,
20
20
  170px 只够三四行,看不出「在动」。 */
21
21
  .pd__log--script{max-height:260px}
22
+
23
+ /* ══════════ 空态:一个脚本都没有 ══════════
24
+ 2026-10 用户:「发现中各项没有的时候界面设计一下」。从前是一行居中灰字,
25
+ 整页看着像「坏了 / 没加载出来」。现在收成一条路:
26
+ 一枚图标(说清这一页管什么)→ 一句标题(说清现状)→ 一句说明(说清下一步)
27
+ → 主按钮(直达「添加」)→ 三行示例(告诉用户要填什么、怎么填)。
28
+ ⚠️ 三行示例是**静态文案**,不是可点的假入口 —— 点了就该有事,不能摆给你看。
29
+ ⚠️ 颜色全走 var(),不定义令牌(同本文件开头);明/暗两套主题自动跟随。
30
+ 入页时若清单还没回来,先把这块**淡**一点画出来(.is-loading):
31
+ 手指一碰就有东西,不会先白屏、再闪一句「读取中…」、再换成空态,跳两次。 */
32
+ .scriptempty{display:flex;flex-direction:column;align-items:center;padding:36px 20px 16px;text-align:center}
33
+ .scriptempty__ico{position:relative;width:64px;height:64px;border-radius:50%;background:var(--subtle);color:var(--muted);
34
+ display:flex;align-items:center;justify-content:center;margin-bottom:14px;
35
+ box-shadow:inset 0 0 0 1px var(--border)}
36
+ .scriptempty__t{font-size:16px;font-weight:600;line-height:24px;color:var(--ink)}
37
+ .scriptempty__why{margin-top:6px;max-width:280px;font-size:13px;line-height:1.65;color:var(--muted)}
38
+ .scriptempty__act{width:200px;margin-top:18px}
39
+ .scriptempty__act .btn{margin-top:0}
40
+ /* 图标中央一枚 +:与右上角「添加」同一个 i-plus,形成「点这儿 = 点右上角」的暗示。 */
41
+ .scriptempty__plus{position:absolute;width:22px;height:22px;border-radius:50%;
42
+ background:var(--primary-action);color:var(--on-primary-action);
43
+ display:flex;align-items:center;justify-content:center;
44
+ box-shadow:0 0 0 2px var(--canvas)}
45
+ /* 示例:一行一条「什么 → 填什么」,等宽值用 --mono,像在交代格式而不是堆正文。 */
46
+ .scriptempty__eg{margin-top:22px;width:100%;max-width:320px;padding:12px 14px;border-radius:var(--r);
47
+ background:var(--subtle);text-align:left}
48
+ .scriptempty__eg-h{font-size:11px;font-weight:700;letter-spacing:.04em;color:var(--faint);margin-bottom:8px}
49
+ .scriptempty__eg-row{display:flex;align-items:baseline;gap:10px;font-size:13px;line-height:22px}
50
+ .scriptempty__eg-row+.scriptempty__eg-row{margin-top:2px}
51
+ .scriptempty__eg-k{flex:0 0 auto;min-width:44px;color:var(--muted)}
52
+ .scriptempty__eg-v{min-width:0;color:var(--ink-soft);font:12px/22px var(--mono);
53
+ white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
54
+ .scriptempty__foot{margin-top:14px;font-size:12px;line-height:1.6;color:var(--faint)}
55
+ /* 清单回来之前:「读取中 → 空态」不该跳成两帧。先以 55% 透明度摆出空态骨架,
56
+ 回来还是空 → 原地变实;回来了脚本 → 直接换成清单。 */
57
+ .scriptempty.is-loading{opacity:.55}
58
+ @media (prefers-reduced-motion: no-preference){
59
+ .scriptempty:not(.is-loading){animation:scriptempty-in .18s ease-out}
60
+ @keyframes scriptempty-in{from{opacity:.55}to{opacity:1}}
61
+ }
62
+
63
+ /* ══════════ 读取失败 ══════════
64
+ 与上面同一条竖向节奏,只是图标换成警示色、按钮换成「重试」。
65
+ 失败态和空态必须一眼分得开 —— 用户看到空壳时要知道「是真没有」还是「没读到」。 */
66
+ .scriptempty--fail .scriptempty__ico{background:var(--tint-fail);color:var(--fail);box-shadow:none}
67
+ .scriptempty--fail .scriptempty__why{color:var(--error-text);max-width:300px;word-break:break-word}
@@ -96,12 +96,61 @@
96
96
  }
97
97
 
98
98
  /* ── 一级:脚本清单 ── */
99
+ /* ── 空态 / 失败态(竖排:图标 → 标题 → 说明 → 按钮 → 示例)──
100
+ `intro`:只有标题与说明,颜色更淡,用来在清单回来之前先占位(见 renderList 注释)。
101
+ 样式在 scripts.css 的「空态」一节,颜色全走 var()。 */
102
+ function emptyBox(icon, title, why, actionText, onAction, intro) {
103
+ var box = hEl('div', 'scriptempty' + (intro ? ' is-loading' : ''));
104
+ var ico = hEl('span', 'scriptempty__ico');
105
+ ico.appendChild(svgUse(icon, 26));
106
+ if (!intro) {
107
+ var plus = hEl('span', 'scriptempty__plus');
108
+ plus.appendChild(svgUse('#i-plus', 14));
109
+ ico.appendChild(plus);
110
+ }
111
+ box.appendChild(ico);
112
+ box.appendChild(hEl('div', 'scriptempty__t', title));
113
+ box.appendChild(hEl('div', 'scriptempty__why', why));
114
+ if (actionText) {
115
+ var act = hEl('div', 'scriptempty__act');
116
+ var btn = hEl('button', 'btn btn--filled', actionText);
117
+ btn.type = 'button';
118
+ btn.onclick = onAction;
119
+ act.appendChild(btn);
120
+ box.appendChild(act);
121
+ }
122
+ if (intro) return box;
123
+ /* 示例:告诉用户「要填什么、长什么样」。纯文案,不可点。 */
124
+ var eg = hEl('div', 'scriptempty__eg');
125
+ eg.appendChild(hEl('div', 'scriptempty__eg-h', '示例'));
126
+ [['路径', 'D:\\tools\\build.cmd'],
127
+ ['参数', '--port 8080'],
128
+ ['解释器', '可选,如 python.exe']].forEach(function (r) {
129
+ var row = hEl('div', 'scriptempty__eg-row');
130
+ row.appendChild(hEl('span', 'scriptempty__eg-k', r[0]));
131
+ row.appendChild(hEl('span', 'scriptempty__eg-v', r[1]));
132
+ eg.appendChild(row);
133
+ });
134
+ box.appendChild(eg);
135
+ box.appendChild(hEl('div', 'scriptempty__foot', '脚本跑在桌面端,手机上可以随时启停、看日志。'));
136
+ return box;
137
+ }
138
+
139
+ /* 空态的标题与说明只写一份:清单回来前的淡色占位、与正式空态,
140
+ 文案必须逐字相同 —— 两处各写一遍,改一处就会在「淡 → 实」时看到文字跳一下。 */
141
+ var EMPTY_TITLE = '还没有脚本';
142
+ var EMPTY_WHY = '登记一个桌面端上的脚本文件,就能在手机上启动、停止它,并实时看它的日志。';
143
+
99
144
  function renderList() {
100
145
  if (!els.list) return;
101
146
  var frag = document.createDocumentFragment();
102
147
  if (!ITEMS.length) {
103
- frag.appendChild(hEl('div', 'history__hint',
104
- '还没有脚本。点右上角「添加」登记一个脚本文件(路径 + 参数),就能在这里启停它、看它的日志。'));
148
+ /* 2026-10 用户:「发现中各项没有的时候界面设计一下」。
149
+ 从前是一行居中灰字,整页看着像没加载出来;现在给图标 + 标题 + 下一步 + 示例。
150
+ 清单**真的读到了**才显示为正式空态 —— 读失败是另一块(见 loadList 的 catch),
151
+ 两者长得像的话,用户分不清「没有脚本」还是「没连上」。 */
152
+ frag.appendChild(emptyBox('#i-cpu', EMPTY_TITLE, EMPTY_WHY,
153
+ '添加脚本', function () { openForm(null); }));
105
154
  els.list.replaceChildren(frag);
106
155
  return;
107
156
  }
@@ -128,7 +177,13 @@
128
177
 
129
178
  function loadList(quiet) {
130
179
  if (!els.list) return;
131
- if (!quiet) els.list.innerHTML = '<div class="history__hint">读取中…</div>';
180
+ if (!quiet) {
181
+ /* 不先闪一句「读取中…」:直接按空态骨架淡着画出来(.is-loading),
182
+ 读到空 → 原地变实;读到脚本 → 换成清单;读失败 → 换成失败块。
183
+ 本地服务一般几十毫秒就回来,三帧闪烁反而更像卡住了。 */
184
+ ITEMS = [];
185
+ els.list.replaceChildren(emptyBox('#i-cpu', EMPTY_TITLE, EMPTY_WHY, null, null, true));
186
+ }
132
187
  return fetch(api('/desk/scripts'), { headers: headers() })
133
188
  .then(function (r) {
134
189
  if (r.status === 404) throw new Error('当前桌面端没有脚本能力(需要较新版本)');
@@ -136,14 +191,19 @@
136
191
  return r.json();
137
192
  })
138
193
  .then(function (j) {
194
+ /* 空 → 有 / 有 → 空 都走同一条 renderList:
195
+ 从前 quiet 刷新在「空了」的时候会停在上一张旧卡片上,删掉最后一个脚本后
196
+ 返回清单还是空的(2026-10 顺带修)。 */
139
197
  ITEMS = (j && j.scripts) || [];
140
198
  renderList();
141
199
  })
142
200
  .catch(function (e) {
201
+ /* quiet 且有内容:一次网络抖动不该把用户正看的清单换掉(同宿主会话列表的口径)。 */
143
202
  if (quiet && ITEMS.length) return;
144
- els.list.innerHTML = '';
145
- els.list.appendChild(hEl('div', 'history__hint',
146
- '读取脚本清单失败:' + ((e && e.message) || e)));
203
+ var msg = (e && e.message) || String(e);
204
+ ITEMS = [];
205
+ els.list.replaceChildren(emptyBox('#i-bang', '脚本清单没读到',
206
+ msg, '重试', function () { loadList(false); }));
147
207
  });
148
208
  }
149
209
 
@@ -58,7 +58,7 @@ func runBrowseDriver(args []string) int {
58
58
  if len(argv) < 1 {
59
59
  return browseUsage("open 需要 <url>")
60
60
  }
61
- return browseCall("open " + argv[0], func() (any, error) {
61
+ return browseCall("open "+argv[0], func() (any, error) {
62
62
  return nil, client.ABOpen(context.Background(), argv[0])
63
63
  })
64
64
  case "state":
@@ -136,9 +136,18 @@ func runClientStart(args []string) int {
136
136
  func runClientStop(args []string) int {
137
137
  fs := newFlagSet("stop")
138
138
  statePath := fs.String("state", "", "状态文件路径(默认 ~/.magic-client/client.json)")
139
+ profilePath := fs.String("profile", "", "profile JSON 路径(决定单实例锁在哪个数据目录;空 = 内置默认)")
139
140
  _ = fs.Parse(args)
140
141
 
141
- stopped, st, err := client.StopDaemon(client.StopOptions{StatePath: *statePath})
142
+ /* AlsoLockHolder:前台 serve 起的实例、以及旧版(*.stale)起的实例都不写状态文件,
143
+ 只读状态文件的 stop 会答「本来就没在跑」,而端口与隧道实际还占着 —— 用户此时
144
+ 唯一的自救途径就是这条。DataDir / AppID 决定去查哪两处登记。 */
145
+ stopped, st, err := client.StopDaemon(client.StopOptions{
146
+ StatePath: *statePath,
147
+ AlsoLockHolder: true,
148
+ DataDir: dataDirOf(*profilePath),
149
+ AppID: appIDOf(*profilePath),
150
+ })
142
151
  if err != nil {
143
152
  fmt.Fprintln(os.Stderr, "magic-client:", err)
144
153
  return 1
@@ -161,10 +170,15 @@ func runClientStop(args []string) int {
161
170
  func runClientRestart(args []string) int {
162
171
  fs := newFlagSet("restart")
163
172
  statePath := fs.String("state", "", "状态文件路径(默认 ~/.magic-client/client.json)")
173
+ profilePath := fs.String("profile", "", "profile JSON 路径(决定单实例锁在哪个数据目录;空 = 内置默认)")
164
174
  delay := fs.Duration("delay", 0, "停旧实例前等多久(服务端自重启用,给响应留时间)")
165
175
  _ = fs.Parse(args)
166
176
 
167
- st, err := client.RestartDaemon(client.RestartOptions{StatePath: *statePath, Delay: *delay})
177
+ st, err := client.RestartDaemon(client.RestartOptions{
178
+ StatePath: *statePath,
179
+ Delay: *delay,
180
+ DataDir: dataDirOf(*profilePath),
181
+ })
168
182
  if err != nil {
169
183
  fmt.Fprintln(os.Stderr, "magic-client:", err)
170
184
  return 1
@@ -173,6 +187,27 @@ func runClientRestart(args []string) int {
173
187
  return 0
174
188
  }
175
189
 
190
+ // dataDirOf 按 profile 算数据目录(单实例锁的位置由它决定)。
191
+ //
192
+ // 读不到 profile 就返回空串 —— 调用方会回落到「从状态文件目录推」的老行为,
193
+ // 不能因为 profile 路径写错就让 `stop` 变成「什么都不做」。
194
+ func dataDirOf(profilePath string) string {
195
+ prof, err := client.LoadProfile(profilePath)
196
+ if err != nil {
197
+ return ""
198
+ }
199
+ return prof.DataDir()
200
+ }
201
+
202
+ // appIDOf 按 profile 算 AppID(AppID 级占用登记的位置由它决定)。
203
+ func appIDOf(profilePath string) string {
204
+ prof, err := client.LoadProfile(profilePath)
205
+ if err != nil {
206
+ return ""
207
+ }
208
+ return prof.AppID()
209
+ }
210
+
176
211
  func runClientStatus(args []string) int {
177
212
  fs := newFlagSet("status")
178
213
  statePath := fs.String("state", "", "状态文件路径(默认 ~/.magic-client/client.json)")
@@ -31,6 +31,7 @@ import (
31
31
  "time"
32
32
 
33
33
  "github.com/darren/magic-agent/internal/client"
34
+ "github.com/darren/magic-agent/internal/config"
34
35
  )
35
36
 
36
37
  func main() {
@@ -68,9 +69,59 @@ func main() {
68
69
  browseUA = fs.String("browse-ua", "", "内置浏览器中转的 User-Agent:mobile(默认)/ desktop / 任意自定义串")
69
70
  readyFile = fs.String("ready-file", "", "监听成功后把 {pid,port,url,lanAddrs} 原子写到这个路径(`magic-client start` 用它拿端口,别手工传)")
70
71
  anyOrigin = fs.Bool("allow-any-origin", false, "对任意 Origin 回 CORS 头(默认只放行本机页面)。局域网模式自动开启;令牌仍是唯一门锁")
72
+ /* 前台 serve 自登记(2026-10-26):
73
+ `POST /desk/restart` 需要知道「这台实例是怎么起的」,否则手机点重启只能 409。
74
+ `magic-client start` 起的实例一直有 client.json 可读;前台 serve(magic-test 的
75
+ 桥接就是这种)过去什么都不写,于是按钮永远点不动。 */
76
+ statePath = fs.String("state", "", "把本实例的启动参数登记到这个状态文件(空 = <数据目录>/serve.json)。重启接口靠它认领自己")
77
+ hostPID = fs.Int("host-pid", 0, "托管本服务的父进程 pid(桌面壳)。它退出后本服务跟着退出,避免重启后留下孤儿占端口")
71
78
  )
72
79
  _ = fs.Parse(os.Args[2:])
73
80
 
81
+ prof, err := client.LoadProfile(*profilePath)
82
+ if err != nil {
83
+ fmt.Fprintln(os.Stderr, "magic-client:", err)
84
+ os.Exit(1)
85
+ }
86
+
87
+ /* 单实例互斥(2026-10-26 用户:「同时只启动1个」)。
88
+ *
89
+ * 位置:紧跟 LoadProfile(要 profile 才能算出 AppID 与数据目录),**早于
90
+ * client.New / net.Listen**,原因是:
91
+ * · client.New 会跑 `magic-agent --contract`(20s 超时)并建起作业队列,
92
+ * 那一步已经会去碰共用的 <DataDir>/jobs 与状态文件。晚于它就等于
93
+ * 「先动了手再问有没有别人」;
94
+ * · 晚于 net.Listen 的话,第二份实例的报错会变成「端口被占用」—— 那是在描述
95
+ * 症状不是原因(端口撞了只是这份目录上已有一份在跑的结果);
96
+ * · --addr 各不相同的话,端口探测根本发现不了对方,但两份照样各跑一套作业
97
+ * 队列去抢同一批工作区 —— 手机连着 A、任务在 B 里跑完,界面空回复。
98
+ *
99
+ * 两道闸门(缺一不可,见 singleinstance.go 的长注释):
100
+ * ① AppID 登记:同一应用**换了数据目录**也不算两份不同应用;
101
+ * ② DataDir 锁:同一个目录上只能有一份。
102
+ * 只做 ② 会漏掉「同一次部署被算出两个数据目录」这种漂移(本机真有:
103
+ * magic-test 既有 ~/.magic-test 又有 ~/.magic-client/magic-test)。
104
+ *
105
+ * 只跳过 --print-url(脚本探活用的,它连服务都不起,不该占锁)。 */
106
+ var instLock *client.SingleInstanceLock
107
+ if !*printURL {
108
+ lk, holder, which, lerr := client.AcquireInstanceLocks(prof.AppID(), prof.DataDir(), 0)
109
+ if lerr != nil {
110
+ fmt.Fprintln(os.Stderr, "magic-client:", lerr)
111
+ os.Exit(1)
112
+ }
113
+ if lk == nil {
114
+ if which == "appid" {
115
+ fmt.Fprintln(os.Stderr, "magic-client:", client.AppIDConflictMessage(prof.AppID(), holder))
116
+ } else {
117
+ fmt.Fprintln(os.Stderr, "magic-client:", client.SingleInstanceConflictMessage(holder))
118
+ }
119
+ os.Exit(3)
120
+ }
121
+ instLock = lk
122
+ defer instLock.Release()
123
+ }
124
+
74
125
  // 局域网是默认行为(手机开箱能连);--local 或显式回环地址回到「只给本机」。
75
126
  lanEnabled := *lan && !*localOnly
76
127
  listenAddr := strings.TrimSpace(*addr)
@@ -85,11 +136,6 @@ func main() {
85
136
  lanEnabled = false
86
137
  }
87
138
 
88
- prof, err := client.LoadProfile(*profilePath)
89
- if err != nil {
90
- fmt.Fprintln(os.Stderr, "magic-client:", err)
91
- os.Exit(1)
92
- }
93
139
  tok := strings.TrimSpace(*token)
94
140
  if tok == "" {
95
141
  tok = randToken() // 16 字节 = 32 位十六进制,远强于局域网那条线
@@ -102,18 +148,35 @@ func main() {
102
148
  os.Exit(2)
103
149
  }
104
150
  }
151
+ /* 飞书推送凭据(2026-10-26):config.json 的 feishu 节 / MAGIC_AGENT_FEISHU_APP_ID|SECRET。
152
+ 读不到配置文件不算错 —— 没配就是没开推送,界面里会提示去填(见 internal/client/feishu.go)。 */
153
+ feishuCfg, _ := config.Load()
154
+ /* 本实例的自登记路径,**在 New 之前**就算出来:Service 要靠它「认领自己」
155
+ (手机点重启时 selfDaemonState 按这条路找回状态,见 coreupgrade.go)。
156
+ 空串不可能 —— prof 一定有数据目录;真没有就退回 <数据目录>/serve.json 的约定。 */
157
+ selfStatePath := strings.TrimSpace(*statePath)
158
+ if selfStatePath == "" {
159
+ selfStatePath = client.SelfStatePath(prof.DataDir())
160
+ }
105
161
  svc, err := client.New(client.Options{
106
- MagicAgent: *magicAgent,
107
- Profile: prof,
108
- UIRoot: *uiDir,
109
- MobileRoot: *mobileDir,
110
- Token: tok,
111
- Lan: lanEnabled,
112
- BrowseUA: *browseUA,
162
+ MagicAgent: *magicAgent,
163
+ FeishuAppID: feishuCfg.Feishu.AppID,
164
+ FeishuAppSecret: feishuCfg.Feishu.AppSecret,
165
+ Profile: prof,
166
+ UIRoot: *uiDir,
167
+ MobileRoot: *mobileDir,
168
+ Token: tok,
169
+ Lan: lanEnabled,
170
+ BrowseUA: *browseUA,
113
171
  /* 局域网 = 「允许来自任意 IP 的页面访问」:再按 loopback 卡 CORS Origin
114
172
  等于把手机 / 别的设备的页面全判成跨域(用户侧就是「只能本地访问」)。
115
173
  显式 --allow-any-origin 也可以单独打开(绑回环但要给容器页面用)。 */
116
174
  AllowAnyOrigin: *anyOrigin || lanEnabled,
175
+ /* 前台 serve 自登记的落点:重启接口靠它认领本进程(见 daemon.go 的 SelfStateName)。 */
176
+ SelfStatePath: selfStatePath,
177
+ /* 托管父进程:重启之后的实例是 detach 的,壳退出杀不到它。
178
+ 记下来交给服务自己盯(见 service.go 的 WatchHost)。 */
179
+ HostPID: *hostPID,
117
180
  })
118
181
  if err != nil {
119
182
  // 契约不匹配是**要立刻停**的错误:带着不匹配的能力跑起来只会让人以为是界面 bug。
@@ -207,10 +270,94 @@ func main() {
207
270
  }
208
271
  }
209
272
 
273
+ /* 自登记(2026-10-26 用户:「通过 magic-test 启动的无法在移动端点击重启服务重启」)。
274
+ *
275
+ * 手机「重启服务」= POST /desk/restart。它要做的第一件事是「确认我说的是本进程、
276
+ * 并且知道当初是怎么把我起来的」—— 重启助手要照这份参数停旧起新。`magic-client start`
277
+ * 起的实例一直有 client.json;前台 serve(magic-test 桥接、手工调试都是这种)过去
278
+ * 什么都不写,于是 409「当前实例不是后台启动的」,按钮永远点不动。
279
+ *
280
+ * 记到 <数据目录>/serve.json(**不是** client.json:那份是 start 的唯一实例记录,
281
+ * 详见 daemon.go 的 SelfStateName)。带 HostPID 是为了重启出来的 detach 实例还能
282
+ * 跟着壳一起退(见 svc.WatchHost)。
283
+ *
284
+ * 顺序:**监听成功之后**。写早了会把一个还没 Serve 的端口登记出去,手机拿着它去
285
+ * 探活/重启都会撞空。
286
+ *
287
+ * ⚠️ `magic-client start` 拉起的 serve **不登记**(ready 文件就是它那条路的标志):
288
+ * 那条路已经有 client.json(信息更全:LogPath / 幂等判据都在里面),再写一份
289
+ * serve.json 会让 selfDaemonState 先认领到这份**信息更少**的登记 —— 重启后
290
+ * 日志位置丢了、`stop` 的幂等判据也没了。两条启动路径各登记各的,不交叉。 */
291
+ /* 无论走哪条启动路径,只要知道「我那份登记在哪」,退出就清理(下面 defer)。
292
+ *
293
+ * 为什么 start 路径也要清:重启出来的实例不是被 `stop` 停掉的,而是 WatchHost
294
+ * 发现宿主没了、自己 srv.Close() 收摊 —— 那条路上没有任何人删登记,留着就是一个
295
+ * 指向死 pid 的陈旧文件(实测:serve.json 残留在 35444 这个已退出的 pid 上)。
296
+ * RemoveSelfState 带 pid 校验,新实例已经接管时不会误删它的。
297
+ *
298
+ * 但**写**登记只有前台 serve 才做:start 路径的 client.json 由 StartDaemon 写
299
+ *(它信息更全,含日志位置),serve 再写一遍等于用一份更少的覆盖它。 */
300
+ if !*printURL && selfStatePath != "" {
301
+ defer client.RemoveSelfState(selfStatePath)
302
+ }
303
+ if !*printURL && strings.TrimSpace(*readyFile) == "" {
304
+ lanURLs := make([]string, 0, len(lanAddrs))
305
+ for _, a := range lanAddrs {
306
+ lanURLs = append(lanURLs, fmt.Sprintf("http://%s/?token=%s", a, tok))
307
+ }
308
+ /* Exe 必须是**本进程自己的可执行文件**:重启助手照它拉新实例
309
+ (RestartDaemon → StartDaemon 的 Exe)。记空的话新实例会退回
310
+ 「当前进程自己」,而助手是另一个进程 —— 那就成了拉起助手自己。 */
311
+ exePath, _ := os.Executable()
312
+ p, werr := client.WriteSelfStateAt(selfStatePath, prof.DataDir(), &client.DaemonState{
313
+ PID: os.Getpid(),
314
+ Port: port,
315
+ URL: url,
316
+ LanAddrs: lanURLs,
317
+ Lan: lanEnabled,
318
+ Profile: *profilePath,
319
+ UIRoot: *uiDir,
320
+ MobileDir: *mobileDir,
321
+ ProxyAddr: proxyActual,
322
+ Token: tok,
323
+ StartedAt: time.Now(),
324
+ LogPath: "",
325
+ Exe: exePath,
326
+ Addr: strings.TrimSpace(*addr),
327
+ BrowseUA: *browseUA,
328
+ MagicAgent: *magicAgent,
329
+ AnyOrigin: *anyOrigin,
330
+ HostPID: *hostPID,
331
+ })
332
+ if werr != nil {
333
+ /* 登记失败不致命:服务照常能用,只是手机点重启会 409。
334
+ 绝不因此退出 —— 那等于「为了一个按钮把服务弄挂」。 */
335
+ fmt.Fprintf(os.Stderr, "magic-client: 警告 - 没能登记本实例状态(%v):手机端「重启服务」会不可用\n", werr)
336
+ } else {
337
+ fmt.Fprintf(os.Stderr, "magic-client: 实例状态 %s(手机可一键重启本实例)\n", p)
338
+ }
339
+ }
340
+
341
+ /* 飞书启动通知(2026-10-26 用户:「重新启动后如果绑定了飞书 触发一下发送访问地址」):
342
+ 重启是手机最容易失联的时刻(换网 / 换端口 / 隧道换域名),飞书是唯一不依赖那条
343
+ 连接的通道。没绑定 / 关了推送时 NotifyStartup 直接返回,不发任何请求;它在后台
344
+ 等隧道、可能试几十秒,不能挡住 Serve。局域网入口传进去,没有公网时拿它兜底。 */
345
+ startupEntry := ""
346
+ if len(lanAddrs) > 0 {
347
+ startupEntry = fmt.Sprintf("http://%s/?token=%s", lanAddrs[0], tok)
348
+ }
349
+ go svc.NotifyStartup(startupEntry)
350
+
210
351
  srv := &http.Server{
211
352
  Handler: svc.Handler(),
212
353
  ReadHeaderTimeout: 10 * time.Second,
213
354
  }
355
+ /* 托管父进程没了就收摊(见 service.go 的 WatchHost)。
356
+ 只在重启链路里才有意义:重启出来的实例是 detach 的,壳的 r.stop() 杀不到它,
357
+ 没有这一条就会留下孤儿占着端口与单实例锁,下次开壳「插件启动即退出」。 */
358
+ svc.WatchHost(func() {
359
+ _ = srv.Close()
360
+ })
214
361
  go func() {
215
362
  stop := make(chan os.Signal, 1)
216
363
  signal.Notify(stop, os.Interrupt, syscall.SIGTERM)