@yeaft/webchat-agent 0.1.74 → 0.1.76

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/claude.js CHANGED
@@ -8,7 +8,9 @@ import {
8
8
  detectRoleSignal,
9
9
  processRolePlayRoutes,
10
10
  buildRouteEventMessage,
11
- getRolePlayRouteState
11
+ getRolePlayRouteState,
12
+ refreshCrewContext,
13
+ writeBackRouteContext
12
14
  } from './roleplay.js';
13
15
 
14
16
  /**
@@ -447,6 +449,12 @@ async function processClaudeOutput(conversationId, claudeQuery, state) {
447
449
  ctx.sendToServer(routeEvent);
448
450
  }
449
451
 
452
+ // ★ Write-back: persist route task info to .crew/context/features
453
+ const taskRoutes = routes.filter(r => r.taskId && r.summary);
454
+ if (taskRoutes.length > 0 && state.workDir) {
455
+ writeBackRouteContext(state.workDir, taskRoutes, rpSession.currentRole || 'unknown', rpSession);
456
+ }
457
+
450
458
  // Send route state update
451
459
  const routeState = getRolePlayRouteState(conversationId);
452
460
  if (routeState) {
package/conversation.js CHANGED
@@ -2,7 +2,7 @@ import ctx from './context.js';
2
2
  import { loadSessionHistory } from './history.js';
3
3
  import { startClaudeQuery } from './claude.js';
4
4
  import { crewSessions, loadCrewIndex } from './crew.js';
5
- import { rolePlaySessions, saveRolePlayIndex, removeRolePlaySession, loadRolePlayIndex, validateRolePlayConfig, initRolePlayRouteState, loadCrewContext } from './roleplay.js';
5
+ import { rolePlaySessions, saveRolePlayIndex, removeRolePlaySession, loadRolePlayIndex, validateRolePlayConfig, initRolePlayRouteState, loadCrewContext, refreshCrewContext, initCrewContextMtimes } from './roleplay.js';
6
6
 
7
7
  // Restore persisted roleplay sessions on module load (agent startup)
8
8
  loadRolePlayIndex();
@@ -256,10 +256,21 @@ export async function resumeConversation(msg) {
256
256
  // Claude 进程会在用户发送第一条消息时启动 (见 handleUserInput)
257
257
  // Restore rolePlayConfig from persisted rolePlaySessions if available
258
258
  const rolePlayEntry = rolePlaySessions.get(conversationId);
259
- const rolePlayConfig = rolePlayEntry
259
+ let rolePlayConfig = rolePlayEntry
260
260
  ? { roles: rolePlayEntry.roles, teamType: rolePlayEntry.teamType, language: rolePlayEntry.language }
261
261
  : null;
262
262
 
263
+ // ★ RolePlay resume: refresh .crew context to get latest kanban/features
264
+ if (rolePlayConfig && rolePlayEntry) {
265
+ const crewContext = loadCrewContext(effectiveWorkDir);
266
+ if (crewContext) {
267
+ rolePlayConfig.crewContext = crewContext;
268
+ // Initialize mtime snapshot (without re-loading) so subsequent refreshes can detect changes
269
+ initCrewContextMtimes(effectiveWorkDir, rolePlayEntry);
270
+ console.log(`[Resume] RolePlay: refreshed .crew context (${crewContext.features.length} features)`);
271
+ }
272
+ }
273
+
263
274
  ctx.conversations.set(conversationId, {
264
275
  query: null,
265
276
  inputStream: null,
@@ -483,6 +494,18 @@ export async function handleUserInput(msg) {
483
494
  const resumeSessionId = claudeSessionId || state?.claudeSessionId || null;
484
495
  const effectiveWorkDir = workDir || state?.workDir || ctx.CONFIG.workDir;
485
496
 
497
+ // ★ RolePlay: refresh .crew context before starting a new query
498
+ // so the appendSystemPrompt has the latest kanban/features
499
+ if (state?.rolePlayConfig) {
500
+ const rpSession = rolePlaySessions.get(conversationId);
501
+ if (rpSession) {
502
+ const refreshed = refreshCrewContext(effectiveWorkDir, rpSession, state);
503
+ if (refreshed) {
504
+ console.log(`[SDK] RolePlay: .crew context refreshed before query start`);
505
+ }
506
+ }
507
+ }
508
+
486
509
  console.log(`[SDK] Starting Claude for ${conversationId}, resume: ${resumeSessionId || 'none'}`);
487
510
  state = await startClaudeQuery(conversationId, effectiveWorkDir, resumeSessionId);
488
511
  }
package/crew/control.js CHANGED
@@ -362,6 +362,11 @@ async function clearSession(session) {
362
362
 
363
363
  session.round = 0;
364
364
 
365
+ // 重置计费统计(clearSession 清除了所有 claudeSessionId,后续 query 全新,费用从零开始)
366
+ session.costUsd = 0;
367
+ session.totalInputTokens = 0;
368
+ session.totalOutputTokens = 0;
369
+
365
370
  const messagesPath = join(session.sharedDir, 'messages.json');
366
371
  await fs.writeFile(messagesPath, '[]').catch(() => {});
367
372
  await cleanupMessageShards(session.sharedDir);
@@ -15,11 +15,40 @@ const getMaxContext = () => ctx.CONFIG?.maxContextTokens || 128000;
15
15
  * 处理角色的流式输出
16
16
  */
