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