@yeaft/webchat-agent 1.0.63 → 1.0.65

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.
@@ -16,51 +16,84 @@ export const BUFFERABLE_TYPES = new Set([
16
16
  'crew_role_compact', 'crew_context_usage'
17
17
  ]);
18
18
 
19
- // Send message to server (with encryption if available)
20
- // 断连时对关键消息类型进行缓冲,重连后自动 flush
21
- export async function sendToServer(msg) {
19
+ function bufferMessage(msg, reason) {
20
+ if (!BUFFERABLE_TYPES.has(msg.type)) {
21
+ console.warn(`[WS] Cannot send message, WebSocket not open: ${msg.type}`);
22
+ return 'dropped';
23
+ }
24
+ if (ctx.messageBuffer.length < ctx.messageBufferMaxSize) {
25
+ ctx.messageBuffer.push(msg);
26
+ console.log(`[WS] ${reason}, buffered: ${msg.type} (queue: ${ctx.messageBuffer.length})`);
27
+ return 'buffered';
28
+ }
29
+ // Buffer full: drop oldest non-status messages to make room
30
+ const dropIdx = ctx.messageBuffer.findIndex(m => m.type !== 'crew_status' && m.type !== 'turn_completed');
31
+ if (dropIdx >= 0) {
32
+ ctx.messageBuffer.splice(dropIdx, 1);
33
+ ctx.messageBuffer.push(msg);
34
+ console.warn(`[WS] Buffer full, dropped oldest to make room for: ${msg.type}`);
35
+ return 'buffered';
36
+ }
37
+ console.warn(`[WS] Buffer full (${ctx.messageBufferMaxSize}), dropping: ${msg.type}`);
38
+ return 'dropped';
39
+ }
40
+
41
+ async function sendNow(msg) {
22
42
  if (!ctx.ws || ctx.ws.readyState !== WebSocket.OPEN) {
23
- // 缓冲关键消息
24
- if (BUFFERABLE_TYPES.has(msg.type)) {
25
- if (ctx.messageBuffer.length < ctx.messageBufferMaxSize) {
26
- ctx.messageBuffer.push(msg);
27
- console.log(`[WS] Buffered message: ${msg.type} (queue: ${ctx.messageBuffer.length})`);
28
- } else {
29
- // Buffer full: drop oldest non-status messages to make room
30
- const dropIdx = ctx.messageBuffer.findIndex(m => m.type !== 'crew_status' && m.type !== 'turn_completed');
31
- if (dropIdx >= 0) {
32
- ctx.messageBuffer.splice(dropIdx, 1);
33
- ctx.messageBuffer.push(msg);
34
- console.warn(`[WS] Buffer full, dropped oldest to make room for: ${msg.type}`);
35
- } else {
36
- console.warn(`[WS] Buffer full (${ctx.messageBufferMaxSize}), dropping: ${msg.type}`);
43
+ return bufferMessage(msg, 'Disconnected');
44
+ }
45
+
46
+ // feat-ws-plaintext-negotiation: encrypt only when the server has
47
+ // NOT advertised plaintext acceptance. Defaults to encrypted for
48
+ // back-compat with old servers; flipped to plaintext when the
49
+ // `registered` frame includes `acceptPlaintext: true`.
50
+ if (ctx.serverEncryptionRequired && ctx.sessionKey) {
51
+ const encrypted = await encrypt(msg, ctx.sessionKey);
52
+ ctx.ws.send(JSON.stringify(encrypted));
53
+ } else {
54
+ ctx.ws.send(JSON.stringify(msg));
55
+ }
56
+ return 'sent';
57
+ }
58
+
59
+ function scheduleOutboundDrain() {
60
+ if (ctx.outboundSendQueueActive) return;
61
+ ctx.outboundSendQueueActive = true;
62
+ setImmediate(async () => {
63
+ try {
64
+ while (ctx.outboundSendQueue.length > 0) {
65
+ const item = ctx.outboundSendQueue.shift();
66
+ const msg = item?.msg ?? item;
67
+ try {
68
+ const outcome = await sendNow(msg);
69
+ item?.resolve?.(outcome);
70
+ } catch (e) {
71
+ console.error(`[WS] Error sending message ${msg?.type}:`, e.message);
72
+ const outcome = msg ? bufferMessage(msg, 'Send failed') : 'dropped';
73
+ item?.resolve?.(outcome);
37
74
  }
75
+ // Yield between frames so ping/pong, inbound control messages and UI
76
+ // events cannot be starved by a reconnect flush or a burst of tool output.
77
+ await new Promise(resolve => setImmediate(resolve));
38
78
  }
39
- } else {
40
- console.warn(`[WS] Cannot send message, WebSocket not open: ${msg.type}`);
79
+ } finally {
80
+ ctx.outboundSendQueueActive = false;
81
+ if (ctx.outboundSendQueue.length > 0) scheduleOutboundDrain();
41
82
  }
42
- return;
43
- }
83
+ });
84
+ }
44
85
 
45
- try {
46
- // feat-ws-plaintext-negotiation: encrypt only when the server has
47
- // NOT advertised plaintext acceptance. Defaults to encrypted for
48
- // back-compat with old servers; flipped to plaintext when the
49
- // `registered` frame includes `acceptPlaintext: true`.
50
- if (ctx.serverEncryptionRequired && ctx.sessionKey) {
51
- const encrypted = await encrypt(msg, ctx.sessionKey);
52
- ctx.ws.send(JSON.stringify(encrypted));
53
- } else {
54
- ctx.ws.send(JSON.stringify(msg));
55
- }
56
- } catch (e) {
57
- console.error(`[WS] Error sending message ${msg.type}:`, e.message);
58
- // 发送失败也缓冲
59
- if (BUFFERABLE_TYPES.has(msg.type) && ctx.messageBuffer.length < ctx.messageBufferMaxSize) {
60
- ctx.messageBuffer.push(msg);
61
- console.log(`[WS] Send failed, buffered: ${msg.type}`);
62
- }
86
+ // Send message to server (with encryption if available)
87
+ // 断连时对关键消息类型进行缓冲,重连后自动 flush
88
+ export async function sendToServer(msg) {
89
+ if (!ctx.ws || ctx.ws.readyState !== WebSocket.OPEN) {
90
+ return bufferMessage(msg, 'Disconnected');
63
91
  }
92
+ const promise = new Promise((resolve, reject) => {
93
+ ctx.outboundSendQueue.push({ msg, resolve, reject });
94
+ });
95
+ scheduleOutboundDrain();
96
+ return promise;
64
97
  }
65
98
 
66
99
  // Flush 断连期间缓冲的消息
@@ -74,7 +107,7 @@ export async function flushMessageBuffer() {
74
107
  await sendToServer(msg);
75
108
  }
76
109
 
77
- console.log(`[WS] Flush complete`);
110
+ console.log(`[WS] Flush queued`);
78
111
  }
79
112
 
80
113
  // Parse incoming message (decrypt if encrypted)
@@ -1,4 +1,5 @@
1
1
  const DEFAULT_AGENT_HEARTBEAT_TIMEOUT_MS = 180000;
2
+ const DEFAULT_AGENT_HEARTBEAT_STALL_GRACE_MS = 5000;
2
3
 
3
4
  function parsePositiveInt(value, fallback) {
4
5
  const n = Number(value);
@@ -10,6 +11,17 @@ export const AGENT_HEARTBEAT_TIMEOUT_MS = parsePositiveInt(
10
11
  DEFAULT_AGENT_HEARTBEAT_TIMEOUT_MS,
11
12
  );
12
13
 
13
- export function shouldReconnectForHeartbeat(lastPongAt, now = Date.now(), timeoutMs = AGENT_HEARTBEAT_TIMEOUT_MS) {
14
+ export const AGENT_HEARTBEAT_STALL_GRACE_MS = parsePositiveInt(
15
+ process.env.YEAFT_AGENT_HEARTBEAT_STALL_GRACE_MS || process.env.AGENT_HEARTBEAT_STALL_GRACE_MS,
16
+ DEFAULT_AGENT_HEARTBEAT_STALL_GRACE_MS,
17
+ );
18
+
19
+ export function shouldReconnectForHeartbeat(
20
+ lastPongAt,
21
+ now = Date.now(),
22
+ timeoutMs = AGENT_HEARTBEAT_TIMEOUT_MS,
23
+ { timerDriftMs = 0, stallGraceMs = AGENT_HEARTBEAT_STALL_GRACE_MS } = {},
24
+ ) {
25
+ if (Number.isFinite(timerDriftMs) && timerDriftMs > stallGraceMs) return false;
14
26
  return Number.isFinite(lastPongAt) && lastPongAt > 0 && (now - lastPongAt) > timeoutMs;
15
27
  }
@@ -2,9 +2,13 @@ import WebSocket from 'ws';
2
2
  import ctx from '../context.js';
3
3
  import { shouldReconnectForHeartbeat } from './heartbeat-policy.js';
4
4
 
5
+ const AGENT_HEARTBEAT_INTERVAL_MS = 25000;
6
+ const HEARTBEAT_STALL_LOG_MS = 5000;
7
+
5
8
  export function startAgentHeartbeat() {
6
9
  stopAgentHeartbeat();
7
10
  ctx.lastPongAt = Date.now();
11
+ ctx.lastHeartbeatTickAt = Date.now();
8
12
 
9
13
  // 监听 pong 帧
10
14
  if (ctx.ws) {
@@ -18,8 +22,15 @@ export function startAgentHeartbeat() {
18
22
 
19
23
  // 检查上次 pong 是否超时
20
24
  const now = Date.now();
25
+ const timerDriftMs = Math.max(0, now - (ctx.lastHeartbeatTickAt || now) - AGENT_HEARTBEAT_INTERVAL_MS);
26
+ ctx.lastHeartbeatTickAt = now;
21
27
  const sincePong = now - ctx.lastPongAt;
22
- if (shouldReconnectForHeartbeat(ctx.lastPongAt, now)) {
28
+ if (timerDriftMs > HEARTBEAT_STALL_LOG_MS) {
29
+ ctx.lastHeartbeatStallAt = now;
30
+ ctx.lastHeartbeatStallMs = timerDriftMs;
31
+ console.warn(`[Heartbeat] Agent event loop delayed heartbeat by ${Math.round(timerDriftMs)}ms; deferring reconnect`);
32
+ }
33
+ if (shouldReconnectForHeartbeat(ctx.lastPongAt, now, undefined, { timerDriftMs })) {
23
34
  console.warn(`[Heartbeat] No pong for ${Math.round(sincePong / 1000)}s, reconnecting...`);
24
35
  ctx.ws.terminate();
25
36
  return;
@@ -30,7 +41,7 @@ export function startAgentHeartbeat() {
30
41
  } catch (e) {
31
42
  console.warn('[Heartbeat] Failed to send ping:', e.message);
32
43
  }
33
- }, 25000);
44
+ }, AGENT_HEARTBEAT_INTERVAL_MS);
34
45
  }
35
46
 
36
47
  export function stopAgentHeartbeat() {
@@ -329,7 +329,7 @@ export async function handleMessage(msg) {
329
329
  break;
330
330
 
331
331
  case 'restart_agent':
332
- handleRestartAgent();
332
+ await handleRestartAgent();
333
333
  break;
334
334
 
335
335
  case 'upgrade_agent':
@@ -64,9 +64,9 @@ function cleanupAndExit(exitCode) {
64
64
  }, 500);
65
65
  }
66
66
 
67
- export function handleRestartAgent() {
67
+ export async function handleRestartAgent() {
68
68
  console.log('[Agent] Restart requested, shutting down for PM2/systemd restart...');
69
- sendToServer({ type: 'restart_agent_ack' });
69
+ await sendToServer({ type: 'restart_agent_ack' });
70
70
  cleanupAndExit(1);
71
71
  }
72
72
 
@@ -162,7 +162,7 @@ export async function handleUpgradeAgent() {
162
162
  });
163
163
  if (latestVersion === ctx.agentVersion) {
164
164
  console.log(`[Agent] Already at latest version (${ctx.agentVersion}), skipping upgrade.`);
165
- sendToServer({ type: 'upgrade_agent_ack', success: true, alreadyLatest: true, version: ctx.agentVersion });
165
+ await sendToServer({ type: 'upgrade_agent_ack', success: true, alreadyLatest: true, version: ctx.agentVersion });
166
166
  return;
167
167
  }
168
168
 
@@ -175,7 +175,7 @@ export async function handleUpgradeAgent() {
175
175
  if (requiredNode && !nodeRangeSatisfied(currentNode, requiredNode)) {
176
176
  const msg = `Node ${currentNode} does not satisfy required ${requiredNode} for ${pkgName}@${latestVersion}`;
177
177
  console.warn(`[Agent] Upgrade aborted: ${msg}`);
178
- sendToServer({
178
+ await sendToServer({
179
179
  type: 'upgrade_agent_ack',
180
180
  success: false,
181
181
  reason: 'node_incompatible',
@@ -197,7 +197,7 @@ export async function handleUpgradeAgent() {
197
197
  if (!isNpmInstall) {
198
198
  // 源码运行不支持远程升级(代码在 git repo 中,需要手动 git pull)
199
199
  console.log('[Agent] Source-based install detected, remote upgrade not supported.');
200
- sendToServer({ type: 'upgrade_agent_ack', success: false, error: 'Source-based install: please use git pull to upgrade' });
200
+ await sendToServer({ type: 'upgrade_agent_ack', success: false, error: 'Source-based install: please use git pull to upgrade' });
201
201
  return;
202
202
  }
203
203
 
@@ -217,9 +217,9 @@ export async function handleUpgradeAgent() {
217
217
  const instanceId = resolveInstanceId();
218
218
 
219
219
  if (isWindows) {
220
- spawnWindowsUpgradeScript(pkgName, installDir, isGlobalInstall, latestVersion, instanceId);
220
+ await spawnWindowsUpgradeScript(pkgName, installDir, isGlobalInstall, latestVersion, instanceId);
221
221
  } else {
222
- spawnUnixUpgradeScript(pkgName, installDir, isGlobalInstall, latestVersion, instanceId);
222
+ await spawnUnixUpgradeScript(pkgName, installDir, isGlobalInstall, latestVersion, instanceId);
223
223
  }
224
224
 
225
225
  // On PM2: delete the app BEFORE exiting so PM2 won't auto-restart the old version.
@@ -238,11 +238,11 @@ export async function handleUpgradeAgent() {
238
238
  cleanupAndExit(0);
239
239
  } catch (e) {
240
240
  console.error('[Agent] Upgrade failed:', e.message);
241
- sendToServer({ type: 'upgrade_agent_ack', success: false, error: e.message });
241
+ await sendToServer({ type: 'upgrade_agent_ack', success: false, error: e.message });
242
242
  }
243
243
  }
244
244
 
245
- function spawnWindowsUpgradeScript(pkgName, installDir, isGlobalInstall, latestVersion, instanceId = DEFAULT_INSTANCE_ID) {
245
+ async function spawnWindowsUpgradeScript(pkgName, installDir, isGlobalInstall, latestVersion, instanceId = DEFAULT_INSTANCE_ID) {
246
246
  const pid = process.pid;
247
247
  const configDir = getConfigDir(instanceId);
248
248
  mkdirSync(configDir, { recursive: true });
@@ -364,7 +364,7 @@ function spawnWindowsUpgradeScript(pkgName, installDir, isGlobalInstall, latestV
364
364
  }).unref();
365
365
 
366
366
  console.log(`[Agent] Spawned upgrade via VBScript (PID wait for ${pid}, pm2=${isPm2}, dir=${installDir}): ${batPath}`);
367
- sendToServer({ type: 'upgrade_agent_ack', success: true, version: latestVersion, pendingRestart: true });
367
+ await sendToServer({ type: 'upgrade_agent_ack', success: true, version: latestVersion, pendingRestart: true });
368
368
  }
369
369
 
370
370
  /**
@@ -526,7 +526,7 @@ export function buildUnixUpgradeScript({
526
526
  return shLines.join('\n');
527
527
  }
528
528
 
529
- function spawnUnixUpgradeScript(pkgName, installDir, isGlobalInstall, latestVersion, instanceId = DEFAULT_INSTANCE_ID) {
529
+ async function spawnUnixUpgradeScript(pkgName, installDir, isGlobalInstall, latestVersion, instanceId = DEFAULT_INSTANCE_ID) {
530
530
  const configDir = getConfigDir(instanceId);
531
531
  // Create logs/ too: the generated script's `exec > "$LOGFILE" 2>&1` aborts
532
532
  // the whole upgrade silently if that directory is missing.
@@ -551,5 +551,5 @@ function spawnUnixUpgradeScript(pkgName, installDir, isGlobalInstall, latestVers
551
551
  });
552
552
  child.unref();
553
553
  console.log(`[Agent] Spawned upgrade script: ${shPath}`);
554
- sendToServer({ type: 'upgrade_agent_ack', success: true, version: latestVersion, pendingRestart: true });
554
+ await sendToServer({ type: 'upgrade_agent_ack', success: true, version: latestVersion, pendingRestart: true });
555
555
  }
package/context.js CHANGED
@@ -28,6 +28,11 @@ export default {
28
28
  pendingAuthTempId: null,
29
29
  agentHeartbeatTimer: null,
30
30
  lastPongAt: 0,
31
+ lastHeartbeatTickAt: 0,
32
+ lastHeartbeatStallAt: 0,
33
+ lastHeartbeatStallMs: 0,
34
+ outboundSendQueue: [],
35
+ outboundSendQueueActive: false,
31
36
  // 断连期间的消息缓冲队列(重连后 flush)
32
37
  messageBuffer: [],
33
38
  messageBufferMaxSize: 5000, // 防止内存无限增长
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yeaft/webchat-agent",
3
- "version": "1.0.63",
3
+ "version": "1.0.65",
4
4
  "description": "Remote worker agent for Yeaft Web Code Agent — connects the native Yeaft engine, CLI providers, and workbench tools",
5
5
  "main": "index.js",
6
6
  "type": "module",
@@ -1386,31 +1386,7 @@ Users turn to you when the request is messy, cross-functional, or drifting. Give
1386
1386
  你在模糊里保持冷静。你会把含糊意图收束成清楚的问题,把事实和假设分开,选择能继续推进工作的最小路径。该由专家处理时,你直接把任务交出去,不假装所有事都该自己完成。
1387
1387
 
1388
1388
  用户找你,通常是因为事情跨角色、范围漂移、或者目标还没被说清。你的回答要给出目标、取舍、下一位负责人和审计链;简洁,但不能遮住流程状态。`,
1389
- legacyPersonas: [`You are Omni Assistant / 全能助手, a cross-domain, execution-focused general AI partner.
1390
-
1391
- Language policy / 语言策略:
1392
- - Prefer Chinese when the user writes in Chinese; prefer English when the user writes in English.
1393
- - If the conversation is bilingual, mirror the user's latest language unless they ask otherwise.
1394
-
1395
- Core capabilities / 核心能力:
1396
- - Cross-domain synthesis: handle writing, coding, product thinking, research, planning, analysis, learning, translation, troubleshooting, and everyday reasoning.
1397
- - Task shaping: turn vague requests into concrete next steps, ask clarifying questions only when needed, and otherwise make reasonable assumptions.
1398
- - Execution support: produce actionable answers, drafts, code-oriented guidance, checklists, and concise summaries.
1399
- - Coordination: when a specialized session member is better suited, route or recommend routing instead of pretending one generic voice should solve everything.
1400
-
1401
- Answering style / 回答风格:
1402
- - Be direct, useful, and structured.
1403
- - Prefer concise answers, but include enough detail for the user to act.
1404
- - Say when you are uncertain; do not invent facts or claim tool work you did not perform.
1405
- - Adapt depth to the task: quick answers for simple questions, plans and verification for complex work.`, `You are Omni Assistant / 全能助手, a cross-domain, execution-focused general AI partner.
1406
-
1407
- Language policy / 语言策略:
1408
- - Prefer Chinese when the user writes in Chinese; prefer English when the user writes in English.
1409
- - If the conversation is bilingual, mirror the user's latest language unless they ask otherwise.
1410
-
1411
- Core capabilities / 核心能力:
1412
- - Cross-domain synthesis: handle writing, coding, product thinking, research, planning, analysis, learning, translation, troubleshooting, and creative work without forcing the user to pick a specialist first.
1413
- - Strong execution: when a task needs action, clarify only the blocking unknowns, make a short plan, use available tools, produce the deliverable, and verify the result.`],
1389
+ legacyPersonas: [],
1414
1390
  legacyPersonaEn: `You are Omni, a VP responsible for requirement analysis, goal clarification, workflow orchestration, and delivery coordination. You are not a generic helper; you are the team lead who defines the problem correctly and keeps the handoff chain moving.
1415
1391
 
1416
1392
  Traits: broad-context, calm, and execution-minded. You turn vague user intent into concrete work without losing the user's actual goal.
@@ -252,10 +252,18 @@ export function backfillLocalizedPersonaBody(source, vp) {
252
252
  .filter(value => typeof value === 'string' && value.trim())
253
253
  .map(value => value.trim());
254
254
 
255
- if (!acceptedOldBodies.includes(body)) return null;
255
+ if (!acceptedOldBodies.includes(body) && !isObsoleteOmniAssistantBody(vp, body)) return null;
256
256
  return replaceRoleBody(source, nextBody);
257
257
  }
258
258
 
259
+ function isObsoleteOmniAssistantBody(vp, body) {
260
+ if (!vp || vp.vpId !== 'omni') return false;
261
+ const text = String(body || '').trim();
262
+ return text.startsWith('You are Omni Assistant / 全能助手,')
263
+ && text.includes('Language policy / 语言策略:')
264
+ && text.includes('Core capabilities / 核心能力:');
265
+ }
266
+
259
267
  /**
260
268
  * Top-up the default VPs into an existing `libDir`.
261
269
  *