@yeaft/webchat-agent 0.1.225 → 0.1.227

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.
@@ -1,156 +1,219 @@
1
1
  /**
2
- * Conductor — 持久化管理
2
+ * Conductor — 持久化管理 (V5)
3
3
  *
4
- * 全局 session 索引 (~/.claude/conductor-sessions.json)
5
- * 每 task 的 .conductor/ 目录结构
6
- * Session 元数据 + UI 消息分片(复用 crew 的 logrotate 模式)
4
+ * Conductor Home: ~/.config/yeaft-agent/.conductor/
5
+ * session.json — Conductor Claude session 元数据
6
+ * state.json — 全局 task 注册表
7
+ * messages.json — UI 消息(分片)
8
+ *
9
+ * Task 目录: {workDir}/.conductor/task-N/
10
+ * CLAUDE.md, memory.md, plan.json, status.json
11
+ * actors/{specialty}-{persona}/CLAUDE.md
7
12
  */
8
13
  import { promises as fs } from 'fs';
9
14
  import { join } from 'path';
10
- import { homedir } from 'os';
15
+ import { getConfigDir } from '../service.js';
16
+ import { createTaskWorktree } from './worktree.js';
11
17
 
12
18
  // =====================================================================
13
- // Conductor Session Index (~/.claude/conductor-sessions.json)
19
+ // Conductor Home (Agent-level, singleton)
14
20
  // =====================================================================
15
21
 
16
- const CONDUCTOR_INDEX_PATH = join(homedir(), '.claude', 'conductor-sessions.json');
17
-
18
- let _indexWriteLock = Promise.resolve();
22
+ const CONDUCTOR_HOME = join(getConfigDir(), '.conductor');
19
23
 
