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.
Files changed (37) hide show
  1. package/CHANGELOG.md +5 -0
  2. package/README.md +3 -3
  3. package/lib/placeholder-registry.js +617 -0
  4. package/lib/smart-json-file.js +205 -1
  5. package/lib/table-formatter.js +504 -0
  6. package/lib/transient-status.js +374 -0
  7. package/lib/ui-manager.js +612 -0
  8. package/lib/validate-args.js +213 -0
  9. package/lib/validate-names.js +143 -0
  10. package/lib/validate-paths.js +434 -0
  11. package/lib/validate.js +37 -737
  12. package/package.json +4 -1
  13. package/scripts/check-update.js +16 -3
  14. package/scripts/lib/sessionRegistry.js +682 -0
  15. package/scripts/session-manager.js +77 -10
  16. package/scripts/tui/App.js +176 -0
  17. package/scripts/tui/index.js +75 -0
  18. package/scripts/tui/lib/crashRecovery.js +302 -0
  19. package/scripts/tui/lib/eventStream.js +316 -0
  20. package/scripts/tui/lib/keyboard.js +252 -0
  21. package/scripts/tui/lib/loopControl.js +371 -0
  22. package/scripts/tui/panels/OutputPanel.js +278 -0
  23. package/scripts/tui/panels/SessionPanel.js +178 -0
  24. package/scripts/tui/panels/TracePanel.js +333 -0
  25. package/src/core/commands/tui.md +91 -0
  26. package/tools/cli/commands/config.js +7 -30
  27. package/tools/cli/commands/doctor.js +18 -38
  28. package/tools/cli/commands/list.js +47 -35
  29. package/tools/cli/commands/status.js +13 -37
  30. package/tools/cli/commands/uninstall.js +9 -38
  31. package/tools/cli/installers/core/installer.js +13 -0
  32. package/tools/cli/lib/command-context.js +374 -0
  33. package/tools/cli/lib/config-manager.js +394 -0
  34. package/tools/cli/lib/ide-registry.js +186 -0
  35. package/tools/cli/lib/npm-utils.js +16 -3
  36. package/tools/cli/lib/self-update.js +148 -0
  37. 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
+ };