magic-agent 2.3.0 → 2.3.2

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 (52) hide show
  1. package/CLIENT-CONTRACT.md +51 -12
  2. package/README.md +10 -4
  3. package/client-ui/pc/app.css +14 -0
  4. package/client-ui/pc/js/93-rail.js +3 -1
  5. package/client-ui/shared/conv/03-chat-message.js +20 -3
  6. package/client-ui/shared/conv/27-conv-ledger.js +1 -1
  7. package/client-ui/shared/conv/conv.css +17 -0
  8. package/client-ui/shared/conversation.js +170 -39
  9. package/client-ui/shared/transport-http.js +183 -38
  10. package/client-ui-mobile/index.html +370 -68
  11. package/client-ui-mobile/reqs.js +42 -29
  12. package/internal/agent/capability.go +8 -1
  13. package/internal/agent/claude.go +5 -3
  14. package/internal/agent/codebuddy.go +6 -2
  15. package/internal/agent/codex.go +7 -2
  16. package/internal/agent/dsh_sdk.go +8 -0
  17. package/internal/agent/dsh_sdk_test.go +17 -0
  18. package/internal/agent/engine.go +20 -0
  19. package/internal/agent/engine_test.go +52 -0
  20. package/internal/agent/llmengine.go +14 -0
  21. package/internal/agent/llmengine_test.go +24 -0
  22. package/internal/agent/llmhttp.go +6 -0
  23. package/internal/agent/llmhttp_test.go +27 -0
  24. package/internal/agent/llmmodels.go +48 -0
  25. package/internal/agent/permission.go +5 -0
  26. package/internal/cli/ask.go +32 -17
  27. package/internal/cli/requirements.go +2 -0
  28. package/internal/cli/sessionlog.go +5 -0
  29. package/internal/cli/sessionlog_user_test.go +49 -0
  30. package/internal/client/requirements.go +27 -7
  31. package/internal/client/requirements_test.go +51 -0
  32. package/internal/client/service.go +24 -2
  33. package/internal/client/service_test.go +35 -0
  34. package/internal/requirements/consolidate.go +86 -24
  35. package/internal/requirements/requirements.go +9 -0
  36. package/internal/requirements/requirements_test.go +129 -0
  37. package/internal/requirements/store.go +11 -3
  38. package/internal/suggest/suggest.go +118 -7
  39. package/internal/suggest/suggest_test.go +50 -0
  40. package/npm/apk/magic-agent-mobile-release.apk +0 -0
  41. package/npm/apk/version.json +3 -3
  42. package/npm/dist/darwin-arm64/magic-agent +0 -0
  43. package/npm/dist/darwin-arm64/magic-client +0 -0
  44. package/npm/dist/darwin-x64/magic-agent +0 -0
  45. package/npm/dist/darwin-x64/magic-client +0 -0
  46. package/npm/dist/linux-arm64/magic-agent +0 -0
  47. package/npm/dist/linux-arm64/magic-client +0 -0
  48. package/npm/dist/linux-x64/magic-agent +0 -0
  49. package/npm/dist/linux-x64/magic-client +0 -0
  50. package/npm/dist/win32-x64/magic-agent.exe +0 -0
  51. package/npm/dist/win32-x64/magic-client.exe +0 -0
  52. package/package.json +3 -2
@@ -364,49 +364,194 @@
364
364
  event: snapshot data: {session, after, lastSeq} 游标超前 → 重拉历史
365
365
  event: end data: {session, after, state, reason} reason ∈ terminal / max_duration
366
366
  h.onOpen(open) / h.onEvent(ev) / h.onSnapshot(snap) / h.onEnd(end) / h.onError(err)。
