sinapse-ai 1.19.0 → 1.19.2

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,1067 +0,0 @@
1
- /**
2
- * Terminal Spawner - Node.js wrapper for pm.sh
3
- *
4
- * Provides async API for spawning SINAPSE agents in separate terminals
5
- * to maintain clean context isolation during orchestration.
6
- *
7
- * Story 11.2: Bob Terminal Spawning
8
- *
9
- * @module core/orchestration/terminal-spawner
10
- * @version 1.0.0
11
- */
12
-
13
- 'use strict';
14
-
15
- const { spawn, execSync, execFileSync } = require('child_process');
16
- const fs = require('fs').promises;
17
- const fsSync = require('fs');
18
- const path = require('path');
19
- const os = require('os');
20
-
21
- // Constants
22
- const POLL_INTERVAL_MS = 500;
23
- const DEFAULT_TIMEOUT_MS = 300000; // 5 minutes
24
- const MAX_RETRIES = 3;
25
- const RETRY_DELAY_MS = 1000;
26
-
27
- /**
28
- * Environment types for spawn strategy selection
29
- * @readonly
30
- * @enum {string}
31
- */
32
- const ENVIRONMENT_TYPE = {
33
- NATIVE_TERMINAL: 'NATIVE_TERMINAL',
34
- VSCODE: 'VSCODE',
35
- SSH: 'SSH',
36
- DOCKER: 'DOCKER',
37
- CI: 'CI',
38
- };
39
-
40
- /**
41
- * Environment detection result
42
- * @typedef {Object} EnvironmentInfo
43
- * @property {string} type - Environment type (ENVIRONMENT_TYPE enum value)
44
- * @property {boolean} supportsVisualTerminal - Whether visual terminal spawn is supported
45
- * @property {string} reason - Human-readable reason for detection
46
- */
47
-
48
- /**
49
- * Detects the current execution environment (Story 12.10 - Task 1)
50
- *
51
- * Detection priority:
52
- * 1. CI/CD (GitHub Actions, GitLab CI, etc.)
53
- * 2. Docker container
54
- * 3. SSH session
55
- * 4. VS Code integrated terminal
56
- * 5. Native terminal (default)
57
- *
58
- * @returns {EnvironmentInfo} Environment detection result
59
- */
60
- function detectEnvironment() {
61
- // 1. CI/CD detection (Task 1.5)
62
- // Check common CI environment variables
63
- if (
64
- process.env.CI === 'true' ||
65
- process.env.GITHUB_ACTIONS === 'true' ||
66
- process.env.GITLAB_CI === 'true' ||
67
- process.env.JENKINS_URL ||
68
- process.env.TRAVIS === 'true' ||
69
- process.env.CIRCLECI === 'true' ||
70
- process.env.BUILDKITE === 'true' ||
71
- process.env.AZURE_PIPELINES === 'true' ||
72
- process.env.TF_BUILD === 'True'
73
- ) {
74
- return {
75
- type: ENVIRONMENT_TYPE.CI,
76
- supportsVisualTerminal: false,
77
- reason: 'CI/CD environment detected (headless)',
78
- };
79
- }
80
-
81
- // 2. Docker container detection (Task 1.4)
82
- // Check for /.dockerenv file or cgroup indicators
83
- try {
84
- if (fsSync.existsSync('/.dockerenv')) {
85
- return {
86
- type: ENVIRONMENT_TYPE.DOCKER,
87
- supportsVisualTerminal: false,
88
- reason: 'Docker container detected (/.dockerenv exists)',
89
- };
90
- }
91
-
92
- // Check cgroup for docker/containerd/kubepods
93
- if (fsSync.existsSync('/proc/1/cgroup')) {
94
- const cgroup = fsSync.readFileSync('/proc/1/cgroup', 'utf8');
95
- if (cgroup.includes('docker') || cgroup.includes('containerd') || cgroup.includes('kubepods')) {
96
- return {
97
- type: ENVIRONMENT_TYPE.DOCKER,
98
- supportsVisualTerminal: false,
99
- reason: 'Container detected via cgroup',
100
- };
101
- }
102
- }
103
- } catch {
104
- // Ignore file read errors (e.g., on Windows)
105
- }
106
-
107
- // 3. SSH session detection (Task 1.3)
108
- if (process.env.SSH_CLIENT || process.env.SSH_TTY || process.env.SSH_CONNECTION) {
109
- return {
110
- type: ENVIRONMENT_TYPE.SSH,
111
- supportsVisualTerminal: false,
112
- reason: 'SSH session detected (no display available)',
113
- };
114
- }
115
-
116
- // 4. VS Code integrated terminal detection (Task 1.2)
117
- if (
118
- process.env.TERM_PROGRAM === 'vscode' ||
119
- process.env.VSCODE_PID ||
120
- process.env.VSCODE_CWD ||
121
- process.env.VSCODE_GIT_IPC_HANDLE
122
- ) {
123
- return {
124
- type: ENVIRONMENT_TYPE.VSCODE,
125
- supportsVisualTerminal: false,
126
- reason: 'VS Code integrated terminal detected',
127
- };
128
- }
129
-
130
- // 5. Native terminal (default) - supports visual spawn
131
- return {
132
- type: ENVIRONMENT_TYPE.NATIVE_TERMINAL,
133
- supportsVisualTerminal: true,
134
- reason: 'Native terminal environment',
135
- };
136
- }
137
-
138
- /**
139
- * Default spawn options
140
- * @type {Object}
141
- */
142
- const DEFAULT_OPTIONS = {
143
- params: '',
144
- context: null,
145
- timeout: DEFAULT_TIMEOUT_MS,
146
- outputDir: os.tmpdir(),
147
- retries: MAX_RETRIES,
148
- debug: false,
149
- };
150
-
151
- /**
152
- * Context schema for agent execution
153
- * @typedef {Object} AgentContext
154
- * @property {string} story - Story file path or content
155
- * @property {string[]} files - Array of relevant file paths
156
- * @property {string} instructions - Additional instructions for the agent
157
- * @property {Object} metadata - Additional metadata
158
- */
159
-
160
- /**
161
- * Spawn result object
162
- * @typedef {Object} SpawnResult
163
- * @property {boolean} success - Whether spawn was successful
164
- * @property {string} output - Agent output content
165
- * @property {string} outputFile - Path to output file
166
- * @property {number} duration - Execution duration in ms
167
- * @property {string} [error] - Error message if failed
168
- */
169
-
170
- /**
171
- * Gets the path to the pm.sh script
172
- * @returns {string} Absolute path to pm.sh
173
- */
174
- function getScriptPath() {
175
- return path.join(__dirname, '../../scripts/pm.sh');
176
- }
177
-
178
- /**
179
- * Validates spawn arguments
180
- * @param {string} agent - Agent ID
181
- * @param {string} task - Task to execute
182
- * @throws {Error} If arguments are invalid
183
- */
184
- function validateArgs(agent, task) {
185
- if (!agent || typeof agent !== 'string') {
186
- throw new Error('Agent ID is required and must be a string');
187
- }
188
-
189
- if (!task || typeof task !== 'string') {
190
- throw new Error('Task is required and must be a string');
191
- }
192
-
193
- // Validate agent format (should be alphanumeric with optional hyphen)
194
- const agentPattern = /^[a-zA-Z][a-zA-Z0-9-]*$/;
195
- if (!agentPattern.test(agent)) {
196
- throw new Error(`Invalid agent ID format: ${agent}`);
197
- }
198
-
199
- // Validate task format
200
- const taskPattern = /^[a-zA-Z][a-zA-Z0-9-]*$/;
201
- if (!taskPattern.test(task)) {
202
- throw new Error(`Invalid task format: ${task}`);
203
- }
204
- }
205
-
206
- /**
207
- * Creates a temporary context file for the agent (Task 2.2)
208
- *
209
- * @param {AgentContext} context - Context data to pass to agent
210
- * @param {string} outputDir - Directory for temp files
211
- * @returns {Promise<string>} Path to created context file
212
- */
213
- async function createContextFile(context, outputDir = os.tmpdir()) {
214
- if (!context) {
215
- return '';
216
- }
217
-
218
- // Validate context structure
219
- const validatedContext = {
220
- story: context.story || '',
221
- files: Array.isArray(context.files) ? context.files : [],
222
- instructions: context.instructions || '',
223
- metadata: context.metadata || {},
224
- createdAt: new Date().toISOString(),
225
- };
226
-
227
- const contextPath = path.join(outputDir, `sinapse-context-${Date.now()}.json`);
228
- await fs.writeFile(contextPath, JSON.stringify(validatedContext, null, 2));
229
-
230
- return contextPath;
231
- }
232
-
233
- /**
234
- * Polls for agent output completion (Task 3.2, 3.3)
235
- *
236
- * @param {string} outputFile - Path to output file
237
- * @param {number} timeout - Timeout in milliseconds
238
- * @param {boolean} debug - Enable debug logging
239
- * @returns {Promise<string>} Output content
240
- * @throws {Error} If timeout exceeded
241
- */
242
- async function pollForOutput(outputFile, timeout = DEFAULT_TIMEOUT_MS, debug = false) {
243
- const startTime = Date.now();
244
- const lockFile = outputFile.replace('output', 'lock');
245
-
246
- if (debug) {
247
- console.log(`[TerminalSpawner] Polling for output: ${outputFile}`);
248
- console.log(`[TerminalSpawner] Lock file: ${lockFile}`);
249
- }
250
-
251
- while (Date.now() - startTime < timeout) {
252
- // Check if lock file is gone (agent finished)
253
- try {
254
- await fs.access(lockFile);
255
- // Lock still exists, wait and retry
256
- if (debug) {
257
- console.log(`[TerminalSpawner] Lock exists, waiting ${POLL_INTERVAL_MS}ms...`);
258
- }
259
- await sleep(POLL_INTERVAL_MS);
260
- } catch {
261
- // Lock gone, agent finished - read output
262
- if (debug) {
263
- console.log('[TerminalSpawner] Lock removed, reading output...');
264
- }
265
-
266
- try {
267
- const output = await fs.readFile(outputFile, 'utf8');
268
- return output;
269
- } catch (readError) {
270
- if (debug) {
271
- console.log(`[TerminalSpawner] Output file not found: ${readError.message}`);
272
- }
273
- return 'No output captured';
274
- }
275
- }
276
- }
277
-
278
- // Timeout - cleanup lock file if still exists
279
- try {
280
- await fs.unlink(lockFile);
281
- } catch {
282
- // Ignore cleanup errors
283
- }
284
-
285
- throw new Error(`Timeout waiting for agent output after ${timeout}ms`);
286
- }
287
-
288
- /**
289
- * Sleep utility
290
- * @param {number} ms - Milliseconds to sleep
291
- * @returns {Promise<void>}
292
- */
293
- function sleep(ms) {
294
- return new Promise((resolve) => setTimeout(resolve, ms));
295
- }
296
-
297
- /**
298
- * Spawns an agent inline using child_process.spawn (Story 12.10 - Task 2)
299
- *
300
- * This is used when visual terminal spawning is not available (VS Code, SSH, Docker, CI).
301
- * Output is piped directly instead of using a separate terminal window.
302
- *
303
- * @param {string} agent - Agent ID (e.g., 'dev', 'architect', 'qa')
304
- * @param {string} task - Task to execute
305
- * @param {Object} options - Spawn options
306
- * @param {string} [options.params=''] - Additional parameters
307
- * @param {AgentContext} [options.context=null] - Context data for agent
308
- * @param {number} [options.timeout=300000] - Timeout in ms
309
- * @param {string} [options.outputDir] - Directory for output files
310
- * @param {boolean} [options.debug=false] - Enable debug logging
311
- * @returns {Promise<SpawnResult>} Result with output and status
312
- */
313
- async function spawnInline(agent, task, options = {}) {
314
- const opts = { ...DEFAULT_OPTIONS, ...options };
315
- const startTime = Date.now();
316
-
317
- if (opts.debug) {
318
- console.log('[TerminalSpawner] Using inline spawn (no visual terminal)');
319
- }
320
-
321
- // Create context file if needed
322
- let contextPath = '';
323
- if (opts.context) {
324
- contextPath = await createContextFile(opts.context, opts.outputDir);
325
- if (opts.debug) {
326
- console.log(`[TerminalSpawner] Created context file: ${contextPath}`);
327
- }
328
- }
329
-
330
- // Build command arguments for pm.sh
331
- const args = [agent, task];
332
- if (opts.params) {
333
- args.push(opts.params);
334
- }
335
- if (contextPath) {
336
- args.push('--context', contextPath);
337
- }
338
-
339
- const scriptPath = getScriptPath();
340
-
341
- // Verify script exists
342
- if (!fsSync.existsSync(scriptPath)) {
343
- throw new Error(`pm.sh script not found at: ${scriptPath}`);
344
- }
345
-
346
- return new Promise((resolve) => {
347
- const outputChunks = [];
348
- const errorChunks = [];
349
-
350
- // Spawn the process inline with piped stdout/stderr (Task 2.2)
351
- const child = spawn('bash', [scriptPath, ...args], {
352
- env: {
353
- ...process.env,
354
- SINAPSE_DEBUG: opts.debug ? 'true' : 'false',
355
- SINAPSE_OUTPUT_DIR: opts.outputDir,
356
- SINAPSE_INLINE_MODE: 'true', // Signal to pm.sh that we're running inline
357
- },
358
- stdio: ['ignore', 'pipe', 'pipe'],
359
- });
360
-
361
- // Register child process for cleanup on parent exit (Task 3.4)
362
- registerChildProcess(child);
363
-
364
- // Capture stdout
365
- child.stdout.on('data', (data) => {
366
- outputChunks.push(data);
367
- if (opts.debug) {
368
- process.stdout.write(data);
369
- }
370
- });
371
-
372
- // Capture stderr
373
- child.stderr.on('data', (data) => {
374
- errorChunks.push(data);
375
- if (opts.debug) {
376
- process.stderr.write(data);
377
- }
378
- });
379
-
380
- // Set timeout
381
- const timeoutId = setTimeout(() => {
382
- child.kill('SIGTERM');
383
- const duration = Date.now() - startTime;
384
-
385
- // Cleanup context file
386
- if (contextPath) {
387
- fs.unlink(contextPath).catch(() => {});
388
- }
389
-
390
- resolve({
391
- success: false,
392
- output: Buffer.concat(outputChunks).toString('utf8'),
393
- outputFile: '',
394
- duration,
395
- error: `Timeout after ${opts.timeout}ms`,
396
- });
397
- }, opts.timeout);
398
-
399
- // Handle spawn failure (e.g. bash absent on Windows without Git Bash/WSL).
400
- // Without an 'error' listener Node throws ENOENT as an uncaughtException and
401
- // this Promise would never resolve (hang). pm.sh requires bash by design.
402
- child.on('error', (err) => {
403
- clearTimeout(timeoutId);
404
- if (contextPath) {
405
- fs.unlink(contextPath).catch(() => {});
406
- }
407
- resolve({
408
- success: false,
409
- output: '',
410
- outputFile: '',
411
- duration: Date.now() - startTime,
412
- error: err.code === 'ENOENT'
413
- ? 'bash nao encontrado (instale Git Bash ou WSL no Windows)'
414
- : err.message,
415
- });
416
- });
417
-
418
- // Handle process completion
419
- child.on('close', async (code) => {
420
- clearTimeout(timeoutId);
421
- const duration = Date.now() - startTime;
422
-
423
- // Cleanup context file
424
- if (contextPath) {
425
- fs.unlink(contextPath).catch(() => {});
426
- }
427
-
428
- const stdout = Buffer.concat(outputChunks).toString('utf8').trim();
429
- const stderr = Buffer.concat(errorChunks).toString('utf8');
430
-
431
- if (code === 0) {
432
- // pm.sh returns the output file path in stdout
433
- // Read the actual output from that file
434
- let output = stdout;
435
- const outputFilePath = stdout.split('\n').pop()?.trim();
436
-
437
- if (outputFilePath && outputFilePath.endsWith('.md')) {
438
- try {
439
- output = await fs.readFile(outputFilePath, 'utf8');
440
- // Cleanup the output file after reading
441
- await fs.unlink(outputFilePath).catch(() => {});
442
- } catch {
443
- // If we can't read the file, use stdout as fallback
444
- output = stdout;
445
- }
446
- }
447
-
448
- resolve({
449
- success: true,
450
- output,
451
- outputFile: outputFilePath || '',
452
- duration,
453
- });
454
- } else {
455
- resolve({
456
- success: false,
457
- output: stdout,
458
- outputFile: '',
459
- duration,
460
- error: stderr || `Process exited with code ${code}`,
461
- });
462
- }
463
- });
464
-
465
- // Handle spawn errors
466
- child.on('error', (error) => {
467
- clearTimeout(timeoutId);
468
- const duration = Date.now() - startTime;
469
-
470
- // Cleanup context file
471
- if (contextPath) {
472
- fs.unlink(contextPath).catch(() => {});
473
- }
474
-
475
- resolve({
476
- success: false,
477
- output: Buffer.concat(outputChunks).toString('utf8'),
478
- outputFile: '',
479
- duration,
480
- error: error.message,
481
- });
482
- });
483
- });
484
- }
485
-
486
- /**
487
- * Spawns an agent in a separate terminal (Task 4.1)
488
- *
489
- * Opens a new terminal window with the specified agent and task,
490
- * passing context if provided. Returns the agent's output after completion.
491
- *
492
- * @param {string} agent - Agent ID (e.g., 'dev', 'architect', 'qa')
493
- * @param {string} task - Task to execute (e.g., 'develop', 'review')
494
- * @param {Object} options - Spawn options
495
- * @param {string} [options.params=''] - Additional parameters
496
- * @param {AgentContext} [options.context=null] - Context data for agent
497
- * @param {number} [options.timeout=300000] - Timeout in ms (default: 5 min)
498
- * @param {string} [options.outputDir] - Directory for output files
499
- * @param {number} [options.retries=3] - Number of retry attempts
500
- * @param {boolean} [options.debug=false] - Enable debug logging
501
- * @returns {Promise<SpawnResult>} Result with output and status
502
- *
503
- * @example
504
- * const result = await spawnAgent('dev', 'develop', {
505
- * params: 'story-11.2',
506
- * context: {
507
- * story: 'docs/stories/active/11.2.story.md',
508
- * files: ['src/index.js'],
509
- * instructions: 'Focus on terminal spawning'
510
- * },
511
- * timeout: 600000 // 10 minutes
512
- * });
513
- */
514
- async function spawnAgent(agent, task, options = {}) {
515
- const opts = { ...DEFAULT_OPTIONS, ...options };
516
- const startTime = Date.now();
517
-
518
- // Validate arguments
519
- validateArgs(agent, task);
520
-
521
- // Detect environment to determine spawn strategy (Story 12.10 - Task 2.5)
522
- const environment = detectEnvironment();
523
-
524
- if (opts.debug) {
525
- console.log(`[TerminalSpawner] Environment: ${environment.type} (${environment.reason})`);
526
- }
527
-
528
- // If environment doesn't support visual terminal, use inline spawn directly
529
- if (!environment.supportsVisualTerminal) {
530
- console.log(`⚠️ Terminal visual indisponível. Executando inline. [${environment.reason}]`);
531
- return spawnInline(agent, task, opts);
532
- }
533
-
534
- // Create context file if needed (Task 2.2)
535
- let contextPath = '';
536
- if (opts.context) {
537
- contextPath = await createContextFile(opts.context, opts.outputDir);
538
- if (opts.debug) {
539
- console.log(`[TerminalSpawner] Created context file: ${contextPath}`);
540
- }
541
- }
542
-
543
- // Build command arguments
544
- const args = [agent, task];
545
- if (opts.params) {
546
- args.push(opts.params);
547
- }
548
- if (contextPath) {
549
- args.push('--context', contextPath);
550
- }
551
-
552
- // Get script path
553
- const scriptPath = getScriptPath();
554
-
555
- // Verify script exists
556
- if (!fsSync.existsSync(scriptPath)) {
557
- throw new Error(`pm.sh script not found at: ${scriptPath}`);
558
- }
559
-
560
- // Execute with retry logic (Task 4.2)
561
- let lastError;
562
- for (let attempt = 1; attempt <= opts.retries; attempt++) {
563
- try {
564
- if (opts.debug) {
565
- console.log(`[TerminalSpawner] Attempt ${attempt}/${opts.retries}`);
566
- console.log(`[TerminalSpawner] Executing: bash ${scriptPath} ${args.join(' ')}`);
567
- }
568
-
569
- // Execute pm.sh
570
- const env = {
571
- ...process.env,
572
- SINAPSE_DEBUG: opts.debug ? 'true' : 'false',
573
- SINAPSE_OUTPUT_DIR: opts.outputDir,
574
- };
575
-
576
- const result = execFileSync('bash', [scriptPath, ...args], {
577
- encoding: 'utf8',
578
- timeout: opts.timeout,
579
- env,
580
- });
581
-
582
- // Get output file path from script output
583
- const outputFile = result.trim();
584
-
585
- if (opts.debug) {
586
- console.log(`[TerminalSpawner] Output file: ${outputFile}`);
587
- }
588
-
589
- // Poll for completion (Task 3.2, 3.3)
590
- const output = await pollForOutput(outputFile, opts.timeout, opts.debug);
591
-
592
- // Cleanup context file (Task 2.4)
593
- if (contextPath) {
594
- await fs.unlink(contextPath).catch(() => {});
595
- }
596
-
597
- const duration = Date.now() - startTime;
598
-
599
- return {
600
- success: true,
601
- output,
602
- outputFile,
603
- duration,
604
- };
605
- } catch (error) {
606
- lastError = error;
607
- if (opts.debug) {
608
- console.log(`[TerminalSpawner] Attempt ${attempt} failed: ${error.message}`);
609
- }
610
-
611
- if (attempt < opts.retries) {
612
- await sleep(RETRY_DELAY_MS * attempt);
613
- }
614
- }
615
- }
616
-
617
- // Fallback to inline spawn if visual terminal fails (Story 12.10 - Task 2.3)
618
- console.log('⚠️ Terminal visual falhou. Tentando execução inline como fallback...');
619
-
620
- // Cleanup context file before retry with inline
621
- if (contextPath) {
622
- await fs.unlink(contextPath).catch(() => {});
623
- }
624
-
625
- // Try inline spawn as fallback
626
- const inlineResult = await spawnInline(agent, task, opts);
627
-
628
- if (inlineResult.success) {
629
- console.log('✅ Execução inline bem-sucedida.');
630
- return inlineResult;
631
- }
632
-
633
- // Both methods failed
634
- const duration = Date.now() - startTime;
635
-
636
- return {
637
- success: false,
638
- output: inlineResult.output || '',
639
- outputFile: '',
640
- duration,
641
- error: `Visual spawn failed: ${lastError?.message || 'Unknown'}. Inline fallback also failed: ${inlineResult.error || 'Unknown'}`,
642
- };
643
- }
644
-
645
- /**
646
- * Checks if the terminal spawner is available on this platform
647
- * @returns {boolean} True if spawning is supported
648
- */
649
- function isSpawnerAvailable() {
650
- const platform = process.platform;
651
- return ['darwin', 'linux', 'win32'].includes(platform);
652
- }
653
-
654
- /**
655
- * Gets the current platform name
656
- * @returns {string} Platform name (macos, linux, windows, or unknown)
657
- */
658
- function getPlatform() {
659
- switch (process.platform) {
660
- case 'darwin':
661
- return 'macos';
662
- case 'linux':
663
- return 'linux';
664
- case 'win32':
665
- return 'windows';
666
- default:
667
- return 'unknown';
668
- }
669
- }
670
-
671
- /**
672
- * Cleans up old output and lock files (Task 2.4)
673
- *
674
- * @param {string} outputDir - Directory to clean
675
- * @param {number} maxAgeMs - Maximum age in milliseconds (default: 1 hour)
676
- * @returns {Promise<number>} Number of files cleaned
677
- */
678
- async function cleanupOldFiles(outputDir = os.tmpdir(), maxAgeMs = 3600000) {
679
- const now = Date.now();
680
- let cleaned = 0;
681
-
682
- try {
683
- const files = await fs.readdir(outputDir);
684
- const sinapseFiles = files.filter(
685
- (f) => f.startsWith('sinapse-output-') || f.startsWith('sinapse-lock-') || f.startsWith('sinapse-context-'),
686
- );
687
-
688
- for (const file of sinapseFiles) {
689
- const filePath = path.join(outputDir, file);
690
- try {
691
- const stats = await fs.stat(filePath);
692
- if (now - stats.mtimeMs > maxAgeMs) {
693
- await fs.unlink(filePath);
694
- cleaned++;
695
- }
696
- } catch {
697
- // Ignore errors for individual files
698
- }
699
- }
700
- } catch {
701
- // Ignore directory read errors
702
- }
703
-
704
- return cleaned;
705
- }
706
-
707
- // ============================================
708
- // OS Compatibility Matrix (Story 12.10 - Task 7)
709
- // ============================================
710
-
711
- /**
712
- * OS Compatibility Matrix definition (PRD §9.6 — D19)
713
- * @type {Object}
714
- */
715
- const OS_COMPATIBILITY_MATRIX = {
716
- must_pass: [
717
- { os: 'macOS Sonoma', arch: 'arm64', docker: 'Docker Desktop', description: 'Apple Silicon' },
718
- { os: 'macOS Sonoma', arch: 'x64', docker: 'Docker Desktop', description: 'Intel' },
719
- { os: 'Windows 11', arch: 'x64', docker: 'Docker Desktop', wsl: 'Ubuntu 22.04', description: 'WSL2' },
720
- { os: 'Ubuntu 22.04', arch: 'x64', docker: 'Docker Engine', description: 'Native Linux' },
721
- ],
722
- should_pass: [
723
- { os: 'Windows 10', arch: 'x64', wsl: 'Ubuntu', description: 'WSL2' },
724
- { os: 'macOS Ventura', arch: 'arm64', docker: 'Docker Desktop', description: 'Previous macOS' },
725
- { os: 'macOS Ventura', arch: 'x64', docker: 'Docker Desktop', description: 'Previous macOS Intel' },
726
- { os: 'Ubuntu 24.04', arch: 'x64', docker: 'Docker Engine', description: 'Latest Ubuntu' },
727
- ],
728
- };
729
-
730
- /**
731
- * Compatibility test result
732
- * @typedef {Object} CompatibilityTestResult
733
- * @property {string} testName - Name of the test
734
- * @property {string} result - 'pass' | 'fail' | 'skip'
735
- * @property {string} [failureReason] - Reason for failure if applicable
736
- * @property {number} duration - Test duration in ms
737
- */
738
-
739
- /**
740
- * Compatibility report structure
741
- * @typedef {Object} CompatibilityReport
742
- * @property {string} generatedAt - ISO timestamp
743
- * @property {Object} system - System information
744
- * @property {string} system.os_name - Operating system name
745
- * @property {string} system.os_version - OS version
746
- * @property {string} system.architecture - CPU architecture (x64, arm64)
747
- * @property {string} system.shell - Default shell
748
- * @property {string} system.docker_version - Docker version or 'not installed'
749
- * @property {string} system.node_version - Node.js version
750
- * @property {Object} environment - Detected environment
751
- * @property {CompatibilityTestResult[]} tests - Test results
752
- * @property {Object} summary - Summary statistics
753
- */
754
-
755
- /**
756
- * Gets current system information for compatibility reporting (Task 7.4)
757
- * @returns {Object} System information
758
- */
759
- function getSystemInfo() {
760
- const { execSync } = require('child_process');
761
-
762
- // Get OS info
763
- const platform = os.platform();
764
- const release = os.release();
765
- const arch = os.arch();
766
-
767
- // Get OS name
768
- let osName = 'Unknown';
769
- if (platform === 'darwin') {
770
- try {
771
- const swVers = execSync('sw_vers -productName 2>/dev/null', { encoding: 'utf8' }).trim();
772
- const swVersion = execSync('sw_vers -productVersion 2>/dev/null', { encoding: 'utf8' }).trim();
773
- osName = `${swVers} ${swVersion}`;
774
- } catch {
775
- osName = `macOS ${release}`;
776
- }
777
- } else if (platform === 'linux') {
778
- try {
779
- const lsbRelease = execSync('lsb_release -d 2>/dev/null || cat /etc/os-release 2>/dev/null | grep PRETTY_NAME | cut -d= -f2 | tr -d \'"\'', { encoding: 'utf8' }).trim();
780
- osName = lsbRelease.replace('Description:\t', '') || `Linux ${release}`;
781
- } catch {
782
- osName = `Linux ${release}`;
783
- }
784
- } else if (platform === 'win32') {
785
- osName = `Windows ${release}`;
786
- }
787
-
788
- // Get shell
789
- const shell = process.env.SHELL || process.env.ComSpec || 'unknown';
790
-
791
- // Get Docker version
792
- let dockerVersion = 'not installed';
793
- try {
794
- dockerVersion = execSync('docker --version', {
795
- encoding: 'utf8',
796
- stdio: ['ignore', 'pipe', 'ignore'],
797
- windowsHide: true,
798
- }).trim();
799
- } catch {
800
- // Docker not available
801
- }
802
-
803
- // Get Node version
804
- const nodeVersion = process.version;
805
-
806
- return {
807
- os_name: osName,
808
- os_version: release,
809
- architecture: arch,
810
- shell: path.basename(shell),
811
- docker_version: dockerVersion,
812
- node_version: nodeVersion,
813
- };
814
- }
815
-
816
- /**
817
- * Generates a compatibility report after test run (Task 7.4, 7.5)
818
- *
819
- * @param {CompatibilityTestResult[]} testResults - Array of test results
820
- * @returns {CompatibilityReport} Generated compatibility report
821
- */
822
- function generateCompatibilityReport(testResults = []) {
823
- const systemInfo = getSystemInfo();
824
- const environment = detectEnvironment();
825
-
826
- // Calculate summary
827
- const passed = testResults.filter((t) => t.result === 'pass').length;
828
- const failed = testResults.filter((t) => t.result === 'fail').length;
829
- const skipped = testResults.filter((t) => t.result === 'skip').length;
830
- const total = testResults.length;
831
-
832
- // Determine matrix classification
833
- const matchesMustPass = OS_COMPATIBILITY_MATRIX.must_pass.some(
834
- (m) => systemInfo.os_name.toLowerCase().includes(m.os.toLowerCase().split(' ')[0]),
835
- );
836
- const matchesShouldPass = OS_COMPATIBILITY_MATRIX.should_pass.some(
837
- (m) => systemInfo.os_name.toLowerCase().includes(m.os.toLowerCase().split(' ')[0]),
838
- );
839
-
840
- return {
841
- generatedAt: new Date().toISOString(),
842
- system: systemInfo,
843
- environment: {
844
- type: environment.type,
845
- supportsVisualTerminal: environment.supportsVisualTerminal,
846
- reason: environment.reason,
847
- },
848
- matrixClassification: matchesMustPass ? 'must_pass' : matchesShouldPass ? 'should_pass' : 'not_in_matrix',
849
- tests: testResults,
850
- summary: {
851
- total,
852
- passed,
853
- failed,
854
- skipped,
855
- passRate: total > 0 ? Math.round((passed / total) * 100) : 0,
856
- },
857
- };
858
- }
859
-
860
- /**
861
- * Formats compatibility report for console output (Task 7.5)
862
- * @param {CompatibilityReport} report - Compatibility report
863
- * @returns {string} Formatted report string
864
- */
865
- function formatCompatibilityReport(report) {
866
- const lines = [
867
- '═══════════════════════════════════════════════════════════════',
868
- ' SINAPSE Terminal Spawner Compatibility Report ',
869
- '═══════════════════════════════════════════════════════════════',
870
- '',
871
- `Generated: ${report.generatedAt}`,
872
- '',
873
- '── System Information ──────────────────────────────────────────',
874
- ` OS: ${report.system.os_name}`,
875
- ` Version: ${report.system.os_version}`,
876
- ` Architecture: ${report.system.architecture}`,
877
- ` Shell: ${report.system.shell}`,
878
- ` Docker: ${report.system.docker_version}`,
879
- ` Node.js: ${report.system.node_version}`,
880
- '',
881
- '── Environment Detection ───────────────────────────────────────',
882
- ` Type: ${report.environment.type}`,
883
- ` Visual Term: ${report.environment.supportsVisualTerminal ? 'Yes' : 'No'}`,
884
- ` Reason: ${report.environment.reason}`,
885
- '',
886
- ` Matrix Class: ${report.matrixClassification.toUpperCase()}`,
887
- '',
888
- ];
889
-
890
- if (report.tests.length > 0) {
891
- lines.push('── Test Results ────────────────────────────────────────────────');
892
- for (const test of report.tests) {
893
- const icon = test.result === 'pass' ? '✅' : test.result === 'fail' ? '❌' : '⏭️';
894
- const duration = test.duration ? ` (${test.duration}ms)` : '';
895
- lines.push(` ${icon} ${test.testName}${duration}`);
896
- if (test.failureReason) {
897
- lines.push(` └─ ${test.failureReason}`);
898
- }
899
- }
900
- lines.push('');
901
- }
902
-
903
- lines.push('── Summary ─────────────────────────────────────────────────────');
904
- lines.push(` Total: ${report.summary.total}`);
905
- lines.push(` Passed: ${report.summary.passed}`);
906
- lines.push(` Failed: ${report.summary.failed}`);
907
- lines.push(` Skipped: ${report.summary.skipped}`);
908
- lines.push(` Rate: ${report.summary.passRate}%`);
909
- lines.push('');
910
- lines.push('═══════════════════════════════════════════════════════════════');
911
-
912
- return lines.join('\n');
913
- }
914
-
915
- // ============================================
916
- // Lock File Cleanup and Signal Handling (Story 12.10 - Task 3)
917
- // ============================================
918
-
919
- /**
920
- * Active lock files being tracked for cleanup
921
- * @type {Set<string>}
922
- */
923
- const activeLockFiles = new Set();
924
-
925
- /**
926
- * Active child processes being tracked for cleanup
927
- * @type {Set<ChildProcess>}
928
- */
929
- const activeChildProcesses = new Set();
930
-
931
- /**
932
- * Registers a lock file for cleanup on process exit
933
- * @param {string} lockPath - Path to lock file
934
- */
935
- function registerLockFile(lockPath) {
936
- activeLockFiles.add(lockPath);
937
- }
938
-
939
- /**
940
- * Unregisters a lock file (called when process completes normally)
941
- * @param {string} lockPath - Path to lock file
942
- */
943
- function unregisterLockFile(lockPath) {
944
- activeLockFiles.delete(lockPath);
945
- }
946
-
947
- /**
948
- * Registers a child process for cleanup on parent exit
949
- * @param {ChildProcess} child - Child process to track
950
- */
951
- function registerChildProcess(child) {
952
- activeChildProcesses.add(child);
953
- child.on('close', () => {
954
- activeChildProcesses.delete(child);
955
- });
956
- }
957
-
958
- /**
959
- * Cleans up all registered lock files (Task 3.3)
960
- * Called on process exit or signal
961
- */
962
- function cleanupLocks() {
963
- for (const lockPath of activeLockFiles) {
964
- try {
965
- if (fsSync.existsSync(lockPath)) {
966
- fsSync.unlinkSync(lockPath);
967
- }
968
- } catch {
969
- // Ignore cleanup errors
970
- }
971
- }
972
- activeLockFiles.clear();
973
- }
974
-
975
- /**
976
- * Terminates all active child processes (Task 3.4)
977
- */
978
- function terminateChildProcesses() {
979
- for (const child of activeChildProcesses) {
980
- try {
981
- child.kill('SIGTERM');
982
- } catch {
983
- // Ignore if already dead
984
- }
985
- }
986
- activeChildProcesses.clear();
987
- }
988
-
989
- /**
990
- * Cleanup handler for process exit/signals (Task 3.4)
991
- * @param {string} signal - Signal name or 'exit'
992
- */
993
- function cleanupHandler(signal) {
994
- console.log(`[TerminalSpawner] Cleanup triggered by ${signal}`);
995
- cleanupLocks();
996
- terminateChildProcesses();
997
- }
998
-
999
- // Register cleanup handlers (Task 3.4)
1000
- // Only register once, even if module is required multiple times
1001
- if (!process._terminalSpawnerCleanupRegistered) {
1002
- process._terminalSpawnerCleanupRegistered = true;
1003
-
1004
- // Normal exit
1005
- process.on('exit', () => cleanupHandler('exit'));
1006
-
1007
- // Ctrl+C
1008
- process.on('SIGINT', () => {
1009
- cleanupHandler('SIGINT');
1010
- process.exit(1);
1011
- });
1012
-
1013
- // Termination signal (kill command)
1014
- process.on('SIGTERM', () => {
1015
- cleanupHandler('SIGTERM');
1016
- process.exit(1);
1017
- });
1018
-
1019
- // Uncaught exception (try to cleanup before crash)
1020
- process.on('uncaughtException', (error) => {
1021
- console.error('[TerminalSpawner] Uncaught exception:', error);
1022
- cleanupHandler('uncaughtException');
1023
- process.exit(1);
1024
- });
1025
-
1026
- // Unhandled promise rejection
1027
- process.on('unhandledRejection', (reason) => {
1028
- console.error('[TerminalSpawner] Unhandled rejection:', reason);
1029
- cleanupHandler('unhandledRejection');
1030
- process.exit(1);
1031
- });
1032
- }
1033
-
1034
- module.exports = {
1035
- // Main API
1036
- spawnAgent,
1037
- spawnInline,
1038
- createContextFile,
1039
- pollForOutput,
1040
-
1041
- // Environment Detection (Story 12.10)
1042
- detectEnvironment,
1043
- ENVIRONMENT_TYPE,
1044
-
1045
- // OS Compatibility Matrix (Story 12.10 - Task 7)
1046
- OS_COMPATIBILITY_MATRIX,
1047
- getSystemInfo,
1048
- generateCompatibilityReport,
1049
- formatCompatibilityReport,
1050
-
1051
- // Utilities
1052
- isSpawnerAvailable,
1053
- getPlatform,
1054
- cleanupOldFiles,
1055
- getScriptPath,
1056
-
1057
- // Lock Management (Story 12.10)
1058
- registerLockFile,
1059
- unregisterLockFile,
1060
- cleanupLocks,
1061
-
1062
- // Constants
1063
- DEFAULT_TIMEOUT_MS,
1064
- POLL_INTERVAL_MS,
1065
- MAX_RETRIES,
1066
- };
1067
-