claudia-orchestrator 0.1.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/LICENSE +201 -0
- package/README.md +109 -0
- package/dist/cli-parser.d.ts +11 -0
- package/dist/cli-parser.d.ts.map +1 -0
- package/dist/cli-parser.js +57 -0
- package/dist/cli-parser.js.map +1 -0
- package/dist/cui-server.d.ts +69 -0
- package/dist/cui-server.d.ts.map +1 -0
- package/dist/cui-server.js +705 -0
- package/dist/cui-server.js.map +1 -0
- package/dist/mcp-server/claudia-tools.d.ts +15 -0
- package/dist/mcp-server/claudia-tools.d.ts.map +1 -0
- package/dist/mcp-server/claudia-tools.js +366 -0
- package/dist/mcp-server/claudia-tools.js.map +1 -0
- package/dist/mcp-server/index.d.ts +3 -0
- package/dist/mcp-server/index.d.ts.map +1 -0
- package/dist/mcp-server/index.js +176 -0
- package/dist/mcp-server/index.js.map +1 -0
- package/dist/middleware/auth.d.ts +18 -0
- package/dist/middleware/auth.d.ts.map +1 -0
- package/dist/middleware/auth.js +136 -0
- package/dist/middleware/auth.js.map +1 -0
- package/dist/middleware/cors-setup.d.ts +7 -0
- package/dist/middleware/cors-setup.d.ts.map +1 -0
- package/dist/middleware/cors-setup.js +8 -0
- package/dist/middleware/cors-setup.js.map +1 -0
- package/dist/middleware/error-handler.d.ts +4 -0
- package/dist/middleware/error-handler.d.ts.map +1 -0
- package/dist/middleware/error-handler.js +27 -0
- package/dist/middleware/error-handler.js.map +1 -0
- package/dist/middleware/query-parser.d.ts +11 -0
- package/dist/middleware/query-parser.d.ts.map +1 -0
- package/dist/middleware/query-parser.js +68 -0
- package/dist/middleware/query-parser.js.map +1 -0
- package/dist/middleware/request-logger.d.ts +4 -0
- package/dist/middleware/request-logger.d.ts.map +1 -0
- package/dist/middleware/request-logger.js +29 -0
- package/dist/middleware/request-logger.js.map +1 -0
- package/dist/process-daemon/index.d.ts +14 -0
- package/dist/process-daemon/index.d.ts.map +1 -0
- package/dist/process-daemon/index.js +51 -0
- package/dist/process-daemon/index.js.map +1 -0
- package/dist/process-daemon/process-daemon.d.ts +78 -0
- package/dist/process-daemon/process-daemon.d.ts.map +1 -0
- package/dist/process-daemon/process-daemon.js +568 -0
- package/dist/process-daemon/process-daemon.js.map +1 -0
- package/dist/process-daemon/process-manager-client.d.ts +108 -0
- package/dist/process-daemon/process-manager-client.d.ts.map +1 -0
- package/dist/process-daemon/process-manager-client.js +314 -0
- package/dist/process-daemon/process-manager-client.js.map +1 -0
- package/dist/process-daemon/process-manager-interface.d.ts +47 -0
- package/dist/process-daemon/process-manager-interface.d.ts.map +1 -0
- package/dist/process-daemon/process-manager-interface.js +8 -0
- package/dist/process-daemon/process-manager-interface.js.map +1 -0
- package/dist/process-daemon/test-daemon.d.ts +12 -0
- package/dist/process-daemon/test-daemon.d.ts.map +1 -0
- package/dist/process-daemon/test-daemon.js +81 -0
- package/dist/process-daemon/test-daemon.js.map +1 -0
- package/dist/process-daemon/types.d.ts +85 -0
- package/dist/process-daemon/types.d.ts.map +1 -0
- package/dist/process-daemon/types.js +8 -0
- package/dist/process-daemon/types.js.map +1 -0
- package/dist/routes/claudia.routes.d.ts +10 -0
- package/dist/routes/claudia.routes.d.ts.map +1 -0
- package/dist/routes/claudia.routes.js +123 -0
- package/dist/routes/claudia.routes.js.map +1 -0
- package/dist/routes/config.routes.d.ts +4 -0
- package/dist/routes/config.routes.d.ts.map +1 -0
- package/dist/routes/config.routes.js +27 -0
- package/dist/routes/config.routes.js.map +1 -0
- package/dist/routes/conversation.routes.d.ts +8 -0
- package/dist/routes/conversation.routes.d.ts.map +1 -0
- package/dist/routes/conversation.routes.js +870 -0
- package/dist/routes/conversation.routes.js.map +1 -0
- package/dist/routes/filesystem.routes.d.ts +4 -0
- package/dist/routes/filesystem.routes.d.ts.map +1 -0
- package/dist/routes/filesystem.routes.js +86 -0
- package/dist/routes/filesystem.routes.js.map +1 -0
- package/dist/routes/gemini.routes.d.ts +4 -0
- package/dist/routes/gemini.routes.d.ts.map +1 -0
- package/dist/routes/gemini.routes.js +93 -0
- package/dist/routes/gemini.routes.js.map +1 -0
- package/dist/routes/insights.routes.d.ts +17 -0
- package/dist/routes/insights.routes.d.ts.map +1 -0
- package/dist/routes/insights.routes.js +417 -0
- package/dist/routes/insights.routes.js.map +1 -0
- package/dist/routes/license.routes.d.ts +3 -0
- package/dist/routes/license.routes.d.ts.map +1 -0
- package/dist/routes/license.routes.js +111 -0
- package/dist/routes/license.routes.js.map +1 -0
- package/dist/routes/log.routes.d.ts +3 -0
- package/dist/routes/log.routes.d.ts.map +1 -0
- package/dist/routes/log.routes.js +65 -0
- package/dist/routes/log.routes.js.map +1 -0
- package/dist/routes/notifications.routes.d.ts +4 -0
- package/dist/routes/notifications.routes.d.ts.map +1 -0
- package/dist/routes/notifications.routes.js +71 -0
- package/dist/routes/notifications.routes.js.map +1 -0
- package/dist/routes/permission.routes.d.ts +4 -0
- package/dist/routes/permission.routes.d.ts.map +1 -0
- package/dist/routes/permission.routes.js +116 -0
- package/dist/routes/permission.routes.js.map +1 -0
- package/dist/routes/question.routes.d.ts +8 -0
- package/dist/routes/question.routes.d.ts.map +1 -0
- package/dist/routes/question.routes.js +82 -0
- package/dist/routes/question.routes.js.map +1 -0
- package/dist/routes/streaming.routes.d.ts +4 -0
- package/dist/routes/streaming.routes.d.ts.map +1 -0
- package/dist/routes/streaming.routes.js +28 -0
- package/dist/routes/streaming.routes.js.map +1 -0
- package/dist/routes/system.routes.d.ts +5 -0
- package/dist/routes/system.routes.d.ts.map +1 -0
- package/dist/routes/system.routes.js +103 -0
- package/dist/routes/system.routes.js.map +1 -0
- package/dist/routes/working-directories.routes.d.ts +4 -0
- package/dist/routes/working-directories.routes.d.ts.map +1 -0
- package/dist/routes/working-directories.routes.js +25 -0
- package/dist/routes/working-directories.routes.js.map +1 -0
- package/dist/server.d.ts +3 -0
- package/dist/server.d.ts.map +1 -0
- package/dist/server.js +34 -0
- package/dist/server.js.map +1 -0
- package/dist/services/ToolMetricsService.d.ts +53 -0
- package/dist/services/ToolMetricsService.d.ts.map +1 -0
- package/dist/services/ToolMetricsService.js +230 -0
- package/dist/services/ToolMetricsService.js.map +1 -0
- package/dist/services/anthropic-service.d.ts +186 -0
- package/dist/services/anthropic-service.d.ts.map +1 -0
- package/dist/services/anthropic-service.js +1132 -0
- package/dist/services/anthropic-service.js.map +1 -0
- package/dist/services/claude-history-reader.d.ts +126 -0
- package/dist/services/claude-history-reader.d.ts.map +1 -0
- package/dist/services/claude-history-reader.js +717 -0
- package/dist/services/claude-history-reader.js.map +1 -0
- package/dist/services/claude-process-manager.d.ts +108 -0
- package/dist/services/claude-process-manager.d.ts.map +1 -0
- package/dist/services/claude-process-manager.js +909 -0
- package/dist/services/claude-process-manager.js.map +1 -0
- package/dist/services/claude-router-service.d.ts +19 -0
- package/dist/services/claude-router-service.d.ts.map +1 -0
- package/dist/services/claude-router-service.js +160 -0
- package/dist/services/claude-router-service.js.map +1 -0
- package/dist/services/claudia-service.d.ts +77 -0
- package/dist/services/claudia-service.d.ts.map +1 -0
- package/dist/services/claudia-service.js +194 -0
- package/dist/services/claudia-service.js.map +1 -0
- package/dist/services/commands-service.d.ts +18 -0
- package/dist/services/commands-service.d.ts.map +1 -0
- package/dist/services/commands-service.js +76 -0
- package/dist/services/commands-service.js.map +1 -0
- package/dist/services/config-service.d.ts +68 -0
- package/dist/services/config-service.d.ts.map +1 -0
- package/dist/services/config-service.js +429 -0
- package/dist/services/config-service.js.map +1 -0
- package/dist/services/conversation-cache.d.ts +86 -0
- package/dist/services/conversation-cache.d.ts.map +1 -0
- package/dist/services/conversation-cache.js +235 -0
- package/dist/services/conversation-cache.js.map +1 -0
- package/dist/services/conversation-status-manager.d.ts +98 -0
- package/dist/services/conversation-status-manager.d.ts.map +1 -0
- package/dist/services/conversation-status-manager.js +295 -0
- package/dist/services/conversation-status-manager.js.map +1 -0
- package/dist/services/cost-tracker.d.ts +87 -0
- package/dist/services/cost-tracker.d.ts.map +1 -0
- package/dist/services/cost-tracker.js +335 -0
- package/dist/services/cost-tracker.js.map +1 -0
- package/dist/services/file-system-service.d.ts +61 -0
- package/dist/services/file-system-service.d.ts.map +1 -0
- package/dist/services/file-system-service.js +348 -0
- package/dist/services/file-system-service.js.map +1 -0
- package/dist/services/gemini-service.d.ts +72 -0
- package/dist/services/gemini-service.d.ts.map +1 -0
- package/dist/services/gemini-service.js +431 -0
- package/dist/services/gemini-service.js.map +1 -0
- package/dist/services/insight-queue.d.ts +99 -0
- package/dist/services/insight-queue.d.ts.map +1 -0
- package/dist/services/insight-queue.js +277 -0
- package/dist/services/insight-queue.js.map +1 -0
- package/dist/services/insights-service.d.ts +102 -0
- package/dist/services/insights-service.d.ts.map +1 -0
- package/dist/services/insights-service.js +1152 -0
- package/dist/services/insights-service.js.map +1 -0
- package/dist/services/json-lines-parser.d.ts +19 -0
- package/dist/services/json-lines-parser.d.ts.map +1 -0
- package/dist/services/json-lines-parser.js +56 -0
- package/dist/services/json-lines-parser.js.map +1 -0
- package/dist/services/license-service.d.ts +69 -0
- package/dist/services/license-service.d.ts.map +1 -0
- package/dist/services/license-service.js +330 -0
- package/dist/services/license-service.js.map +1 -0
- package/dist/services/log-formatter.d.ts +5 -0
- package/dist/services/log-formatter.d.ts.map +1 -0
- package/dist/services/log-formatter.js +77 -0
- package/dist/services/log-formatter.js.map +1 -0
- package/dist/services/log-stream-buffer.d.ts +11 -0
- package/dist/services/log-stream-buffer.d.ts.map +1 -0
- package/dist/services/log-stream-buffer.js +36 -0
- package/dist/services/log-stream-buffer.js.map +1 -0
- package/dist/services/logger.d.ts +71 -0
- package/dist/services/logger.d.ts.map +1 -0
- package/dist/services/logger.js +215 -0
- package/dist/services/logger.js.map +1 -0
- package/dist/services/mcp-config-generator.d.ts +32 -0
- package/dist/services/mcp-config-generator.d.ts.map +1 -0
- package/dist/services/mcp-config-generator.js +126 -0
- package/dist/services/mcp-config-generator.js.map +1 -0
- package/dist/services/message-filter.d.ts +22 -0
- package/dist/services/message-filter.d.ts.map +1 -0
- package/dist/services/message-filter.js +57 -0
- package/dist/services/message-filter.js.map +1 -0
- package/dist/services/notification-service.d.ts +45 -0
- package/dist/services/notification-service.d.ts.map +1 -0
- package/dist/services/notification-service.js +184 -0
- package/dist/services/notification-service.js.map +1 -0
- package/dist/services/permission-tracker.d.ts +67 -0
- package/dist/services/permission-tracker.d.ts.map +1 -0
- package/dist/services/permission-tracker.js +161 -0
- package/dist/services/permission-tracker.js.map +1 -0
- package/dist/services/process-manager-factory.d.ts +81 -0
- package/dist/services/process-manager-factory.d.ts.map +1 -0
- package/dist/services/process-manager-factory.js +211 -0
- package/dist/services/process-manager-factory.js.map +1 -0
- package/dist/services/question-tracker.d.ts +47 -0
- package/dist/services/question-tracker.d.ts.map +1 -0
- package/dist/services/question-tracker.js +105 -0
- package/dist/services/question-tracker.js.map +1 -0
- package/dist/services/session-activity-watcher.d.ts +33 -0
- package/dist/services/session-activity-watcher.d.ts.map +1 -0
- package/dist/services/session-activity-watcher.js +194 -0
- package/dist/services/session-activity-watcher.js.map +1 -0
- package/dist/services/session-info-service.d.ts +228 -0
- package/dist/services/session-info-service.d.ts.map +1 -0
- package/dist/services/session-info-service.js +920 -0
- package/dist/services/session-info-service.js.map +1 -0
- package/dist/services/session-insights-service.d.ts +119 -0
- package/dist/services/session-insights-service.d.ts.map +1 -0
- package/dist/services/session-insights-service.js +889 -0
- package/dist/services/session-insights-service.js.map +1 -0
- package/dist/services/stream-manager.d.ts +62 -0
- package/dist/services/stream-manager.d.ts.map +1 -0
- package/dist/services/stream-manager.js +239 -0
- package/dist/services/stream-manager.js.map +1 -0
- package/dist/services/web-push-service.d.ts +48 -0
- package/dist/services/web-push-service.d.ts.map +1 -0
- package/dist/services/web-push-service.js +186 -0
- package/dist/services/web-push-service.js.map +1 -0
- package/dist/services/working-directories-service.d.ts +19 -0
- package/dist/services/working-directories-service.d.ts.map +1 -0
- package/dist/services/working-directories-service.js +103 -0
- package/dist/services/working-directories-service.js.map +1 -0
- package/dist/types/config.d.ts +111 -0
- package/dist/types/config.d.ts.map +1 -0
- package/dist/types/config.js +14 -0
- package/dist/types/config.js.map +1 -0
- package/dist/types/express.d.ts +5 -0
- package/dist/types/express.d.ts.map +1 -0
- package/dist/types/express.js +2 -0
- package/dist/types/express.js.map +1 -0
- package/dist/types/index.d.ts +325 -0
- package/dist/types/index.d.ts.map +1 -0
- package/dist/types/index.js +18 -0
- package/dist/types/index.js.map +1 -0
- package/dist/types/insights.d.ts +99 -0
- package/dist/types/insights.d.ts.map +1 -0
- package/dist/types/insights.js +7 -0
- package/dist/types/insights.js.map +1 -0
- package/dist/types/license.d.ts +70 -0
- package/dist/types/license.d.ts.map +1 -0
- package/dist/types/license.js +5 -0
- package/dist/types/license.js.map +1 -0
- package/dist/types/router-config.d.ts +13 -0
- package/dist/types/router-config.d.ts.map +1 -0
- package/dist/types/router-config.js +2 -0
- package/dist/types/router-config.js.map +1 -0
- package/dist/utils/constants.d.ts +26 -0
- package/dist/utils/constants.d.ts.map +1 -0
- package/dist/utils/constants.js +28 -0
- package/dist/utils/constants.js.map +1 -0
- package/dist/utils/machine-id.d.ts +7 -0
- package/dist/utils/machine-id.d.ts.map +1 -0
- package/dist/utils/machine-id.js +76 -0
- package/dist/utils/machine-id.js.map +1 -0
- package/dist/utils/server-startup.d.ts +13 -0
- package/dist/utils/server-startup.d.ts.map +1 -0
- package/dist/utils/server-startup.js +20 -0
- package/dist/utils/server-startup.js.map +1 -0
- package/dist/web/assets/main-DAc2rjJ2.css +1 -0
- package/dist/web/assets/main-DvlZ02mT.js +137 -0
- package/dist/web/favicon.png +0 -0
- package/dist/web/favicon.svg +22 -0
- package/dist/web/icon-192x192.png +0 -0
- package/dist/web/icon-512x512.png +0 -0
- package/dist/web/index.html +36 -0
- package/dist/web/manifest.json +61 -0
- package/package.json +174 -0
- package/scripts/postinstall.js +30 -0
|
@@ -0,0 +1,909 @@
|
|
|
1
|
+
import * as pty from 'node-pty';
|
|
2
|
+
import { CUIError } from '../types/index.js';
|
|
3
|
+
import { v4 as uuidv4 } from 'uuid';
|
|
4
|
+
import { EventEmitter } from 'events';
|
|
5
|
+
import { existsSync, readFileSync } from 'fs';
|
|
6
|
+
import { fileURLToPath } from 'url';
|
|
7
|
+
import { dirname } from 'path';
|
|
8
|
+
import os from 'os';
|
|
9
|
+
import { JsonLinesParser } from './json-lines-parser.js';
|
|
10
|
+
import { createLogger } from './logger.js';
|
|
11
|
+
import path from 'path';
|
|
12
|
+
/**
|
|
13
|
+
* Expand tilde (~) in paths to the user's home directory.
|
|
14
|
+
* Node.js spawn doesn't expand ~ like shells do, causing ENOENT errors.
|
|
15
|
+
*/
|
|
16
|
+
function expandTilde(filePath) {
|
|
17
|
+
if (filePath === '~' || filePath.startsWith('~/')) {
|
|
18
|
+
return path.join(os.homedir(), filePath.slice(1));
|
|
19
|
+
}
|
|
20
|
+
return filePath;
|
|
21
|
+
}
|
|
22
|
+
// Get the directory of this module
|
|
23
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
24
|
+
const __dirname = dirname(__filename);
|
|
25
|
+
/**
|
|
26
|
+
* Manages Claude CLI processes and their lifecycle
|
|
27
|
+
*/
|
|
28
|
+
export class ClaudeProcessManager extends EventEmitter {
|
|
29
|
+
processes = new Map();
|
|
30
|
+
outputBuffers = new Map();
|
|
31
|
+
timeouts = new Map();
|
|
32
|
+
conversationConfigs = new Map();
|
|
33
|
+
sessionIds = new Map(); // streamingId -> claude session_id
|
|
34
|
+
claudeExecutablePath;
|
|
35
|
+
logger;
|
|
36
|
+
envOverrides;
|
|
37
|
+
historyReader;
|
|
38
|
+
mcpConfigPath;
|
|
39
|
+
statusTracker;
|
|
40
|
+
conversationStatusManager;
|
|
41
|
+
toolMetricsService;
|
|
42
|
+
sessionInfoService;
|
|
43
|
+
fileSystemService;
|
|
44
|
+
notificationService;
|
|
45
|
+
routerService;
|
|
46
|
+
killedProcesses = new Set(); // Track which processes we've killed
|
|
47
|
+
constructor(historyReader, statusTracker, claudeExecutablePath, envOverrides, toolMetricsService, sessionInfoService, fileSystemService) {
|
|
48
|
+
super();
|
|
49
|
+
this.historyReader = historyReader;
|
|
50
|
+
this.statusTracker = statusTracker;
|
|
51
|
+
this.claudeExecutablePath = claudeExecutablePath || this.findClaudeExecutable();
|
|
52
|
+
this.logger = createLogger('ClaudeProcessManager');
|
|
53
|
+
this.envOverrides = envOverrides || {};
|
|
54
|
+
this.toolMetricsService = toolMetricsService;
|
|
55
|
+
this.sessionInfoService = sessionInfoService;
|
|
56
|
+
this.fileSystemService = fileSystemService;
|
|
57
|
+
}
|
|
58
|
+
setRouterService(service) {
|
|
59
|
+
this.routerService = service;
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Find the Claude executable - check user-local and system paths
|
|
63
|
+
*/
|
|
64
|
+
findClaudeExecutable() {
|
|
65
|
+
// Check common installation paths including user-local installs
|
|
66
|
+
const userLocalPath = path.join(os.homedir(), '.local', 'bin', 'claude');
|
|
67
|
+
const candidatePaths = [
|
|
68
|
+
userLocalPath, // ~/.local/bin/claude (common for user installs)
|
|
69
|
+
'/usr/local/bin/claude',
|
|
70
|
+
'/usr/bin/claude',
|
|
71
|
+
];
|
|
72
|
+
for (const candidate of candidatePaths) {
|
|
73
|
+
if (existsSync(candidate)) {
|
|
74
|
+
return candidate;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
// Fallback: try PATH, excluding node_modules
|
|
78
|
+
const pathEnv = process.env.PATH || '';
|
|
79
|
+
const pathDirs = pathEnv.split(path.delimiter);
|
|
80
|
+
for (const dir of pathDirs) {
|
|
81
|
+
if (dir.includes('node_modules'))
|
|
82
|
+
continue; // Skip node_modules
|
|
83
|
+
const candidate = path.join(dir, 'claude');
|
|
84
|
+
if (existsSync(candidate)) {
|
|
85
|
+
return candidate;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
throw new Error('Claude executable not found. Ensure Claude CLI is installed.');
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Set the MCP config path to be used for all conversations
|
|
92
|
+
*/
|
|
93
|
+
setMCPConfigPath(path) {
|
|
94
|
+
this.mcpConfigPath = path;
|
|
95
|
+
this.logger.debug('MCP config path set', { path });
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Set the optimistic conversation service
|
|
99
|
+
*/
|
|
100
|
+
setConversationStatusManager(service) {
|
|
101
|
+
this.conversationStatusManager = service;
|
|
102
|
+
this.logger.debug('Conversation status manager set');
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Set the notification service
|
|
106
|
+
*/
|
|
107
|
+
setNotificationService(service) {
|
|
108
|
+
this.notificationService = service;
|
|
109
|
+
this.logger.debug('Notification service set');
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* Start a new Claude conversation (or resume if resumedSessionId is provided)
|
|
113
|
+
*/
|
|
114
|
+
async startConversation(config) {
|
|
115
|
+
const isResume = !!config.resumedSessionId;
|
|
116
|
+
// Guard against duplicate spawns for the same session
|
|
117
|
+
if (isResume && config.resumedSessionId && this.statusTracker.isSessionActive(config.resumedSessionId)) {
|
|
118
|
+
this.logger.warn('Attempted to resume already-active session', {
|
|
119
|
+
sessionId: config.resumedSessionId
|
|
120
|
+
});
|
|
121
|
+
throw new CUIError('SESSION_ALREADY_ACTIVE', `Cannot resume session ${config.resumedSessionId}: already has an active Claude process`, 409 // Conflict
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
this.logger.debug('Start conversation requested', {
|
|
125
|
+
hasInitialPrompt: !!config.initialPrompt,
|
|
126
|
+
promptLength: config.initialPrompt?.length,
|
|
127
|
+
workingDirectory: config.workingDirectory,
|
|
128
|
+
model: config.model,
|
|
129
|
+
allowedTools: config.allowedTools,
|
|
130
|
+
disallowedTools: config.disallowedTools,
|
|
131
|
+
hasSystemPrompt: !!config.systemPrompt,
|
|
132
|
+
claudePath: config.claudeExecutablePath || this.claudeExecutablePath,
|
|
133
|
+
isResume,
|
|
134
|
+
resumedSessionId: config.resumedSessionId,
|
|
135
|
+
previousMessageCount: config.previousMessages?.length || 0
|
|
136
|
+
});
|
|
137
|
+
// If resuming and no working directory provided, fetch from original session
|
|
138
|
+
let workingDirectory = config.workingDirectory;
|
|
139
|
+
if (isResume && !workingDirectory && config.resumedSessionId) {
|
|
140
|
+
const fetchedWorkingDirectory = await this.historyReader.getConversationWorkingDirectory(config.resumedSessionId);
|
|
141
|
+
if (!fetchedWorkingDirectory) {
|
|
142
|
+
throw new CUIError('CONVERSATION_NOT_FOUND', `Could not find working directory for session ${config.resumedSessionId}`, 404);
|
|
143
|
+
}
|
|
144
|
+
workingDirectory = fetchedWorkingDirectory;
|
|
145
|
+
this.logger.debug('Found working directory for resume session', {
|
|
146
|
+
sessionId: config.resumedSessionId,
|
|
147
|
+
workingDirectory
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
const args = isResume && config.resumedSessionId
|
|
151
|
+
? this.buildResumeArgs({ sessionId: config.resumedSessionId, message: config.initialPrompt, permissionMode: config.permissionMode })
|
|
152
|
+
: this.buildStartArgs(config);
|
|
153
|
+
const spawnConfig = {
|
|
154
|
+
executablePath: config.claudeExecutablePath || this.claudeExecutablePath,
|
|
155
|
+
cwd: workingDirectory || config.workingDirectory || process.cwd(),
|
|
156
|
+
env: { ...process.env, ...this.envOverrides }
|
|
157
|
+
};
|
|
158
|
+
this.logger.debug('Spawn config prepared', {
|
|
159
|
+
executablePath: spawnConfig.executablePath,
|
|
160
|
+
cwd: spawnConfig.cwd,
|
|
161
|
+
hasEnvOverrides: Object.keys(this.envOverrides).length > 0,
|
|
162
|
+
envOverrideKeys: Object.keys(this.envOverrides),
|
|
163
|
+
isResume
|
|
164
|
+
});
|
|
165
|
+
return this.executeConversationFlow(isResume ? 'resuming' : 'starting', isResume && config.resumedSessionId ? { resumeSessionId: config.resumedSessionId } : {}, config, args, spawnConfig, isResume ? 'PROCESS_RESUME_FAILED' : 'PROCESS_START_FAILED', isResume ? 'Failed to resume Claude process' : 'Failed to start Claude process');
|
|
166
|
+
}
|
|
167
|
+
/**
|
|
168
|
+
* Stop a conversation gracefully (SIGTERM, then SIGKILL after 3s)
|
|
169
|
+
*/
|
|
170
|
+
async stopConversation(streamingId) {
|
|
171
|
+
this.logger.debug('Stopping conversation', { streamingId });
|
|
172
|
+
const process = this.processes.get(streamingId);
|
|
173
|
+
if (!process) {
|
|
174
|
+
this.logger.warn('No process found for conversation', { streamingId });
|
|
175
|
+
return false;
|
|
176
|
+
}
|
|
177
|
+
try {
|
|
178
|
+
// Force kill if not already killed
|
|
179
|
+
if (!this.killedProcesses.has(streamingId)) {
|
|
180
|
+
this.logger.info('Sending SIGTERM to process', { streamingId, pid: process.pid });
|
|
181
|
+
process.kill('SIGTERM');
|
|
182
|
+
this.killedProcesses.add(streamingId);
|
|
183
|
+
// If SIGTERM doesn't work, use SIGKILL after 3 seconds
|
|
184
|
+
const killTimeout = setTimeout(() => {
|
|
185
|
+
try {
|
|
186
|
+
if (this.processes.has(streamingId)) {
|
|
187
|
+
this.logger.warn('Process not responding to SIGTERM, sending SIGKILL', { streamingId, pid: process.pid });
|
|
188
|
+
process.kill('SIGKILL');
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
catch {
|
|
192
|
+
// Process may have already exited, ignore
|
|
193
|
+
}
|
|
194
|
+
}, 3000);
|
|
195
|
+
// Track timeout for cleanup on process exit
|
|
196
|
+
const sessionTimeouts = this.timeouts.get(streamingId) || [];
|
|
197
|
+
sessionTimeouts.push(killTimeout);
|
|
198
|
+
this.timeouts.set(streamingId, sessionTimeouts);
|
|
199
|
+
}
|
|
200
|
+
// Note: Cleanup happens in handleProcessClose when process actually exits
|
|
201
|
+
// Don't clean up here - the process may still be running
|
|
202
|
+
this.logger.info('Stop signal sent to process', { streamingId });
|
|
203
|
+
return true;
|
|
204
|
+
}
|
|
205
|
+
catch (error) {
|
|
206
|
+
this.logger.error('Error stopping conversation', error, { streamingId });
|
|
207
|
+
return false;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
/**
|
|
211
|
+
* Force kill a conversation immediately (SIGKILL)
|
|
212
|
+
*/
|
|
213
|
+
forceKillConversation(streamingId) {
|
|
214
|
+
this.logger.info('Force killing conversation', { streamingId });
|
|
215
|
+
const process = this.processes.get(streamingId);
|
|
216
|
+
if (!process) {
|
|
217
|
+
this.logger.warn('No process found for force kill', { streamingId });
|
|
218
|
+
return false;
|
|
219
|
+
}
|
|
220
|
+
try {
|
|
221
|
+
this.logger.warn('Sending SIGKILL to process', { streamingId, pid: process.pid });
|
|
222
|
+
process.kill('SIGKILL');
|
|
223
|
+
this.killedProcesses.add(streamingId);
|
|
224
|
+
return true;
|
|
225
|
+
}
|
|
226
|
+
catch (error) {
|
|
227
|
+
this.logger.error('Error force killing conversation', error, { streamingId });
|
|
228
|
+
return false;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
/**
|
|
232
|
+
* Get active sessions
|
|
233
|
+
*/
|
|
234
|
+
getActiveSessions() {
|
|
235
|
+
const sessions = Array.from(this.processes.keys());
|
|
236
|
+
this.logger.debug('Getting active sessions', { sessionCount: sessions.length });
|
|
237
|
+
return sessions;
|
|
238
|
+
}
|
|
239
|
+
/**
|
|
240
|
+
* Check if a session is active
|
|
241
|
+
*/
|
|
242
|
+
isSessionActive(streamingId) {
|
|
243
|
+
const active = this.processes.has(streamingId);
|
|
244
|
+
return active;
|
|
245
|
+
}
|
|
246
|
+
/**
|
|
247
|
+
* Wait for the system init message from Claude CLI
|
|
248
|
+
* This should always be the first message in the stream
|
|
249
|
+
*/
|
|
250
|
+
async waitForSystemInit(streamingId) {
|
|
251
|
+
this.logger.debug('Waiting for system init message', { streamingId });
|
|
252
|
+
return new Promise((resolve, reject) => {
|
|
253
|
+
let isResolved = false;
|
|
254
|
+
let stderrOutput = '';
|
|
255
|
+
// Set up timeout (1 minute)
|
|
256
|
+
const timeout = setTimeout(() => {
|
|
257
|
+
if (!isResolved) {
|
|
258
|
+
isResolved = true;
|
|
259
|
+
cleanup();
|
|
260
|
+
this.logger.error('Timeout waiting for system init message', {
|
|
261
|
+
streamingId,
|
|
262
|
+
stderrOutput: stderrOutput || '(no stderr output)'
|
|
263
|
+
});
|
|
264
|
+
// Include stderr output in error message if available
|
|
265
|
+
let errorMessage = 'Timeout waiting for system initialization from Claude CLI';
|
|
266
|
+
if (stderrOutput) {
|
|
267
|
+
errorMessage += `. Error output: ${stderrOutput}`;
|
|
268
|
+
}
|
|
269
|
+
reject(new CUIError('SYSTEM_INIT_TIMEOUT', errorMessage, 500));
|
|
270
|
+
}
|
|
271
|
+
}, 60000);
|
|
272
|
+
// Cleanup function to remove all listeners
|
|
273
|
+
const cleanup = () => {
|
|
274
|
+
clearTimeout(timeout);
|
|
275
|
+
this.removeListener('claude-message', messageHandler);
|
|
276
|
+
this.removeListener('process-closed', processClosedHandler);
|
|
277
|
+
this.removeListener('process-error', processErrorHandler);
|
|
278
|
+
};
|
|
279
|
+
// Register timeout for cleanup on process termination
|
|
280
|
+
const existingTimeouts = this.timeouts.get(streamingId) || [];
|
|
281
|
+
existingTimeouts.push(timeout);
|
|
282
|
+
this.timeouts.set(streamingId, existingTimeouts);
|
|
283
|
+
// Listen for process exit before system init is received
|
|
284
|
+
const processClosedHandler = ({ streamingId: closedStreamingId, code }) => {
|
|
285
|
+
if (closedStreamingId !== streamingId || isResolved) {
|
|
286
|
+
return; // Not our process or already resolved
|
|
287
|
+
}
|
|
288
|
+
isResolved = true;
|
|
289
|
+
cleanup();
|
|
290
|
+
this.logger.error('Claude process exited before system init message', {
|
|
291
|
+
streamingId,
|
|
292
|
+
exitCode: code,
|
|
293
|
+
stderrOutput: stderrOutput || '(no stderr output)'
|
|
294
|
+
});
|
|
295
|
+
// Create error message with Claude CLI output if available
|
|
296
|
+
let errorMessage = 'Claude CLI process exited before sending system initialization message';
|
|
297
|
+
if (stderrOutput) {
|
|
298
|
+
// Extract Claude CLI's actual output from parser errors
|
|
299
|
+
const claudeOutputMatch = stderrOutput.match(/Invalid JSON: (.+)/);
|
|
300
|
+
if (claudeOutputMatch) {
|
|
301
|
+
errorMessage += `. Claude CLI said: "${claudeOutputMatch[1]}"`;
|
|
302
|
+
}
|
|
303
|
+
else {
|
|
304
|
+
errorMessage += `. Error output: ${stderrOutput}`;
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
if (code !== null) {
|
|
308
|
+
errorMessage += `. Exit code: ${code}`;
|
|
309
|
+
}
|
|
310
|
+
reject(new CUIError('CLAUDE_PROCESS_EXITED_EARLY', errorMessage, 500));
|
|
311
|
+
};
|
|
312
|
+
// Listen for process errors (including stderr output)
|
|
313
|
+
const processErrorHandler = ({ streamingId: errorStreamingId, error }) => {
|
|
314
|
+
if (errorStreamingId !== streamingId) {
|
|
315
|
+
return; // Not our process
|
|
316
|
+
}
|
|
317
|
+
// Capture stderr output for error context
|
|
318
|
+
stderrOutput += error;
|
|
319
|
+
this.logger.debug('Captured stderr output during system init wait', {
|
|
320
|
+
streamingId,
|
|
321
|
+
errorLength: error.length,
|
|
322
|
+
totalStderrLength: stderrOutput.length
|
|
323
|
+
});
|
|
324
|
+
};
|
|
325
|
+
// Listen for the first claude-message event for this streamingId
|
|
326
|
+
const messageHandler = ({ streamingId: msgStreamingId, message }) => {
|
|
327
|
+
if (msgStreamingId !== streamingId) {
|
|
328
|
+
return; // Not for our session
|
|
329
|
+
}
|
|
330
|
+
if (isResolved) {
|
|
331
|
+
return; // Already resolved
|
|
332
|
+
}
|
|
333
|
+
this.logger.debug('Received message from Claude CLI during init wait', {
|
|
334
|
+
streamingId,
|
|
335
|
+
messageType: message?.type,
|
|
336
|
+
messageSubtype: 'subtype' in message ? message.subtype : undefined,
|
|
337
|
+
hasSessionId: 'session_id' in message ? !!message.session_id : false
|
|
338
|
+
});
|
|
339
|
+
// Skip hook_response messages (Claude v2 sends these before init)
|
|
340
|
+
// Cast to unknown string to compare - StreamEvent type doesn't include all system subtypes
|
|
341
|
+
if (message?.type === 'system' && 'subtype' in message && message.subtype === 'hook_response') {
|
|
342
|
+
this.logger.debug('Skipping hook_response message, waiting for init', { streamingId });
|
|
343
|
+
return; // Keep waiting for init message
|
|
344
|
+
}
|
|
345
|
+
// Now we have a non-hook message, mark as resolved
|
|
346
|
+
isResolved = true;
|
|
347
|
+
cleanup();
|
|
348
|
+
// Validate that this message is a system init message
|
|
349
|
+
if (!message || message.type !== 'system' || !('subtype' in message) || message.subtype !== 'init') {
|
|
350
|
+
this.logger.error('Expected system init message', {
|
|
351
|
+
streamingId,
|
|
352
|
+
actualType: message?.type,
|
|
353
|
+
actualSubtype: 'subtype' in message ? message.subtype : undefined,
|
|
354
|
+
expectedType: 'system',
|
|
355
|
+
expectedSubtype: 'init'
|
|
356
|
+
});
|
|
357
|
+
reject(new CUIError('INVALID_SYSTEM_INIT', `Expected system init message, but got: ${message?.type}/${'subtype' in message ? message.subtype : 'undefined'}`, 500));
|
|
358
|
+
return;
|
|
359
|
+
}
|
|
360
|
+
// At this point, TypeScript knows message is SystemInitMessage
|
|
361
|
+
const systemInitMessage = message;
|
|
362
|
+
// Validate required fields
|
|
363
|
+
const requiredFields = ['session_id', 'cwd', 'tools', 'mcp_servers', 'model', 'permissionMode', 'apiKeySource'];
|
|
364
|
+
const missingFields = requiredFields.filter(field => systemInitMessage[field] === undefined);
|
|
365
|
+
if (missingFields.length > 0) {
|
|
366
|
+
this.logger.error('System init message missing required fields', {
|
|
367
|
+
streamingId,
|
|
368
|
+
missingFields,
|
|
369
|
+
availableFields: Object.keys(systemInitMessage)
|
|
370
|
+
});
|
|
371
|
+
reject(new CUIError('INCOMPLETE_SYSTEM_INIT', `System init message missing required fields: ${missingFields.join(', ')}`, 500));
|
|
372
|
+
return;
|
|
373
|
+
}
|
|
374
|
+
this.logger.debug('Successfully received valid system init message', {
|
|
375
|
+
streamingId,
|
|
376
|
+
sessionId: systemInitMessage.session_id,
|
|
377
|
+
cwd: systemInitMessage.cwd,
|
|
378
|
+
model: systemInitMessage.model,
|
|
379
|
+
toolCount: systemInitMessage.tools?.length || 0,
|
|
380
|
+
mcpServerCount: systemInitMessage.mcp_servers?.length || 0
|
|
381
|
+
});
|
|
382
|
+
// Register active session immediately when we have the session_id
|
|
383
|
+
// Include optimistic context if available
|
|
384
|
+
const config = this.conversationConfigs.get(streamingId);
|
|
385
|
+
if (this.conversationStatusManager && config) {
|
|
386
|
+
const optimisticContext = {
|
|
387
|
+
initialPrompt: config.initialPrompt || '',
|
|
388
|
+
workingDirectory: config.workingDirectory || process.cwd(),
|
|
389
|
+
model: config.model || 'default',
|
|
390
|
+
timestamp: new Date().toISOString(),
|
|
391
|
+
inheritedMessages: config.previousMessages
|
|
392
|
+
};
|
|
393
|
+
this.conversationStatusManager.registerActiveSession(streamingId, systemInitMessage.session_id, optimisticContext);
|
|
394
|
+
this.logger.debug('Registered conversation context', {
|
|
395
|
+
streamingId,
|
|
396
|
+
claudeSessionId: systemInitMessage.session_id,
|
|
397
|
+
inheritedMessageCount: config.previousMessages?.length || 0
|
|
398
|
+
});
|
|
399
|
+
}
|
|
400
|
+
else {
|
|
401
|
+
// Fallback to old behavior if service not set
|
|
402
|
+
this.statusTracker.registerActiveSession(streamingId, systemInitMessage.session_id);
|
|
403
|
+
this.logger.debug('Registered active session with status tracker (no optimistic service)', {
|
|
404
|
+
streamingId,
|
|
405
|
+
claudeSessionId: systemInitMessage.session_id
|
|
406
|
+
});
|
|
407
|
+
}
|
|
408
|
+
resolve(systemInitMessage);
|
|
409
|
+
};
|
|
410
|
+
// Set up all event listeners
|
|
411
|
+
this.on('claude-message', messageHandler);
|
|
412
|
+
this.on('process-closed', processClosedHandler);
|
|
413
|
+
this.on('process-error', processErrorHandler);
|
|
414
|
+
});
|
|
415
|
+
}
|
|
416
|
+
/**
|
|
417
|
+
* Execute common conversation flow for both start and resume operations
|
|
418
|
+
*/
|
|
419
|
+
async executeConversationFlow(operation, loggerContext, config, args, spawnConfig, errorCode, errorPrefix) {
|
|
420
|
+
const streamingId = uuidv4(); // CUI's internal streaming identifier
|
|
421
|
+
// Store config for use in waitForSystemInit
|
|
422
|
+
this.conversationConfigs.set(streamingId, config);
|
|
423
|
+
try {
|
|
424
|
+
// Validate Claude executable before proceeding
|
|
425
|
+
if (this.fileSystemService) {
|
|
426
|
+
await this.fileSystemService.validateExecutable(spawnConfig.executablePath);
|
|
427
|
+
}
|
|
428
|
+
this.logger.debug(`${operation.charAt(0).toUpperCase() + operation.slice(1)} conversation`, {
|
|
429
|
+
streamingId,
|
|
430
|
+
operation,
|
|
431
|
+
configKeys: Object.keys(config),
|
|
432
|
+
argCount: args.length,
|
|
433
|
+
...loggerContext
|
|
434
|
+
});
|
|
435
|
+
this.logger.debug(`Built Claude ${operation} args`, {
|
|
436
|
+
streamingId,
|
|
437
|
+
args,
|
|
438
|
+
argsString: args.join(' '),
|
|
439
|
+
...loggerContext
|
|
440
|
+
});
|
|
441
|
+
// Set up system init promise before spawning process
|
|
442
|
+
const systemInitPromise = this.waitForSystemInit(streamingId);
|
|
443
|
+
// Add streamingId to environment for MCP server to use
|
|
444
|
+
// Filter out debugging-related environment variables that would cause
|
|
445
|
+
// the VSCode debugger to attach to the Claude CLI child process
|
|
446
|
+
// Also filter CLAUDECODE to prevent nested session detection issues
|
|
447
|
+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
448
|
+
const { NODE_OPTIONS, VSCODE_INSPECTOR_OPTIONS, CLAUDECODE, CLAUDE_CODE_ENTRYPOINT, ...cleanEnv } = spawnConfig.env;
|
|
449
|
+
// Expand tilde to actual home directory path
|
|
450
|
+
const expandedCwd = expandTilde(spawnConfig.cwd);
|
|
451
|
+
const envWithStreamingId = {
|
|
452
|
+
...cleanEnv,
|
|
453
|
+
CUI_STREAMING_ID: streamingId,
|
|
454
|
+
PWD: expandedCwd,
|
|
455
|
+
INIT_CWD: expandedCwd
|
|
456
|
+
};
|
|
457
|
+
const process = this.spawnProcess({ ...spawnConfig, env: envWithStreamingId }, args, streamingId);
|
|
458
|
+
this.processes.set(streamingId, process);
|
|
459
|
+
this.setupProcessHandlers(streamingId, process);
|
|
460
|
+
// Message is now passed as CLI arg (not via stdin)
|
|
461
|
+
// --input-format stream-json was removed because it causes Claude to wait for input
|
|
462
|
+
// without sending system init message first
|
|
463
|
+
// Handle spawn errors by listening for our custom event
|
|
464
|
+
const spawnErrorPromise = new Promise((_, reject) => {
|
|
465
|
+
this.once('spawn-error', (error) => {
|
|
466
|
+
this.processes.delete(streamingId);
|
|
467
|
+
reject(error);
|
|
468
|
+
});
|
|
469
|
+
});
|
|
470
|
+
// Wait a bit to see if spawn fails immediately
|
|
471
|
+
this.logger.debug('Waiting for spawn validation', { streamingId, ...loggerContext });
|
|
472
|
+
const delayPromise = new Promise(resolve => {
|
|
473
|
+
setTimeout(() => {
|
|
474
|
+
this.logger.debug('Spawn validation period passed, process appears stable', { streamingId, ...loggerContext });
|
|
475
|
+
this.removeAllListeners('spawn-error');
|
|
476
|
+
resolve(streamingId);
|
|
477
|
+
}, 100);
|
|
478
|
+
});
|
|
479
|
+
await Promise.race([spawnErrorPromise, delayPromise]);
|
|
480
|
+
// Now wait for the system init message
|
|
481
|
+
this.logger.debug('Process spawned successfully, waiting for system init message', { streamingId, ...loggerContext });
|
|
482
|
+
const systemInit = await systemInitPromise;
|
|
483
|
+
// Check if cwd is a git repository and set initial_commit_head for new session
|
|
484
|
+
if (this.sessionInfoService && this.fileSystemService) {
|
|
485
|
+
try {
|
|
486
|
+
if (await this.fileSystemService.isGitRepository(systemInit.cwd)) {
|
|
487
|
+
const gitHead = await this.fileSystemService.getCurrentGitHead(systemInit.cwd);
|
|
488
|
+
if (gitHead) {
|
|
489
|
+
await this.sessionInfoService.updateSessionInfo(systemInit.session_id, {
|
|
490
|
+
initial_commit_head: gitHead
|
|
491
|
+
});
|
|
492
|
+
this.logger.debug('Set initial commit head for new session', {
|
|
493
|
+
sessionId: systemInit.session_id,
|
|
494
|
+
gitHead
|
|
495
|
+
});
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
catch (error) {
|
|
500
|
+
this.logger.warn('Failed to set initial commit head for new session', {
|
|
501
|
+
sessionId: systemInit.session_id,
|
|
502
|
+
cwd: systemInit.cwd,
|
|
503
|
+
error: error instanceof Error ? error.message : String(error)
|
|
504
|
+
});
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
this.logger.debug(`${operation.charAt(0).toUpperCase() + operation.slice(1)} conversation successfully`, {
|
|
508
|
+
streamingId,
|
|
509
|
+
sessionId: systemInit.session_id,
|
|
510
|
+
model: systemInit.model,
|
|
511
|
+
cwd: systemInit.cwd,
|
|
512
|
+
processCount: this.processes.size,
|
|
513
|
+
...loggerContext
|
|
514
|
+
});
|
|
515
|
+
return { streamingId, systemInit };
|
|
516
|
+
}
|
|
517
|
+
catch (error) {
|
|
518
|
+
this.logger.error(`Error ${operation} conversation`, error, {
|
|
519
|
+
streamingId,
|
|
520
|
+
errorName: error instanceof Error ? error.name : 'Unknown',
|
|
521
|
+
errorMessage: error instanceof Error ? error.message : String(error),
|
|
522
|
+
errorCode: error instanceof CUIError ? error.code : undefined,
|
|
523
|
+
...loggerContext
|
|
524
|
+
});
|
|
525
|
+
// Clean up any resources if process fails
|
|
526
|
+
const timeouts = this.timeouts.get(streamingId);
|
|
527
|
+
if (timeouts) {
|
|
528
|
+
timeouts.forEach(timeout => clearTimeout(timeout));
|
|
529
|
+
this.timeouts.delete(streamingId);
|
|
530
|
+
}
|
|
531
|
+
this.processes.delete(streamingId);
|
|
532
|
+
this.outputBuffers.delete(streamingId);
|
|
533
|
+
this.conversationConfigs.delete(streamingId);
|
|
534
|
+
if (error instanceof CUIError) {
|
|
535
|
+
throw error;
|
|
536
|
+
}
|
|
537
|
+
throw new CUIError(errorCode, `${errorPrefix}: ${error}`, 500);
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
buildBaseArgs() {
|
|
541
|
+
return [
|
|
542
|
+
'-p', // Print mode - required for programmatic use
|
|
543
|
+
];
|
|
544
|
+
}
|
|
545
|
+
buildResumeArgs(config) {
|
|
546
|
+
this.logger.debug('Building Claude resume args', {
|
|
547
|
+
sessionId: config.sessionId,
|
|
548
|
+
messagePreview: config.message.substring(0, 50) + (config.message.length > 50 ? '...' : '')
|
|
549
|
+
});
|
|
550
|
+
const args = this.buildBaseArgs();
|
|
551
|
+
// Add message as CLI arg - same as start
|
|
552
|
+
// NOTE: For very long messages, this may hit CLI arg length limits
|
|
553
|
+
args.push(config.message);
|
|
554
|
+
args.push('--resume', config.sessionId, '--output-format', 'stream-json', '--input-format', 'stream-json', // Enable stdin for AskUserQuestion responses
|
|
555
|
+
'--verbose');
|
|
556
|
+
// Always use dangerously bypass permissions
|
|
557
|
+
args.push('--dangerously-skip-permissions');
|
|
558
|
+
// NOTE: MCP config disabled for resume - it causes Claude to hang waiting for MCP server init
|
|
559
|
+
// The MCP permission server creates a circular dependency when spawned during resume
|
|
560
|
+
// Since we use --dangerously-skip-permissions, we don't need the permission prompt tool anyway
|
|
561
|
+
this.logger.debug('Built Claude resume args', { args, hasMCPConfig: !!this.mcpConfigPath });
|
|
562
|
+
return args;
|
|
563
|
+
}
|
|
564
|
+
buildStartArgs(config) {
|
|
565
|
+
this.logger.debug('Building Claude start args', {
|
|
566
|
+
hasInitialPrompt: !!config.initialPrompt,
|
|
567
|
+
promptPreview: config.initialPrompt ? config.initialPrompt.substring(0, 50) + (config.initialPrompt.length > 50 ? '...' : '') : null,
|
|
568
|
+
workingDirectory: config.workingDirectory,
|
|
569
|
+
model: config.model
|
|
570
|
+
});
|
|
571
|
+
const args = this.buildBaseArgs();
|
|
572
|
+
// Add initial prompt immediately after -p
|
|
573
|
+
if (config.initialPrompt) {
|
|
574
|
+
args.push(config.initialPrompt);
|
|
575
|
+
}
|
|
576
|
+
args.push('--output-format', 'stream-json', // JSONL output format
|
|
577
|
+
'--input-format', 'stream-json', // Enable stdin for AskUserQuestion responses
|
|
578
|
+
'--verbose' // Required when using stream-json with print mode
|
|
579
|
+
);
|
|
580
|
+
// Add working directory access
|
|
581
|
+
// if (config.workingDirectory) {
|
|
582
|
+
// args.push('--add-dir', config.workingDirectory);
|
|
583
|
+
// }
|
|
584
|
+
// Add model specification
|
|
585
|
+
if (config.model) {
|
|
586
|
+
args.push('--model', config.model);
|
|
587
|
+
}
|
|
588
|
+
// Add allowed tools
|
|
589
|
+
if (config.allowedTools && config.allowedTools.length > 0) {
|
|
590
|
+
args.push('--allowedTools', config.allowedTools.join(','));
|
|
591
|
+
}
|
|
592
|
+
// Add disallowed tools
|
|
593
|
+
if (config.disallowedTools && config.disallowedTools.length > 0) {
|
|
594
|
+
args.push('--disallowedTools', config.disallowedTools.join(','));
|
|
595
|
+
}
|
|
596
|
+
// Add system prompt with Claudia environment info
|
|
597
|
+
const claudiaEnvInfo = `
|
|
598
|
+
## Claudia Orchestrator Environment
|
|
599
|
+
|
|
600
|
+
You are running inside the Claudia orchestrator dashboard.
|
|
601
|
+
|
|
602
|
+
**Server logs** (for debugging issues):
|
|
603
|
+
- \`~/.claudia/logs/server.log\` - Express server logs
|
|
604
|
+
- \`~/.claudia/logs/daemon.log\` - Process daemon logs
|
|
605
|
+
`;
|
|
606
|
+
const systemPrompt = config.systemPrompt
|
|
607
|
+
? `${config.systemPrompt}\n${claudiaEnvInfo}`
|
|
608
|
+
: claudiaEnvInfo;
|
|
609
|
+
args.push('--system-prompt', systemPrompt);
|
|
610
|
+
// Add MCP config if available
|
|
611
|
+
if (this.mcpConfigPath) {
|
|
612
|
+
args.push('--mcp-config', this.mcpConfigPath);
|
|
613
|
+
}
|
|
614
|
+
// Always use dangerously bypass permissions
|
|
615
|
+
// Note: When using --dangerously-skip-permissions, we don't need the MCP permission prompt tool
|
|
616
|
+
// Adding --permission-prompt-tool with skip-permissions causes startup failures if MCP server is slow
|
|
617
|
+
args.push('--dangerously-skip-permissions');
|
|
618
|
+
this.logger.debug('Built Claude args', { args, hasMCPConfig: !!this.mcpConfigPath });
|
|
619
|
+
return args;
|
|
620
|
+
}
|
|
621
|
+
/**
|
|
622
|
+
* Consolidated method to spawn Claude processes for both start and resume operations
|
|
623
|
+
* Uses node-pty to provide a pseudo-terminal, which is required for Claude CLI -p mode
|
|
624
|
+
*/
|
|
625
|
+
spawnProcess(spawnConfig, args, streamingId) {
|
|
626
|
+
const { executablePath } = spawnConfig;
|
|
627
|
+
// Expand tilde to actual home directory - Node.js spawn doesn't do shell expansion
|
|
628
|
+
const cwd = expandTilde(spawnConfig.cwd);
|
|
629
|
+
let { env } = spawnConfig;
|
|
630
|
+
// Inject router proxy if enabled
|
|
631
|
+
if (this.routerService?.isEnabled()) {
|
|
632
|
+
env = {
|
|
633
|
+
...env,
|
|
634
|
+
ANTHROPIC_BASE_URL: this.routerService.getProxyUrl(),
|
|
635
|
+
ANTHROPIC_API_KEY: 'router-managed'
|
|
636
|
+
};
|
|
637
|
+
this.logger.info('Using router proxy', {
|
|
638
|
+
streamingId,
|
|
639
|
+
proxyUrl: this.routerService.getProxyUrl()
|
|
640
|
+
});
|
|
641
|
+
}
|
|
642
|
+
// Check if MCP config is in args and validate it
|
|
643
|
+
const mcpConfigIndex = args.indexOf('--mcp-config');
|
|
644
|
+
if (mcpConfigIndex !== -1 && mcpConfigIndex + 1 < args.length) {
|
|
645
|
+
const mcpConfigPath = args[mcpConfigIndex + 1];
|
|
646
|
+
this.logger.debug('MCP config specified', {
|
|
647
|
+
streamingId,
|
|
648
|
+
mcpConfigPath,
|
|
649
|
+
exists: existsSync(mcpConfigPath)
|
|
650
|
+
});
|
|
651
|
+
// Try to read and log the MCP config content
|
|
652
|
+
try {
|
|
653
|
+
const mcpConfigContent = readFileSync(mcpConfigPath, 'utf-8');
|
|
654
|
+
this.logger.debug('MCP config content', {
|
|
655
|
+
streamingId,
|
|
656
|
+
mcpConfig: JSON.parse(mcpConfigContent)
|
|
657
|
+
});
|
|
658
|
+
}
|
|
659
|
+
catch (error) {
|
|
660
|
+
this.logger.error('Failed to read MCP config', { streamingId, error });
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
this.logger.debug('Spawning Claude process with PTY', {
|
|
664
|
+
streamingId,
|
|
665
|
+
executablePath,
|
|
666
|
+
args,
|
|
667
|
+
cwd,
|
|
668
|
+
PATH: env.PATH,
|
|
669
|
+
nodeVersion: process.version,
|
|
670
|
+
platform: process.platform
|
|
671
|
+
});
|
|
672
|
+
try {
|
|
673
|
+
// Log the exact command for debugging
|
|
674
|
+
const fullCommand = `${executablePath} ${args.join(' ')}`;
|
|
675
|
+
this.logger.info('SPAWNING CLAUDE COMMAND (PTY): ' + fullCommand, {
|
|
676
|
+
streamingId,
|
|
677
|
+
fullCommand,
|
|
678
|
+
executablePath,
|
|
679
|
+
args,
|
|
680
|
+
cwd,
|
|
681
|
+
env: Object.entries(env).reduce((acc, [key, value]) => {
|
|
682
|
+
acc[key] = value;
|
|
683
|
+
return acc;
|
|
684
|
+
}, {})
|
|
685
|
+
});
|
|
686
|
+
// Use node-pty to spawn with a pseudo-terminal
|
|
687
|
+
// This is required because Claude CLI -p mode only outputs when connected to a TTY
|
|
688
|
+
const claudeProcess = pty.spawn(executablePath, args, {
|
|
689
|
+
name: 'xterm-256color',
|
|
690
|
+
cols: 200,
|
|
691
|
+
rows: 50,
|
|
692
|
+
cwd,
|
|
693
|
+
env: env
|
|
694
|
+
});
|
|
695
|
+
if (!claudeProcess.pid) {
|
|
696
|
+
this.logger.error('Failed to spawn Claude process - no PID assigned', {
|
|
697
|
+
streamingId
|
|
698
|
+
});
|
|
699
|
+
throw new Error('Failed to spawn Claude process - no PID assigned');
|
|
700
|
+
}
|
|
701
|
+
this.logger.info('Claude process spawned successfully (PTY)', {
|
|
702
|
+
streamingId,
|
|
703
|
+
pid: claudeProcess.pid
|
|
704
|
+
});
|
|
705
|
+
return claudeProcess;
|
|
706
|
+
}
|
|
707
|
+
catch (error) {
|
|
708
|
+
this.logger.error('Error in spawnProcess', error, { streamingId });
|
|
709
|
+
if (error instanceof CUIError) {
|
|
710
|
+
throw error;
|
|
711
|
+
}
|
|
712
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
713
|
+
if (errorMessage.includes('ENOENT') || errorMessage.includes('not found')) {
|
|
714
|
+
throw new CUIError('CLAUDE_NOT_FOUND', 'Claude CLI not found. Please ensure Claude is installed and in PATH.', 500);
|
|
715
|
+
}
|
|
716
|
+
throw new CUIError('PROCESS_SPAWN_FAILED', `Failed to spawn Claude process: ${errorMessage}`, 500);
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
setupProcessHandlers(streamingId, process) {
|
|
720
|
+
this.logger.debug('Setting up PTY process handlers', { streamingId, pid: process.pid });
|
|
721
|
+
// Create JSONL parser for Claude output
|
|
722
|
+
const parser = new JsonLinesParser();
|
|
723
|
+
// Initialize output buffer for this session
|
|
724
|
+
this.outputBuffers.set(streamingId, '');
|
|
725
|
+
// Handle parsed JSONL messages from Claude
|
|
726
|
+
parser.on('data', (message) => {
|
|
727
|
+
// Skip echoed stdin messages - PTY echoes our input back
|
|
728
|
+
// BUT: tool_result messages are also type: 'user' (Claude API protocol:
|
|
729
|
+
// user = input TO Claude, assistant = output FROM Claude)
|
|
730
|
+
// We need to let tool_result messages through so the UI can update tool status
|
|
731
|
+
if (message?.type === 'user') {
|
|
732
|
+
const content = message.message?.content;
|
|
733
|
+
const isToolResult = Array.isArray(content) &&
|
|
734
|
+
content.some((block) => block.type === 'tool_result');
|
|
735
|
+
if (!isToolResult) {
|
|
736
|
+
this.logger.debug('Skipping echoed stdin message', {
|
|
737
|
+
streamingId,
|
|
738
|
+
messageType: message?.type
|
|
739
|
+
});
|
|
740
|
+
return;
|
|
741
|
+
}
|
|
742
|
+
// Fall through to emit tool_result messages
|
|
743
|
+
this.logger.debug('Allowing tool_result message through', {
|
|
744
|
+
streamingId,
|
|
745
|
+
toolResultCount: content.filter((b) => b.type === 'tool_result').length
|
|
746
|
+
});
|
|
747
|
+
}
|
|
748
|
+
this.logger.debug('Received Claude message', {
|
|
749
|
+
streamingId,
|
|
750
|
+
messageType: message?.type,
|
|
751
|
+
hasContent: !!message?.content,
|
|
752
|
+
contentLength: message?.content?.length,
|
|
753
|
+
messageKeys: message ? Object.keys(message) : [],
|
|
754
|
+
timestamp: new Date().toISOString()
|
|
755
|
+
});
|
|
756
|
+
this.handleClaudeMessage(streamingId, message);
|
|
757
|
+
});
|
|
758
|
+
parser.on('error', (error) => {
|
|
759
|
+
this.logger.error('Parser error', error, {
|
|
760
|
+
streamingId,
|
|
761
|
+
errorType: error.name,
|
|
762
|
+
errorMessage: error.message,
|
|
763
|
+
bufferState: this.outputBuffers.get(streamingId)?.length || 0
|
|
764
|
+
});
|
|
765
|
+
this.handleProcessError(streamingId, error);
|
|
766
|
+
});
|
|
767
|
+
// Handle PTY data output (combined stdout/stderr from the pseudo-terminal)
|
|
768
|
+
// PTY combines both streams, so we need to parse JSONL from the raw output
|
|
769
|
+
process.onData((data) => {
|
|
770
|
+
// Log raw PTY data for debugging
|
|
771
|
+
this.logger.info('RAW PTY DATA', {
|
|
772
|
+
streamingId,
|
|
773
|
+
dataLength: data.length,
|
|
774
|
+
dataPreview: data.substring(0, 200)
|
|
775
|
+
});
|
|
776
|
+
// Strip ANSI escape sequences that might interfere with JSON parsing
|
|
777
|
+
// Common PTY escape sequences: cursor control, colors, etc.
|
|
778
|
+
const cleanedData = data.replace(/\x1b\[[0-9;]*[a-zA-Z]|\x1b\][^\x07]*\x07/g, '');
|
|
779
|
+
// Feed cleaned data to the JSONL parser
|
|
780
|
+
if (cleanedData.trim()) {
|
|
781
|
+
parser.write(cleanedData);
|
|
782
|
+
}
|
|
783
|
+
});
|
|
784
|
+
// Handle PTY process exit (node-pty uses onExit, not EventEmitter 'exit')
|
|
785
|
+
process.onExit(({ exitCode, signal }) => {
|
|
786
|
+
this.logger.debug('PTY process exited', {
|
|
787
|
+
streamingId,
|
|
788
|
+
exitCode,
|
|
789
|
+
signal,
|
|
790
|
+
normalExit: exitCode === 0,
|
|
791
|
+
timestamp: new Date().toISOString(),
|
|
792
|
+
outputBuffer: this.outputBuffers.get(streamingId) || 'No output captured'
|
|
793
|
+
});
|
|
794
|
+
this.handleProcessClose(streamingId, exitCode);
|
|
795
|
+
});
|
|
796
|
+
}
|
|
797
|
+
handleClaudeMessage(streamingId, message) {
|
|
798
|
+
this.logger.debug('Handling Claude message', {
|
|
799
|
+
streamingId,
|
|
800
|
+
messageType: message?.type,
|
|
801
|
+
isError: message?.type === 'error',
|
|
802
|
+
isResult: message?.type === 'result'
|
|
803
|
+
});
|
|
804
|
+
this.emit('claude-message', { streamingId, message });
|
|
805
|
+
// When we receive a 'result' message, the conversation turn is complete.
|
|
806
|
+
// For PTY, we don't need to close stdin - the process will exit naturally after result.
|
|
807
|
+
// The PTY's onExit handler will clean up.
|
|
808
|
+
if (message?.type === 'result') {
|
|
809
|
+
this.logger.debug('Received result message, process will exit naturally', {
|
|
810
|
+
streamingId,
|
|
811
|
+
resultSubtype: 'subtype' in message ? message.subtype : undefined
|
|
812
|
+
});
|
|
813
|
+
}
|
|
814
|
+
}
|
|
815
|
+
handleProcessClose(streamingId, code) {
|
|
816
|
+
// Clear any pending timeouts for this session
|
|
817
|
+
const timeouts = this.timeouts.get(streamingId);
|
|
818
|
+
if (timeouts) {
|
|
819
|
+
timeouts.forEach(timeout => clearTimeout(timeout));
|
|
820
|
+
this.timeouts.delete(streamingId);
|
|
821
|
+
}
|
|
822
|
+
this.processes.delete(streamingId);
|
|
823
|
+
this.outputBuffers.delete(streamingId);
|
|
824
|
+
const config = this.conversationConfigs.get(streamingId);
|
|
825
|
+
this.conversationConfigs.delete(streamingId);
|
|
826
|
+
// Send notification if service is available
|
|
827
|
+
if (this.notificationService && config) {
|
|
828
|
+
// Get session ID from conversation status or config
|
|
829
|
+
const sessionId = this.statusTracker.getSessionId(streamingId) || 'unknown';
|
|
830
|
+
// Try to get conversation metadata for summary
|
|
831
|
+
this.historyReader.fetchConversationDirect(sessionId)
|
|
832
|
+
.then(({ metadata }) => {
|
|
833
|
+
if (this.notificationService && metadata) {
|
|
834
|
+
return this.notificationService.sendConversationEndNotification(streamingId, sessionId, metadata.summary);
|
|
835
|
+
}
|
|
836
|
+
})
|
|
837
|
+
.catch((error) => {
|
|
838
|
+
this.logger.error('Failed to send conversation end notification', error);
|
|
839
|
+
});
|
|
840
|
+
}
|
|
841
|
+
this.emit('process-closed', { streamingId, code });
|
|
842
|
+
}
|
|
843
|
+
handleProcessError(streamingId, error) {
|
|
844
|
+
const errorMessage = error.toString();
|
|
845
|
+
const isBuffer = Buffer.isBuffer(error);
|
|
846
|
+
this.logger.error('Process error occurred', {
|
|
847
|
+
streamingId,
|
|
848
|
+
error: errorMessage,
|
|
849
|
+
errorType: isBuffer ? 'stderr-output' : error.constructor.name,
|
|
850
|
+
errorLength: errorMessage.length,
|
|
851
|
+
processStillActive: this.processes.has(streamingId),
|
|
852
|
+
timestamp: new Date().toISOString()
|
|
853
|
+
});
|
|
854
|
+
this.emit('process-error', { streamingId, error: errorMessage });
|
|
855
|
+
}
|
|
856
|
+
/**
|
|
857
|
+
* Send an answer to an AskUserQuestion tool via stdin.
|
|
858
|
+
* The Claude CLI with --input-format stream-json expects JSON messages on stdin.
|
|
859
|
+
*/
|
|
860
|
+
sendQuestionAnswer(streamingId, answer) {
|
|
861
|
+
const process = this.processes.get(streamingId);
|
|
862
|
+
if (!process) {
|
|
863
|
+
this.logger.warn('Cannot send question answer: process not found', { streamingId });
|
|
864
|
+
return false;
|
|
865
|
+
}
|
|
866
|
+
const sessionId = this.sessionIds.get(streamingId);
|
|
867
|
+
if (!sessionId) {
|
|
868
|
+
this.logger.warn('Cannot send question answer: session ID not found', { streamingId });
|
|
869
|
+
return false;
|
|
870
|
+
}
|
|
871
|
+
// Format the stdin message per Claude CLI stream-json protocol
|
|
872
|
+
const stdinMessage = JSON.stringify({
|
|
873
|
+
type: 'user',
|
|
874
|
+
message: {
|
|
875
|
+
role: 'user',
|
|
876
|
+
content: answer
|
|
877
|
+
},
|
|
878
|
+
session_id: sessionId
|
|
879
|
+
});
|
|
880
|
+
this.logger.info('Sending question answer via stdin', {
|
|
881
|
+
streamingId,
|
|
882
|
+
sessionId,
|
|
883
|
+
answerLength: answer.length
|
|
884
|
+
});
|
|
885
|
+
// Write to PTY stdin with newline
|
|
886
|
+
process.write(stdinMessage + '\n');
|
|
887
|
+
return true;
|
|
888
|
+
}
|
|
889
|
+
/**
|
|
890
|
+
* Send a raw stdin message to a Claude process.
|
|
891
|
+
* Used by ClaudiaService for sending messages to Claudia's session.
|
|
892
|
+
* The message should already be formatted as a JSON string.
|
|
893
|
+
*/
|
|
894
|
+
sendStdinMessage(streamingId, message) {
|
|
895
|
+
const process = this.processes.get(streamingId);
|
|
896
|
+
if (!process) {
|
|
897
|
+
this.logger.warn('Cannot send stdin message: process not found', { streamingId });
|
|
898
|
+
return false;
|
|
899
|
+
}
|
|
900
|
+
this.logger.info('Sending stdin message', {
|
|
901
|
+
streamingId,
|
|
902
|
+
messageLength: message.length
|
|
903
|
+
});
|
|
904
|
+
// Write to PTY stdin with newline
|
|
905
|
+
process.write(message + '\n');
|
|
906
|
+
return true;
|
|
907
|
+
}
|
|
908
|
+
}
|
|
909
|
+
//# sourceMappingURL=claude-process-manager.js.map
|