@sanqianx/claude-ai-workbench 0.11.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 +129 -0
- package/apps/agent-terminal/app.css +567 -0
- package/apps/agent-terminal/app.js +2392 -0
- package/apps/agent-terminal/index.html +158 -0
- package/apps/ai-profile-settings-host/index.html +25 -0
- package/apps/ai-profile-settings-host/page.css +1 -0
- package/apps/ai-profile-settings-host/page.js +113 -0
- package/apps/minimal-test-host/README.md +15 -0
- package/apps/minimal-test-host/host.css +2 -0
- package/apps/minimal-test-host/host.js +35 -0
- package/apps/minimal-test-host/index.html +58 -0
- package/apps/minimal-test-host/optional-mock/fault-injection.js +15 -0
- package/docs/sdk/FILE_LAYOUT.md +31 -0
- package/docs/sdk/INTEGRATION.md +126 -0
- package/docs/sdk/README.md +21 -0
- package/docs/sdk/openapi.yaml +760 -0
- package/package.json +51 -0
- package/packages/client/README.md +11 -0
- package/packages/client/index.d.ts +81 -0
- package/packages/client/index.js +198 -0
- package/packages/client/package.json +29 -0
- package/packages/contracts/README.md +9 -0
- package/packages/contracts/index.d.ts +252 -0
- package/packages/contracts/index.js +295 -0
- package/packages/contracts/package.json +24 -0
- package/packages/core/README.md +8 -0
- package/packages/core/index.d.ts +76 -0
- package/packages/core/index.js +214 -0
- package/packages/core/package.json +29 -0
- package/packages/server/README.md +17 -0
- package/packages/server/bin/agent-terminal-server.js +36 -0
- package/packages/server/bin/claude-workbench-server.js +46 -0
- package/packages/server/bin/claude-workbench.js +225 -0
- package/packages/server/index.d.ts +145 -0
- package/packages/server/index.js +183 -0
- package/packages/server/lib/agent-discovery.js +112 -0
- package/packages/server/lib/agent-registry.js +98 -0
- package/packages/server/lib/attachment-store.js +78 -0
- package/packages/server/lib/bridges/app-server-bridge.js +372 -0
- package/packages/server/lib/bridges/opencode-server-bridge.js +490 -0
- package/packages/server/lib/capture-settings-store.js +34 -0
- package/packages/server/lib/context-resolver.js +40 -0
- package/packages/server/lib/drivers/cli-json-runner.js +960 -0
- package/packages/server/lib/drivers/codex-driver.js +1413 -0
- package/packages/server/lib/drivers/opencode-driver.js +1527 -0
- package/packages/server/lib/drivers/zcode-driver.js +1167 -0
- package/packages/server/lib/event-stream.js +35 -0
- package/packages/server/lib/folder-picker.js +623 -0
- package/packages/server/lib/http-handler.js +254 -0
- package/packages/server/lib/json-file.js +19 -0
- package/packages/server/lib/model-detection.js +295 -0
- package/packages/server/lib/profile-store.js +149 -0
- package/packages/server/lib/project-store.js +102 -0
- package/packages/server/lib/session-archive-store.js +53 -0
- package/packages/server/lib/system-doctor.js +119 -0
- package/packages/server/lib/ui-preferences-store.js +48 -0
- package/packages/server/lib/workbench-service.js +620 -0
- package/packages/server/package.json +34 -0
- package/packages/ui/README.md +30 -0
- package/packages/ui/ai-profile-settings.d.ts +45 -0
- package/packages/ui/ai-profile-settings.js +283 -0
- package/packages/ui/claude-terminal.d.ts +31 -0
- package/packages/ui/claude-terminal.js +343 -0
- package/packages/ui/index.d.ts +18 -0
- package/packages/ui/index.js +51 -0
- package/packages/ui/package.json +42 -0
- package/packages/ui/theme.css +13 -0
- package/src/assets/claude-prompts.json +21 -0
- package/src/backend/http/ai-profile-routes.js +61 -0
- package/src/backend/http/claude-routes.js +170 -0
- package/src/backend/http/node-helpers.js +45 -0
- package/src/backend/index.js +21 -0
- package/src/backend/lib/ai-adapter.js +144 -0
- package/src/backend/lib/ai-profile-store.js +259 -0
- package/src/backend/lib/ai-vendor-presets.js +83 -0
- package/src/backend/lib/ai-workspace.js +140 -0
- package/src/backend/lib/automation-config.js +253 -0
- package/src/backend/lib/claude-cli-runner.js +1749 -0
- package/src/backend/lib/data-dir.js +169 -0
- package/src/backend/lib/llm-client.js +176 -0
- package/src/backend/lib/model-context-windows.js +82 -0
- package/src/backend/lib/prompt-registry.js +166 -0
- package/src/backend/lib/runtime-config.js +121 -0
- package/src/index.js +7 -0
- package/src/sdk-wave-a.ts +1 -0
- package/src/sdk-wave-b.ts +1 -0
- package/src/sdk-wave-c.ts +1 -0
- package/src/sdk-wave-d.ts +1 -0
- package/src/sdk-wave-e.ts +1 -0
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { spawnSync } = require('child_process');
|
|
4
|
+
const { AGENTS, AGENT_IDS } = require('../../contracts');
|
|
5
|
+
const { discoverZCodeCli, probeZCodeVersion } = require('./drivers/zcode-driver');
|
|
6
|
+
|
|
7
|
+
const PROBE_TIMEOUT_MS = 5000;
|
|
8
|
+
const CACHE_TTL_MS = 30000;
|
|
9
|
+
|
|
10
|
+
// Probes locally installed agent CLIs. Results are cached briefly so
|
|
11
|
+
// GET /agents stays cheap; a failed probe is a valid (negative) result.
|
|
12
|
+
function parseVersionToken(text) {
|
|
13
|
+
const trimmed = String(text || '').trim();
|
|
14
|
+
const match = trimmed.match(/(\d+\.\d+[\w.-]*)/);
|
|
15
|
+
return match ? match[1] : text.split(/\r?\n/)[0].slice(0, 64) || null;
|
|
16
|
+
}
|
|
17
|
+
// Spawns `<executable> --version` directly (no shell) and returns the first
|
|
18
|
+
// version-looking token, or null when the probe fails. This is the only
|
|
19
|
+
// version source: nothing here invents or hardcodes one.
|
|
20
|
+
function probeExecutableVersion(cmd) {
|
|
21
|
+
try {
|
|
22
|
+
const result = spawnSync(cmd, ['--version'], {
|
|
23
|
+
timeout: PROBE_TIMEOUT_MS,
|
|
24
|
+
windowsHide: true,
|
|
25
|
+
encoding: 'utf8',
|
|
26
|
+
});
|
|
27
|
+
if (result.error || result.status !== 0) return null;
|
|
28
|
+
return parseVersionToken(String(result.stdout || result.stderr || ''));
|
|
29
|
+
} catch {
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
function createAgentDiscovery(options = {}) {
|
|
34
|
+
const claudeProbe = options.claudeProbe || null; // () => {available, version}
|
|
35
|
+
let cache = null;
|
|
36
|
+
let cacheAt = 0;
|
|
37
|
+
|
|
38
|
+
function probeVersion(command) {
|
|
39
|
+
try {
|
|
40
|
+
const result = spawnSync(command, ['--version'], {
|
|
41
|
+
timeout: PROBE_TIMEOUT_MS,
|
|
42
|
+
windowsHide: true,
|
|
43
|
+
encoding: 'utf8',
|
|
44
|
+
shell: process.platform === 'win32',
|
|
45
|
+
});
|
|
46
|
+
if (result.error || result.status !== 0) return null;
|
|
47
|
+
return parseVersionToken(String(result.stdout || result.stderr || ''));
|
|
48
|
+
} catch {
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// ZCode detection runs the driver's discovery chain (ZCODE_CLI_PATH ->
|
|
54
|
+
// install roots -> registry App Paths): the bundled CLI is what the
|
|
55
|
+
// app-server driver needs, so a found CLI means a working driver. The
|
|
56
|
+
// ~/.zcode marker alone (previous heuristic) says nothing about the CLI.
|
|
57
|
+
function probeZCodeInstall() {
|
|
58
|
+
const cli = discoverZCodeCli();
|
|
59
|
+
if (!cli) return { installed: false, version: null, cliPath: null };
|
|
60
|
+
return { installed: true, version: probeZCodeVersion(cli.path), cliPath: cli.path };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function discover() {
|
|
64
|
+
const now = Date.now();
|
|
65
|
+
if (cache && now - cacheAt < CACHE_TTL_MS) return cache;
|
|
66
|
+
const statuses = {};
|
|
67
|
+
for (const id of AGENT_IDS) {
|
|
68
|
+
const definition = AGENTS[id];
|
|
69
|
+
let available = false;
|
|
70
|
+
let version = null;
|
|
71
|
+
let reason = '';
|
|
72
|
+
if (id === 'claude-code') {
|
|
73
|
+
const probed = claudeProbe ? claudeProbe() : { available: false, version: null };
|
|
74
|
+
available = Boolean(probed.available);
|
|
75
|
+
version = probed.version || null;
|
|
76
|
+
if (!available) reason = 'Claude Code executable was not found';
|
|
77
|
+
} else if (id === 'zcode') {
|
|
78
|
+
const install = probeZCodeInstall();
|
|
79
|
+
available = install.installed;
|
|
80
|
+
version = install.version;
|
|
81
|
+
reason = install.installed
|
|
82
|
+
? `ZCode desktop install found (bundled zcode CLI at ${install.cliPath} speaks app-server --stdio)`
|
|
83
|
+
: 'ZCode was not found on this machine';
|
|
84
|
+
} else {
|
|
85
|
+
version = probeVersion(id);
|
|
86
|
+
available = version != null;
|
|
87
|
+
if (!available) reason = `\`${id}\` command was not found on PATH`;
|
|
88
|
+
}
|
|
89
|
+
statuses[id] = {
|
|
90
|
+
id: definition.id,
|
|
91
|
+
label: definition.label,
|
|
92
|
+
transport: definition.transport,
|
|
93
|
+
authModes: [...definition.authModes],
|
|
94
|
+
capabilities: {
|
|
95
|
+
...definition.capabilities,
|
|
96
|
+
generationParams: [...definition.capabilities.generationParams],
|
|
97
|
+
},
|
|
98
|
+
available,
|
|
99
|
+
version,
|
|
100
|
+
reason,
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
cache = statuses;
|
|
104
|
+
cacheAt = now;
|
|
105
|
+
return statuses;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
discover.invalidate = () => { cache = null; cacheAt = 0; };
|
|
109
|
+
return discover;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
module.exports = { createAgentDiscovery, probeExecutableVersion };
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { AGENT_IDS, normalizeAgentId } = require('../../contracts');
|
|
4
|
+
|
|
5
|
+
// Routes every WorkbenchRunner call to the driver that owns the session.
|
|
6
|
+
// The claude-code driver is whatever runner the host injected (legacy
|
|
7
|
+
// claude-cli-runner in production, fake runners in tests); CLI drivers plug
|
|
8
|
+
// in beside it with the same interface.
|
|
9
|
+
class AgentRegistry {
|
|
10
|
+
constructor(options = {}) {
|
|
11
|
+
this.drivers = new Map();
|
|
12
|
+
this.sessionAgents = new Map();
|
|
13
|
+
for (const [agentId, driver] of Object.entries(options.drivers || {})) {
|
|
14
|
+
this.register(agentId, driver);
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
register(agentId, driver) {
|
|
19
|
+
const id = normalizeAgentId(agentId);
|
|
20
|
+
if (!driver) throw new Error(`driver for ${id} is required`);
|
|
21
|
+
this.drivers.set(id, driver);
|
|
22
|
+
return driver;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
driverFor(agentId) {
|
|
26
|
+
const id = normalizeAgentId(agentId);
|
|
27
|
+
const driver = this.drivers.get(id);
|
|
28
|
+
if (!driver) {
|
|
29
|
+
throw Object.assign(new Error(`agent "${id}" has no registered driver`), { status: 501 });
|
|
30
|
+
}
|
|
31
|
+
return driver;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
agentOf(sessionId) {
|
|
35
|
+
return this.sessionAgents.get(sessionId) || 'claude-code';
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
_route(sessionId) {
|
|
39
|
+
try {
|
|
40
|
+
return this.driverFor(this.agentOf(sessionId));
|
|
41
|
+
} catch {
|
|
42
|
+
throw Object.assign(new Error('session not found'), { status: 404 });
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
asRunner() {
|
|
47
|
+
const registry = this;
|
|
48
|
+
return {
|
|
49
|
+
startChatSession(args = {}) {
|
|
50
|
+
const agentId = normalizeAgentId(args.agentId);
|
|
51
|
+
const driver = registry.driverFor(agentId);
|
|
52
|
+
const started = driver.startChatSession({ ...args, agentId });
|
|
53
|
+
registry.sessionAgents.set(started.sessionId, agentId);
|
|
54
|
+
return started;
|
|
55
|
+
},
|
|
56
|
+
listSessions(filter = {}) {
|
|
57
|
+
const all = [];
|
|
58
|
+
for (const id of AGENT_IDS) {
|
|
59
|
+
const driver = registry.drivers.get(id);
|
|
60
|
+
if (!driver) continue;
|
|
61
|
+
for (const session of driver.listSessions(filter)) {
|
|
62
|
+
registry.sessionAgents.set(session.sessionId, id);
|
|
63
|
+
all.push({ ...session, agentId: registry.agentOf(session.sessionId) });
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return all;
|
|
67
|
+
},
|
|
68
|
+
getState(id) { return registry._route(id).getState(id); },
|
|
69
|
+
getSession(id) { return registry._route(id).getSession(id); },
|
|
70
|
+
getSessionTokenUsage(id) { return registry._route(id).getSessionTokenUsage(id); },
|
|
71
|
+
subscribe(id, listener) { return registry._route(id).subscribe(id, listener); },
|
|
72
|
+
sendInput(id, text, profile, options) { return registry._route(id).sendInput(id, text, profile, options); },
|
|
73
|
+
updateSelection(id, profile, selectedModel) {
|
|
74
|
+
const driver = registry._route(id);
|
|
75
|
+
if (typeof driver.updateSelection === 'function') return driver.updateSelection(id, profile, selectedModel);
|
|
76
|
+
// Bare runners without the method still get rebound so the service
|
|
77
|
+
// fallback and the proxy stay interchangeable.
|
|
78
|
+
const session = driver.getSession && driver.getSession(id);
|
|
79
|
+
if (!session) throw Object.assign(new Error('session not found'), { status: 404 });
|
|
80
|
+
session.aiProfileId = profile && profile.id || null;
|
|
81
|
+
session.selectedModel = selectedModel || null;
|
|
82
|
+
return session;
|
|
83
|
+
},
|
|
84
|
+
resolvePermission(id, requestId, decision) { return registry._route(id).resolvePermission(id, requestId, decision); },
|
|
85
|
+
abort(id) { return registry._route(id).abort(id); },
|
|
86
|
+
deleteSession(id) { return registry._route(id).deleteSession(id); },
|
|
87
|
+
listSupportedCommands(id, profile) { return registry._route(id).listSupportedCommands(id, profile); },
|
|
88
|
+
findClaudeExecutableForSdk() {
|
|
89
|
+
const driver = registry.drivers.get('claude-code');
|
|
90
|
+
return driver && typeof driver.findClaudeExecutableForSdk === 'function'
|
|
91
|
+
? driver.findClaudeExecutableForSdk()
|
|
92
|
+
: null;
|
|
93
|
+
},
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
module.exports = { AgentRegistry };
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const crypto = require('crypto');
|
|
4
|
+
const fs = require('fs');
|
|
5
|
+
const path = require('path');
|
|
6
|
+
const { cleanId, normalizeAttachments, IMAGE_MEDIA_TYPES } = require('../../contracts');
|
|
7
|
+
|
|
8
|
+
const EXTENSIONS = {
|
|
9
|
+
'image/jpeg': '.jpg', 'image/png': '.png', 'image/gif': '.gif', 'image/webp': '.webp',
|
|
10
|
+
'text/plain': '.txt', 'text/markdown': '.md', 'application/json': '.json.txt', 'text/csv': '.csv',
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
class AttachmentStore {
|
|
14
|
+
constructor(options = {}) {
|
|
15
|
+
if (!options.root) throw new Error('AttachmentStore root is required');
|
|
16
|
+
this.root = path.resolve(options.root);
|
|
17
|
+
fs.mkdirSync(this.root, { recursive: true });
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
saveMany(sessionId, input) {
|
|
21
|
+
const id = cleanId(sessionId, 'sessionId');
|
|
22
|
+
const items = normalizeAttachments(input);
|
|
23
|
+
for (const item of items) if (IMAGE_MEDIA_TYPES.includes(item.mediaType)) verifyImage(Buffer.from(item.data, 'base64'), item.mediaType);
|
|
24
|
+
return items.map(item => this.save(id, item));
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
save(sessionId, input) {
|
|
28
|
+
const sid = cleanId(sessionId, 'sessionId');
|
|
29
|
+
const item = normalizeAttachments([input])[0];
|
|
30
|
+
const isImage = IMAGE_MEDIA_TYPES.includes(item.mediaType);
|
|
31
|
+
const id = item.id || `${isImage ? 'img' : 'file'}-${crypto.randomUUID()}`;
|
|
32
|
+
const buffer = Buffer.from(item.data, 'base64');
|
|
33
|
+
if (isImage) verifyImage(buffer, item.mediaType);
|
|
34
|
+
const directory = path.join(this.root, hash(sid));
|
|
35
|
+
const stem = hash(id);
|
|
36
|
+
const extension = EXTENSIONS[item.mediaType];
|
|
37
|
+
fs.mkdirSync(directory, { recursive: true });
|
|
38
|
+
const file = path.join(directory, `${stem}${extension}`);
|
|
39
|
+
const metaFile = path.join(directory, `${stem}.json`);
|
|
40
|
+
fs.writeFileSync(file, buffer, { mode: 0o600 });
|
|
41
|
+
const metadata = {
|
|
42
|
+
id, sessionId: sid, name: item.name, mediaType: item.mediaType, size: buffer.length,
|
|
43
|
+
width: item.width, height: item.height, createdAt: new Date().toISOString(), file: path.basename(file),
|
|
44
|
+
};
|
|
45
|
+
fs.writeFileSync(metaFile, `${JSON.stringify(metadata, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 });
|
|
46
|
+
return metadata;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
read(sessionId, attachmentId) {
|
|
50
|
+
const sid = cleanId(sessionId, 'sessionId');
|
|
51
|
+
const id = cleanId(attachmentId, 'attachmentId');
|
|
52
|
+
const directory = path.join(this.root, hash(sid));
|
|
53
|
+
const metaFile = path.join(directory, `${hash(id)}.json`);
|
|
54
|
+
if (!fs.existsSync(metaFile)) return null;
|
|
55
|
+
const metadata = JSON.parse(fs.readFileSync(metaFile, 'utf8'));
|
|
56
|
+
if (metadata.id !== id || metadata.sessionId !== sid) return null;
|
|
57
|
+
const file = path.resolve(directory, metadata.file);
|
|
58
|
+
if (!file.startsWith(`${directory}${path.sep}`) || !fs.existsSync(file)) return null;
|
|
59
|
+
return { metadata, data: fs.readFileSync(file) };
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function hash(value) { return crypto.createHash('sha256').update(String(value)).digest('hex'); }
|
|
64
|
+
|
|
65
|
+
function verifyImage(buffer, mediaType) {
|
|
66
|
+
const valid = mediaType === 'image/png'
|
|
67
|
+
? buffer.length >= 8 && buffer.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))
|
|
68
|
+
: mediaType === 'image/jpeg'
|
|
69
|
+
? buffer.length >= 3 && buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff
|
|
70
|
+
: mediaType === 'image/gif'
|
|
71
|
+
? buffer.length >= 6 && ['GIF87a', 'GIF89a'].includes(buffer.subarray(0, 6).toString('ascii'))
|
|
72
|
+
: mediaType === 'image/webp'
|
|
73
|
+
? buffer.length >= 12 && buffer.subarray(0, 4).toString('ascii') === 'RIFF' && buffer.subarray(8, 12).toString('ascii') === 'WEBP'
|
|
74
|
+
: false;
|
|
75
|
+
if (!valid) throw Object.assign(new Error(`attachment content does not match ${mediaType}`), { status: 415 });
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
module.exports = { AttachmentStore, verifyImage };
|
|
@@ -0,0 +1,372 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { spawn: realSpawn } = require('child_process');
|
|
4
|
+
|
|
5
|
+
// Generic persistent app-server bridge: manages one long-lived child process
|
|
6
|
+
// speaking line-delimited JSON-RPC over stdio (the dialect `codex app-server`
|
|
7
|
+
// uses — messages may omit the `jsonrpc` field in both directions).
|
|
8
|
+
//
|
|
9
|
+
// Responsibilities:
|
|
10
|
+
// - process lifecycle: lazy single-flight start, crash auto-restart with
|
|
11
|
+
// backoff (bounded), idle shutdown after a configurable quiet period,
|
|
12
|
+
// terminal close();
|
|
13
|
+
// - JSON-RPC client: id-correlated request/response promises, client
|
|
14
|
+
// notifications, server notification fan-out (onEvent) and server-initiated
|
|
15
|
+
// requests answered through the onServerRequest callback;
|
|
16
|
+
// - line-buffered stdio parsing (same framing as the CLI JSON runners).
|
|
17
|
+
//
|
|
18
|
+
// Everything is injectable for tests: pass `spawn` to substitute the child.
|
|
19
|
+
//
|
|
20
|
+
// config:
|
|
21
|
+
// command, args, env, cwd child invocation (env/cwd are merged/defaults)
|
|
22
|
+
// spawn injectable spawn(command, args, options)
|
|
23
|
+
// onEvent ({ method, params }) => void — notifications
|
|
24
|
+
// onServerRequest async (method, params) => result — throws to send an error response
|
|
25
|
+
// onUnexpectedExit (error) => void — child died outside idle/close (turns may hang)
|
|
26
|
+
// requestTimeoutMs per-request default timeout (0 disables; default 120000)
|
|
27
|
+
// idleTimeoutMs quiet period before shutdown (0 disables; default 600000)
|
|
28
|
+
// maxRestarts crash restart budget before giving up (default 3)
|
|
29
|
+
// restartBackoffMs array of escalating delays (default [250, 500, 1000])
|
|
30
|
+
// log (message, meta) => void diagnostics hook
|
|
31
|
+
//
|
|
32
|
+
// States: idle -> starting -> ready -> (restarting -> ready)* -> idle|crashed|closed
|
|
33
|
+
|
|
34
|
+
const DEFAULT_REQUEST_TIMEOUT_MS = 120000;
|
|
35
|
+
const DEFAULT_IDLE_TIMEOUT_MS = 10 * 60 * 1000;
|
|
36
|
+
const DEFAULT_MAX_RESTARTS = 3;
|
|
37
|
+
const DEFAULT_RESTART_BACKOFF_MS = [250, 500, 1000];
|
|
38
|
+
|
|
39
|
+
function createAppServerBridge(config = {}) {
|
|
40
|
+
const spawnFn = config.spawn || realSpawn;
|
|
41
|
+
const onEvent = typeof config.onEvent === 'function' ? config.onEvent : () => {};
|
|
42
|
+
const onUnexpectedExit = typeof config.onUnexpectedExit === 'function' ? config.onUnexpectedExit : () => {};
|
|
43
|
+
const onReady = typeof config.onReady === 'function' ? config.onReady : () => {};
|
|
44
|
+
const onServerRequest = config.onServerRequest;
|
|
45
|
+
const log = typeof config.log === 'function' ? config.log : () => {};
|
|
46
|
+
const requestTimeoutMs = config.requestTimeoutMs !== undefined ? config.requestTimeoutMs : DEFAULT_REQUEST_TIMEOUT_MS;
|
|
47
|
+
const idleTimeoutMs = config.idleTimeoutMs !== undefined ? config.idleTimeoutMs : DEFAULT_IDLE_TIMEOUT_MS;
|
|
48
|
+
const maxRestarts = config.maxRestarts !== undefined ? config.maxRestarts : DEFAULT_MAX_RESTARTS;
|
|
49
|
+
const restartBackoffMs = config.restartBackoffMs || DEFAULT_RESTART_BACKOFF_MS;
|
|
50
|
+
|
|
51
|
+
let state = 'idle';
|
|
52
|
+
let child = null;
|
|
53
|
+
let generation = 0; // bumped on every successful (re)launch of the child
|
|
54
|
+
let bootPromise = null;
|
|
55
|
+
let restartTimer = null;
|
|
56
|
+
let idleTimer = null;
|
|
57
|
+
let restartsUsed = 0;
|
|
58
|
+
let intentionalStop = false;
|
|
59
|
+
let closed = false;
|
|
60
|
+
let nextRequestId = 1;
|
|
61
|
+
let bootSettle = null; // { resolve, reject } of the in-flight launch promise
|
|
62
|
+
let inFlightServerRequests = 0; // unanswered server requests are activity
|
|
63
|
+
const pending = new Map(); // id -> { resolve, reject, timer }
|
|
64
|
+
|
|
65
|
+
function safeWrite(message) {
|
|
66
|
+
const line = `${JSON.stringify(message)}\n`;
|
|
67
|
+
if (!child || !child.stdin || child.stdin.destroyed) throw bridgeError('bridge child stdin is not writable', 503, 'BRIDGE_NOT_READY');
|
|
68
|
+
child.stdin.write(line);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function touchActivity() {
|
|
72
|
+
if (idleTimeoutMs > 0 && state === 'ready') {
|
|
73
|
+
clearTimeout(idleTimer);
|
|
74
|
+
idleTimer = setTimeout(idleShutdown, idleTimeoutMs);
|
|
75
|
+
idleTimer.unref && idleTimer.unref();
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function idleShutdown() {
|
|
80
|
+
if (closed || pending.size > 0 || inFlightServerRequests > 0) { touchActivity(); return; }
|
|
81
|
+
log('bridge idle shutdown', { idleTimeoutMs });
|
|
82
|
+
intentionalStop = true;
|
|
83
|
+
killChild('idle-timeout');
|
|
84
|
+
state = 'idle';
|
|
85
|
+
bootPromise = null;
|
|
86
|
+
restartsUsed = 0;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function killChild() {
|
|
90
|
+
if (!child) return;
|
|
91
|
+
const target = child;
|
|
92
|
+
child = null;
|
|
93
|
+
try {
|
|
94
|
+
// npm shims spawn a node child on Windows; killing the shell alone
|
|
95
|
+
// orphans it. Fakes injected by tests have no pid and skip taskkill.
|
|
96
|
+
if (process.platform === 'win32' && Number.isInteger(target.pid)) {
|
|
97
|
+
realSpawn('taskkill', ['/pid', String(target.pid), '/T', '/F'], { windowsHide: true });
|
|
98
|
+
}
|
|
99
|
+
} catch { /* already gone */ }
|
|
100
|
+
try { target.kill(); } catch { /* already gone */ }
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function failPending(error) {
|
|
104
|
+
for (const entry of pending.values()) {
|
|
105
|
+
clearTimeout(entry.timer);
|
|
106
|
+
entry.reject(error);
|
|
107
|
+
}
|
|
108
|
+
pending.clear();
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function wire(childInstance) {
|
|
112
|
+
child = childInstance;
|
|
113
|
+
let buffer = '';
|
|
114
|
+
childInstance.stdout.setEncoding('utf8');
|
|
115
|
+
childInstance.stdout.on('data', chunk => {
|
|
116
|
+
buffer += chunk;
|
|
117
|
+
let index;
|
|
118
|
+
while ((index = buffer.indexOf('\n')) >= 0) {
|
|
119
|
+
const line = buffer.slice(0, index).trim();
|
|
120
|
+
buffer = buffer.slice(index + 1);
|
|
121
|
+
if (line) handleMessage(line);
|
|
122
|
+
}
|
|
123
|
+
});
|
|
124
|
+
childInstance.stderr.setEncoding && childInstance.stderr.setEncoding('utf8');
|
|
125
|
+
childInstance.stderr.on('data', chunk => {
|
|
126
|
+
const text = String(chunk).trim();
|
|
127
|
+
if (text) log(`bridge stderr: ${text.slice(0, 400)}`);
|
|
128
|
+
});
|
|
129
|
+
childInstance.on('error', error => {
|
|
130
|
+
log(`bridge child error: ${error.message}`);
|
|
131
|
+
handleExit(error);
|
|
132
|
+
});
|
|
133
|
+
childInstance.on('close', code => {
|
|
134
|
+
log(`bridge child exited code=${code}`);
|
|
135
|
+
handleExit(null);
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function handleMessage(line) {
|
|
140
|
+
touchActivity();
|
|
141
|
+
let message;
|
|
142
|
+
try { message = JSON.parse(line); } catch {
|
|
143
|
+
log(`bridge discarded unparseable line: ${line.slice(0, 200)}`);
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
if (!message || typeof message !== 'object') return;
|
|
147
|
+
if (message.method && message.id !== undefined && message.id !== null) {
|
|
148
|
+
handleServerRequest(message);
|
|
149
|
+
} else if (message.method) {
|
|
150
|
+
try { onEvent({ method: message.method, params: message.params }); } catch (error) {
|
|
151
|
+
log(`bridge onEvent handler failed for ${message.method}: ${error.message}`);
|
|
152
|
+
}
|
|
153
|
+
} else if (message.id !== undefined && message.id !== null) {
|
|
154
|
+
const entry = pending.get(message.id);
|
|
155
|
+
if (!entry) return; // response to an unknown/aborted request
|
|
156
|
+
pending.delete(message.id);
|
|
157
|
+
clearTimeout(entry.timer);
|
|
158
|
+
if (restartsUsed > 0) restartsUsed = 0;
|
|
159
|
+
if (message.error) entry.reject(rpcError(message.error));
|
|
160
|
+
else entry.resolve(message.result !== undefined ? message.result : null);
|
|
161
|
+
}
|
|
162
|
+
// anything else is ignored
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
async function handleServerRequest(message) {
|
|
166
|
+
const id = message.id;
|
|
167
|
+
// An unanswered server request (e.g. an approval) is bridge activity:
|
|
168
|
+
// it must keep the child alive and never be silently idled away.
|
|
169
|
+
inFlightServerRequests += 1;
|
|
170
|
+
try {
|
|
171
|
+
if (!onServerRequest) throw bridgeError(`no onServerRequest handler for ${message.method}`, 501, 'BRIDGE_UNSUPPORTED');
|
|
172
|
+
const result = await onServerRequest(message.method, message.params);
|
|
173
|
+
safeWrite({ id, result: result === undefined ? null : result });
|
|
174
|
+
} catch (error) {
|
|
175
|
+
try {
|
|
176
|
+
safeWrite({ id, error: { code: error.rpcCode || -32000, message: String(error && error.message || error).slice(0, 1000) } });
|
|
177
|
+
} catch { /* child already gone; nothing to answer */ }
|
|
178
|
+
} finally {
|
|
179
|
+
inFlightServerRequests -= 1;
|
|
180
|
+
touchActivity();
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function handleExit(spawnError) {
|
|
185
|
+
if (!child) return;
|
|
186
|
+
const wasReady = state === 'ready';
|
|
187
|
+
child = null;
|
|
188
|
+
bootPromise = null;
|
|
189
|
+
clearTimeout(idleTimer);
|
|
190
|
+
if (closed || intentionalStop) return;
|
|
191
|
+
const error = spawnError
|
|
192
|
+
? bridgeError(`bridge child failed: ${spawnError.message}`, 503, 'BRIDGE_SPAWN_FAILED', { cause: spawnError })
|
|
193
|
+
: bridgeError('bridge child exited unexpectedly', 503, 'BRIDGE_CRASHED');
|
|
194
|
+
failPending(error);
|
|
195
|
+
try { onUnexpectedExit(error); } catch (hookError) { log(`bridge onUnexpectedExit hook failed: ${hookError.message}`); }
|
|
196
|
+
if (!wasReady) {
|
|
197
|
+
// The boot promise never resolved; fail it so start() callers see why.
|
|
198
|
+
state = 'idle';
|
|
199
|
+
if (bootSettle) {
|
|
200
|
+
const settle = bootSettle;
|
|
201
|
+
bootSettle = null;
|
|
202
|
+
settle.reject(spawnError
|
|
203
|
+
? bridgeError(`bridge child failed to start: ${spawnError.message}`, 503, 'BRIDGE_SPAWN_FAILED', { cause: spawnError })
|
|
204
|
+
: bridgeError('bridge child exited before becoming ready', 503, 'BRIDGE_SPAWN_FAILED'));
|
|
205
|
+
}
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
if (restartsUsed < maxRestarts) {
|
|
209
|
+
const backoff = restartBackoffMs[Math.min(restartsUsed, restartBackoffMs.length - 1)];
|
|
210
|
+
restartsUsed += 1;
|
|
211
|
+
state = 'restarting';
|
|
212
|
+
log(`bridge crashed; restarting (${restartsUsed}/${maxRestarts}) in ${backoff}ms`);
|
|
213
|
+
bootPromise = new Promise((resolve, reject) => {
|
|
214
|
+
// Register the restart promise's settle so a close() during the
|
|
215
|
+
// backoff window cannot leave start() callers hanging forever.
|
|
216
|
+
bootSettle = { resolve, reject };
|
|
217
|
+
restartTimer = setTimeout(() => {
|
|
218
|
+
launchChild().then(resolve, reject);
|
|
219
|
+
}, backoff);
|
|
220
|
+
});
|
|
221
|
+
// This promise is held internally until someone calls start(); without
|
|
222
|
+
// a handler a close()-time rejection would surface as unhandled.
|
|
223
|
+
bootPromise.catch(() => {});
|
|
224
|
+
} else {
|
|
225
|
+
state = 'crashed';
|
|
226
|
+
log('bridge exhausted its restart budget');
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function launchChild() {
|
|
231
|
+
state = 'starting';
|
|
232
|
+
return new Promise((resolve, reject) => {
|
|
233
|
+
bootSettle = { resolve, reject };
|
|
234
|
+
let instance;
|
|
235
|
+
let command = config.command;
|
|
236
|
+
let args = config.args || [];
|
|
237
|
+
try {
|
|
238
|
+
// Windows bare commands (npm shims, PATH lookups) don't spawn without
|
|
239
|
+
// shell:true; resolve to the exact executable like the CLI runner
|
|
240
|
+
// does. ENOENT here would permanently demote the driver to
|
|
241
|
+
// per-turn-spawn CLI mode, which is exactly the "every turn is slow"
|
|
242
|
+
// failure mode.
|
|
243
|
+
if (process.platform === 'win32' && config.resolveInvocation) {
|
|
244
|
+
const invocation = config.resolveInvocation(command, args);
|
|
245
|
+
command = invocation.command;
|
|
246
|
+
args = invocation.args;
|
|
247
|
+
}
|
|
248
|
+
instance = spawnFn(command, args, {
|
|
249
|
+
cwd: config.cwd,
|
|
250
|
+
windowsHide: true,
|
|
251
|
+
env: { ...process.env, ...(config.env || {}) },
|
|
252
|
+
});
|
|
253
|
+
} catch (error) {
|
|
254
|
+
state = 'idle';
|
|
255
|
+
bootSettle = null;
|
|
256
|
+
reject(bridgeError(`failed to start bridge child: ${error.message}`, 503, 'BRIDGE_SPAWN_FAILED', { cause: error }));
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
wire(instance);
|
|
260
|
+
const markReady = () => {
|
|
261
|
+
if (!child || closed) return;
|
|
262
|
+
bootSettle = null;
|
|
263
|
+
generation += 1;
|
|
264
|
+
state = 'ready';
|
|
265
|
+
intentionalStop = false;
|
|
266
|
+
touchActivity();
|
|
267
|
+
try { onReady(generation); } catch (error) { log(`bridge onReady hook failed: ${error.message}`); }
|
|
268
|
+
resolve();
|
|
269
|
+
};
|
|
270
|
+
if (instance.pid === undefined) {
|
|
271
|
+
// Fake children (EventEmitter stand-ins) never emit 'spawn'.
|
|
272
|
+
setImmediate(markReady);
|
|
273
|
+
} else {
|
|
274
|
+
// Real children signal readiness through 'spawn'; a fallback timer
|
|
275
|
+
// covers exotic platforms that are slow to emit it.
|
|
276
|
+
if (typeof instance.once === 'function') instance.once('spawn', markReady);
|
|
277
|
+
const fallback = setTimeout(markReady, 500);
|
|
278
|
+
fallback.unref && fallback.unref();
|
|
279
|
+
}
|
|
280
|
+
});
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function start() {
|
|
284
|
+
if (closed) return Promise.reject(bridgeError('bridge is closed', 503, 'BRIDGE_CLOSED'));
|
|
285
|
+
if (state === 'ready' && bootPromise) return bootPromise;
|
|
286
|
+
if (state === 'crashed') return Promise.reject(bridgeError('bridge exhausted its restart budget', 503, 'BRIDGE_CRASHED'));
|
|
287
|
+
if (!bootPromise) bootPromise = launchChild();
|
|
288
|
+
return bootPromise;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function request(method, params, options = {}) {
|
|
292
|
+
if (closed) return Promise.reject(bridgeError('bridge is closed', 503, 'BRIDGE_CLOSED'));
|
|
293
|
+
if (state === 'crashed') return Promise.reject(bridgeError('bridge exhausted its restart budget', 503, 'BRIDGE_CRASHED'));
|
|
294
|
+
const timeoutMs = options.timeoutMs !== undefined ? options.timeoutMs : requestTimeoutMs;
|
|
295
|
+
return start().then(() => new Promise((resolve, reject) => {
|
|
296
|
+
const id = nextRequestId++;
|
|
297
|
+
const entry = { resolve, reject, timer: null };
|
|
298
|
+
if (timeoutMs > 0) {
|
|
299
|
+
entry.timer = setTimeout(() => {
|
|
300
|
+
pending.delete(id);
|
|
301
|
+
reject(bridgeError(`bridge request ${method} timed out after ${timeoutMs}ms`, 504, 'BRIDGE_TIMEOUT'));
|
|
302
|
+
}, timeoutMs);
|
|
303
|
+
entry.timer.unref && entry.timer.unref();
|
|
304
|
+
}
|
|
305
|
+
pending.set(id, entry);
|
|
306
|
+
try {
|
|
307
|
+
// The app-server dialect omits the jsonrpc field; we mirror that.
|
|
308
|
+
safeWrite(params !== undefined ? { id, method, params } : { id, method });
|
|
309
|
+
touchActivity();
|
|
310
|
+
} catch (error) {
|
|
311
|
+
pending.delete(id);
|
|
312
|
+
clearTimeout(entry.timer);
|
|
313
|
+
reject(error);
|
|
314
|
+
}
|
|
315
|
+
}));
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function notify(method, params) {
|
|
319
|
+
if (closed) throw bridgeError('bridge is closed', 503, 'BRIDGE_CLOSED');
|
|
320
|
+
return start().then(() => {
|
|
321
|
+
safeWrite(params !== undefined ? { method, params } : { method });
|
|
322
|
+
touchActivity();
|
|
323
|
+
});
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
function close() {
|
|
327
|
+
if (closed) return Promise.resolve();
|
|
328
|
+
closed = true;
|
|
329
|
+
state = 'closed';
|
|
330
|
+
clearTimeout(restartTimer);
|
|
331
|
+
clearTimeout(idleTimer);
|
|
332
|
+
intentionalStop = true;
|
|
333
|
+
killChild();
|
|
334
|
+
failPending(bridgeError('bridge closed', 503, 'BRIDGE_CLOSED'));
|
|
335
|
+
// A close() while still starting (or in a restart backoff) must settle
|
|
336
|
+
// the boot promise, otherwise every awaiting start() caller hangs.
|
|
337
|
+
if (bootSettle) {
|
|
338
|
+
const settle = bootSettle;
|
|
339
|
+
bootSettle = null;
|
|
340
|
+
settle.reject(bridgeError('bridge closed', 503, 'BRIDGE_CLOSED'));
|
|
341
|
+
}
|
|
342
|
+
return Promise.resolve();
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
return {
|
|
346
|
+
command: config.command,
|
|
347
|
+
args: config.args || [],
|
|
348
|
+
env: config.env || {},
|
|
349
|
+
get state() { return state; },
|
|
350
|
+
get pendingCount() { return pending.size; },
|
|
351
|
+
get restartsUsed() { return restartsUsed; },
|
|
352
|
+
get generation() { return generation; },
|
|
353
|
+
start,
|
|
354
|
+
request,
|
|
355
|
+
notify,
|
|
356
|
+
close,
|
|
357
|
+
};
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
function bridgeError(message, status, code, extra = {}) {
|
|
361
|
+
return Object.assign(new Error(message), { status, code, ...extra });
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
function rpcError(rpc) {
|
|
365
|
+
return Object.assign(new Error(String(rpc && rpc.message || 'bridge rpc error').slice(0, 1000)), {
|
|
366
|
+
status: 502,
|
|
367
|
+
code: 'BRIDGE_RPC_ERROR',
|
|
368
|
+
rpcCode: rpc && rpc.code,
|
|
369
|
+
});
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
module.exports = { createAppServerBridge };
|