@yeaft/webchat-agent 1.0.40 → 1.0.42

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.
@@ -5,6 +5,7 @@ import { encrypt, decrypt, isEncrypted } from '../encryption.js';
5
5
  // 需要在断连期间缓冲的消息类型(CLI / Session 输出相关的关键消息)
6
6
  export const BUFFERABLE_TYPES = new Set([
7
7
  'claude_output', 'yeaft_output', 'yeaft_session_output', 'session_output',
8
+ 'yeaft_history_chunk',
8
9
  'turn_completed', 'conversation_closed',
9
10
  'session_id_update', 'compact_status', 'slash_commands_update',
10
11
  'background_task_started', 'background_task_output',
@@ -2,13 +2,24 @@ import { execFile, execFileSync, spawn } from 'child_process';
2
2
  import { writeFileSync, mkdirSync, existsSync, cpSync } from 'fs';
3
3
  import { join, dirname } from 'path';
4
4
  import { fileURLToPath } from 'url';
5
- import { platform } from 'os';
5
+ import { platform, homedir } from 'os';
6
6
  import ctx from '../context.js';
7
- import { getConfigDir } from '../service.js';
7
+ import { getConfigDir, getServiceName, getPm2AppName, getLaunchdPlistPath, DEFAULT_INSTANCE_ID } from '../service.js';
8
8
  import { sendToServer } from './buffer.js';
9
9
  import { stopAgentHeartbeat } from './heartbeat.js';
10
10
 
11
- const PM2_APP_NAME = 'yeaft-agent';
11
+ /**
12
+ * Resolve the local service instance id for this running agent. Each agent
13
+ * process is pinned to one instance (set in index.js from
14
+ * YEAFT_AGENT_INSTANCE / config), so the upgrade flow must target THAT
15
+ * instance's service unit — not the bare `yeaft-agent` default. Without this,
16
+ * a named instance (e.g. `server-e7a9eb`) installs the new package but its
17
+ * systemd unit `yeaft-agent@server-e7a9eb` is never restarted, leaving the
18
+ * agent permanently offline after a UI-triggered upgrade.
19
+ */
20
+ export function resolveInstanceId() {
21
+ return ctx.CONFIG?.instanceId || process.env.YEAFT_AGENT_INSTANCE || DEFAULT_INSTANCE_ID;
22
+ }
12
23
 
13
24
  // Derive absolute paths for npm/pm2 from current node executable.
14
25
  // In launchd/systemd environments, PATH may not include nvm/node dirs,
@@ -203,11 +214,12 @@ export async function handleUpgradeAgent() {
203
214
  });
204
215
 
205
216
  const isWindows = platform() === 'win32';
217
+ const instanceId = resolveInstanceId();
206
218
 
207
219
  if (isWindows) {
208
- spawnWindowsUpgradeScript(pkgName, installDir, isGlobalInstall, latestVersion);
220
+ spawnWindowsUpgradeScript(pkgName, installDir, isGlobalInstall, latestVersion, instanceId);
209
221
  } else {
210
- spawnUnixUpgradeScript(pkgName, installDir, isGlobalInstall, latestVersion);
222
+ spawnUnixUpgradeScript(pkgName, installDir, isGlobalInstall, latestVersion, instanceId);
211
223
  }
212
224
 
213
225
  // On PM2: delete the app BEFORE exiting so PM2 won't auto-restart the old version.
@@ -215,7 +227,7 @@ export async function handleUpgradeAgent() {
215
227
  const isPm2 = !!process.env.pm_id;
216
228
  if (isPm2) {
217
229
  try {
218
- execFileSync(pm2Path, ['delete', PM2_APP_NAME], { stdio: 'pipe', env: safeEnv, ...shellOpt });
230
+ execFileSync(pm2Path, ['delete', getPm2AppName(instanceId)], { stdio: 'pipe', env: safeEnv, ...shellOpt });
219
231
  console.log(`[Agent] PM2 app deleted to prevent auto-restart during upgrade`);
220
232
  } catch {
221
233
  console.log(`[Agent] PM2 delete skipped (app may not be registered)`);
@@ -230,9 +242,9 @@ export async function handleUpgradeAgent() {
230
242
  }
231
243
  }
232
244
 
233
- function spawnWindowsUpgradeScript(pkgName, installDir, isGlobalInstall, latestVersion) {
245
+ function spawnWindowsUpgradeScript(pkgName, installDir, isGlobalInstall, latestVersion, instanceId = DEFAULT_INSTANCE_ID) {
234
246
  const pid = process.pid;
235
- const configDir = getConfigDir();
247
+ const configDir = getConfigDir(instanceId);
236
248
  mkdirSync(configDir, { recursive: true });
237
249
  const logDir = join(configDir, 'logs');
238
250
  mkdirSync(logDir, { recursive: true });
@@ -355,13 +367,45 @@ function spawnWindowsUpgradeScript(pkgName, installDir, isGlobalInstall, latestV
355
367
  sendToServer({ type: 'upgrade_agent_ack', success: true, version: latestVersion, pendingRestart: true });
356
368
  }
357
369
 
358
- function spawnUnixUpgradeScript(pkgName, installDir, isGlobalInstall, latestVersion) {
359
- const pid = process.pid;
360
- const configDir = getConfigDir();
361
- mkdirSync(configDir, { recursive: true });
370
+ /**
371
+ * Build the Unix (systemd/launchd) upgrade shell script as a string.
372
+ *
373
+ * Side-effect-free apart from reading the environment FS (`existsSync` to
374
+ * detect which service manager is installed) — and crucially it does NOT
375
+ * spawn, so the generated unit names can be asserted in unit tests. The
376
+ * service-manager target is resolved from `instanceId` via the shared helpers
377
+ * (`getServiceName` / `getLaunchdPlistPath`), so a named instance restarts
378
+ * `yeaft-agent@<id>` instead of the bare `yeaft-agent` default.
379
+ *
380
+ * @param {object} opts
381
+ * @param {string} opts.pkgName npm package name
382
+ * @param {string} opts.installDir install dir (local install) — used as cwd
383
+ * @param {boolean} opts.isGlobalInstall global vs local npm install
384
+ * @param {number} opts.pid pid of the exiting agent to wait on
385
+ * @param {string} opts.configDir instance config dir (holds logs/upgrade.log)
386
+ * @param {string} opts.npmPath absolute npm path
387
+ * @param {string} opts.safePath PATH with nodeBinDir prepended
388
+ * @param {string} opts.instanceId local service instance id
389
+ * @param {boolean} opts.isDarwin whether the platform is macOS
390
+ * @returns {string} the upgrade.sh contents
391
+ */
392
+ export function buildUnixUpgradeScript({
393
+ pkgName,
394
+ installDir,
395
+ isGlobalInstall,
396
+ pid,
397
+ configDir,
398
+ npmPath,
399
+ safePath,
400
+ instanceId = DEFAULT_INSTANCE_ID,
401
+ isDarwin = platform() === 'darwin',
402
+ }) {
362
403
  const shPath = join(configDir, 'upgrade.sh');
363
- const isSystemd = existsSync(join(process.env.HOME || '', '.config', 'systemd', 'user', 'yeaft-agent.service'));
364
- const isLaunchd = platform() === 'darwin' && existsSync(join(process.env.HOME || '', 'Library', 'LaunchAgents', 'com.yeaft.agent.plist'));
404
+ const serviceName = getServiceName(instanceId);
405
+ const systemdUnitPath = join(homedir(), '.config', 'systemd', 'user', `${serviceName}.service`);
406
+ const isSystemd = existsSync(systemdUnitPath);
407
+ const plistPath = getLaunchdPlistPath(instanceId);
408
+ const isLaunchd = isDarwin && existsSync(plistPath);
365
409
  const cwd = isGlobalInstall ? undefined : installDir;
366
410
 
367
411
  const shLines = [
@@ -395,12 +439,11 @@ function spawnUnixUpgradeScript(pkgName, installDir, isGlobalInstall, latestVers
395
439
  if (isSystemd) {
396
440
  shLines.push(
397
441
  '# Stop systemd service to prevent restart loop',
398
- 'systemctl --user stop yeaft-agent 2>/dev/null',
442
+ `systemctl --user stop "${serviceName}" 2>/dev/null`,
399
443
  'sleep 1',
400
444
  '',
401
445
  );
402
446
  } else if (isLaunchd) {
403
- const plistPath = join(process.env.HOME || '', 'Library', 'LaunchAgents', 'com.yeaft.agent.plist');
404
447
  shLines.push(
405
448
  '# Unload launchd service to prevent restart loop',
406
449
  `launchctl unload "${plistPath}" 2>/dev/null`,
@@ -430,11 +473,10 @@ function spawnUnixUpgradeScript(pkgName, installDir, isGlobalInstall, latestVers
430
473
  if (isSystemd) {
431
474
  shLines.push(
432
475
  '# Restart systemd service',
433
- 'systemctl --user start yeaft-agent',
476
+ `systemctl --user start "${serviceName}"`,
434
477
  'echo "[Upgrade] Service restarted via systemd"',
435
478
  );
436
479
  } else if (isLaunchd) {
437
- const plistPath = join(process.env.HOME || '', 'Library', 'LaunchAgents', 'com.yeaft.agent.plist');
438
480
  shLines.push(
439
481
  '# Reload launchd service',
440
482
  `launchctl load "${plistPath}"`,
@@ -445,7 +487,26 @@ function spawnUnixUpgradeScript(pkgName, installDir, isGlobalInstall, latestVers
445
487
  // 清理脚本自身
446
488
  shLines.push('', `rm -f "${shPath}"`);
447
489
 
448
- writeFileSync(shPath, shLines.join('\n'), { mode: 0o755 });
490
+ return shLines.join('\n');
491
+ }
492
+
493
+ function spawnUnixUpgradeScript(pkgName, installDir, isGlobalInstall, latestVersion, instanceId = DEFAULT_INSTANCE_ID) {
494
+ const configDir = getConfigDir(instanceId);
495
+ mkdirSync(configDir, { recursive: true });
496
+ const shPath = join(configDir, 'upgrade.sh');
497
+
498
+ const script = buildUnixUpgradeScript({
499
+ pkgName,
500
+ installDir,
501
+ isGlobalInstall,
502
+ pid: process.pid,
503
+ configDir,
504
+ npmPath,
505
+ safePath,
506
+ instanceId,
507
+ });
508
+
509
+ writeFileSync(shPath, script, { mode: 0o755 });
449
510
  const child = spawn('bash', [shPath], {
450
511
  detached: true,
451
512
  stdio: 'ignore',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yeaft/webchat-agent",
3
- "version": "1.0.40",
3
+ "version": "1.0.42",
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",
package/service/index.js CHANGED
@@ -34,6 +34,9 @@ export {
34
34
  getLaunchdLabel,
35
35
  getDefaultYeaftDir,
36
36
  } from './config.js';
37
+ // Re-export the launchd plist-path resolver so consumers (e.g. the upgrade
38
+ // flow) reach it via the service barrel instead of deep-importing macos.js.
39
+ export { getLaunchdPlistPath } from './macos.js';
37
40
 
38
41
  const os = platform();
39
42
 
package/service.js CHANGED
@@ -25,6 +25,7 @@ export {
25
25
  getServiceName,
26
26
  getPm2AppName,
27
27
  getLaunchdLabel,
28
+ getLaunchdPlistPath,
28
29
  getDefaultYeaftDir,
29
30
  install,
30
31
  uninstall,
@@ -4827,6 +4827,110 @@ export async function handleYeaftLoadHistory(msg) {
4827
4827
  sessionId ? store.loadRecentBySession(sessionId, lim) : store.loadRecent(lim);
4828
4828
  let historyAlreadyReplayed = false;
4829
4829
 
4830
+ const replayHistoryFromStore = () => {
4831
+ // Delta path: caller knows the latest seq (or message id) it has cached
4832
+ // and wants only the messages that arrived after that cursor. Returns
4833
+ // mode:'delta' so the frontend can append+dedupe instead of replacing
4834
+ // the pane.
4835
+ const afterSeqRaw = (msg && Number.isFinite(msg.afterSeq)) ? msg.afterSeq : null;
4836
+ const afterMessageId = (msg && typeof msg.afterMessageId === 'string') ? msg.afterMessageId : null;
4837
+ let afterSeq = afterSeqRaw;
4838
+ if (afterSeq === null && afterMessageId && typeof session.conversationStore.getMessageSeqById === 'function') {
4839
+ afterSeq = session.conversationStore.getMessageSeqById(afterMessageId);
4840
+ }
4841
+ if (sessionId && afterSeq !== null && typeof session.conversationStore.loadAfterSeqByGroup === 'function') {
4842
+ const delta = session.conversationStore.loadAfterSeqByGroup(sessionId, afterSeq);
4843
+ const projectedMessages = emitHistoryChunk({
4844
+ sessionId,
4845
+ messages: delta.messages,
4846
+ mode: 'delta',
4847
+ latestSeq: delta.latestSeq,
4848
+ afterSeq,
4849
+ });
4850
+ sendSessionEvent({
4851
+ type: 'history_loaded',
4852
+ mode: 'delta',
4853
+ count: projectedMessages.length,
4854
+ sessionId,
4855
+ latestSeq: delta.latestSeq,
4856
+ afterSeq,
4857
+ });
4858
+ return;
4859
+ }
4860
+
4861
+ // `msg.limit` is the replay-scrollback request from the frontend (UI
4862
+ // history pane, not engine context). Keep the bootstrap window small so
4863
+ // opening a group can paint the latest messages quickly; older rows are
4864
+ // paged via `yeaft_load_more_history` when the user scrolls upward.
4865
+ const limit = (typeof msg.limit === 'number') ? msg.limit : 10;
4866
+ const visiblePage = sessionId
4867
+ ? loadVisibleGroupHistoryPage(session.conversationStore, sessionId, limit)
4868
+ : { messages: limit > 0 ? pickRecent(session.conversationStore, limit) : [], oldestSeq: null, hasMore: false };
4869
+ // Legacy compact.md is a non-group fallback only. For group replay, reading
4870
+ // it makes every group show "has compact" once any legacy/non-scoped compact
4871
+ // exists, even when this group has no scoped summary.
4872
+ const compactSummary = sessionId ? '' : session.conversationStore.readCompactSummary();
4873
+ const replayEntries = sessionId
4874
+ ? visiblePage.messages
4875
+ : visiblePage.messages
4876
+ .map(projectPersistedToVisibleHistoryEntry)
4877
+ .filter(Boolean);
4878
+
4879
+ let latestSeq = null;
4880
+ if (replayEntries.length > 0 && typeof session.conversationStore.getMessageSeqById === 'function') {
4881
+ const last = replayEntries[replayEntries.length - 1];
4882
+ if (last && last.id) latestSeq = session.conversationStore.getMessageSeqById(last.id);
4883
+ }
4884
+
4885
+ if (sessionId) {
4886
+ emitHistoryChunk({
4887
+ sessionId,
4888
+ messages: replayEntries,
4889
+ mode: 'recent',
4890
+ oldestSeq: visiblePage.oldestSeq,
4891
+ hasMore: visiblePage.hasMore,
4892
+ latestSeq,
4893
+ turns: limit,
4894
+ });
4895
+ } else {
4896
+ emitLegacyHistoryOutputFrames(replayEntries);
4897
+ }
4898
+
4899
+ // Compute the pagination cursor for the bootstrap load so the frontend
4900
+ // knows whether a "Load older messages" hint should be shown and where
4901
+ // to start the next page. For group history, this is computed from the
4902
+ // visible projected page, not raw persisted rows, so reflection/internal
4903
+ // tail rows cannot consume the bootstrap window or create false hasMore.
4904
+ let hasMore = false;
4905
+ let oldestSeq = null;
4906
+ if (sessionId) {
4907
+ hasMore = visiblePage.hasMore;
4908
+ oldestSeq = visiblePage.oldestSeq;
4909
+ }
4910
+
4911
+ // hasCompactSummary used to read a single session-global file, so it was
4912
+ // always true once ANY group/VP in the session had compacted. For group
4913
+ // replay, only scoped per-(group, vp) summaries count; legacy compact.md is
4914
+ // reserved for non-group / pre-scoped 1:1 callers.
4915
+ let hasCompactSummaryFlag = !!compactSummary;
4916
+ if (sessionId && typeof session.conversationStore.hasAnyCompactSummaryForSession === 'function') {
4917
+ hasCompactSummaryFlag = session.conversationStore.hasAnyCompactSummaryForSession(sessionId);
4918
+ }
4919
+
4920
+ sendSessionEvent({
4921
+ type: 'history_loaded',
4922
+ mode: 'recent',
4923
+ count: replayEntries.length,
4924
+ hasCompactSummary: hasCompactSummaryFlag,
4925
+ totalHot: session.conversationStore.countHot(),
4926
+ totalCold: session.conversationStore.countCold(),
4927
+ sessionId,
4928
+ hasMore,
4929
+ oldestSeq,
4930
+ latestSeq,
4931
+ });
4932
+ };
4933
+
4830
4934
  if (!session) {
4831
4935
  const yeaftDir = ctx.CONFIG?.yeaftDir || DEFAULT_YEAFT_DIR;
4832
4936
  const afterSeqRaw = (msg && Number.isFinite(msg.afterSeq)) ? msg.afterSeq : null;
@@ -4878,6 +4982,8 @@ export async function handleYeaftLoadHistory(msg) {
4878
4982
  // hydration handles it.
4879
4983
  if (sessionId) setGroupHistory(sessionId, hydrateGroupHistory(sessionId));
4880
4984
  } else {
4985
+ replayHistoryFromStore();
4986
+ historyAlreadyReplayed = true;
4881
4987
  refreshLiveSessionConfig();
4882
4988
  hydrateYeaftStatusFromSession(session, { reason: 'history_load', emitEvent: true });
4883
4989
  }
@@ -4917,107 +5023,7 @@ export async function handleYeaftLoadHistory(msg) {
4917
5023
 
4918
5024
  if (historyAlreadyReplayed) return;
4919
5025
 
4920
- // Delta path: caller knows the latest seq (or message id) it has cached
4921
- // and wants only the messages that arrived after that cursor. Returns
4922
- // early with mode:'delta' so the frontend can append+dedupe instead of
4923
- // replacing the pane.
4924
- const afterSeqRaw = (msg && Number.isFinite(msg.afterSeq)) ? msg.afterSeq : null;
4925
- const afterMessageId = (msg && typeof msg.afterMessageId === 'string') ? msg.afterMessageId : null;
4926
- let afterSeq = afterSeqRaw;
4927
- if (afterSeq === null && afterMessageId && typeof session.conversationStore.getMessageSeqById === 'function') {
4928
- afterSeq = session.conversationStore.getMessageSeqById(afterMessageId);
4929
- }
4930
- if (sessionId && afterSeq !== null && typeof session.conversationStore.loadAfterSeqByGroup === 'function') {
4931
- const delta = session.conversationStore.loadAfterSeqByGroup(sessionId, afterSeq);
4932
- const projectedMessages = emitHistoryChunk({
4933
- sessionId,
4934
- messages: delta.messages,
4935
- mode: 'delta',
4936
- latestSeq: delta.latestSeq,
4937
- afterSeq,
4938
- });
4939
- sendSessionEvent({
4940
- type: 'history_loaded',
4941
- mode: 'delta',
4942
- count: projectedMessages.length,
4943
- sessionId,
4944
- latestSeq: delta.latestSeq,
4945
- afterSeq,
4946
- });
4947
- return;
4948
- }
4949
-
4950
- // `msg.limit` is the replay-scrollback request from the frontend (UI
4951
- // history pane, not engine context). Keep the bootstrap window small so
4952
- // opening a group can paint the latest messages quickly; older rows are
4953
- // paged via `yeaft_load_more_history` when the user scrolls upward.
4954
- const limit = (typeof msg.limit === 'number') ? msg.limit : 10;
4955
- const visiblePage = sessionId
4956
- ? loadVisibleGroupHistoryPage(session.conversationStore, sessionId, limit)
4957
- : { messages: limit > 0 ? pickRecent(session.conversationStore, limit) : [], oldestSeq: null, hasMore: false };
4958
- // Legacy compact.md is a non-group fallback only. For group replay, reading
4959
- // it makes every group show "has compact" once any legacy/non-scoped compact
4960
- // exists, even when this group has no scoped summary.
4961
- const compactSummary = sessionId ? '' : session.conversationStore.readCompactSummary();
4962
- const replayEntries = sessionId
4963
- ? visiblePage.messages
4964
- : visiblePage.messages
4965
- .map(projectPersistedToVisibleHistoryEntry)
4966
- .filter(Boolean);
4967
-
4968
- let latestSeq = null;
4969
- if (replayEntries.length > 0 && typeof session.conversationStore.getMessageSeqById === 'function') {
4970
- const last = replayEntries[replayEntries.length - 1];
4971
- if (last && last.id) latestSeq = session.conversationStore.getMessageSeqById(last.id);
4972
- }
4973
-
4974
- if (sessionId) {
4975
- emitHistoryChunk({
4976
- sessionId,
4977
- messages: replayEntries,
4978
- mode: 'recent',
4979
- oldestSeq: visiblePage.oldestSeq,
4980
- hasMore: visiblePage.hasMore,
4981
- latestSeq,
4982
- turns: limit,
4983
- });
4984
- } else {
4985
- emitLegacyHistoryOutputFrames(replayEntries);
4986
- }
4987
-
4988
- // Compute the pagination cursor for the bootstrap load so the frontend
4989
- // knows whether a "Load older messages" hint should be shown and where
4990
- // to start the next page. For group history, this is computed from the
4991
- // visible projected page, not raw persisted rows, so reflection/internal
4992
- // tail rows cannot consume the bootstrap window or create false hasMore.
4993
- let hasMore = false;
4994
- let oldestSeq = null;
4995
- if (sessionId) {
4996
- hasMore = visiblePage.hasMore;
4997
- oldestSeq = visiblePage.oldestSeq;
4998
- }
4999
-
5000
- // hasCompactSummary used to read a single session-global file, so it was
5001
- // always true once ANY group/VP in the session had compacted. For group
5002
- // replay, only scoped per-(group, vp) summaries count; legacy compact.md is
5003
- // reserved for non-group / pre-scoped 1:1 callers.
5004
- let hasCompactSummaryFlag = !!compactSummary;
5005
- if (sessionId && typeof session.conversationStore.hasAnyCompactSummaryForSession === 'function') {
5006
- hasCompactSummaryFlag = session.conversationStore.hasAnyCompactSummaryForSession(sessionId);
5007
- }
5008
-
5009
- sendSessionEvent({
5010
- type: 'history_loaded',
5011
- mode: 'recent',
5012
- count: replayEntries.length,
5013
- hasCompactSummary: hasCompactSummaryFlag,
5014
- totalHot: session.conversationStore.countHot(),
5015
- totalCold: session.conversationStore.countCold(),
5016
- sessionId,
5017
- hasMore,
5018
- oldestSeq,
5019
- latestSeq,
5020
- });
5026
+ replayHistoryFromStore();
5021
5027
  }
5022
5028
 
5023
5029
  /**