367
- 返回 {cancel}:换会话 / 组件销毁时调它断流。老服务端没有这个路由 → onError(404)。 */
367
+ 返回 {cancel}:换会话 / 组件销毁时调它断流。老服务端没有这个路由 → onError(404)。
368
+
369
+ ── 断线自动重连(2026-10-24 用户:「移动端会话同步断连时要有重试机制」)──
370
+ 移动网络 / WebView 切后台 / 反向代理空闲超时都会把这条长连接掐掉,
371
+ 而服务端明确把 `reason=max_duration` 定义为「会话还在跑,客户端重连接着跟」。
372
+ 所以断流**不再是终态**:这里保留游标、按指数退避重新 `GET .../follow?after=`,
373
+ 从断点续推,只有真正拿到 `end` 或重试用尽才交给 onEnd。
374
+ · 游标 = 最后一条事件的 seq(文件级,允许跳号;不能取全文件最大,见契约 3.4);
375
+ · 退避 0.8s 起、翻倍、15s 封顶;连续 12 次或 6 分钟仍连不上才算失败;
376
+ · 4xx(404 老网关 / 401 令牌失效)**不重试** —— 重试也不会有,立即如实收尾;
377
+ · `max_duration` 是服务端 30 分钟轮换,不是断开:健康连接(>60s)立刻续接;
378
+ · 重连成功但一次事件都没收到就再断(代理秒断)→ 不重置退避,避免空转。
379
+ h.onRetry({reason, attempt, delay, after}) / h.onResume({attempts, after}) 可选:
380
+ 给界面显示「正在重连 / 已恢复」用,老宿主不实现也不影响续流。 */
368
381
  followSession: function (id, after, h) {
369
- var ctl = typeof AbortController !== 'undefined' ? new AbortController() : null;
370
- var ended = false;
382
+ /* 几档退避集中在这里,排查「为什么两分钟才放弃」时只看这一处。 */
383
+ var FOLLOW_BASE = 800; // 第一次重连等待(移动网络瞬断通常 <1s 就回来)
384
+ var FOLLOW_CAP = 15000; // 单次退避上限
385
+ var FOLLOW_MAX_TRIES = 12; // 连续失败上限(合计约 2 分钟)
386
+ var FOLLOW_MAX_WINDOW = 6 * 60 * 1000; // 或连续断了 6 分钟(后台计时器被节流等)
387
+
388
+ var cursor = Math.max(0, Number(after) || 0);
389
+ var stopped = false, finished = false, timer = 0, abort = null;
390
+ var tries = 0, firstDropAt = 0;
391
+ var wakeOnline = null, wakeVisible = null;
392
+
393
+ var clearTimer = function () { if (timer) { clearTimeout(timer); timer = 0; } };
394
+ /* 网络刚回来 / 用户切回前台时,别干等最长 15s 的退避 —— 立刻重连一次。
395
+ 只在「有重连计时器在等」时生效;正常连接期间这两个监听什么都不做。 */
396
+ var wake = function () {
397
+ if (stopped || finished || !timer) return;
398
+ clearTimer();
399
+ connect();
400
+ };
401
+ var bindWake = function () {
402
+ if (wakeOnline || typeof window === 'undefined' || !window.addEventListener) return;
403
+ wakeOnline = wake;
404
+ wakeVisible = function () {
405
+ if (typeof document === 'undefined' || !document.visibilityState
406
+ || document.visibilityState === 'visible') wake();
407
+ };
408
+ try { window.addEventListener('online', wakeOnline); } catch (e) {}
409
+ if (typeof document !== 'undefined' && document.addEventListener) {
410
+ try { document.addEventListener('visibilitychange', wakeVisible); } catch (e) {}
411
+ }
412
+ };
413
+ var unbindWake = function () {
414
+ if (wakeOnline && typeof window !== 'undefined' && window.removeEventListener) {
415
+ try { window.removeEventListener('online', wakeOnline); } catch (e) {}
416
+ }
417
+ if (wakeVisible && typeof document !== 'undefined' && document.removeEventListener) {
418
+ try { document.removeEventListener('visibilitychange', wakeVisible); } catch (e) {}
419
+ }
420
+ wakeOnline = null; wakeVisible = null;
421
+ };
422
+ var detach = function () { var a = abort; abort = null; if (a) { try { a(); } catch (e) {} } };
371
423
  var finish = function (info) {
372
- if (ended) return;
373
- ended = true;
374
- if (h && h.onEnd) h.onEnd(info || {});
424
+ if (finished) return;
425
+ finished = true;
426
+ clearTimer();
427
+ unbindWake();
428
+ detach();
429
+ if (!stopped && h && h.onEnd) h.onEnd(info || {});
375
430
  };
376
- var q = '?lean=1' + (after == null ? '' : '&after=' + encodeURIComponent(after));
377
- api('/desk/session/' + encodeURIComponent(id) + '/follow' + q, { signal: ctl ? ctl.signal : undefined })
378
- .then(function (resp) {
379
- var reader = resp.body.getReader(), dec = new TextDecoder(), buf = '';
380
- return (function pump() {
381
- return reader.read().then(function (chunk) {
382
- if (chunk.done) return finish({ reason: 'closed' });
383
- buf += dec.decode(chunk.value, { stream: true });
384
- var blocks = buf.split('\n\n');
385
- buf = blocks.pop();
386
- blocks.forEach(function (b) {
387
- var name = '', data = '';
388
- b.split('\n').forEach(function (ln) {
389
- if (ln.indexOf('event: ') === 0) name = ln.slice(7).trim();
390
- else if (ln.indexOf('data: ') === 0) data += ln.slice(6);
431
+ var stop = function () {
432
+ if (stopped) return;
433
+ stopped = true;
434
+ clearTimer();
435
+ unbindWake();
436
+ detach();
437
+ };
438
+ var retryDelay = function (n) {
439
+ return Math.min(FOLLOW_CAP, FOLLOW_BASE * Math.pow(2, Math.max(0, n)));
440
+ };
441
+ var schedule = function (reason, lived) {
442
+ if (stopped || finished || timer) return;
443
+ /* 服务端 30 分钟主动轮换:健康连接立刻续接,不计失败次数。
444
+ 若不到 60s 就轮换(异常代理 / 测试桩),按普通断流退避,别原地打转。 */
445
+ var rotate = (reason === 'max_duration' && lived >= 60000);
446
+ if (rotate) {
447
+ tries = 0; firstDropAt = 0;
448
+ timer = setTimeout(function () { timer = 0; connect(); }, 250);
449
+ return;
450
+ }
451
+ if (!firstDropAt) firstDropAt = Date.now();
452
+ if (tries >= FOLLOW_MAX_TRIES || Date.now() - firstDropAt > FOLLOW_MAX_WINDOW) {
453
+ var err = new Error('会话同步连续重连失败:' + (reason || 'error'));
454
+ err.reason = reason || 'error';
455
+ if (h && h.onError) { try { h.onError(err); } catch (e) {} }
456
+ finish({ reason: reason === 'max_duration' ? 'max_duration' : 'error' });
457
+ return;
458
+ }
459
+ var delay = retryDelay(tries++);
460
+ if (h && h.onRetry) {
461
+ try { h.onRetry({ reason: reason || 'error', attempt: tries, delay: delay, after: cursor }); } catch (e) {}
462
+ }
463
+ timer = setTimeout(function () { timer = 0; connect(); }, delay);
464
+ };
465
+
466
+ /* 打开一轮连接。每轮都有自己的 AbortController 与 settled 标记:
467
+ 旧连接迟到的 error / end 不许影响新一轮(否则会多排一次重连或提前收尾)。 */
468
+ var connect = function () {
469
+ if (stopped || finished) return;
470
+ var my = { settled: false, openedAt: 0, gotEvent: false };
471
+ var ctl = typeof AbortController !== 'undefined' ? new AbortController() : null;
472
+ abort = function () { if (ctl) ctl.abort(); };
473
+ var drop = function (reason, info) {
474
+ if (my.settled) return;
475
+ my.settled = true;
476
+ if (stopped || finished) return;
477
+ detach();
478
+ var lived = my.openedAt ? Date.now() - my.openedAt : 0;
479
+ if (reason === 'terminal') {
480
+ finish(info || { reason: 'terminal' });
481
+ return;
482
+ }
483
+ /* 健康过(收到过事件或稳定 5s 以上)才把退避清零:
484
+ 「连上→秒断→再连」不该每次从 0.8s 重来。 */
485
+ if (my.gotEvent || lived >= 5000) { tries = 0; firstDropAt = 0; }
486
+ schedule(reason, lived);
487
+ };
488
+ var q = '?lean=1&after=' + encodeURIComponent(cursor);
489
+ api('/desk/session/' + encodeURIComponent(id) + '/follow' + q, { signal: ctl ? ctl.signal : undefined })
490
+ .then(function (resp) {
491
+ if (!resp || !resp.body) throw new Error('服务端没有返回流');
492
+ var reader = resp.body.getReader(), dec = new TextDecoder(), buf = '';
493
+ return (function pump() {
494
+ return reader.read().then(function (chunk) {
495
+ if (my.settled) return;
496
+ if (chunk.done) { drop('closed'); return; }
497
+ buf += dec.decode(chunk.value, { stream: true });
498
+ var blocks = buf.split('\n\n');
499
+ buf = blocks.pop();
500
+ blocks.forEach(function (b) {
501
+ if (my.settled) return;
502
+ var name = '', data = '';
503
+ b.split('\n').forEach(function (ln) {
504
+ if (ln.indexOf('event: ') === 0) name = ln.slice(7).trim();
505
+ else if (ln.indexOf('data: ') === 0) data += ln.slice(6);
506
+ });
507
+ if (!data) return; // `: ping` 心跳注释帧
508
+ var ev; try { ev = JSON.parse(data); } catch (e) { return; }
509
+ if (name === 'open') {
510
+ if (!my.openedAt) {
511
+ my.openedAt = Date.now();
512
+ /* 只有「断过又回来」才报恢复:首连不打扰界面。 */
513
+ if (tries > 0 && h && h.onResume) {
514
+ try { h.onResume({ attempts: tries, after: cursor }); } catch (e) {}
515
+ }
516
+ }
517
+ if (h && h.onOpen) h.onOpen(ev);
518
+ return;
519
+ }
520
+ if (name === 'snapshot') {
521
+ var last = Number(ev && ev.lastSeq);
522
+ if (isFinite(last) && last >= 0) cursor = last;
523
+ if (h && h.onSnapshot) h.onSnapshot(ev);
524
+ return;
525
+ }
526
+ if (name === 'end') {
527
+ drop(String((ev && ev.reason) || 'closed'), ev);
528
+ return;
529
+ }
530
+ my.gotEvent = true;
531
+ var seq = Number(ev && ev.seq);
532
+ if (isFinite(seq) && seq > cursor) cursor = seq;
533
+ if (h && h.onEvent) h.onEvent(ev);
391
534
  });
392
- if (!data) return; // `: ping` 心跳注释帧
393
- var ev; try { ev = JSON.parse(data); } catch (e) { return; }
394
- if (name === 'open') { if (h && h.onOpen) h.onOpen(ev); return; }
395
- if (name === 'snapshot') { if (h && h.onSnapshot) h.onSnapshot(ev); return; }
396
- if (name === 'end') { finish(ev); return; }
397
- if (h && h.onEvent) h.onEvent(ev);
535
+ if (my.settled) return;
536
+ return pump();
398
537
  });
399
- if (ended) return;
400
- return pump();
401
- });
402
- })();
403
- })
404
- .catch(function (e) {
405
- if (ended) return;
406
- if (h && h.onError) h.onError(e);
407
- finish({ reason: 'error' });
408
- });
409
- return { cancel: function () { if (ctl) ctl.abort(); } };
538
+ })();
539
+ })
540
+ .catch(function (e) {
541
+ if (my.settled || stopped || finished) return;
542
+ /* 4xx 是确定性失败:老网关没有这个路由(404)/ 令牌失效(401/403)——
543
+ 重试只会白等两分钟,立即按原样交给调用方(它会退回 ask 缓冲或报错)。 */
544
+ if (e && (e.status === 400 || e.status === 401 || e.status === 403 || e.status === 404)) {
545
+ if (h && h.onError) { try { h.onError(e); } catch (e2) {} }
546
+ finish({ reason: 'error' });
547
+ return;
548
+ }
549
+ drop('error');
550
+ });
551
+ };
552
+ bindWake();
553
+ connect();
554
+ return { cancel: stop };
410
555
  }
411
556
  };
412
557
  }