20
- export async function loadConductorIndex() {
21
- try { return JSON.parse(await fs.readFile(CONDUCTOR_INDEX_PATH, 'utf-8')); }
22
- catch { return []; }
24
+ export function getConductorHome() {
25
+ return CONDUCTOR_HOME;
23
26
  }
24
27
 
25
- async function saveConductorIndex(index) {
26
- const doWrite = async () => {
27
- await fs.mkdir(join(homedir(), '.claude'), { recursive: true });
28
- const data = JSON.stringify(index, null, 2);
29
- const tmpPath = CONDUCTOR_INDEX_PATH + '.tmp';
30
- await fs.writeFile(tmpPath, data);
31
- await fs.rename(tmpPath, CONDUCTOR_INDEX_PATH);
32
- };
33
- _indexWriteLock = _indexWriteLock.then(doWrite, doWrite);
34
- return _indexWriteLock;
28
+ export async function ensureConductorHome() {
29
+ await fs.mkdir(CONDUCTOR_HOME, { recursive: true });
30
+ return CONDUCTOR_HOME;
35
31
  }
36
32
 
37
- function sessionToIndexEntry(session) {
38
- return {
39
- sessionId: session.id,
40
- status: session.status,
41
- name: session.name || '',
42
- workDir: session.workDir || null,
43
- userId: session.userId,
44
- username: session.username,
45
- agentId: session.agentId || null,
46
- scenarioId: session.scenarioId || null,
47
- createdAt: session.createdAt,
48
- updatedAt: Date.now()
49
- };
50
- }
33
+ // =====================================================================
34
+ // Task Directory ({workDir}/.conductor/{taskId}/)
35
+ // =====================================================================
51
36
 
52
- export async function upsertConductorIndex(session) {
53
- const index = await loadConductorIndex();
54
- const entry = sessionToIndexEntry(session);
55
- const idx = index.findIndex(e => e.sessionId === session.id);
56
- if (idx >= 0) index[idx] = entry; else index.push(entry);
57
- await saveConductorIndex(index);
37
+ export function getTaskDir(workDir, taskId) {
38
+ return join(workDir, '.conductor', taskId);
58
39
  }
59
40
 
60
- export async function hideConductorSession(sessionId, conductorSessions) {
61
- const index = await loadConductorIndex();
62
- const entry = index.find(e => e.sessionId === sessionId);
63
- if (entry) {
64
- entry.hidden = true;
65
- entry.hiddenAt = Date.now();
66
- await saveConductorIndex(index);
67
- console.log(`[Conductor] Hidden session ${sessionId}`);
41
+ export async function initTaskDir(workDir, taskId) {
42
+ const dir = getTaskDir(workDir, taskId);
43
+ await fs.mkdir(join(dir, 'actors'), { recursive: true });
44
+
45
+ // Initialize empty files if they don't exist
46
+ const files = {
47
+ 'CLAUDE.md': '',
48
+ 'memory.md': '',
49
+ 'status.json': JSON.stringify({ taskId, status: 'created', updatedAt: Date.now() }, null, 2)
50
+ };
51
+ for (const [name, content] of Object.entries(files)) {
52
+ const filePath = join(dir, name);
53
+ try {
54
+ await fs.access(filePath);
55
+ } catch {
56
+ await fs.writeFile(filePath, content);
57
+ }
68
58
  }
69
- if (conductorSessions.has(sessionId)) {
70
- conductorSessions.delete(sessionId);
59
+
60
+ // Create worktree for this task (isolates file modifications per task)
61
+ let worktreePath = null;
62
+ try {
63
+ worktreePath = await createTaskWorktree(workDir, taskId, dir);
64
+ } catch (e) {
65
+ console.warn(`[Conductor] Failed to create worktree for ${taskId}:`, e.message);
71
66
  }
67
+
68
+ return { dir, worktreePath };
72
69
  }
73
70
 
74
71
  // =====================================================================
75
- // Conductor Directory Structure
72
+ // State JSON (Global Task Registry)
76
73
  // =====================================================================
77
74
 
75
+ let _stateWriteLock = Promise.resolve();
76
+
77
+ /**
78
+ * Read state.json from disk (no locking).
79
+ * Internal helper — use inside _stateWriteLock or via loadState().
80
+ */
81
+ async function loadStateRaw() {
82
+ const filePath = join(CONDUCTOR_HOME, 'state.json');
83
+ try {
84
+ return JSON.parse(await fs.readFile(filePath, 'utf-8'));
85
+ } catch {
86
+ return { tasks: {}, lastUpdate: 0 };
87
+ }
88
+ }
89
+
90
+ /**
91
+ * Write state to disk (no locking, atomic via tmp+rename).
92
+ * Internal helper — must only be called inside _stateWriteLock chain.
93
+ */
94
+ async function writeState(state) {
95
+ await fs.mkdir(CONDUCTOR_HOME, { recursive: true });
96
+ state.lastUpdate = Date.now();
97
+ const data = JSON.stringify(state, null, 2);
98
+ const filePath = join(CONDUCTOR_HOME, 'state.json');
99
+ const tmpPath = filePath + '.tmp';
100
+ await fs.writeFile(tmpPath, data);
101
+ await fs.rename(tmpPath, filePath);
102
+ }
103
+
104
+ /**
105
+ * Load global task registry from state.json
106
+ * @returns {ConductorState} { tasks: Record<taskId, TaskRegistryEntry>, lastUpdate }
107
+ */
108
+ export async function loadState() {
109
+ return loadStateRaw();
110
+ }
111
+
78
112
  /**
79
- * 初始化 .conductor 目录结构
80
- * 注意:.conductor 放在 task 绑定的 workDir 下,不在 session 级
81
- * session 级元数据存储在 ~/.claude/conductor/<sessionId>/
113
+ * Save global task registry to state.json (serialized via lock)
82
114
  */
83
- const CONDUCTOR_DATA_DIR = join(homedir(), '.claude', 'conductor');
115
+ export async function saveState(state) {
116
+ const doWrite = () => writeState(state);
117
+ _stateWriteLock = _stateWriteLock.then(doWrite, doWrite);
118
+ return _stateWriteLock;
119
+ }
84
120
 
85
- export function getSessionDataDir(sessionId) {
86
- return join(CONDUCTOR_DATA_DIR, sessionId);
121
+ /**
122
+ * Update a single task entry in state.json
123
+ * Read-modify-write is serialized through _stateWriteLock to prevent races.
124
+ */
125
+ export async function updateTaskInState(taskId, entry) {
126
+ const doUpdate = async () => {
127
+ const state = await loadStateRaw();
128
+ state.tasks[taskId] = { ...entry, lastUpdate: Date.now() };
129
+ await writeState(state);
130
+ };
131
+ _stateWriteLock = _stateWriteLock.then(doUpdate, doUpdate);
132
+ return _stateWriteLock;
87
133
  }
88
134
 
89
- export async function initSessionDataDir(sessionId) {
90
- const dir = getSessionDataDir(sessionId);
91
- await fs.mkdir(dir, { recursive: true });
92
- return dir;
135
+ /**
136
+ * Remove a task entry from state.json
137
+ * Read-modify-write is serialized through _stateWriteLock to prevent races.
138
+ */
139
+ export async function removeTaskFromState(taskId) {
140
+ const doRemove = async () => {
141
+ const state = await loadStateRaw();
142
+ delete state.tasks[taskId];
143
+ await writeState(state);
144
+ };
145
+ _stateWriteLock = _stateWriteLock.then(doRemove, doRemove);
146
+ return _stateWriteLock;
93
147
  }
94
148
 
95
149
  // =====================================================================
96
- // Session Metadata
150
+ // Conductor Metadata (session.json — Claude session persistence)
97
151
  // =====================================================================
98
152
 
99
- const MESSAGE_SHARD_SIZE = 256 * 1024;
100
-
101
- export async function saveSessionMeta(session) {
102
- const dir = getSessionDataDir(session.id);
103
- await fs.mkdir(dir, { recursive: true });
153
+ export async function saveConductorMeta(conductor) {
154
+ const dir = await ensureConductorHome();
104
155
 
105
156
  const meta = {
106
- sessionId: session.id,
107
- name: session.name || '',
108
- status: session.status,
109
- workDir: session.workDir || null,
110
- scenarioId: session.scenarioId || null,
111
- tasks: Array.from(session.tasks.values()).map(t => ({
112
- taskId: t.taskId,
113
- title: t.title,
114
- workDir: t.workDir,
115
- status: t.status,
116
- phase: t.phase,
117
- progress: t.progress,
118
- createdAt: t.createdAt,
119
- updatedAt: t.updatedAt
120
- })),
121
- costUsd: session.costUsd,
122
- totalInputTokens: session.totalInputTokens,
123
- totalOutputTokens: session.totalOutputTokens,
124
- userId: session.userId,
125
- username: session.username,
126
- agentId: session.agentId || null,
127
- createdAt: session.createdAt,
157
+ status: conductor.status,
158
+ costUsd: conductor.costUsd,
159
+ totalInputTokens: conductor.totalInputTokens,
160
+ totalOutputTokens: conductor.totalOutputTokens,
161
+ userId: conductor.userId,
162
+ username: conductor.username,
163
+ createdAt: conductor.createdAt,
128
164
  updatedAt: Date.now()
129
165
  };
130
166
 
131
167
  await fs.writeFile(join(dir, 'session.json'), JSON.stringify(meta, null, 2));
132
168
 
133
- // 保存 UI 消息(logrotate 分片)
134
- if (session.uiMessages && session.uiMessages.length > 0) {
135
- const cleaned = session.uiMessages.map(m => {
169
+ // Save UI messages (logrotate shards)
170
+ if (conductor.uiMessages && conductor.uiMessages.length > 0) {
171
+ const cleaned = conductor.uiMessages.map(m => {
136
172
  const { _streaming, ...rest } = m;
137
173
  return rest;
138
174
  });
139
175
  const json = JSON.stringify(cleaned);
140
- if (json.length > MESSAGE_SHARD_SIZE && !session._rotating) {
141
- await rotateMessages(session, dir, cleaned);
176
+ if (json.length > MESSAGE_SHARD_SIZE && !conductor._rotating) {
177
+ await rotateMessages(conductor, dir, cleaned);
142
178
  } else {
143
179
  await fs.writeFile(join(dir, 'messages.json'), json);
144
180
  }
145
181
  }
146
182
  }
147
183
 
148
- async function rotateMessages(session, dir, cleaned) {
149
- session._rotating = true;
184
+ export async function loadConductorMeta() {
185
+ const filePath = join(CONDUCTOR_HOME, 'session.json');
186
+ try {
187
+ return JSON.parse(await fs.readFile(filePath, 'utf-8'));
188
+ } catch {
189
+ return null;
190
+ }
191
+ }
192
+
193
+ // =====================================================================
194
+ // UI Message Persistence (logrotate shards in conductor home)
195
+ // =====================================================================
196
+
197
+ const MESSAGE_SHARD_SIZE = 256 * 1024;
198
+
199
+ export async function loadConductorMessages() {
200
+ let messages = [];
201
+ try {
202
+ messages = JSON.parse(await fs.readFile(join(CONDUCTOR_HOME, 'messages.json'), 'utf-8'));
203
+ } catch { /* file may not exist */ }
204
+ let hasOlderMessages = false;
205
+ try {
206
+ await fs.access(join(CONDUCTOR_HOME, 'messages.1.json'));
207
+ hasOlderMessages = true;
208
+ } catch { /* no older shards */ }
209
+ return { messages, hasOlderMessages };
210
+ }
211
+
212
+ async function rotateMessages(conductor, dir, cleaned) {
213
+ conductor._rotating = true;
150
214
  try {
151
215
  const halfLen = Math.floor(cleaned.length / 2);
152
216
  let splitIdx = halfLen;
153
- // 尝试找 system/route 类型消息作为断点
154
217
  for (let i = halfLen; i > Math.max(0, halfLen - 20); i--) {
155
218
  if (cleaned[i].type === 'system' || cleaned[i].type === 'task_created') {
156
219
  splitIdx = i + 1;
@@ -171,11 +234,11 @@ async function rotateMessages(session, dir, cleaned) {
171
234
 
172
235
  await fs.writeFile(join(dir, 'messages.1.json'), JSON.stringify(archivePart));
173
236
  await fs.writeFile(join(dir, 'messages.json'), JSON.stringify(remainPart));
174
- session.uiMessages = remainPart.map(m => ({ ...m }));
237
+ conductor.uiMessages = remainPart.map(m => ({ ...m }));
175
238
 
176
239
  console.log(`[Conductor] Rotated: archived ${archivePart.length} msgs, kept ${remainPart.length}`);
177
240
  } finally {
178
- session._rotating = false;
241
+ conductor._rotating = false;
179
242
  }
180
243
  }
181
244
 
@@ -205,47 +268,20 @@ export async function cleanupMessageShards(dir) {
205
268
  } catch { /* dir may not exist */ }
206
269
  }
207
270
 
208
- export async function loadSessionMeta(sessionId) {
209
- const dir = getSessionDataDir(sessionId);
210
- try { return JSON.parse(await fs.readFile(join(dir, 'session.json'), 'utf-8')); }
211
- catch { return null; }
212
- }
213
-
214
- export async function loadSessionMessages(sessionId) {
215
- const dir = getSessionDataDir(sessionId);
216
- let messages = [];
217
- try { messages = JSON.parse(await fs.readFile(join(dir, 'messages.json'), 'utf-8')); }
218
- catch { /* file may not exist */ }
219
- let hasOlderMessages = false;
220
- try {
221
- await fs.access(join(dir, 'messages.1.json'));
222
- hasOlderMessages = true;
223
- } catch { /* no older shards */ }
224
- return { messages, hasOlderMessages };
225
- }
226
-
227
- export async function handleLoadConductorHistory(msg, conductorSessions, sendConductorMessage) {
228
- const { sessionId, requestId } = msg;
271
+ export async function handleLoadConductorHistory(msg, conductor, sendMsg) {
272
+ const { requestId } = msg;
229
273
  const shardIndex = parseInt(msg.shardIndex, 10);
230
274
 
231
- if (!Number.isFinite(shardIndex) || shardIndex < 1) {
232
- sendConductorMessage({
233
- type: 'conductor_history_loaded',
234
- sessionId, shardIndex: msg.shardIndex, requestId,
235
- messages: [], hasMore: false
236
- });
237
- return;
238
- }
239
- if (!conductorSessions.has(sessionId)) {
240
- sendConductorMessage({
275
+ if (!Number.isFinite(shardIndex) || shardIndex < 1 || !conductor) {
276
+ sendMsg({
241
277
  type: 'conductor_history_loaded',
242
- sessionId, shardIndex, requestId,
278
+ shardIndex: msg.shardIndex, requestId,
243
279
  messages: [], hasMore: false
244
280
  });
245
281
  return;
246
282
  }
247
283
 
248
- const dir = getSessionDataDir(sessionId);
284
+ const dir = getConductorHome();
249
285
  const shardPath = join(dir, `messages.${shardIndex}.json`);
250
286
  let messages = [];
251
287
  try {
@@ -253,9 +289,9 @@ export async function handleLoadConductorHistory(msg, conductorSessions, sendCon
253
289
  } catch { /* shard doesn't exist */ }
254
290
 
255
291
  const hasMore = shardIndex < await getMaxShardIndex(dir);
256
- sendConductorMessage({
292
+ sendMsg({
257
293
  type: 'conductor_history_loaded',
258
- sessionId, shardIndex, requestId,
294
+ shardIndex, requestId,
259
295
  messages, hasMore
260
296
  });
261
297
  }