@yeaft/webchat-agent 0.1.75 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yeaft/webchat-agent",
3
- "version": "0.1.75",
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
+ }