@cloudcli-ai/cloudcli 1.34.0 → 1.35.1
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/README.de.md +9 -1
- package/README.ja.md +8 -0
- package/README.ko.md +9 -1
- package/README.md +23 -5
- package/README.ru.md +9 -1
- package/README.tr.md +8 -1
- package/README.zh-CN.md +9 -1
- package/README.zh-TW.md +9 -1
- package/dist/api-docs.html +36 -18
- package/dist/assets/index-BvX30NW5.css +32 -0
- package/dist/assets/index-D1IdCTT6.js +1418 -0
- package/dist/index.html +12 -4
- package/dist-server/server/browser-use-mcp.js +350 -0
- package/dist-server/server/browser-use-mcp.js.map +1 -0
- package/dist-server/server/claude-sdk.js +25 -5
- package/dist-server/server/claude-sdk.js.map +1 -1
- package/dist-server/server/cli.js +14 -6
- package/dist-server/server/cli.js.map +1 -1
- package/dist-server/server/cursor-cli.js +27 -11
- package/dist-server/server/cursor-cli.js.map +1 -1
- package/dist-server/server/gemini-cli.js +17 -2
- package/dist-server/server/gemini-cli.js.map +1 -1
- package/dist-server/server/index.js +136 -40
- package/dist-server/server/index.js.map +1 -1
- package/dist-server/server/load-env.js +1 -1
- package/dist-server/server/load-env.js.map +1 -1
- package/dist-server/server/modules/browser-use/browser-use-mcp.routes.js +107 -0
- package/dist-server/server/modules/browser-use/browser-use-mcp.routes.js.map +1 -0
- package/dist-server/server/modules/browser-use/browser-use.routes.js +93 -0
- package/dist-server/server/modules/browser-use/browser-use.routes.js.map +1 -0
- package/dist-server/server/modules/browser-use/browser-use.service.js +688 -0
- package/dist-server/server/modules/browser-use/browser-use.service.js.map +1 -0
- package/dist-server/server/modules/browser-use/tests/browser-use.service.test.js +8 -0
- package/dist-server/server/modules/browser-use/tests/browser-use.service.test.js.map +1 -0
- package/dist-server/server/modules/database/index.js +1 -0
- package/dist-server/server/modules/database/index.js.map +1 -1
- package/dist-server/server/modules/database/migrations.js +23 -1
- package/dist-server/server/modules/database/migrations.js.map +1 -1
- package/dist-server/server/modules/database/repositories/notification-channel-endpoints.js +100 -0
- package/dist-server/server/modules/database/repositories/notification-channel-endpoints.js.map +1 -0
- package/dist-server/server/modules/database/repositories/notification-preferences.js +8 -0
- package/dist-server/server/modules/database/repositories/notification-preferences.js.map +1 -1
- package/dist-server/server/modules/database/repositories/sessions.db.js +173 -17
- package/dist-server/server/modules/database/repositories/sessions.db.js.map +1 -1
- package/dist-server/server/modules/database/schema.js +26 -0
- package/dist-server/server/modules/database/schema.js.map +1 -1
- package/dist-server/server/modules/database/tests/projects.db.integration.test.js.map +1 -0
- package/dist-server/server/modules/database/tests/sessions-provider-mapping.test.js +78 -0
- package/dist-server/server/modules/database/tests/sessions-provider-mapping.test.js.map +1 -0
- package/dist-server/server/modules/database/{repositories → tests}/sessions.db.integration.test.js +10 -0
- package/dist-server/server/modules/database/tests/sessions.db.integration.test.js.map +1 -0
- package/dist-server/server/modules/notifications/index.js +4 -0
- package/dist-server/server/modules/notifications/index.js.map +1 -0
- package/dist-server/server/modules/notifications/notifications.routes.js +115 -0
- package/dist-server/server/modules/notifications/notifications.routes.js.map +1 -0
- package/dist-server/server/modules/notifications/services/desktop-notification-clients.service.js +97 -0
- package/dist-server/server/modules/notifications/services/desktop-notification-clients.service.js.map +1 -0
- package/dist-server/server/modules/notifications/services/notification-orchestrator.service.js +229 -0
- package/dist-server/server/modules/notifications/services/notification-orchestrator.service.js.map +1 -0
- package/dist-server/server/modules/notifications/websocket/desktop-notifications-websocket.service.js +79 -0
- package/dist-server/server/modules/notifications/websocket/desktop-notifications-websocket.service.js.map +1 -0
- package/dist-server/server/modules/projects/services/project-management.service.js +0 -4
- package/dist-server/server/modules/projects/services/project-management.service.js.map +1 -1
- package/dist-server/server/modules/projects/services/projects-with-sessions-fetch.service.js +10 -38
- package/dist-server/server/modules/projects/services/projects-with-sessions-fetch.service.js.map +1 -1
- package/dist-server/server/modules/providers/index.js +1 -0
- package/dist-server/server/modules/providers/index.js.map +1 -1
- package/dist-server/server/modules/providers/list/claude/claude-session-synchronizer.provider.js +24 -1
- package/dist-server/server/modules/providers/list/claude/claude-session-synchronizer.provider.js.map +1 -1
- package/dist-server/server/modules/providers/list/claude/claude-sessions.provider.js +9 -12
- package/dist-server/server/modules/providers/list/claude/claude-sessions.provider.js.map +1 -1
- package/dist-server/server/modules/providers/list/claude/claude-skills.provider.js +7 -0
- package/dist-server/server/modules/providers/list/claude/claude-skills.provider.js.map +1 -1
- package/dist-server/server/modules/providers/list/codex/codex-session-synchronizer.provider.js +7 -3
- package/dist-server/server/modules/providers/list/codex/codex-session-synchronizer.provider.js.map +1 -1
- package/dist-server/server/modules/providers/list/codex/codex-sessions.provider.js +3 -9
- package/dist-server/server/modules/providers/list/codex/codex-sessions.provider.js.map +1 -1
- package/dist-server/server/modules/providers/list/codex/codex-skills.provider.js +7 -0
- package/dist-server/server/modules/providers/list/codex/codex-skills.provider.js.map +1 -1
- package/dist-server/server/modules/providers/list/cursor/cursor-sessions.provider.js +13 -22
- package/dist-server/server/modules/providers/list/cursor/cursor-sessions.provider.js.map +1 -1
- package/dist-server/server/modules/providers/list/cursor/cursor-skills.provider.js +7 -0
- package/dist-server/server/modules/providers/list/cursor/cursor-skills.provider.js.map +1 -1
- package/dist-server/server/modules/providers/list/gemini/gemini-models.provider.js +5 -8
- package/dist-server/server/modules/providers/list/gemini/gemini-models.provider.js.map +1 -1
- package/dist-server/server/modules/providers/list/gemini/gemini-sessions.provider.js +6 -6
- package/dist-server/server/modules/providers/list/gemini/gemini-sessions.provider.js.map +1 -1
- package/dist-server/server/modules/providers/list/gemini/gemini-skills.provider.js +7 -0
- package/dist-server/server/modules/providers/list/gemini/gemini-skills.provider.js.map +1 -1
- package/dist-server/server/modules/providers/list/opencode/opencode-session-synchronizer.provider.js +17 -3
- package/dist-server/server/modules/providers/list/opencode/opencode-session-synchronizer.provider.js.map +1 -1
- package/dist-server/server/modules/providers/list/opencode/opencode-sessions.provider.js +9 -10
- package/dist-server/server/modules/providers/list/opencode/opencode-sessions.provider.js.map +1 -1
- package/dist-server/server/modules/providers/provider.routes.js +123 -1
- package/dist-server/server/modules/providers/provider.routes.js.map +1 -1
- package/dist-server/server/modules/providers/services/mcp.service.js +23 -0
- package/dist-server/server/modules/providers/services/mcp.service.js.map +1 -1
- package/dist-server/server/modules/providers/services/provider-capabilities.service.js +65 -0
- package/dist-server/server/modules/providers/services/provider-capabilities.service.js.map +1 -0
- package/dist-server/server/modules/providers/services/provider-models.service.js +1 -1
- package/dist-server/server/modules/providers/services/provider-models.service.js.map +1 -1
- package/dist-server/server/modules/providers/services/sessions-watcher.service.js +62 -23
- package/dist-server/server/modules/providers/services/sessions-watcher.service.js.map +1 -1
- package/dist-server/server/modules/providers/services/sessions.service.js +62 -4
- package/dist-server/server/modules/providers/services/sessions.service.js.map +1 -1
- package/dist-server/server/modules/providers/services/skills.service.js +11 -0
- package/dist-server/server/modules/providers/services/skills.service.js.map +1 -1
- package/dist-server/server/modules/providers/shared/skills/skills.provider.js +163 -1
- package/dist-server/server/modules/providers/shared/skills/skills.provider.js.map +1 -1
- package/dist-server/server/modules/providers/tests/opencode-sessions.test.js +45 -0
- package/dist-server/server/modules/providers/tests/opencode-sessions.test.js.map +1 -1
- package/dist-server/server/modules/providers/tests/skills.test.js +160 -0
- package/dist-server/server/modules/providers/tests/skills.test.js.map +1 -1
- package/dist-server/server/modules/websocket/index.js +1 -0
- package/dist-server/server/modules/websocket/index.js.map +1 -1
- package/dist-server/server/modules/websocket/services/chat-run-registry.service.js +252 -0
- package/dist-server/server/modules/websocket/services/chat-run-registry.service.js.map +1 -0
- package/dist-server/server/modules/websocket/services/chat-session-writer.service.js +105 -0
- package/dist-server/server/modules/websocket/services/chat-session-writer.service.js.map +1 -0
- package/dist-server/server/modules/websocket/services/chat-websocket.service.js +218 -156
- package/dist-server/server/modules/websocket/services/chat-websocket.service.js.map +1 -1
- package/dist-server/server/modules/websocket/services/shell-websocket.service.js +83 -31
- package/dist-server/server/modules/websocket/services/shell-websocket.service.js.map +1 -1
- package/dist-server/server/modules/websocket/services/websocket-server.service.js +5 -0
- package/dist-server/server/modules/websocket/services/websocket-server.service.js.map +1 -1
- package/dist-server/server/modules/websocket/tests/chat-run-registry.test.js +216 -0
- package/dist-server/server/modules/websocket/tests/chat-run-registry.test.js.map +1 -0
- package/dist-server/server/openai-codex.js +24 -14
- package/dist-server/server/openai-codex.js.map +1 -1
- package/dist-server/server/opencode-cli.js +21 -8
- package/dist-server/server/opencode-cli.js.map +1 -1
- package/dist-server/server/opencode-cli.test.js +19 -1
- package/dist-server/server/opencode-cli.test.js.map +1 -1
- package/dist-server/server/routes/settings.js +1 -1
- package/dist-server/server/routes/settings.js.map +1 -1
- package/dist-server/server/services/notification-orchestrator.js +1 -176
- package/dist-server/server/services/notification-orchestrator.js.map +1 -1
- package/dist-server/server/services/tests/notification-orchestrator.test.js +64 -0
- package/dist-server/server/services/tests/notification-orchestrator.test.js.map +1 -0
- package/dist-server/server/shared/{claude-cli-path.test.js → tests/claude-cli-path.test.js} +1 -1
- package/dist-server/server/shared/tests/claude-cli-path.test.js.map +1 -0
- package/dist-server/server/shared/tests/slice-tail-page.test.js +35 -0
- package/dist-server/server/shared/tests/slice-tail-page.test.js.map +1 -0
- package/dist-server/server/shared/utils.js +73 -1
- package/dist-server/server/shared/utils.js.map +1 -1
- package/dist-server/server/voice-proxy.js +226 -0
- package/dist-server/server/voice-proxy.js.map +1 -0
- package/electron/assets/logo-macos.icns +0 -0
- package/electron/assets/logo-macos.png +0 -0
- package/electron/assets/logo-windows.ico +0 -0
- package/electron/cloud.js +260 -0
- package/electron/desktopNotifications.js +378 -0
- package/electron/desktopWindow.js +766 -0
- package/electron/launcher/index.html +14 -0
- package/electron/launcher/launcher.css +801 -0
- package/electron/launcher/launcher.js +687 -0
- package/electron/localServer.js +550 -0
- package/electron/main.js +944 -0
- package/electron/preload.cjs +60 -0
- package/electron/scripts/generate-macos-icon.js +62 -0
- package/electron/serverInstaller.js +277 -0
- package/electron/tabs.js +87 -0
- package/electron/viewHost.js +331 -0
- package/package.json +79 -3
- package/public/api-docs.html +36 -18
- package/scripts/release/build-server-bundle.js +176 -0
- package/scripts/release/prepare-desktop-app.js +152 -0
- package/server/browser-use-mcp.ts +384 -0
- package/server/claude-sdk.js +27 -5
- package/server/cli.js +15 -6
- package/server/cursor-cli.js +27 -11
- package/server/gemini-cli.js +17 -2
- package/server/index.js +137 -47
- package/server/load-env.js +1 -1
- package/server/modules/browser-use/browser-use-mcp.routes.ts +120 -0
- package/server/modules/browser-use/browser-use.routes.ts +96 -0
- package/server/modules/browser-use/browser-use.service.ts +836 -0
- package/server/modules/browser-use/tests/browser-use.service.test.ts +10 -0
- package/server/modules/database/index.ts +1 -0
- package/server/modules/database/migrations.ts +25 -0
- package/server/modules/database/repositories/notification-channel-endpoints.ts +153 -0
- package/server/modules/database/repositories/notification-preferences.ts +12 -1
- package/server/modules/database/repositories/sessions.db.ts +227 -23
- package/server/modules/database/schema.ts +27 -0
- package/server/modules/database/tests/sessions-provider-mapping.test.ts +108 -0
- package/server/modules/database/{repositories → tests}/sessions.db.integration.test.ts +12 -0
- package/server/modules/notifications/index.ts +13 -0
- package/server/modules/notifications/notifications.routes.ts +127 -0
- package/server/modules/notifications/services/desktop-notification-clients.service.ts +124 -0
- package/server/modules/notifications/services/notification-orchestrator.service.js +288 -0
- package/server/modules/notifications/websocket/desktop-notifications-websocket.service.ts +109 -0
- package/server/modules/projects/services/project-management.service.ts +0 -8
- package/server/modules/projects/services/projects-with-sessions-fetch.service.ts +12 -53
- package/server/modules/providers/README.md +1 -1
- package/server/modules/providers/index.ts +1 -0
- package/server/modules/providers/list/claude/claude-session-synchronizer.provider.ts +26 -1
- package/server/modules/providers/list/claude/claude-sessions.provider.ts +9 -14
- package/server/modules/providers/list/claude/claude-skills.provider.ts +8 -0
- package/server/modules/providers/list/codex/codex-session-synchronizer.provider.ts +7 -3
- package/server/modules/providers/list/codex/codex-sessions.provider.ts +3 -12
- package/server/modules/providers/list/codex/codex-skills.provider.ts +8 -0
- package/server/modules/providers/list/cursor/cursor-sessions.provider.ts +13 -22
- package/server/modules/providers/list/cursor/cursor-skills.provider.ts +8 -0
- package/server/modules/providers/list/gemini/gemini-models.provider.ts +5 -8
- package/server/modules/providers/list/gemini/gemini-sessions.provider.ts +6 -6
- package/server/modules/providers/list/gemini/gemini-skills.provider.ts +8 -0
- package/server/modules/providers/list/opencode/opencode-session-synchronizer.provider.ts +18 -4
- package/server/modules/providers/list/opencode/opencode-sessions.provider.ts +9 -12
- package/server/modules/providers/provider.routes.ts +167 -1
- package/server/modules/providers/services/mcp.service.ts +26 -0
- package/server/modules/providers/services/provider-capabilities.service.ts +91 -0
- package/server/modules/providers/services/provider-models.service.ts +1 -1
- package/server/modules/providers/services/sessions-watcher.service.ts +71 -25
- package/server/modules/providers/services/sessions.service.ts +79 -4
- package/server/modules/providers/services/skills.service.ts +25 -1
- package/server/modules/providers/shared/skills/skills.provider.ts +223 -0
- package/server/modules/providers/tests/opencode-sessions.test.ts +49 -0
- package/server/modules/providers/tests/skills.test.ts +212 -0
- package/server/modules/websocket/README.md +33 -27
- package/server/modules/websocket/index.ts +1 -0
- package/server/modules/websocket/services/chat-run-registry.service.ts +327 -0
- package/server/modules/websocket/services/chat-session-writer.service.ts +145 -0
- package/server/modules/websocket/services/chat-websocket.service.ts +292 -207
- package/server/modules/websocket/services/shell-websocket.service.ts +108 -32
- package/server/modules/websocket/services/websocket-server.service.ts +6 -0
- package/server/modules/websocket/tests/chat-run-registry.test.ts +244 -0
- package/server/openai-codex.js +24 -14
- package/server/opencode-cli.js +21 -8
- package/server/opencode-cli.test.js +20 -1
- package/server/routes/settings.js +6 -1
- package/server/services/notification-orchestrator.js +3 -224
- package/server/services/tests/notification-orchestrator.test.js +80 -0
- package/server/shared/interfaces.ts +15 -0
- package/server/shared/tests/slice-tail-page.test.ts +42 -0
- package/server/shared/types.ts +84 -1
- package/server/shared/utils.ts +95 -1
- package/server/voice-proxy.js +224 -0
- package/dist/assets/index-73Xw3i9v.css +0 -32
- package/dist/assets/index-BIHaviaA.js +0 -1361
- package/dist/modelConstants.js +0 -854
- package/dist-server/server/modules/database/repositories/projects.db.integration.test.js.map +0 -1
- package/dist-server/server/modules/database/repositories/sessions.db.integration.test.js.map +0 -1
- package/dist-server/server/shared/claude-cli-path.test.js.map +0 -1
- package/public/modelConstants.js +0 -854
- /package/dist-server/server/modules/database/{repositories → tests}/projects.db.integration.test.js +0 -0
- /package/server/modules/database/{repositories → tests}/projects.db.integration.test.ts +0 -0
- /package/server/shared/{claude-cli-path.test.ts → tests/claude-cli-path.test.ts} +0 -0
|
@@ -35,7 +35,10 @@ const PTY_SESSION_TIMEOUT = 30 * 60 * 1000;
|
|
|
35
35
|
const SHELL_URL_PARSE_BUFFER_LIMIT = 32768;
|
|
36
36
|
|
|
37
37
|
type ShellWebSocketDependencies = {
|
|
38
|
-
|
|
38
|
+
resolveProviderSessionId: (
|
|
39
|
+
sessionId: string,
|
|
40
|
+
provider: string,
|
|
41
|
+
) => string | null | undefined;
|
|
39
42
|
stripAnsiSequences: (content: string) => string;
|
|
40
43
|
normalizeDetectedUrl: (url: string) => string | null;
|
|
41
44
|
extractUrlsFromText: (content: string) => string[];
|
|
@@ -76,6 +79,36 @@ function parseShellMessage(rawMessage: RawData): ShellIncomingMessage | null {
|
|
|
76
79
|
return payload as ShellIncomingMessage;
|
|
77
80
|
}
|
|
78
81
|
|
|
82
|
+
const SAFE_SESSION_ID_PATTERN = /^[a-zA-Z0-9_.\-:]+$/;
|
|
83
|
+
|
|
84
|
+
function resolveResumeSessionId(
|
|
85
|
+
message: ShellIncomingMessage,
|
|
86
|
+
dependencies: ShellWebSocketDependencies
|
|
87
|
+
): string {
|
|
88
|
+
const hasSession = readBoolean(message.hasSession);
|
|
89
|
+
const sessionId = readString(message.sessionId);
|
|
90
|
+
const provider = readString(message.provider, 'claude');
|
|
91
|
+
|
|
92
|
+
if (!hasSession || !sessionId) {
|
|
93
|
+
return '';
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
let resumeSessionId: string | null | undefined;
|
|
97
|
+
try {
|
|
98
|
+
resumeSessionId = dependencies.resolveProviderSessionId(sessionId, provider);
|
|
99
|
+
} catch (error) {
|
|
100
|
+
console.error('Failed to resolve provider session ID:', error);
|
|
101
|
+
resumeSessionId = undefined;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const resolvedSessionId = resumeSessionId === undefined ? sessionId : resumeSessionId;
|
|
105
|
+
if (!resolvedSessionId || !SAFE_SESSION_ID_PATTERN.test(resolvedSessionId)) {
|
|
106
|
+
return '';
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return resolvedSessionId;
|
|
110
|
+
}
|
|
111
|
+
|
|
79
112
|
/**
|
|
80
113
|
* Resolves provider command line for plain shell and agent-backed shell modes.
|
|
81
114
|
*/
|
|
@@ -84,10 +117,9 @@ function buildShellCommand(
|
|
|
84
117
|
dependencies: ShellWebSocketDependencies
|
|
85
118
|
): string {
|
|
86
119
|
const hasSession = readBoolean(message.hasSession);
|
|
87
|
-
const sessionId = readString(message.sessionId);
|
|
88
120
|
const initialCommand = readString(message.initialCommand);
|
|
89
121
|
const provider = readString(message.provider, 'claude');
|
|
90
|
-
const
|
|
122
|
+
const resumeSessionId = resolveResumeSessionId(message, dependencies);
|
|
91
123
|
const isPlainShell =
|
|
92
124
|
readBoolean(message.isPlainShell) ||
|
|
93
125
|
(!!initialCommand && !hasSession) ||
|
|
@@ -98,62 +130,103 @@ function buildShellCommand(
|
|
|
98
130
|
}
|
|
99
131
|
|
|
100
132
|
if (provider === 'cursor') {
|
|
101
|
-
if (
|
|
102
|
-
return `cursor-agent --resume="${
|
|
133
|
+
if (resumeSessionId) {
|
|
134
|
+
return `cursor-agent --resume="${resumeSessionId}"`;
|
|
103
135
|
}
|
|
104
136
|
return 'cursor-agent';
|
|
105
137
|
}
|
|
106
138
|
|
|
107
139
|
if (provider === 'codex') {
|
|
108
|
-
if (
|
|
140
|
+
if (resumeSessionId) {
|
|
109
141
|
if (os.platform() === 'win32') {
|
|
110
|
-
return `codex resume "${
|
|
142
|
+
return `codex resume "${resumeSessionId}"; if ($LASTEXITCODE -ne 0) { codex }`;
|
|
111
143
|
}
|
|
112
|
-
return `codex resume "${
|
|
144
|
+
return `codex resume "${resumeSessionId}" || codex`;
|
|
113
145
|
}
|
|
114
146
|
return 'codex';
|
|
115
147
|
}
|
|
116
148
|
|
|
117
149
|
if (provider === 'gemini') {
|
|
118
150
|
const command = initialCommand || 'gemini';
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
try {
|
|
122
|
-
const existingSession = dependencies.getSessionById(sessionId);
|
|
123
|
-
if (existingSession && existingSession.cliSessionId) {
|
|
124
|
-
resumeId = existingSession.cliSessionId;
|
|
125
|
-
if (!safeSessionIdPattern.test(resumeId)) {
|
|
126
|
-
resumeId = '';
|
|
127
|
-
}
|
|
128
|
-
}
|
|
129
|
-
} catch (error) {
|
|
130
|
-
console.error('Failed to get Gemini CLI session ID:', error);
|
|
131
|
-
}
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
if (hasSession && resumeId) {
|
|
135
|
-
return `${command} --resume "${resumeId}"`;
|
|
151
|
+
if (resumeSessionId) {
|
|
152
|
+
return `${command} --resume "${resumeSessionId}"`;
|
|
136
153
|
}
|
|
137
154
|
return command;
|
|
138
155
|
}
|
|
139
156
|
|
|
140
157
|
if (provider === 'opencode') {
|
|
141
|
-
if (
|
|
142
|
-
return `opencode --session "${
|
|
158
|
+
if (resumeSessionId) {
|
|
159
|
+
return `opencode --session "${resumeSessionId}"`;
|
|
143
160
|
}
|
|
144
161
|
return initialCommand || 'opencode';
|
|
145
162
|
}
|
|
146
163
|
|
|
147
164
|
const command = initialCommand || 'claude';
|
|
148
|
-
if (
|
|
165
|
+
if (resumeSessionId) {
|
|
149
166
|
if (os.platform() === 'win32') {
|
|
150
|
-
return `claude --resume "${
|
|
167
|
+
return `claude --resume "${resumeSessionId}"; if ($LASTEXITCODE -ne 0) { claude }`;
|
|
151
168
|
}
|
|
152
|
-
return `claude --resume "${
|
|
169
|
+
return `claude --resume "${resumeSessionId}" || claude`;
|
|
153
170
|
}
|
|
154
171
|
return command;
|
|
155
172
|
}
|
|
156
173
|
|
|
174
|
+
function readEnvValue(env: NodeJS.ProcessEnv, key: string): string | undefined {
|
|
175
|
+
const resolvedKey = Object.keys(env).find((envKey) => envKey.toLowerCase() === key.toLowerCase());
|
|
176
|
+
return resolvedKey ? env[resolvedKey] : undefined;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function getPathEnvKey(env: NodeJS.ProcessEnv): string {
|
|
180
|
+
return Object.keys(env).find((key) => key.toLowerCase() === 'path') || 'PATH';
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function prioritizeUserNpmGlobalBin(env: NodeJS.ProcessEnv): { key: string; value: string | undefined } {
|
|
184
|
+
const pathKey = getPathEnvKey(env);
|
|
185
|
+
const currentPath = env[pathKey];
|
|
186
|
+
if (!currentPath) {
|
|
187
|
+
return { key: pathKey, value: currentPath };
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const delimiter = path.delimiter;
|
|
191
|
+
const pathEntries = currentPath.split(delimiter).filter(Boolean);
|
|
192
|
+
const npmPrefix = readEnvValue(env, 'npm_config_prefix');
|
|
193
|
+
const appData = readEnvValue(env, 'APPDATA');
|
|
194
|
+
const candidates = [
|
|
195
|
+
npmPrefix || '',
|
|
196
|
+
npmPrefix ? path.join(npmPrefix, 'bin') : '',
|
|
197
|
+
appData ? path.join(appData, 'npm') : '',
|
|
198
|
+
path.join(os.homedir(), 'AppData', 'Roaming', 'npm'),
|
|
199
|
+
path.join(os.homedir(), '.npm-global', 'bin'),
|
|
200
|
+
].filter(Boolean);
|
|
201
|
+
|
|
202
|
+
const normalizedPathEntries = pathEntries.map((entry) => os.platform() === 'win32' ? entry.toLowerCase() : entry);
|
|
203
|
+
const preferredEntries = candidates.filter((candidate, index) => {
|
|
204
|
+
const normalizedCandidate = os.platform() === 'win32' ? candidate.toLowerCase() : candidate;
|
|
205
|
+
return (
|
|
206
|
+
candidates.indexOf(candidate) === index &&
|
|
207
|
+
normalizedPathEntries.includes(normalizedCandidate)
|
|
208
|
+
);
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
if (preferredEntries.length === 0) {
|
|
212
|
+
return { key: pathKey, value: currentPath };
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const normalizedPreferredEntries = preferredEntries.map((entry) =>
|
|
216
|
+
os.platform() === 'win32' ? entry.toLowerCase() : entry
|
|
217
|
+
);
|
|
218
|
+
|
|
219
|
+
const value = [
|
|
220
|
+
...preferredEntries,
|
|
221
|
+
...pathEntries.filter((entry) => {
|
|
222
|
+
const normalizedEntry = os.platform() === 'win32' ? entry.toLowerCase() : entry;
|
|
223
|
+
return !normalizedPreferredEntries.includes(normalizedEntry);
|
|
224
|
+
}),
|
|
225
|
+
].join(delimiter);
|
|
226
|
+
|
|
227
|
+
return { key: pathKey, value };
|
|
228
|
+
}
|
|
229
|
+
|
|
157
230
|
/**
|
|
158
231
|
* Handles websocket connections used by the standalone shell terminal UI.
|
|
159
232
|
*/
|
|
@@ -261,11 +334,13 @@ export function handleShellConnection(
|
|
|
261
334
|
}
|
|
262
335
|
|
|
263
336
|
const shellCommand = buildShellCommand(data, dependencies);
|
|
337
|
+
const resumeSessionId = resolveResumeSessionId(data, dependencies);
|
|
264
338
|
const shell = os.platform() === 'win32' ? 'powershell.exe' : 'bash';
|
|
265
339
|
const shellArgs =
|
|
266
340
|
os.platform() === 'win32' ? ['-Command', shellCommand] : ['-c', shellCommand];
|
|
267
341
|
const termCols = readNumber(data.cols, 80);
|
|
268
342
|
const termRows = readNumber(data.rows, 24);
|
|
343
|
+
const prioritizedPath = prioritizeUserNpmGlobalBin(process.env);
|
|
269
344
|
|
|
270
345
|
shellProcess = pty.spawn(shell, shellArgs, {
|
|
271
346
|
name: 'xterm-256color',
|
|
@@ -274,6 +349,7 @@ export function handleShellConnection(
|
|
|
274
349
|
cwd: resolvedProjectPath,
|
|
275
350
|
env: {
|
|
276
351
|
...process.env,
|
|
352
|
+
[prioritizedPath.key]: prioritizedPath.value,
|
|
277
353
|
TERM: 'xterm-256color',
|
|
278
354
|
COLORTERM: 'truecolor',
|
|
279
355
|
FORCE_COLOR: '3',
|
|
@@ -406,8 +482,8 @@ export function handleShellConnection(
|
|
|
406
482
|
: provider === 'opencode'
|
|
407
483
|
? 'OpenCode'
|
|
408
484
|
: 'Claude';
|
|
409
|
-
welcomeMsg = hasSession
|
|
410
|
-
? `\x1b[36mResuming ${providerName} session ${
|
|
485
|
+
welcomeMsg = hasSession && resumeSessionId
|
|
486
|
+
? `\x1b[36mResuming ${providerName} session ${resumeSessionId} in: ${projectPath}\x1b[0m\r\n`
|
|
411
487
|
: `\x1b[36mStarting new ${providerName} session in: ${projectPath}\x1b[0m\r\n`;
|
|
412
488
|
}
|
|
413
489
|
|
|
@@ -6,6 +6,7 @@ import { handleChatConnection } from '@/modules/websocket/services/chat-websocke
|
|
|
6
6
|
import { verifyWebSocketClient } from '@/modules/websocket/services/websocket-auth.service.js';
|
|
7
7
|
import { handlePluginWsProxy } from '@/modules/websocket/services/plugin-websocket-proxy.service.js';
|
|
8
8
|
import { handleShellConnection } from '@/modules/websocket/services/shell-websocket.service.js';
|
|
9
|
+
import { handleDesktopNotificationsConnection } from '@/modules/notifications/index.js';
|
|
9
10
|
import type { AuthenticatedWebSocketRequest } from '@/shared/types.js';
|
|
10
11
|
|
|
11
12
|
type WebSocketServerDependencies = {
|
|
@@ -63,6 +64,11 @@ export function createWebSocketServer(
|
|
|
63
64
|
return;
|
|
64
65
|
}
|
|
65
66
|
|
|
67
|
+
if (pathname === '/desktop-notifications') {
|
|
68
|
+
handleDesktopNotificationsConnection(ws, incomingRequest);
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
|
|
66
72
|
if (pathname.startsWith('/plugin-ws/')) {
|
|
67
73
|
handlePluginWsProxy(ws, pathname, dependencies.getPluginPort);
|
|
68
74
|
return;
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { mkdtemp, rm } from 'node:fs/promises';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import test from 'node:test';
|
|
6
|
+
|
|
7
|
+
import { closeConnection, initializeDatabase, sessionsDb } from '@/modules/database/index.js';
|
|
8
|
+
import { chatRunRegistry } from '@/modules/websocket/services/chat-run-registry.service.js';
|
|
9
|
+
import { connectedClients } from '@/modules/websocket/services/websocket-state.service.js';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Minimal stand-in for a websocket connection: collects every JSON frame the
|
|
13
|
+
* gateway writer forwards so assertions can inspect the outbound protocol.
|
|
14
|
+
*/
|
|
15
|
+
class FakeConnection {
|
|
16
|
+
readyState = 1; // WS_OPEN_STATE
|
|
17
|
+
frames: Array<Record<string, unknown>> = [];
|
|
18
|
+
|
|
19
|
+
send(data: string): void {
|
|
20
|
+
this.frames.push(JSON.parse(data) as Record<string, unknown>);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async function withIsolatedDatabase(runTest: () => void | Promise<void>): Promise<void> {
|
|
25
|
+
const previousDatabasePath = process.env.DATABASE_PATH;
|
|
26
|
+
const tempDirectory = await mkdtemp(path.join(tmpdir(), 'chat-run-registry-'));
|
|
27
|
+
const databasePath = path.join(tempDirectory, 'auth.db');
|
|
28
|
+
|
|
29
|
+
closeConnection();
|
|
30
|
+
process.env.DATABASE_PATH = databasePath;
|
|
31
|
+
await initializeDatabase();
|
|
32
|
+
|
|
33
|
+
try {
|
|
34
|
+
await runTest();
|
|
35
|
+
} finally {
|
|
36
|
+
connectedClients.clear();
|
|
37
|
+
chatRunRegistry.clearAll();
|
|
38
|
+
closeConnection();
|
|
39
|
+
if (previousDatabasePath === undefined) {
|
|
40
|
+
delete process.env.DATABASE_PATH;
|
|
41
|
+
} else {
|
|
42
|
+
process.env.DATABASE_PATH = previousDatabasePath;
|
|
43
|
+
}
|
|
44
|
+
await rm(tempDirectory, { recursive: true, force: true });
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
test('live events are remapped to the app session id and sequenced', async () => {
|
|
49
|
+
await withIsolatedDatabase(() => {
|
|
50
|
+
sessionsDb.createAppSession('app-run-1', 'claude', '/workspace/demo');
|
|
51
|
+
const connection = new FakeConnection();
|
|
52
|
+
const run = chatRunRegistry.startRun({
|
|
53
|
+
appSessionId: 'app-run-1',
|
|
54
|
+
provider: 'claude',
|
|
55
|
+
providerSessionId: null,
|
|
56
|
+
connection,
|
|
57
|
+
userId: 'user-1',
|
|
58
|
+
});
|
|
59
|
+
assert.ok(run);
|
|
60
|
+
|
|
61
|
+
run.writer.send({ kind: 'stream_delta', provider: 'claude', sessionId: 'provider-id-9', content: 'hello' });
|
|
62
|
+
run.writer.send({ kind: 'text', provider: 'claude', sessionId: 'provider-id-9', content: 'hello world' });
|
|
63
|
+
|
|
64
|
+
assert.equal(connection.frames.length, 2);
|
|
65
|
+
assert.equal(connection.frames[0]?.sessionId, 'app-run-1');
|
|
66
|
+
assert.equal(connection.frames[0]?.seq, 1);
|
|
67
|
+
assert.equal(connection.frames[1]?.sessionId, 'app-run-1');
|
|
68
|
+
assert.equal(connection.frames[1]?.seq, 2);
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test('session_created is swallowed and persisted as the provider-id mapping', async () => {
|
|
73
|
+
await withIsolatedDatabase(() => {
|
|
74
|
+
sessionsDb.createAppSession('app-run-2', 'cursor', '/workspace/demo');
|
|
75
|
+
const connection = new FakeConnection();
|
|
76
|
+
connectedClients.add(connection as never);
|
|
77
|
+
const run = chatRunRegistry.startRun({
|
|
78
|
+
appSessionId: 'app-run-2',
|
|
79
|
+
provider: 'cursor',
|
|
80
|
+
providerSessionId: null,
|
|
81
|
+
connection,
|
|
82
|
+
userId: null,
|
|
83
|
+
});
|
|
84
|
+
assert.ok(run);
|
|
85
|
+
|
|
86
|
+
run.writer.send({
|
|
87
|
+
kind: 'session_created',
|
|
88
|
+
provider: 'cursor',
|
|
89
|
+
sessionId: 'cursor-native-7',
|
|
90
|
+
newSessionId: 'cursor-native-7',
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
// The provider-native event itself is never forwarded...
|
|
94
|
+
const sessionUpserts = connection.frames.filter((frame) => frame.kind === 'session_upserted');
|
|
95
|
+
assert.equal(sessionUpserts.length, 1);
|
|
96
|
+
assert.equal(sessionUpserts[0]?.sessionId, 'app-run-2');
|
|
97
|
+
assert.equal(sessionUpserts[0]?.providerSessionId, 'cursor-native-7');
|
|
98
|
+
// ...but the canonical mapping is recorded and persisted in the database.
|
|
99
|
+
assert.equal(run.providerSessionId, 'cursor-native-7');
|
|
100
|
+
assert.equal(sessionsDb.getSessionById('app-run-2')?.provider_session_id, 'cursor-native-7');
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test('complete marks the run finished and duplicate completes are dropped', async () => {
|
|
105
|
+
await withIsolatedDatabase(() => {
|
|
106
|
+
sessionsDb.createAppSession('app-run-3', 'codex', '/workspace/demo');
|
|
107
|
+
const connection = new FakeConnection();
|
|
108
|
+
const run = chatRunRegistry.startRun({
|
|
109
|
+
appSessionId: 'app-run-3',
|
|
110
|
+
provider: 'codex',
|
|
111
|
+
providerSessionId: null,
|
|
112
|
+
connection,
|
|
113
|
+
userId: null,
|
|
114
|
+
});
|
|
115
|
+
assert.ok(run);
|
|
116
|
+
|
|
117
|
+
run.writer.send({ kind: 'complete', provider: 'codex', sessionId: 'native-3', exitCode: 0 });
|
|
118
|
+
// Late duplicate from a killed runtime's exit handler.
|
|
119
|
+
run.writer.send({ kind: 'complete', provider: 'codex', sessionId: 'native-3', exitCode: 1 });
|
|
120
|
+
|
|
121
|
+
const completes = connection.frames.filter((frame) => frame.kind === 'complete');
|
|
122
|
+
assert.equal(completes.length, 1);
|
|
123
|
+
assert.equal(completes[0]?.actualSessionId, 'app-run-3');
|
|
124
|
+
assert.equal(chatRunRegistry.isProcessing('app-run-3'), false);
|
|
125
|
+
|
|
126
|
+
// completeRun is also a no-op once the run already completed.
|
|
127
|
+
chatRunRegistry.completeRun('app-run-3', { exitCode: 1 });
|
|
128
|
+
assert.equal(connection.frames.filter((frame) => frame.kind === 'complete').length, 1);
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test('listRunningRuns returns only currently running app sessions', async () => {
|
|
133
|
+
await withIsolatedDatabase(() => {
|
|
134
|
+
sessionsDb.createAppSession('app-run-7', 'claude', '/workspace/demo');
|
|
135
|
+
sessionsDb.createAppSession('app-run-8', 'codex', '/workspace/demo');
|
|
136
|
+
const connection = new FakeConnection();
|
|
137
|
+
|
|
138
|
+
const completedRun = chatRunRegistry.startRun({
|
|
139
|
+
appSessionId: 'app-run-7',
|
|
140
|
+
provider: 'claude',
|
|
141
|
+
providerSessionId: null,
|
|
142
|
+
connection,
|
|
143
|
+
userId: null,
|
|
144
|
+
});
|
|
145
|
+
assert.ok(completedRun);
|
|
146
|
+
|
|
147
|
+
const runningRun = chatRunRegistry.startRun({
|
|
148
|
+
appSessionId: 'app-run-8',
|
|
149
|
+
provider: 'codex',
|
|
150
|
+
providerSessionId: null,
|
|
151
|
+
connection,
|
|
152
|
+
userId: null,
|
|
153
|
+
});
|
|
154
|
+
assert.ok(runningRun);
|
|
155
|
+
|
|
156
|
+
chatRunRegistry.completeRun('app-run-7', { exitCode: 0 });
|
|
157
|
+
|
|
158
|
+
const runningSessions = chatRunRegistry.listRunningRuns();
|
|
159
|
+
assert.deepEqual(runningSessions.map((session) => session.sessionId), ['app-run-8']);
|
|
160
|
+
assert.equal(runningSessions[0]?.provider, 'codex');
|
|
161
|
+
});
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
test('replayEvents returns only events after the requested seq', async () => {
|
|
165
|
+
await withIsolatedDatabase(() => {
|
|
166
|
+
sessionsDb.createAppSession('app-run-4', 'claude', '/workspace/demo');
|
|
167
|
+
const connection = new FakeConnection();
|
|
168
|
+
const run = chatRunRegistry.startRun({
|
|
169
|
+
appSessionId: 'app-run-4',
|
|
170
|
+
provider: 'claude',
|
|
171
|
+
providerSessionId: null,
|
|
172
|
+
connection,
|
|
173
|
+
userId: null,
|
|
174
|
+
});
|
|
175
|
+
assert.ok(run);
|
|
176
|
+
|
|
177
|
+
run.writer.send({ kind: 'stream_delta', provider: 'claude', sessionId: 'x', content: 'a' });
|
|
178
|
+
run.writer.send({ kind: 'stream_delta', provider: 'claude', sessionId: 'x', content: 'b' });
|
|
179
|
+
run.writer.send({ kind: 'stream_delta', provider: 'claude', sessionId: 'x', content: 'c' });
|
|
180
|
+
|
|
181
|
+
const replayed = chatRunRegistry.replayEvents('app-run-4', 1);
|
|
182
|
+
assert.deepEqual(replayed.map((event) => event.content), ['b', 'c']);
|
|
183
|
+
assert.deepEqual(replayed.map((event) => event.seq), [2, 3]);
|
|
184
|
+
});
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
test('attachConnection reroutes the live stream to a new socket', async () => {
|
|
188
|
+
await withIsolatedDatabase(() => {
|
|
189
|
+
sessionsDb.createAppSession('app-run-5', 'gemini', '/workspace/demo');
|
|
190
|
+
const firstConnection = new FakeConnection();
|
|
191
|
+
const run = chatRunRegistry.startRun({
|
|
192
|
+
appSessionId: 'app-run-5',
|
|
193
|
+
provider: 'gemini',
|
|
194
|
+
providerSessionId: null,
|
|
195
|
+
connection: firstConnection,
|
|
196
|
+
userId: null,
|
|
197
|
+
});
|
|
198
|
+
assert.ok(run);
|
|
199
|
+
|
|
200
|
+
run.writer.send({ kind: 'stream_delta', provider: 'gemini', sessionId: 'g', content: 'before' });
|
|
201
|
+
|
|
202
|
+
const secondConnection = new FakeConnection();
|
|
203
|
+
assert.equal(chatRunRegistry.attachConnection('app-run-5', secondConnection), true);
|
|
204
|
+
run.writer.send({ kind: 'stream_delta', provider: 'gemini', sessionId: 'g', content: 'after' });
|
|
205
|
+
|
|
206
|
+
assert.deepEqual(firstConnection.frames.map((frame) => frame.content), ['before']);
|
|
207
|
+
assert.deepEqual(secondConnection.frames.map((frame) => frame.content), ['after']);
|
|
208
|
+
});
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
test('startRun rejects a second concurrent run for the same session', async () => {
|
|
212
|
+
await withIsolatedDatabase(() => {
|
|
213
|
+
sessionsDb.createAppSession('app-run-6', 'opencode', '/workspace/demo');
|
|
214
|
+
const connection = new FakeConnection();
|
|
215
|
+
const first = chatRunRegistry.startRun({
|
|
216
|
+
appSessionId: 'app-run-6',
|
|
217
|
+
provider: 'opencode',
|
|
218
|
+
providerSessionId: null,
|
|
219
|
+
connection,
|
|
220
|
+
userId: null,
|
|
221
|
+
});
|
|
222
|
+
assert.ok(first);
|
|
223
|
+
|
|
224
|
+
const second = chatRunRegistry.startRun({
|
|
225
|
+
appSessionId: 'app-run-6',
|
|
226
|
+
provider: 'opencode',
|
|
227
|
+
providerSessionId: null,
|
|
228
|
+
connection,
|
|
229
|
+
userId: null,
|
|
230
|
+
});
|
|
231
|
+
assert.equal(second, null);
|
|
232
|
+
|
|
233
|
+
// After the run finishes a new one is allowed again.
|
|
234
|
+
chatRunRegistry.completeRun('app-run-6', { exitCode: 0 });
|
|
235
|
+
const third = chatRunRegistry.startRun({
|
|
236
|
+
appSessionId: 'app-run-6',
|
|
237
|
+
provider: 'opencode',
|
|
238
|
+
providerSessionId: null,
|
|
239
|
+
connection,
|
|
240
|
+
userId: null,
|
|
241
|
+
});
|
|
242
|
+
assert.ok(third);
|
|
243
|
+
});
|
|
244
|
+
});
|
package/server/openai-codex.js
CHANGED
|
@@ -18,7 +18,7 @@ import { notifyRunFailed, notifyRunStopped } from './services/notification-orche
|
|
|
18
18
|
import { sessionsService } from './modules/providers/services/sessions.service.js';
|
|
19
19
|
import { providerAuthService } from './modules/providers/services/provider-auth.service.js';
|
|
20
20
|
import { providerModelsService } from './modules/providers/services/provider-models.service.js';
|
|
21
|
-
import { createNormalizedMessage } from './shared/utils.js';
|
|
21
|
+
import { createCompleteMessage, createNormalizedMessage } from './shared/utils.js';
|
|
22
22
|
|
|
23
23
|
// Track active sessions
|
|
24
24
|
const activeCodexSessions = new Map();
|
|
@@ -352,21 +352,26 @@ export async function queryCodex(command, options = {}, ws) {
|
|
|
352
352
|
}
|
|
353
353
|
}
|
|
354
354
|
|
|
355
|
-
// Send completion event
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
provider: 'codex'
|
|
362
|
-
}));
|
|
363
|
-
notifyRunStopped({
|
|
364
|
-
userId: ws?.userId || null,
|
|
355
|
+
// Send the terminal completion event — skipped for aborted runs, whose
|
|
356
|
+
// terminal `complete` (aborted: true) was already sent by abort-session.
|
|
357
|
+
const runSession = capturedSessionId ? activeCodexSessions.get(capturedSessionId) : null;
|
|
358
|
+
const runAborted = runSession?.status === 'aborted' || abortController.signal.aborted;
|
|
359
|
+
if (!runAborted) {
|
|
360
|
+
sendMessage(ws, createCompleteMessage({
|
|
365
361
|
provider: 'codex',
|
|
366
362
|
sessionId: capturedSessionId || sessionId || null,
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
});
|
|
363
|
+
actualSessionId: capturedSessionId || thread.id || sessionId || null,
|
|
364
|
+
exitCode: terminalFailure ? 1 : 0,
|
|
365
|
+
}));
|
|
366
|
+
if (!terminalFailure) {
|
|
367
|
+
notifyRunStopped({
|
|
368
|
+
userId: ws?.userId || null,
|
|
369
|
+
provider: 'codex',
|
|
370
|
+
sessionId: capturedSessionId || sessionId || null,
|
|
371
|
+
sessionName: sessionSummary,
|
|
372
|
+
stopReason: 'completed'
|
|
373
|
+
});
|
|
374
|
+
}
|
|
370
375
|
}
|
|
371
376
|
|
|
372
377
|
} catch (error) {
|
|
@@ -386,6 +391,11 @@ export async function queryCodex(command, options = {}, ws) {
|
|
|
386
391
|
: error.message;
|
|
387
392
|
|
|
388
393
|
sendMessage(ws, createNormalizedMessage({ kind: 'error', content: errorContent, sessionId: capturedSessionId || sessionId || null, provider: 'codex' }));
|
|
394
|
+
sendMessage(ws, createCompleteMessage({
|
|
395
|
+
provider: 'codex',
|
|
396
|
+
sessionId: capturedSessionId || sessionId || null,
|
|
397
|
+
exitCode: 1,
|
|
398
|
+
}));
|
|
389
399
|
if (!terminalFailure) {
|
|
390
400
|
notifyRunFailed({
|
|
391
401
|
userId: ws?.userId || null,
|
package/server/opencode-cli.js
CHANGED
|
@@ -8,7 +8,7 @@ import { sessionsService } from './modules/providers/services/sessions.service.j
|
|
|
8
8
|
import { providerAuthService } from './modules/providers/services/provider-auth.service.js';
|
|
9
9
|
import { providerModelsService } from './modules/providers/services/provider-models.service.js';
|
|
10
10
|
import { notifyRunFailed, notifyRunStopped } from './services/notification-orchestrator.js';
|
|
11
|
-
import { createNormalizedMessage, getOpenCodeDatabasePath } from './shared/utils.js';
|
|
11
|
+
import { createCompleteMessage, createNormalizedMessage, getOpenCodeDatabasePath } from './shared/utils.js';
|
|
12
12
|
|
|
13
13
|
const spawnFunction = process.platform === 'win32' ? crossSpawn : spawn;
|
|
14
14
|
|
|
@@ -92,6 +92,9 @@ async function spawnOpenCode(command, options = {}, ws) {
|
|
|
92
92
|
let stdoutLineBuffer = '';
|
|
93
93
|
let terminalNotificationSent = false;
|
|
94
94
|
let opencodeProcess = null;
|
|
95
|
+
// Unified lifecycle contract: exactly one terminal `complete` per run
|
|
96
|
+
// (close and error handlers can both fire for spawn failures).
|
|
97
|
+
let completeSent = false;
|
|
95
98
|
|
|
96
99
|
const notifyTerminalState = ({ code = null, error = null } = {}) => {
|
|
97
100
|
if (terminalNotificationSent) {
|
|
@@ -191,6 +194,10 @@ async function spawnOpenCode(command, options = {}, ws) {
|
|
|
191
194
|
|
|
192
195
|
void providerModelsService.resolveResumeModel('opencode', sessionId, model).then((resolvedModel) => {
|
|
193
196
|
const args = ['run', '--format', 'json'];
|
|
197
|
+
// OpenCode's `run` command owns workspace selection through `--dir`.
|
|
198
|
+
// Relying on the child-process cwd alone is not enough on Linux, where
|
|
199
|
+
// the CLI can still resolve the session under the server install dir.
|
|
200
|
+
args.push('--dir', workingDir);
|
|
194
201
|
if (sessionId) {
|
|
195
202
|
args.push('--session', sessionId);
|
|
196
203
|
}
|
|
@@ -256,13 +263,12 @@ async function spawnOpenCode(command, options = {}, ws) {
|
|
|
256
263
|
}));
|
|
257
264
|
}
|
|
258
265
|
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
sessionId: finalSessionId,
|
|
264
|
-
|
|
265
|
-
}));
|
|
266
|
+
// Terminal complete — skipped for aborted runs (abort-session
|
|
267
|
+
// already sent the aborted complete on this run's behalf).
|
|
268
|
+
if (!completeSent && !opencodeProcess.aborted) {
|
|
269
|
+
completeSent = true;
|
|
270
|
+
ws.send(createCompleteMessage({ provider: 'opencode', sessionId: finalSessionId, exitCode: code }));
|
|
271
|
+
}
|
|
266
272
|
|
|
267
273
|
if (code === 0) {
|
|
268
274
|
notifyTerminalState({ code });
|
|
@@ -302,6 +308,10 @@ async function spawnOpenCode(command, options = {}, ws) {
|
|
|
302
308
|
sessionId: finalSessionId,
|
|
303
309
|
provider: 'opencode',
|
|
304
310
|
}));
|
|
311
|
+
if (!completeSent && !opencodeProcess.aborted) {
|
|
312
|
+
completeSent = true;
|
|
313
|
+
ws.send(createCompleteMessage({ provider: 'opencode', sessionId: finalSessionId, exitCode: 1 }));
|
|
314
|
+
}
|
|
305
315
|
notifyTerminalState({ error });
|
|
306
316
|
reject(error);
|
|
307
317
|
});
|
|
@@ -315,6 +325,9 @@ function abortOpenCodeSession(sessionId) {
|
|
|
315
325
|
return false;
|
|
316
326
|
}
|
|
317
327
|
|
|
328
|
+
// The abort handler sends the terminal complete (aborted: true); flag the
|
|
329
|
+
// process so its close handler does not emit a second one.
|
|
330
|
+
process.aborted = true;
|
|
318
331
|
process.kill('SIGTERM');
|
|
319
332
|
activeOpenCodeProcesses.delete(sessionId);
|
|
320
333
|
return true;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import assert from 'node:assert/strict';
|
|
2
|
-
import { chmod, mkdtemp, rm, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { chmod, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
|
|
3
3
|
import os from 'node:os';
|
|
4
4
|
import path from 'node:path';
|
|
5
5
|
import test from 'node:test';
|
|
@@ -12,6 +12,11 @@ const findEnvKey = (name) =>
|
|
|
12
12
|
async function createFakeOpenCodeExecutable(binDir) {
|
|
13
13
|
const scriptPath = path.join(binDir, 'opencode.js');
|
|
14
14
|
await writeFile(scriptPath, `
|
|
15
|
+
const capturePath = process.env.OPENCODE_ARGS_CAPTURE;
|
|
16
|
+
if (capturePath) {
|
|
17
|
+
require('node:fs').writeFileSync(capturePath, JSON.stringify(process.argv.slice(2)));
|
|
18
|
+
}
|
|
19
|
+
|
|
15
20
|
const events = [
|
|
16
21
|
{ type: 'text', sessionID: 'open-live-1', text: 'assistant response' },
|
|
17
22
|
{ type: 'step_finish', sessionID: 'open-live-1' },
|
|
@@ -35,10 +40,12 @@ for (const event of events) {
|
|
|
35
40
|
|
|
36
41
|
test('spawnOpenCode emits session_created before normalized live messages for new sessions', async () => {
|
|
37
42
|
const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'opencode-cli-live-'));
|
|
43
|
+
const argsCapturePath = path.join(tempRoot, 'opencode-args.json');
|
|
38
44
|
const pathKey = findEnvKey('PATH');
|
|
39
45
|
const pathExtKey = findEnvKey('PATHEXT');
|
|
40
46
|
const previousPath = process.env[pathKey];
|
|
41
47
|
const previousPathExt = process.env[pathExtKey];
|
|
48
|
+
const previousArgsCapture = process.env.OPENCODE_ARGS_CAPTURE;
|
|
42
49
|
const messages = [];
|
|
43
50
|
const writer = {
|
|
44
51
|
userId: null,
|
|
@@ -54,6 +61,7 @@ test('spawnOpenCode emits session_created before normalized live messages for ne
|
|
|
54
61
|
try {
|
|
55
62
|
await createFakeOpenCodeExecutable(tempRoot);
|
|
56
63
|
process.env[pathKey] = `${tempRoot}${path.delimiter}${previousPath || ''}`;
|
|
64
|
+
process.env.OPENCODE_ARGS_CAPTURE = argsCapturePath;
|
|
57
65
|
if (process.platform === 'win32') {
|
|
58
66
|
process.env[pathExtKey] = previousPathExt?.toUpperCase().includes('.CMD')
|
|
59
67
|
? previousPathExt
|
|
@@ -77,6 +85,11 @@ test('spawnOpenCode emits session_created before normalized live messages for ne
|
|
|
77
85
|
assert.equal(streamEnd?.sessionId, 'open-live-1');
|
|
78
86
|
assert.equal(complete?.sessionId, 'open-live-1');
|
|
79
87
|
assert.equal(messages.some((message) => message.kind === 'error'), false);
|
|
88
|
+
|
|
89
|
+
const launchedArgs = JSON.parse(await readFile(argsCapturePath, 'utf8'));
|
|
90
|
+
assert.ok(Array.isArray(launchedArgs));
|
|
91
|
+
assert.deepEqual(launchedArgs.slice(0, 4), ['run', '--format', 'json', '--dir']);
|
|
92
|
+
assert.equal(launchedArgs[4], tempRoot);
|
|
80
93
|
} finally {
|
|
81
94
|
if (previousPath === undefined) {
|
|
82
95
|
delete process.env[pathKey];
|
|
@@ -90,6 +103,12 @@ test('spawnOpenCode emits session_created before normalized live messages for ne
|
|
|
90
103
|
process.env[pathExtKey] = previousPathExt;
|
|
91
104
|
}
|
|
92
105
|
|
|
106
|
+
if (previousArgsCapture === undefined) {
|
|
107
|
+
delete process.env.OPENCODE_ARGS_CAPTURE;
|
|
108
|
+
} else {
|
|
109
|
+
process.env.OPENCODE_ARGS_CAPTURE = previousArgsCapture;
|
|
110
|
+
}
|
|
111
|
+
|
|
93
112
|
await rm(tempRoot, { recursive: true, force: true });
|
|
94
113
|
}
|
|
95
114
|
});
|