17
17
  export async function processRoleOutput(session, roleName, roleQuery, roleState) {
18
+ // 辅助函数:将 lastSeenUsage 结算到 session(用于 abort/error 场景,避免丢失 token)
19
+ function settleLastSeenUsage() {
20
+ if (!roleState.lastSeenUsage) return;
21
+ const { totalCostUsd, inputTokens, outputTokens } = roleState.lastSeenUsage;
22
+ if (totalCostUsd != null) {
23
+ const costDelta = totalCostUsd - roleState.lastCostUsd;
24
+ if (costDelta > 0) session.costUsd += costDelta;
25
+ roleState.lastCostUsd = totalCostUsd;
26
+ }
27
+ if (inputTokens != null || outputTokens != null) {
28
+ const inputDelta = (inputTokens || 0) - (roleState.lastInputTokens || 0);
29
+ const outputDelta = (outputTokens || 0) - (roleState.lastOutputTokens || 0);
30
+ if (inputDelta > 0) session.totalInputTokens += inputDelta;
31
+ if (outputDelta > 0) session.totalOutputTokens += outputDelta;
32
+ roleState.lastInputTokens = inputTokens || 0;
33
+ roleState.lastOutputTokens = outputTokens || 0;
34
+ }
35
+ roleState.lastSeenUsage = null;
36
+ }
37
+
18
38
  try {
19
39
  for await (const message of roleQuery) {
20
40
  // 检查 session 是否已停止或暂停
21
41
  if (session.status === 'stopped' || session.status === 'paused') break;
22
42
 
43
+ // 每次收到带 usage/cost 的消息,暂存到 lastSeenUsage(供 abort/error 结算)
44
+ if (message.total_cost_usd != null || message.usage) {
45
+ roleState.lastSeenUsage = {
46
+ totalCostUsd: message.total_cost_usd,
47
+ inputTokens: message.usage?.input_tokens,
48
+ outputTokens: message.usage?.output_tokens
49
+ };
50
+ }
51
+
23
52
  if (message.type === 'system' && message.subtype === 'init') {
24
53
  roleState.claudeSessionId = message.session_id;
25
54
  console.log(`[Crew] ${roleName} session: ${message.session_id}`);
@@ -70,20 +99,8 @@ export async function processRoleOutput(session, roleName, roleQuery, roleState)
70
99
 
71
100
  endRoleStreaming(session, roleName);
72
101
 
73
- // 更新费用(差值计算)
74
- if (message.total_cost_usd != null) {
75
- const costDelta = message.total_cost_usd - roleState.lastCostUsd;
76
- if (costDelta > 0) session.costUsd += costDelta;
77
- roleState.lastCostUsd = message.total_cost_usd;
78
- }
79
- if (message.usage) {
80
- const inputDelta = (message.usage.input_tokens || 0) - (roleState.lastInputTokens || 0);
81
- const outputDelta = (message.usage.output_tokens || 0) - (roleState.lastOutputTokens || 0);
82
- if (inputDelta > 0) session.totalInputTokens += inputDelta;
83
- if (outputDelta > 0) session.totalOutputTokens += outputDelta;
84
- roleState.lastInputTokens = message.usage.input_tokens || 0;
85
- roleState.lastOutputTokens = message.usage.output_tokens || 0;
86
- }
102
+ // 更新费用(通过 settleLastSeenUsage 统一处理,避免重复逻辑)
103
+ settleLastSeenUsage();
87
104
 
88
105
  // 持久化 sessionId
89
106
  if (roleState.claudeSessionId) {
@@ -175,6 +192,8 @@ export async function processRoleOutput(session, roleName, roleQuery, roleState)
175
192
  } catch (error) {
176
193
  if (error.name === 'AbortError') {
177
194
  console.log(`[Crew] ${roleName} aborted`);
195
+ // 结算 abort 前累积的 usage,避免丢失 token
196
+ settleLastSeenUsage();
178
197
  if (session.status === 'paused' && roleState.accumulatedText) {
179
198
  const routes = parseRoutes(roleState.accumulatedText);
180
199
  if (routes.length > 0 && session.pendingRoutes.length === 0) {
@@ -186,6 +205,9 @@ export async function processRoleOutput(session, roleName, roleQuery, roleState)
186
205
  } else {
187
206
  console.error(`[Crew] ${roleName} error:`, error.message);
188
207
 
208
+ // 结算 error 前累积的 usage,避免丢失 token
209
+ settleLastSeenUsage();
210
+
189
211
  // Step 1: 清理 roleState
190
212
  endRoleStreaming(session, roleName);
191
213
  const errorTurnText = roleState.accumulatedText;
@@ -127,6 +127,11 @@ export async function createRoleQuery(session, roleName) {
127
127
  options: queryOptions
128
128
  });
129
129
 
130
+ // resume 场景:保留已有 roleState 的 baseline,避免双重计算
131
+ // 只有 fresh query(无 savedSessionId)才用 0 初始化
132
+ const existingState = session.roleStates.get(roleName);
133
+ const isResume = !!savedSessionId;
134
+
130
135
  const roleState = {
131
136
  query: roleQuery,
132
137
  inputStream,
@@ -134,9 +139,10 @@ export async function createRoleQuery(session, roleName) {
134
139
  accumulatedText: '',
135
140
  turnActive: false,
136
141
  claudeSessionId: savedSessionId,
137
- lastCostUsd: 0,
138
- lastInputTokens: 0,
139
- lastOutputTokens: 0,
142
+ lastCostUsd: (isResume && existingState?.lastCostUsd) || 0,
143
+ lastInputTokens: (isResume && existingState?.lastInputTokens) || 0,
144
+ lastOutputTokens: (isResume && existingState?.lastOutputTokens) || 0,
145
+ lastSeenUsage: null,
140
146
  consecutiveErrors: 0,
141
147
  lastDispatchContent: null,
142
148
  lastDispatchFrom: null,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yeaft/webchat-agent",
3
- "version": "0.1.74",
3
+ "version": "0.1.76",
4
4
  "description": "Remote agent for Yeaft WebChat — connects worker machines to the central server",
5
5
  "main": "index.js",
6
6
  "type": "module",
package/roleplay.js CHANGED
@@ -9,7 +9,8 @@
9
9
 
10
10
  import { join } from 'path';
11
11
  import { homedir } from 'os';
12
- import { readFileSync, writeFileSync, existsSync, renameSync, readdirSync } from 'fs';
12
+ import { readFileSync, writeFileSync, existsSync, renameSync, readdirSync, statSync } from 'fs';
13
+ import { promises as fsp } from 'fs';
13
14
  import { parseRoutes } from './crew/routing.js';
14
15
 
15
16
  const ROLEPLAY_INDEX_PATH = join(homedir(), '.claude', 'roleplay-sessions.json');
@@ -26,8 +27,8 @@ export const rolePlaySessions = new Map();
26
27
  export function saveRolePlayIndex() {
27
28
  const data = [];
28
29
  for (const [id, session] of rolePlaySessions) {
29
- // Only persist core fields, skip runtime route state
30
- const { _routeInitialized, currentRole, features, round, roleStates, waitingHuman, waitingHumanContext, ...core } = session;
30
+ // Only persist core fields, skip runtime route state and mtime snapshots
31
+ const { _routeInitialized, _crewContextMtimes, currentRole, features, round, roleStates, waitingHuman, waitingHumanContext, ...core } = session;
31
32
  data.push({ id, ...core });
32
33
  }
33
34
  try {
@@ -218,7 +219,7 @@ function deduplicateRoles(sessionRoles, roleClaudes) {
218
219
  const ALLOWED_TEAM_TYPES = ['dev', 'writing', 'trading', 'video', 'custom'];
219
220
  const MAX_ROLE_NAME_LEN = 64;
220
221
  const MAX_DISPLAY_NAME_LEN = 128;
221
- const MAX_CLAUDE_MD_LEN = 4096;
222
+ const MAX_CLAUDE_MD_LEN = 8192;
222
223
  const MAX_ROLES = 10;
223
224
 
224
225
  /**
@@ -716,3 +717,227 @@ function buildCrewContextBlock(crewContext, isZh) {
716
717
 
717
718
  return sections.join('\n\n');
718
719
  }
720
+
721
+ // ---------------------------------------------------------------------------
722
+ // .crew context refresh (mtime-based change detection)
723
+ // ---------------------------------------------------------------------------
724
+
725
+ /**
726
+ * Get mtime of a file, or 0 if it doesn't exist.
727
+ * @param {string} filePath
728
+ * @returns {number} mtime in ms
729
+ */
730
+ function getMtimeMs(filePath) {
731
+ try {
732
+ return statSync(filePath).mtimeMs;
733
+ } catch {
734
+ return 0;
735
+ }
736
+ }
737
+
738
+ /**
739
+ * Collect mtimes for all .crew context files that matter for RolePlay.
740
+ * Returns a map of { relativePath → mtimeMs }.
741
+ *
742
+ * @param {string} projectDir
743
+ * @returns {Map<string, number>}
744
+ */
745
+ function collectCrewContextMtimes(projectDir) {
746
+ const crewDir = join(projectDir, '.crew');
747
+ const mtimes = new Map();
748
+
749
+ // Shared CLAUDE.md
750
+ mtimes.set('CLAUDE.md', getMtimeMs(join(crewDir, 'CLAUDE.md')));
751
+
752
+ // context/kanban.md
753
+ mtimes.set('context/kanban.md', getMtimeMs(join(crewDir, 'context', 'kanban.md')));
754
+
755
+ // context/features/*.md
756
+ const featuresDir = join(crewDir, 'context', 'features');
757
+ if (existsSync(featuresDir)) {
758
+ try {
759
+ const files = readdirSync(featuresDir).filter(f => f.endsWith('.md') && f !== 'index.md');
760
+ for (const f of files) {
761
+ mtimes.set(`context/features/${f}`, getMtimeMs(join(featuresDir, f)));
762
+ }
763
+ } catch { /* ignore */ }
764
+ }
765
+
766
+ // session.json (roles may change)
767
+ mtimes.set('session.json', getMtimeMs(join(crewDir, 'session.json')));
768
+
769
+ return mtimes;
770
+ }
771
+
772
+ /**
773
+ * Check if .crew context has changed since last snapshot.
774
+ *
775
+ * @param {Map<string, number>} oldMtimes - previous mtime snapshot
776
+ * @param {Map<string, number>} newMtimes - current mtime snapshot
777
+ * @returns {boolean} true if any file has been added, removed, or modified
778
+ */
779
+ function hasCrewContextChanged(oldMtimes, newMtimes) {
780
+ if (!oldMtimes) return true; // first check → always refresh
781
+
782
+ // Defensive: if oldMtimes was deserialized from JSON (plain object, not Map),
783
+ // treat as stale and force refresh
784
+ if (!(oldMtimes instanceof Map)) return true;
785
+
786
+ // Check for new or modified files
787
+ for (const [path, mtime] of newMtimes) {
788
+ if (!oldMtimes.has(path) || oldMtimes.get(path) !== mtime) return true;
789
+ }
790
+
791
+ // Check for deleted files
792
+ for (const path of oldMtimes.keys()) {
793
+ if (!newMtimes.has(path)) return true;
794
+ }
795
+
796
+ return false;
797
+ }
798
+
799
+ /**
800
+ * Initialize the mtime snapshot for a RolePlay session without reloading context.
801
+ * Use this when the caller has already loaded crewContext (e.g. resume path)
802
+ * to avoid a redundant disk read.
803
+ *
804
+ * @param {string} projectDir - absolute path to project root
805
+ * @param {object} rpSession - rolePlaySessions entry
806
+ */
807
+ export function initCrewContextMtimes(projectDir, rpSession) {
808
+ if (!projectDir || !existsSync(join(projectDir, '.crew'))) return;
809
+ rpSession._crewContextMtimes = collectCrewContextMtimes(projectDir);
810
+ }
811
+
812
+ /**
813
+ * Refresh .crew context for a RolePlay session if files have changed.
814
+ * Updates the session's crewContext and returns true if refreshed.
815
+ *
816
+ * Call this before building the system prompt (on resume, or before each turn).
817
+ *
818
+ * @param {string} projectDir - absolute path to project root
819
+ * @param {object} rpSession - rolePlaySessions entry
820
+ * @param {object} convState - ctx.conversations entry (has rolePlayConfig)
821
+ * @returns {boolean} true if context was refreshed
822
+ */
823
+ export function refreshCrewContext(projectDir, rpSession, convState) {
824
+ if (!projectDir || !existsSync(join(projectDir, '.crew'))) return false;
825
+
826
+ const newMtimes = collectCrewContextMtimes(projectDir);
827
+
828
+ // Compare with stored snapshot
829
+ if (!hasCrewContextChanged(rpSession._crewContextMtimes, newMtimes)) {
830
+ return false; // no change
831
+ }
832
+
833
+ // Reload
834
+ const crewContext = loadCrewContext(projectDir);
835
+ if (!crewContext) return false;
836
+
837
+ // Update session and convState
838
+ rpSession._crewContextMtimes = newMtimes;
839
+
840
+ if (convState && convState.rolePlayConfig) {
841
+ convState.rolePlayConfig.crewContext = crewContext;
842
+ }
843
+
844
+ console.log(`[RolePlay] Crew context refreshed from ${projectDir} (${crewContext.features.length} features, kanban: ${crewContext.kanban ? 'yes' : 'no'})`);
845
+ return true;
846
+ }
847
+
848
+ // ---------------------------------------------------------------------------
849
+ // .crew context write-back (RolePlay → .crew/context)
850
+ // ---------------------------------------------------------------------------
851
+
852
+ // Write lock for atomic write-back
853
+ let _writeBackLock = Promise.resolve();
854
+
855
+ /**
856
+ * Atomic write: write to temp file then rename.
857
+ * @param {string} filePath
858
+ * @param {string} content
859
+ */
860
+ async function atomicWrite(filePath, content) {
861
+ const tmpPath = filePath + '.tmp.' + Date.now();
862
+ try {
863
+ await fsp.writeFile(tmpPath, content);
864
+ await fsp.rename(tmpPath, filePath);
865
+ } catch (e) {
866
+ // Clean up temp file on failure
867
+ try { await fsp.unlink(tmpPath); } catch { /* ignore */ }
868
+ throw e;
869
+ }
870
+ }
871
+
872
+ /**
873
+ * Write back RolePlay route output to .crew/context files.
874
+ * Called after processRolePlayRoutes detects ROUTE blocks with task info.
875
+ *
876
+ * - Creates/updates context/features/{taskId}.md with route summary
877
+ * - Serialized via write lock to prevent concurrent corruption
878
+ *
879
+ * NOTE: The atomic write (tmp→rename) prevents partial writes within this
880
+ * process, and the serial lock prevents intra-process races. However, if a
881
+ * Crew session in a separate process writes the same file concurrently,
882
+ * a TOCTOU race is possible (read-then-write is not locked across processes).
883
+ * In practice this is acceptable: Crew and RolePlay rarely write the same
884
+ * task file simultaneously, and the worst case is a lost append (not corruption).
885
+ *
886
+ * @param {string} projectDir - absolute path to project root
887
+ * @param {Array<{to: string, summary: string, taskId?: string, taskTitle?: string}>} routes
888
+ * @param {string} fromRole - name of the role that produced the output
889
+ * @param {object} rpSession - rolePlaySessions entry
890
+ */
891
+ export function writeBackRouteContext(projectDir, routes, fromRole, rpSession) {
892
+ if (!projectDir || !routes || routes.length === 0) return;
893
+
894
+ const crewDir = join(projectDir, '.crew');
895
+ if (!existsSync(crewDir)) return;
896
+
897
+ const doWriteBack = async () => {
898
+ const featuresDir = join(crewDir, 'context', 'features');
899
+
900
+ for (const route of routes) {
901
+ const { taskId, taskTitle, summary, to } = route;
902
+ if (!taskId || !summary) continue;
903
+
904
+ // ★ Sanitize taskId: only allow alphanumeric, hyphens, underscores
905
+ // Prevents path traversal (e.g. "../" in taskId from Claude output)
906
+ if (!/^[a-zA-Z0-9_-]+$/.test(taskId)) {
907
+ console.warn(`[RolePlay] Write-back rejected: invalid taskId "${taskId}"`);
908
+ continue;
909
+ }
910
+
911
+ try {
912
+ await fsp.mkdir(featuresDir, { recursive: true });
913
+ const filePath = join(featuresDir, `${taskId}.md`);
914
+
915
+ let content;
916
+ try {
917
+ content = await fsp.readFile(filePath, 'utf-8');
918
+ } catch {
919
+ // File doesn't exist — create it
920
+ const isZh = rpSession.language === 'zh-CN';
921
+ content = `# ${isZh ? 'Feature' : 'Feature'}: ${taskTitle || taskId}\n- task-id: ${taskId}\n\n## ${isZh ? '工作记录' : 'Work Record'}\n`;
922
+ }
923
+
924
+ // Append the route record
925
+ const fromRoleConfig = rpSession.roles?.find(r => r.name === fromRole);
926
+ const fromLabel = fromRoleConfig
927
+ ? (fromRoleConfig.icon ? `${fromRoleConfig.icon} ${fromRoleConfig.displayName}` : fromRoleConfig.displayName)
928
+ : fromRole;
929
+ const now = new Date().toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai' });
930
+ const record = `\n### ${fromLabel} → ${to} - ${now}\n${summary}\n`;
931
+
932
+ await atomicWrite(filePath, content + record);
933
+ console.log(`[RolePlay] Write-back: task ${taskId} updated (${fromRole} → ${to})`);
934
+ } catch (e) {
935
+ console.warn(`[RolePlay] Write-back failed for ${taskId}:`, e.message);
936
+ }
937
+ }
938
+ };
939
+
940
+ // Serialize write-backs
941
+ _writeBackLock = _writeBackLock.then(doWriteBack, doWriteBack);
942
+ return _writeBackLock;
943
+ }