agileflow 2.89.3 → 2.90.0
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/CHANGELOG.md +5 -0
- package/README.md +3 -3
- package/lib/placeholder-registry.js +617 -0
- package/lib/smart-json-file.js +205 -1
- package/lib/table-formatter.js +504 -0
- package/lib/transient-status.js +374 -0
- package/lib/ui-manager.js +612 -0
- package/lib/validate-args.js +213 -0
- package/lib/validate-names.js +143 -0
- package/lib/validate-paths.js +434 -0
- package/lib/validate.js +37 -737
- package/package.json +4 -1
- package/scripts/check-update.js +16 -3
- package/scripts/lib/sessionRegistry.js +682 -0
- package/scripts/session-manager.js +77 -10
- package/scripts/tui/App.js +176 -0
- package/scripts/tui/index.js +75 -0
- package/scripts/tui/lib/crashRecovery.js +302 -0
- package/scripts/tui/lib/eventStream.js +316 -0
- package/scripts/tui/lib/keyboard.js +252 -0
- package/scripts/tui/lib/loopControl.js +371 -0
- package/scripts/tui/panels/OutputPanel.js +278 -0
- package/scripts/tui/panels/SessionPanel.js +178 -0
- package/scripts/tui/panels/TracePanel.js +333 -0
- package/src/core/commands/tui.md +91 -0
- package/tools/cli/commands/config.js +7 -30
- package/tools/cli/commands/doctor.js +18 -38
- package/tools/cli/commands/list.js +47 -35
- package/tools/cli/commands/status.js +13 -37
- package/tools/cli/commands/uninstall.js +9 -38
- package/tools/cli/installers/core/installer.js +13 -0
- package/tools/cli/lib/command-context.js +374 -0
- package/tools/cli/lib/config-manager.js +394 -0
- package/tools/cli/lib/ide-registry.js +186 -0
- package/tools/cli/lib/npm-utils.js +16 -3
- package/tools/cli/lib/self-update.js +148 -0
- package/tools/cli/lib/validation-middleware.js +491 -0
|
@@ -0,0 +1,682 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Session Registry - Event Bus Architecture for Session Management
|
|
5
|
+
*
|
|
6
|
+
* Encapsulates all registry I/O with event emissions:
|
|
7
|
+
* - registered: When a new session is registered
|
|
8
|
+
* - unregistered: When a session is unregistered
|
|
9
|
+
* - updated: When session data is modified
|
|
10
|
+
* - loaded: When registry is loaded from disk
|
|
11
|
+
* - saved: When registry is saved to disk
|
|
12
|
+
* - locked: When a session lock is acquired
|
|
13
|
+
* - unlocked: When a session lock is released
|
|
14
|
+
* - cleaned: When stale locks are cleaned up
|
|
15
|
+
*
|
|
16
|
+
* Features:
|
|
17
|
+
* - Cached registry with TTL
|
|
18
|
+
* - Batched git operations
|
|
19
|
+
* - Pre-parsed JSON returns
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
const EventEmitter = require('events');
|
|
23
|
+
const fs = require('fs');
|
|
24
|
+
const path = require('path');
|
|
25
|
+
const { spawnSync } = require('child_process');
|
|
26
|
+
|
|
27
|
+
// Import shared utilities
|
|
28
|
+
let getProjectRoot;
|
|
29
|
+
try {
|
|
30
|
+
getProjectRoot = require('../../lib/paths').getProjectRoot;
|
|
31
|
+
} catch (e) {
|
|
32
|
+
getProjectRoot = () => process.cwd();
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Cache configuration
|
|
36
|
+
const CACHE_TTL_MS = 10000; // 10 seconds
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* SessionRegistry - Event-driven session registry manager
|
|
40
|
+
*/
|
|
41
|
+
class SessionRegistry extends EventEmitter {
|
|
42
|
+
constructor(options = {}) {
|
|
43
|
+
super();
|
|
44
|
+
|
|
45
|
+
this.rootDir = options.rootDir || getProjectRoot();
|
|
46
|
+
this.sessionsDir = path.join(this.rootDir, '.agileflow', 'sessions');
|
|
47
|
+
this.registryPath = path.join(this.sessionsDir, 'registry.json');
|
|
48
|
+
this.cacheTTL = options.cacheTTL || CACHE_TTL_MS;
|
|
49
|
+
|
|
50
|
+
// Cache state
|
|
51
|
+
this._cache = null;
|
|
52
|
+
this._cacheTime = 0;
|
|
53
|
+
this._dirty = false;
|
|
54
|
+
|
|
55
|
+
// Batch state
|
|
56
|
+
this._batchMode = false;
|
|
57
|
+
this._batchedWrites = [];
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Ensure sessions directory exists
|
|
62
|
+
*/
|
|
63
|
+
ensureDir() {
|
|
64
|
+
if (!fs.existsSync(this.sessionsDir)) {
|
|
65
|
+
fs.mkdirSync(this.sessionsDir, { recursive: true });
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Check if cache is valid
|
|
71
|
+
*/
|
|
72
|
+
isCacheValid() {
|
|
73
|
+
if (!this._cache) return false;
|
|
74
|
+
if (this._dirty) return false;
|
|
75
|
+
return Date.now() - this._cacheTime < this.cacheTTL;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Invalidate cache
|
|
80
|
+
*/
|
|
81
|
+
invalidateCache() {
|
|
82
|
+
this._cache = null;
|
|
83
|
+
this._cacheTime = 0;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Load registry with caching
|
|
88
|
+
* @returns {Object} Registry data
|
|
89
|
+
*/
|
|
90
|
+
load() {
|
|
91
|
+
// Return cached if valid
|
|
92
|
+
if (this.isCacheValid()) {
|
|
93
|
+
return this._cache;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
this.ensureDir();
|
|
97
|
+
|
|
98
|
+
let registry;
|
|
99
|
+
|
|
100
|
+
if (fs.existsSync(this.registryPath)) {
|
|
101
|
+
try {
|
|
102
|
+
const content = fs.readFileSync(this.registryPath, 'utf8');
|
|
103
|
+
registry = JSON.parse(content);
|
|
104
|
+
} catch (e) {
|
|
105
|
+
this.emit('error', { type: 'load', error: e.message });
|
|
106
|
+
registry = this._createDefault();
|
|
107
|
+
}
|
|
108
|
+
} else {
|
|
109
|
+
registry = this._createDefault();
|
|
110
|
+
this._saveImmediate(registry);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Update cache
|
|
114
|
+
this._cache = registry;
|
|
115
|
+
this._cacheTime = Date.now();
|
|
116
|
+
this._dirty = false;
|
|
117
|
+
|
|
118
|
+
this.emit('loaded', { registry, fromCache: false });
|
|
119
|
+
|
|
120
|
+
return registry;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Create default registry structure
|
|
125
|
+
*/
|
|
126
|
+
_createDefault() {
|
|
127
|
+
return {
|
|
128
|
+
schema_version: '1.0.0',
|
|
129
|
+
next_id: 1,
|
|
130
|
+
project_name: path.basename(this.rootDir),
|
|
131
|
+
sessions: {},
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Save registry (respects batch mode)
|
|
137
|
+
* @param {Object} registry - Registry data
|
|
138
|
+
*/
|
|
139
|
+
save(registry) {
|
|
140
|
+
if (this._batchMode) {
|
|
141
|
+
this._cache = registry;
|
|
142
|
+
this._dirty = true;
|
|
143
|
+
this._batchedWrites.push({ type: 'registry', data: registry });
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
this._saveImmediate(registry);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Immediate save (bypasses batch mode)
|
|
152
|
+
*/
|
|
153
|
+
_saveImmediate(registry) {
|
|
154
|
+
this.ensureDir();
|
|
155
|
+
registry.updated = new Date().toISOString();
|
|
156
|
+
fs.writeFileSync(this.registryPath, JSON.stringify(registry, null, 2) + '\n');
|
|
157
|
+
|
|
158
|
+
// Update cache
|
|
159
|
+
this._cache = registry;
|
|
160
|
+
this._cacheTime = Date.now();
|
|
161
|
+
this._dirty = false;
|
|
162
|
+
|
|
163
|
+
this.emit('saved', { registry });
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Start batch mode (defer writes until flush)
|
|
168
|
+
*/
|
|
169
|
+
startBatch() {
|
|
170
|
+
this._batchMode = true;
|
|
171
|
+
this._batchedWrites = [];
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* End batch mode and flush all writes
|
|
176
|
+
*/
|
|
177
|
+
endBatch() {
|
|
178
|
+
if (!this._batchMode) return;
|
|
179
|
+
|
|
180
|
+
this._batchMode = false;
|
|
181
|
+
|
|
182
|
+
// Only write if we have pending changes
|
|
183
|
+
if (this._dirty && this._cache) {
|
|
184
|
+
this._saveImmediate(this._cache);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// Process lock file writes
|
|
188
|
+
for (const write of this._batchedWrites) {
|
|
189
|
+
if (write.type === 'lock') {
|
|
190
|
+
this._writeLockImmediate(write.sessionId, write.pid);
|
|
191
|
+
} else if (write.type === 'unlock') {
|
|
192
|
+
this._removeLockImmediate(write.sessionId);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
this._batchedWrites = [];
|
|
197
|
+
this.emit('batchFlushed', { writeCount: this._batchedWrites.length });
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Get lock file path
|
|
202
|
+
*/
|
|
203
|
+
getLockPath(sessionId) {
|
|
204
|
+
return path.join(this.sessionsDir, `${sessionId}.lock`);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Read lock file
|
|
209
|
+
*/
|
|
210
|
+
readLock(sessionId) {
|
|
211
|
+
const lockPath = this.getLockPath(sessionId);
|
|
212
|
+
if (!fs.existsSync(lockPath)) return null;
|
|
213
|
+
|
|
214
|
+
try {
|
|
215
|
+
const content = fs.readFileSync(lockPath, 'utf8');
|
|
216
|
+
const lock = {};
|
|
217
|
+
content.split('\n').forEach(line => {
|
|
218
|
+
const [key, value] = line.split('=');
|
|
219
|
+
if (key && value) lock[key.trim()] = value.trim();
|
|
220
|
+
});
|
|
221
|
+
return lock;
|
|
222
|
+
} catch (e) {
|
|
223
|
+
return null;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Write lock file
|
|
229
|
+
*/
|
|
230
|
+
writeLock(sessionId, pid) {
|
|
231
|
+
if (this._batchMode) {
|
|
232
|
+
this._batchedWrites.push({ type: 'lock', sessionId, pid });
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
this._writeLockImmediate(sessionId, pid);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Immediate lock write
|
|
240
|
+
*/
|
|
241
|
+
_writeLockImmediate(sessionId, pid) {
|
|
242
|
+
const lockPath = this.getLockPath(sessionId);
|
|
243
|
+
const content = `pid=${pid}\nstarted=${Math.floor(Date.now() / 1000)}\n`;
|
|
244
|
+
fs.writeFileSync(lockPath, content);
|
|
245
|
+
this.emit('locked', { sessionId, pid });
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Remove lock file
|
|
250
|
+
*/
|
|
251
|
+
removeLock(sessionId) {
|
|
252
|
+
if (this._batchMode) {
|
|
253
|
+
this._batchedWrites.push({ type: 'unlock', sessionId });
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
this._removeLockImmediate(sessionId);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Immediate lock removal
|
|
261
|
+
*/
|
|
262
|
+
_removeLockImmediate(sessionId) {
|
|
263
|
+
const lockPath = this.getLockPath(sessionId);
|
|
264
|
+
if (fs.existsSync(lockPath)) {
|
|
265
|
+
fs.unlinkSync(lockPath);
|
|
266
|
+
this.emit('unlocked', { sessionId });
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* Check if PID is alive
|
|
272
|
+
*/
|
|
273
|
+
isPidAlive(pid) {
|
|
274
|
+
if (!pid) return false;
|
|
275
|
+
try {
|
|
276
|
+
process.kill(pid, 0);
|
|
277
|
+
return true;
|
|
278
|
+
} catch (e) {
|
|
279
|
+
return false;
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* Check if session is active (has lock with alive PID)
|
|
285
|
+
*/
|
|
286
|
+
isActive(sessionId) {
|
|
287
|
+
const lock = this.readLock(sessionId);
|
|
288
|
+
if (!lock || !lock.pid) return false;
|
|
289
|
+
return this.isPidAlive(parseInt(lock.pid, 10));
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Clean up stale locks
|
|
294
|
+
* @param {Object} options - Options
|
|
295
|
+
* @returns {Object} Cleanup result
|
|
296
|
+
*/
|
|
297
|
+
cleanupStaleLocks(options = {}) {
|
|
298
|
+
const { dryRun = false } = options;
|
|
299
|
+
const registry = this.load();
|
|
300
|
+
let cleaned = 0;
|
|
301
|
+
const cleanedSessions = [];
|
|
302
|
+
|
|
303
|
+
for (const [id, session] of Object.entries(registry.sessions)) {
|
|
304
|
+
const lock = this.readLock(id);
|
|
305
|
+
if (lock) {
|
|
306
|
+
const pid = parseInt(lock.pid, 10);
|
|
307
|
+
const isAlive = this.isPidAlive(pid);
|
|
308
|
+
|
|
309
|
+
if (!isAlive) {
|
|
310
|
+
cleanedSessions.push({
|
|
311
|
+
id,
|
|
312
|
+
nickname: session.nickname,
|
|
313
|
+
branch: session.branch,
|
|
314
|
+
pid,
|
|
315
|
+
reason: 'pid_dead',
|
|
316
|
+
path: session.path,
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
if (!dryRun) {
|
|
320
|
+
this._removeLockImmediate(id);
|
|
321
|
+
}
|
|
322
|
+
cleaned++;
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
if (cleaned > 0) {
|
|
328
|
+
this.emit('cleaned', { count: cleaned, sessions: cleanedSessions });
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
return { count: cleaned, sessions: cleanedSessions };
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* Register a session
|
|
336
|
+
* @param {string} sessionPath - Session working directory
|
|
337
|
+
* @param {Object} options - Registration options
|
|
338
|
+
* @returns {Object} Registration result
|
|
339
|
+
*/
|
|
340
|
+
register(sessionPath, options = {}) {
|
|
341
|
+
const { nickname = null, threadType = null, pid = process.ppid || process.pid } = options;
|
|
342
|
+
|
|
343
|
+
const registry = this.load();
|
|
344
|
+
|
|
345
|
+
// Check if this path already has a session
|
|
346
|
+
let existingId = null;
|
|
347
|
+
for (const [id, session] of Object.entries(registry.sessions)) {
|
|
348
|
+
if (session.path === sessionPath) {
|
|
349
|
+
existingId = id;
|
|
350
|
+
break;
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// Gather context in batch
|
|
355
|
+
const context = this._gatherContext(sessionPath);
|
|
356
|
+
|
|
357
|
+
if (existingId) {
|
|
358
|
+
// Update existing session
|
|
359
|
+
const session = registry.sessions[existingId];
|
|
360
|
+
session.branch = context.branch;
|
|
361
|
+
session.story = context.story;
|
|
362
|
+
session.last_active = new Date().toISOString();
|
|
363
|
+
if (nickname) session.nickname = nickname;
|
|
364
|
+
if (threadType) session.thread_type = threadType;
|
|
365
|
+
|
|
366
|
+
this.writeLock(existingId, pid);
|
|
367
|
+
this.save(registry);
|
|
368
|
+
|
|
369
|
+
this.emit('updated', {
|
|
370
|
+
id: existingId,
|
|
371
|
+
session,
|
|
372
|
+
changes: ['branch', 'story', 'last_active'],
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
return { id: existingId, isNew: false, session };
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
// Create new session
|
|
379
|
+
const sessionId = String(registry.next_id);
|
|
380
|
+
registry.next_id++;
|
|
381
|
+
|
|
382
|
+
const isMain = sessionPath === this.rootDir;
|
|
383
|
+
const detectedType = threadType || (isMain ? 'base' : 'parallel');
|
|
384
|
+
|
|
385
|
+
registry.sessions[sessionId] = {
|
|
386
|
+
path: sessionPath,
|
|
387
|
+
branch: context.branch,
|
|
388
|
+
story: context.story,
|
|
389
|
+
nickname: nickname || null,
|
|
390
|
+
created: new Date().toISOString(),
|
|
391
|
+
last_active: new Date().toISOString(),
|
|
392
|
+
is_main: isMain,
|
|
393
|
+
thread_type: detectedType,
|
|
394
|
+
};
|
|
395
|
+
|
|
396
|
+
this.writeLock(sessionId, pid);
|
|
397
|
+
this.save(registry);
|
|
398
|
+
|
|
399
|
+
this.emit('registered', {
|
|
400
|
+
id: sessionId,
|
|
401
|
+
session: registry.sessions[sessionId],
|
|
402
|
+
isNew: true,
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
return {
|
|
406
|
+
id: sessionId,
|
|
407
|
+
isNew: true,
|
|
408
|
+
session: registry.sessions[sessionId],
|
|
409
|
+
thread_type: detectedType,
|
|
410
|
+
};
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
/**
|
|
414
|
+
* Unregister a session
|
|
415
|
+
* @param {string} sessionId - Session ID
|
|
416
|
+
*/
|
|
417
|
+
unregister(sessionId) {
|
|
418
|
+
const registry = this.load();
|
|
419
|
+
|
|
420
|
+
if (registry.sessions[sessionId]) {
|
|
421
|
+
registry.sessions[sessionId].last_active = new Date().toISOString();
|
|
422
|
+
this.removeLock(sessionId);
|
|
423
|
+
this.save(registry);
|
|
424
|
+
|
|
425
|
+
this.emit('unregistered', { id: sessionId });
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
/**
|
|
430
|
+
* Get session by ID
|
|
431
|
+
* @param {string} sessionId - Session ID
|
|
432
|
+
* @returns {Object|null} Session data
|
|
433
|
+
*/
|
|
434
|
+
getSession(sessionId) {
|
|
435
|
+
const registry = this.load();
|
|
436
|
+
return registry.sessions[sessionId] || null;
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
/**
|
|
440
|
+
* Get all sessions with status
|
|
441
|
+
* @returns {Object} Sessions with metadata
|
|
442
|
+
*/
|
|
443
|
+
getSessions() {
|
|
444
|
+
const registry = this.load();
|
|
445
|
+
const cleanupResult = this.cleanupStaleLocks();
|
|
446
|
+
const cwd = process.cwd();
|
|
447
|
+
|
|
448
|
+
const sessions = [];
|
|
449
|
+
for (const [id, session] of Object.entries(registry.sessions)) {
|
|
450
|
+
sessions.push({
|
|
451
|
+
id,
|
|
452
|
+
...session,
|
|
453
|
+
active: this.isActive(id),
|
|
454
|
+
current: session.path === cwd,
|
|
455
|
+
});
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
// Sort by ID (numeric)
|
|
459
|
+
sessions.sort((a, b) => parseInt(a.id) - parseInt(b.id));
|
|
460
|
+
|
|
461
|
+
return {
|
|
462
|
+
sessions,
|
|
463
|
+
cleaned: cleanupResult.count,
|
|
464
|
+
cleanedSessions: cleanupResult.sessions,
|
|
465
|
+
};
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
/**
|
|
469
|
+
* Get count of active sessions (excluding current path)
|
|
470
|
+
* @param {string} excludePath - Path to exclude from count
|
|
471
|
+
* @returns {number} Active session count
|
|
472
|
+
*/
|
|
473
|
+
getActiveCount(excludePath = process.cwd()) {
|
|
474
|
+
const { sessions } = this.getSessions();
|
|
475
|
+
return sessions.filter(s => s.active && s.path !== excludePath).length;
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
/**
|
|
479
|
+
* Delete a session
|
|
480
|
+
* @param {string} sessionId - Session ID
|
|
481
|
+
* @returns {Object} Result
|
|
482
|
+
*/
|
|
483
|
+
delete(sessionId) {
|
|
484
|
+
const registry = this.load();
|
|
485
|
+
const session = registry.sessions[sessionId];
|
|
486
|
+
|
|
487
|
+
if (!session) {
|
|
488
|
+
return { success: false, error: `Session ${sessionId} not found` };
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
if (session.is_main) {
|
|
492
|
+
return { success: false, error: 'Cannot delete main session' };
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
this.removeLock(sessionId);
|
|
496
|
+
delete registry.sessions[sessionId];
|
|
497
|
+
this.save(registry);
|
|
498
|
+
|
|
499
|
+
this.emit('unregistered', { id: sessionId, deleted: true });
|
|
500
|
+
|
|
501
|
+
return { success: true };
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
/**
|
|
505
|
+
* Update session data
|
|
506
|
+
* @param {string} sessionId - Session ID
|
|
507
|
+
* @param {Object} updates - Fields to update
|
|
508
|
+
* @returns {Object} Result
|
|
509
|
+
*/
|
|
510
|
+
update(sessionId, updates) {
|
|
511
|
+
const registry = this.load();
|
|
512
|
+
const session = registry.sessions[sessionId];
|
|
513
|
+
|
|
514
|
+
if (!session) {
|
|
515
|
+
return { success: false, error: `Session ${sessionId} not found` };
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
// Apply updates
|
|
519
|
+
const changedFields = [];
|
|
520
|
+
for (const [key, value] of Object.entries(updates)) {
|
|
521
|
+
if (session[key] !== value) {
|
|
522
|
+
session[key] = value;
|
|
523
|
+
changedFields.push(key);
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
if (changedFields.length > 0) {
|
|
528
|
+
session.last_active = new Date().toISOString();
|
|
529
|
+
this.save(registry);
|
|
530
|
+
|
|
531
|
+
this.emit('updated', {
|
|
532
|
+
id: sessionId,
|
|
533
|
+
session,
|
|
534
|
+
changes: changedFields,
|
|
535
|
+
});
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
return { success: true, session, changes: changedFields };
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
/**
|
|
542
|
+
* Gather context for a session (branch, story) - batched git operations
|
|
543
|
+
* @param {string} sessionPath - Session path
|
|
544
|
+
* @returns {Object} Context data
|
|
545
|
+
*/
|
|
546
|
+
_gatherContext(sessionPath) {
|
|
547
|
+
// Batch git commands into single call for efficiency
|
|
548
|
+
const result = spawnSync(
|
|
549
|
+
'sh',
|
|
550
|
+
[
|
|
551
|
+
'-c',
|
|
552
|
+
`
|
|
553
|
+
cd "${sessionPath}" 2>/dev/null && {
|
|
554
|
+
echo "BRANCH:$(git branch --show-current 2>/dev/null || echo unknown)"
|
|
555
|
+
}
|
|
556
|
+
`.trim(),
|
|
557
|
+
],
|
|
558
|
+
{ encoding: 'utf8' }
|
|
559
|
+
);
|
|
560
|
+
|
|
561
|
+
let branch = 'unknown';
|
|
562
|
+
|
|
563
|
+
if (result.status === 0 && result.stdout) {
|
|
564
|
+
const lines = result.stdout.trim().split('\n');
|
|
565
|
+
for (const line of lines) {
|
|
566
|
+
if (line.startsWith('BRANCH:')) {
|
|
567
|
+
branch = line.slice(7).trim() || 'unknown';
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
// Get story from status.json
|
|
573
|
+
let story = null;
|
|
574
|
+
const statusPath = path.join(this.rootDir, 'docs', '09-agents', 'status.json');
|
|
575
|
+
if (fs.existsSync(statusPath)) {
|
|
576
|
+
try {
|
|
577
|
+
const statusData = JSON.parse(fs.readFileSync(statusPath, 'utf8'));
|
|
578
|
+
for (const [id, storyData] of Object.entries(statusData.stories || {})) {
|
|
579
|
+
if (storyData.status === 'in_progress') {
|
|
580
|
+
story = id;
|
|
581
|
+
break;
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
} catch (e) {
|
|
585
|
+
// Ignore parse errors
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
return { branch, story };
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
/**
|
|
593
|
+
* Full status (combines register + count + status in single operation)
|
|
594
|
+
* @param {string} sessionPath - Session path
|
|
595
|
+
* @param {Object} options - Options
|
|
596
|
+
* @returns {Object} Full status
|
|
597
|
+
*/
|
|
598
|
+
getFullStatus(sessionPath, options = {}) {
|
|
599
|
+
const { nickname = null } = options;
|
|
600
|
+
const pid = process.ppid || process.pid;
|
|
601
|
+
|
|
602
|
+
// Start batch mode for efficiency
|
|
603
|
+
this.startBatch();
|
|
604
|
+
|
|
605
|
+
// Register (or update) session
|
|
606
|
+
const regResult = this.register(sessionPath, { nickname, pid });
|
|
607
|
+
|
|
608
|
+
// End batch mode (flushes writes)
|
|
609
|
+
this.endBatch();
|
|
610
|
+
|
|
611
|
+
// Get counts
|
|
612
|
+
const { sessions, cleaned, cleanedSessions } = this.getSessions();
|
|
613
|
+
const current = sessions.find(s => s.path === sessionPath) || null;
|
|
614
|
+
const otherActive = sessions.filter(s => s.active && s.path !== sessionPath).length;
|
|
615
|
+
|
|
616
|
+
return {
|
|
617
|
+
registered: true,
|
|
618
|
+
id: regResult.id,
|
|
619
|
+
isNew: regResult.isNew,
|
|
620
|
+
current,
|
|
621
|
+
otherActive,
|
|
622
|
+
total: sessions.length,
|
|
623
|
+
cleaned,
|
|
624
|
+
cleanedSessions,
|
|
625
|
+
};
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
/**
|
|
629
|
+
* Get main branch name
|
|
630
|
+
* @returns {string} Main branch name
|
|
631
|
+
*/
|
|
632
|
+
getMainBranch() {
|
|
633
|
+
const checkMain = spawnSync('git', ['show-ref', '--verify', '--quiet', 'refs/heads/main'], {
|
|
634
|
+
cwd: this.rootDir,
|
|
635
|
+
encoding: 'utf8',
|
|
636
|
+
});
|
|
637
|
+
|
|
638
|
+
if (checkMain.status === 0) return 'main';
|
|
639
|
+
|
|
640
|
+
const checkMaster = spawnSync(
|
|
641
|
+
'git',
|
|
642
|
+
['show-ref', '--verify', '--quiet', 'refs/heads/master'],
|
|
643
|
+
{
|
|
644
|
+
cwd: this.rootDir,
|
|
645
|
+
encoding: 'utf8',
|
|
646
|
+
}
|
|
647
|
+
);
|
|
648
|
+
|
|
649
|
+
if (checkMaster.status === 0) return 'master';
|
|
650
|
+
|
|
651
|
+
return 'main'; // Default fallback
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
// Singleton instance
|
|
656
|
+
let _instance = null;
|
|
657
|
+
|
|
658
|
+
/**
|
|
659
|
+
* Get singleton registry instance
|
|
660
|
+
* @param {Object} options - Options
|
|
661
|
+
* @returns {SessionRegistry} Registry instance
|
|
662
|
+
*/
|
|
663
|
+
function getRegistry(options = {}) {
|
|
664
|
+
if (!_instance || options.forceNew) {
|
|
665
|
+
_instance = new SessionRegistry(options);
|
|
666
|
+
}
|
|
667
|
+
return _instance;
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
/**
|
|
671
|
+
* Reset singleton (for testing)
|
|
672
|
+
*/
|
|
673
|
+
function resetRegistry() {
|
|
674
|
+
_instance = null;
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
module.exports = {
|
|
678
|
+
SessionRegistry,
|
|
679
|
+
getRegistry,
|
|
680
|
+
resetRegistry,
|
|
681
|
+
CACHE_TTL_MS,
|
|
682
|
+
};
|