@epic-cloudcontrol/daemon 0.2.1 → 0.3.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/dist/__tests__/config.test.d.ts +1 -0
- package/dist/__tests__/config.test.js +26 -0
- package/dist/__tests__/model-router.test.d.ts +1 -0
- package/dist/__tests__/model-router.test.js +59 -0
- package/dist/__tests__/profile.test.d.ts +1 -0
- package/dist/__tests__/profile.test.js +53 -0
- package/dist/__tests__/sandbox.test.d.ts +1 -0
- package/dist/__tests__/sandbox.test.js +78 -0
- package/dist/__tests__/version.test.d.ts +1 -0
- package/dist/__tests__/version.test.js +11 -0
- package/dist/browser.d.ts +7 -0
- package/dist/browser.js +56 -0
- package/dist/cli.js +17 -13
- package/dist/logger.d.ts +13 -0
- package/dist/logger.js +64 -0
- package/dist/mcp-server.js +8 -6
- package/dist/model-router.js +43 -7
- package/dist/models/gemini-api.d.ts +24 -0
- package/dist/models/gemini-api.js +134 -0
- package/dist/models/openai.d.ts +24 -0
- package/dist/models/openai.js +135 -0
- package/dist/multi-profile.js +9 -7
- package/dist/step-runner.d.ts +34 -0
- package/dist/step-runner.js +94 -0
- package/dist/task-executor.js +64 -27
- package/package.json +13 -2
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { loadConfig } from "../config.js";
|
|
3
|
+
describe("loadConfig", () => {
|
|
4
|
+
it("returns default values when no overrides", () => {
|
|
5
|
+
const config = loadConfig({});
|
|
6
|
+
expect(config.workerType).toBe("daemon");
|
|
7
|
+
expect(config.pollInterval).toBe(15000);
|
|
8
|
+
expect(config.heartbeatInterval).toBe(30000);
|
|
9
|
+
expect(config.capabilities).toContain("ai_execution");
|
|
10
|
+
});
|
|
11
|
+
it("applies overrides", () => {
|
|
12
|
+
const config = loadConfig({
|
|
13
|
+
apiUrl: "https://custom.example.com",
|
|
14
|
+
workerType: "cli",
|
|
15
|
+
pollInterval: 5000,
|
|
16
|
+
});
|
|
17
|
+
expect(config.apiUrl).toBe("https://custom.example.com");
|
|
18
|
+
expect(config.workerType).toBe("cli");
|
|
19
|
+
expect(config.pollInterval).toBe(5000);
|
|
20
|
+
});
|
|
21
|
+
it("capabilities default includes standard set", () => {
|
|
22
|
+
const config = loadConfig({});
|
|
23
|
+
expect(config.capabilities).toEqual(expect.arrayContaining(["browser", "filesystem", "shell", "ai_execution"]));
|
|
24
|
+
});
|
|
25
|
+
});
|
|
26
|
+
//# sourceMappingURL=config.test.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { ModelRouter } from "../model-router.js";
|
|
3
|
+
describe("ModelRouter", () => {
|
|
4
|
+
it("initializes and detects available models", () => {
|
|
5
|
+
const router = new ModelRouter();
|
|
6
|
+
const models = router.listModels();
|
|
7
|
+
expect(Array.isArray(models)).toBe(true);
|
|
8
|
+
});
|
|
9
|
+
it("has a default model string", () => {
|
|
10
|
+
const router = new ModelRouter();
|
|
11
|
+
const defaultModel = router.getDefault();
|
|
12
|
+
// Returns "none" when no models are available, or a real model name
|
|
13
|
+
expect(typeof defaultModel).toBe("string");
|
|
14
|
+
expect(defaultModel.length).toBeGreaterThan(0);
|
|
15
|
+
});
|
|
16
|
+
it("select returns a result or throws when no models available", () => {
|
|
17
|
+
const router = new ModelRouter();
|
|
18
|
+
const models = router.listModels();
|
|
19
|
+
if (models.length === 0) {
|
|
20
|
+
// No models available — select should throw
|
|
21
|
+
expect(() => router.select("auto")).toThrow("No AI models available");
|
|
22
|
+
}
|
|
23
|
+
else {
|
|
24
|
+
const result = router.select("auto");
|
|
25
|
+
expect(result.name).toBe(router.getDefault());
|
|
26
|
+
}
|
|
27
|
+
});
|
|
28
|
+
it("select with null behaves same as auto", () => {
|
|
29
|
+
const router = new ModelRouter();
|
|
30
|
+
const models = router.listModels();
|
|
31
|
+
if (models.length === 0) {
|
|
32
|
+
expect(() => router.select(null)).toThrow("No AI models available");
|
|
33
|
+
}
|
|
34
|
+
else {
|
|
35
|
+
const result = router.select(null);
|
|
36
|
+
expect(result.name).toBe(router.getDefault());
|
|
37
|
+
}
|
|
38
|
+
});
|
|
39
|
+
it("falls back to default for unknown hint when models exist", () => {
|
|
40
|
+
const router = new ModelRouter();
|
|
41
|
+
const models = router.listModels();
|
|
42
|
+
if (models.length === 0) {
|
|
43
|
+
expect(() => router.select("nonexistent-model-xyz")).toThrow("No AI models available");
|
|
44
|
+
}
|
|
45
|
+
else {
|
|
46
|
+
const result = router.select("nonexistent-model-xyz");
|
|
47
|
+
expect(result.name).toBeTruthy();
|
|
48
|
+
}
|
|
49
|
+
});
|
|
50
|
+
it("selects by trait if available", () => {
|
|
51
|
+
const router = new ModelRouter();
|
|
52
|
+
const models = router.listModels();
|
|
53
|
+
if (models.some((m) => m.traits.includes("code"))) {
|
|
54
|
+
const result = router.select("code");
|
|
55
|
+
expect(result.name).toBeTruthy();
|
|
56
|
+
}
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
//# sourceMappingURL=model-router.test.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { describe, it, expect, afterEach } from "vitest";
|
|
2
|
+
import { saveProfile, loadProfile, listProfiles, deleteProfile, profileExists, } from "../profile.js";
|
|
3
|
+
describe("profile", () => {
|
|
4
|
+
const TEST_PROFILE = "vitest-temp-profile";
|
|
5
|
+
afterEach(() => {
|
|
6
|
+
try {
|
|
7
|
+
deleteProfile(TEST_PROFILE);
|
|
8
|
+
}
|
|
9
|
+
catch { }
|
|
10
|
+
});
|
|
11
|
+
it("saves and loads a named profile", () => {
|
|
12
|
+
saveProfile({
|
|
13
|
+
apiUrl: "https://test.example.com",
|
|
14
|
+
apiKey: "cc_test123",
|
|
15
|
+
workerName: "test-worker",
|
|
16
|
+
teamName: "Test Team",
|
|
17
|
+
}, TEST_PROFILE);
|
|
18
|
+
const loaded = loadProfile(TEST_PROFILE);
|
|
19
|
+
expect(loaded).not.toBeNull();
|
|
20
|
+
expect(loaded.apiUrl).toBe("https://test.example.com");
|
|
21
|
+
expect(loaded.apiKey).toBe("cc_test123");
|
|
22
|
+
expect(loaded.workerName).toBe("test-worker");
|
|
23
|
+
expect(loaded.teamName).toBe("Test Team");
|
|
24
|
+
});
|
|
25
|
+
it("returns null for non-existent profile", () => {
|
|
26
|
+
const loaded = loadProfile("nonexistent-profile-xyz");
|
|
27
|
+
expect(loaded).toBeNull();
|
|
28
|
+
});
|
|
29
|
+
it("lists profiles including test profile", () => {
|
|
30
|
+
saveProfile({
|
|
31
|
+
apiUrl: "https://test.example.com",
|
|
32
|
+
apiKey: "cc_test123",
|
|
33
|
+
}, TEST_PROFILE);
|
|
34
|
+
const profiles = listProfiles();
|
|
35
|
+
const found = profiles.find((p) => p.name === TEST_PROFILE);
|
|
36
|
+
expect(found).toBeDefined();
|
|
37
|
+
expect(found.profile.apiUrl).toBe("https://test.example.com");
|
|
38
|
+
});
|
|
39
|
+
it("deletes a profile", () => {
|
|
40
|
+
saveProfile({
|
|
41
|
+
apiUrl: "https://test.example.com",
|
|
42
|
+
apiKey: "cc_test123",
|
|
43
|
+
}, TEST_PROFILE);
|
|
44
|
+
expect(profileExists(TEST_PROFILE)).toBe(true);
|
|
45
|
+
const deleted = deleteProfile(TEST_PROFILE);
|
|
46
|
+
expect(deleted).toBe(true);
|
|
47
|
+
expect(profileExists(TEST_PROFILE)).toBe(false);
|
|
48
|
+
});
|
|
49
|
+
it("profileExists returns false for non-existent", () => {
|
|
50
|
+
expect(profileExists("nonexistent-xyz")).toBe(false);
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
//# sourceMappingURL=profile.test.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { getDefaultSandboxConfig, buildSandboxPrompt, filterEnvironment, truncateOutput, createTaskTmpDir, cleanupTaskTmpDir, getRestrictedPath, } from "../sandbox.js";
|
|
3
|
+
import { existsSync } from "fs";
|
|
4
|
+
describe("sandbox", () => {
|
|
5
|
+
describe("getDefaultSandboxConfig", () => {
|
|
6
|
+
it("returns config with blocked commands", () => {
|
|
7
|
+
const config = getDefaultSandboxConfig();
|
|
8
|
+
expect(config.blockedCommands.length).toBeGreaterThan(0);
|
|
9
|
+
expect(config.blockedCommands).toContain("rm -rf /");
|
|
10
|
+
expect(config.blockedCommands).toContain("sudo");
|
|
11
|
+
});
|
|
12
|
+
it("returns config with blocked paths", () => {
|
|
13
|
+
const config = getDefaultSandboxConfig();
|
|
14
|
+
expect(config.blockedPaths.length).toBeGreaterThan(0);
|
|
15
|
+
expect(config.blockedPaths).toContain("/etc/shadow");
|
|
16
|
+
});
|
|
17
|
+
});
|
|
18
|
+
describe("buildSandboxPrompt", () => {
|
|
19
|
+
it("generates a non-empty prompt string", () => {
|
|
20
|
+
const config = getDefaultSandboxConfig();
|
|
21
|
+
const prompt = buildSandboxPrompt(config);
|
|
22
|
+
expect(prompt.length).toBeGreaterThan(100);
|
|
23
|
+
// The heading is "## Security Restrictions" (mixed case)
|
|
24
|
+
expect(prompt).toContain("Security Restrictions");
|
|
25
|
+
});
|
|
26
|
+
});
|
|
27
|
+
describe("filterEnvironment", () => {
|
|
28
|
+
it("filters to only allowed variables", () => {
|
|
29
|
+
const env = {
|
|
30
|
+
HOME: "/home/user",
|
|
31
|
+
PATH: "/usr/bin",
|
|
32
|
+
SECRET_KEY: "should-be-removed",
|
|
33
|
+
AWS_ACCESS_KEY: "should-be-removed",
|
|
34
|
+
};
|
|
35
|
+
const filtered = filterEnvironment(env, ["HOME", "PATH"]);
|
|
36
|
+
expect(filtered.HOME).toBe("/home/user");
|
|
37
|
+
expect(filtered.PATH).toBe("/usr/bin");
|
|
38
|
+
expect(filtered.SECRET_KEY).toBeUndefined();
|
|
39
|
+
expect(filtered.AWS_ACCESS_KEY).toBeUndefined();
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
describe("truncateOutput", () => {
|
|
43
|
+
it("returns short output unchanged", () => {
|
|
44
|
+
expect(truncateOutput("hello", 1000)).toBe("hello");
|
|
45
|
+
});
|
|
46
|
+
it("truncates long output", () => {
|
|
47
|
+
const long = "x".repeat(2000);
|
|
48
|
+
const result = truncateOutput(long, 100);
|
|
49
|
+
expect(result.length).toBeLessThan(500);
|
|
50
|
+
expect(result).toContain("OUTPUT TRUNCATED");
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
describe("createTaskTmpDir / cleanupTaskTmpDir", () => {
|
|
54
|
+
const testTaskId = "test-1234-abcd-5678";
|
|
55
|
+
it("creates and cleans up temp directory", () => {
|
|
56
|
+
const dir = createTaskTmpDir(testTaskId);
|
|
57
|
+
expect(existsSync(dir)).toBe(true);
|
|
58
|
+
// taskId.slice(0,8) = "test-123", so dir ends with "cloudcontrol-test-123"
|
|
59
|
+
expect(dir).toContain("cloudcontrol-test-123");
|
|
60
|
+
cleanupTaskTmpDir(testTaskId);
|
|
61
|
+
expect(existsSync(dir)).toBe(false);
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
describe("getRestrictedPath", () => {
|
|
65
|
+
it("includes standard system directories", () => {
|
|
66
|
+
const restricted = getRestrictedPath();
|
|
67
|
+
expect(restricted).toContain("/usr/bin");
|
|
68
|
+
expect(restricted).toContain("/bin");
|
|
69
|
+
});
|
|
70
|
+
it("does not include arbitrary non-system directories", () => {
|
|
71
|
+
const restricted = getRestrictedPath();
|
|
72
|
+
// Should not include unrelated user dirs like Documents, Downloads, etc.
|
|
73
|
+
expect(restricted).not.toContain("/Documents");
|
|
74
|
+
expect(restricted).not.toContain("/Downloads");
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
//# sourceMappingURL=sandbox.test.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { DAEMON_VERSION } from "../version.js";
|
|
3
|
+
describe("version", () => {
|
|
4
|
+
it("reads version from package.json", () => {
|
|
5
|
+
expect(DAEMON_VERSION).toMatch(/^\d+\.\d+\.\d+$/);
|
|
6
|
+
});
|
|
7
|
+
it("is not fallback version", () => {
|
|
8
|
+
expect(DAEMON_VERSION).not.toBe("0.0.0");
|
|
9
|
+
});
|
|
10
|
+
});
|
|
11
|
+
//# sourceMappingURL=version.test.js.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Optional Playwright browser automation for process steps.
|
|
3
|
+
* Only loaded when a browser step is encountered.
|
|
4
|
+
* Fails gracefully if Playwright is not installed.
|
|
5
|
+
*/
|
|
6
|
+
import type { ProcessStep } from "./step-runner.js";
|
|
7
|
+
export declare function executeBrowserStep(step: ProcessStep): Promise<string>;
|
package/dist/browser.js
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Optional Playwright browser automation for process steps.
|
|
3
|
+
* Only loaded when a browser step is encountered.
|
|
4
|
+
* Fails gracefully if Playwright is not installed.
|
|
5
|
+
*/
|
|
6
|
+
import { createLogger } from "./logger.js";
|
|
7
|
+
const log = createLogger("browser");
|
|
8
|
+
export async function executeBrowserStep(step) {
|
|
9
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
10
|
+
const { chromium } = await import("playwright");
|
|
11
|
+
const browser = await chromium.launch({ headless: true });
|
|
12
|
+
const page = await browser.newPage();
|
|
13
|
+
try {
|
|
14
|
+
switch (step.action) {
|
|
15
|
+
case "navigate": {
|
|
16
|
+
if (!step.target)
|
|
17
|
+
throw new Error("Navigate step requires a target URL");
|
|
18
|
+
log.info(`Navigating to ${step.target}`);
|
|
19
|
+
await page.goto(step.target, { timeout: 30000 });
|
|
20
|
+
const title = await page.title();
|
|
21
|
+
return `Navigated to ${step.target} — page title: "${title}"`;
|
|
22
|
+
}
|
|
23
|
+
case "click": {
|
|
24
|
+
if (!step.selector)
|
|
25
|
+
throw new Error("Click step requires a selector");
|
|
26
|
+
log.info(`Clicking ${step.selector}`);
|
|
27
|
+
await page.click(step.selector, { timeout: 10000 });
|
|
28
|
+
return `Clicked ${step.selector}`;
|
|
29
|
+
}
|
|
30
|
+
case "fill": {
|
|
31
|
+
if (!step.selector || !step.target)
|
|
32
|
+
throw new Error("Fill step requires selector and target (value)");
|
|
33
|
+
log.info(`Filling ${step.selector}`);
|
|
34
|
+
await page.fill(step.selector, step.target);
|
|
35
|
+
return `Filled ${step.selector} with value`;
|
|
36
|
+
}
|
|
37
|
+
case "screenshot": {
|
|
38
|
+
const path = `/tmp/cloudcontrol-screenshot-${Date.now()}.png`;
|
|
39
|
+
await page.screenshot({ path });
|
|
40
|
+
return `Screenshot saved to ${path}`;
|
|
41
|
+
}
|
|
42
|
+
default: {
|
|
43
|
+
if (step.target) {
|
|
44
|
+
await page.goto(step.target, { timeout: 30000 });
|
|
45
|
+
const title = await page.title();
|
|
46
|
+
return `Navigated to ${step.target} — page title: "${title}"`;
|
|
47
|
+
}
|
|
48
|
+
return `Browser step "${step.id}" completed (no specific action)`;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
finally {
|
|
53
|
+
await browser.close();
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
//# sourceMappingURL=browser.js.map
|
package/dist/cli.js
CHANGED
|
@@ -17,6 +17,8 @@ import { ModelRouter } from "./model-router.js";
|
|
|
17
17
|
import { fetchWithRetry } from "./retry.js";
|
|
18
18
|
import { DAEMON_VERSION } from "./version.js";
|
|
19
19
|
import { install, uninstall, serviceStatus, getLogPath, getErrorLogPath, detectPlatform } from "./service-manager.js";
|
|
20
|
+
import { createLogger } from "./logger.js";
|
|
21
|
+
const log = createLogger("daemon");
|
|
20
22
|
const program = new Command();
|
|
21
23
|
program
|
|
22
24
|
.name("cloudcontrol")
|
|
@@ -299,9 +301,11 @@ program
|
|
|
299
301
|
console.log(`Default: ${router.getDefault()}`);
|
|
300
302
|
console.log("");
|
|
301
303
|
console.log("Add models:");
|
|
302
|
-
console.log("
|
|
303
|
-
console.log("
|
|
304
|
+
console.log(" Anthropic: export ANTHROPIC_API_KEY=sk-ant-...");
|
|
305
|
+
console.log(" OpenAI: export OPENAI_API_KEY=sk-...");
|
|
306
|
+
console.log(" Google: export GOOGLE_API_KEY=...");
|
|
304
307
|
console.log(" Ollama: export CLOUDCONTROL_OLLAMA_MODELS=llama3,mistral");
|
|
308
|
+
console.log(" CLI tool: npm install -g @google/gemini-cli");
|
|
305
309
|
console.log(" Custom CLI: export CLOUDCONTROL_CLI_MODELS=\"mycli:mybinary:-p\"");
|
|
306
310
|
});
|
|
307
311
|
// ── status ────────────────────────────────────────────
|
|
@@ -377,7 +381,7 @@ program
|
|
|
377
381
|
model: opts.model,
|
|
378
382
|
});
|
|
379
383
|
const stop = () => {
|
|
380
|
-
|
|
384
|
+
log.info("Shutting down all workers...");
|
|
381
385
|
shutdown();
|
|
382
386
|
process.exit(0);
|
|
383
387
|
};
|
|
@@ -417,7 +421,7 @@ program
|
|
|
417
421
|
└─────────────────────────────────────┘
|
|
418
422
|
`);
|
|
419
423
|
// Register worker (with retry)
|
|
420
|
-
|
|
424
|
+
log.info("Registering worker...");
|
|
421
425
|
let worker;
|
|
422
426
|
try {
|
|
423
427
|
const registerRes = await fetchWithRetry(`${cfg.apiUrl}/api/workers`, {
|
|
@@ -444,22 +448,22 @@ program
|
|
|
444
448
|
maxRetries: 5,
|
|
445
449
|
baseDelayMs: 2000,
|
|
446
450
|
onRetry: (attempt, err) => {
|
|
447
|
-
|
|
451
|
+
log.info(`Registration retry ${attempt}: ${err.message}`);
|
|
448
452
|
},
|
|
449
453
|
});
|
|
450
454
|
({ worker } = await registerRes.json());
|
|
451
455
|
}
|
|
452
456
|
catch (err) {
|
|
453
|
-
|
|
457
|
+
log.error(`Failed to register after retries: ${err.message}`);
|
|
454
458
|
process.exit(1);
|
|
455
459
|
}
|
|
456
|
-
|
|
460
|
+
log.info(`Registered as worker ${worker.id}`);
|
|
457
461
|
executor.setWorkerId(worker.id);
|
|
458
462
|
// Track if currently executing (one task at a time)
|
|
459
463
|
let executing = false;
|
|
460
464
|
async function executeTask(taskId) {
|
|
461
465
|
if (executing) {
|
|
462
|
-
|
|
466
|
+
log.info(`Already executing, skipping task ${taskId.slice(0, 8)}`);
|
|
463
467
|
return;
|
|
464
468
|
}
|
|
465
469
|
executing = true;
|
|
@@ -468,11 +472,11 @@ program
|
|
|
468
472
|
const task = await executor.claimTask(taskId);
|
|
469
473
|
if (!task)
|
|
470
474
|
return;
|
|
471
|
-
|
|
475
|
+
log.info(`Claimed: ${task.title}`);
|
|
472
476
|
await executor.executeTask(task);
|
|
473
477
|
}
|
|
474
478
|
catch (err) {
|
|
475
|
-
|
|
479
|
+
log.error(`Execution error: ${err.message}`);
|
|
476
480
|
}
|
|
477
481
|
finally {
|
|
478
482
|
executing = false;
|
|
@@ -500,7 +504,7 @@ program
|
|
|
500
504
|
try {
|
|
501
505
|
const pending = await executor.pollTasks();
|
|
502
506
|
if (pending.length > 0) {
|
|
503
|
-
|
|
507
|
+
log.info(`Found ${pending.length} pending task(s)`);
|
|
504
508
|
await executeTask(pending[0].id);
|
|
505
509
|
}
|
|
506
510
|
}
|
|
@@ -512,14 +516,14 @@ program
|
|
|
512
516
|
const heartbeatTimer = setInterval(() => sendHeartbeat(), cfg.heartbeatInterval);
|
|
513
517
|
pollForTasks();
|
|
514
518
|
const shutdown = () => {
|
|
515
|
-
|
|
519
|
+
log.info("Shutting down...");
|
|
516
520
|
clearInterval(pollTimer);
|
|
517
521
|
clearInterval(heartbeatTimer);
|
|
518
522
|
process.exit(0);
|
|
519
523
|
};
|
|
520
524
|
process.on("SIGINT", shutdown);
|
|
521
525
|
process.on("SIGTERM", shutdown);
|
|
522
|
-
|
|
526
|
+
log.info(`Running. Polling every ${cfg.pollInterval / 1000}s. Press Ctrl+C to stop.`);
|
|
523
527
|
});
|
|
524
528
|
program.parse();
|
|
525
529
|
//# sourceMappingURL=cli.js.map
|
package/dist/logger.d.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal structured logger for the CloudControl daemon.
|
|
3
|
+
* - JSON output when NODE_ENV=production or CLOUDCONTROL_LOG_FORMAT=json
|
|
4
|
+
* - Human-readable colored output otherwise
|
|
5
|
+
* - No external dependencies
|
|
6
|
+
*/
|
|
7
|
+
export declare function createLogger(prefix: string): {
|
|
8
|
+
debug: (msg: string, meta?: Record<string, unknown>) => void;
|
|
9
|
+
info: (msg: string, meta?: Record<string, unknown>) => void;
|
|
10
|
+
warn: (msg: string, meta?: Record<string, unknown>) => void;
|
|
11
|
+
error: (msg: string, meta?: Record<string, unknown>) => void;
|
|
12
|
+
};
|
|
13
|
+
export type Logger = ReturnType<typeof createLogger>;
|
package/dist/logger.js
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal structured logger for the CloudControl daemon.
|
|
3
|
+
* - JSON output when NODE_ENV=production or CLOUDCONTROL_LOG_FORMAT=json
|
|
4
|
+
* - Human-readable colored output otherwise
|
|
5
|
+
* - No external dependencies
|
|
6
|
+
*/
|
|
7
|
+
const LEVEL_PRIORITY = {
|
|
8
|
+
debug: 0,
|
|
9
|
+
info: 1,
|
|
10
|
+
warn: 2,
|
|
11
|
+
error: 3,
|
|
12
|
+
};
|
|
13
|
+
const LEVEL_COLORS = {
|
|
14
|
+
debug: "\x1b[90m",
|
|
15
|
+
info: "\x1b[36m",
|
|
16
|
+
warn: "\x1b[33m",
|
|
17
|
+
error: "\x1b[31m",
|
|
18
|
+
};
|
|
19
|
+
const RESET = "\x1b[0m";
|
|
20
|
+
function isJsonMode() {
|
|
21
|
+
return process.env.NODE_ENV === "production" || process.env.CLOUDCONTROL_LOG_FORMAT === "json";
|
|
22
|
+
}
|
|
23
|
+
function getMinLevel() {
|
|
24
|
+
return process.env.CLOUDCONTROL_LOG_LEVEL || "info";
|
|
25
|
+
}
|
|
26
|
+
function shouldLog(level) {
|
|
27
|
+
return LEVEL_PRIORITY[level] >= LEVEL_PRIORITY[getMinLevel()];
|
|
28
|
+
}
|
|
29
|
+
function write(level, prefix, message, meta) {
|
|
30
|
+
if (!shouldLog(level))
|
|
31
|
+
return;
|
|
32
|
+
if (isJsonMode()) {
|
|
33
|
+
const entry = {
|
|
34
|
+
timestamp: new Date().toISOString(),
|
|
35
|
+
level,
|
|
36
|
+
component: prefix,
|
|
37
|
+
message,
|
|
38
|
+
};
|
|
39
|
+
if (meta)
|
|
40
|
+
entry.meta = meta;
|
|
41
|
+
process.stderr.write(JSON.stringify(entry) + "\n");
|
|
42
|
+
}
|
|
43
|
+
else {
|
|
44
|
+
const color = LEVEL_COLORS[level];
|
|
45
|
+
const ts = new Date().toISOString().slice(11, 19);
|
|
46
|
+
const metaStr = meta ? ` ${JSON.stringify(meta)}` : "";
|
|
47
|
+
const line = `${color}${ts}${RESET} [${prefix}] ${message}${metaStr}`;
|
|
48
|
+
if (level === "error") {
|
|
49
|
+
console.error(line);
|
|
50
|
+
}
|
|
51
|
+
else {
|
|
52
|
+
console.log(line);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
export function createLogger(prefix) {
|
|
57
|
+
return {
|
|
58
|
+
debug: (msg, meta) => write("debug", prefix, msg, meta),
|
|
59
|
+
info: (msg, meta) => write("info", prefix, msg, meta),
|
|
60
|
+
warn: (msg, meta) => write("warn", prefix, msg, meta),
|
|
61
|
+
error: (msg, meta) => write("error", prefix, msg, meta),
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
//# sourceMappingURL=logger.js.map
|
package/dist/mcp-server.js
CHANGED
|
@@ -29,6 +29,8 @@ import { z } from "zod";
|
|
|
29
29
|
import os from "os";
|
|
30
30
|
import { DAEMON_VERSION } from "./version.js";
|
|
31
31
|
import { loadProfile, listProfiles } from "./profile.js";
|
|
32
|
+
import { createLogger } from "./logger.js";
|
|
33
|
+
const log = createLogger("mcp");
|
|
32
34
|
const profileName = process.env.CLOUDCONTROL_PROFILE;
|
|
33
35
|
const profile = profileName ? loadProfile(profileName) : null;
|
|
34
36
|
let API_URL = process.env.CLOUDCONTROL_API_URL || profile?.apiUrl || "http://localhost:3000";
|
|
@@ -498,25 +500,25 @@ server.resource("cloudcontrol://health", "cloudcontrol://health", async () => {
|
|
|
498
500
|
async function main() {
|
|
499
501
|
try {
|
|
500
502
|
workerId = await registerWorker();
|
|
501
|
-
|
|
503
|
+
log.info(`Registered as worker ${workerId} (${WORKER_NAME})`);
|
|
502
504
|
}
|
|
503
505
|
catch (err) {
|
|
504
|
-
|
|
506
|
+
log.error(`Worker registration failed: ${err.message}`);
|
|
505
507
|
}
|
|
506
508
|
// Auto-discover new companies on startup (non-blocking)
|
|
507
509
|
refreshProfiles().then((lines) => {
|
|
508
510
|
const newLines = lines.filter((l) => l.startsWith("✓"));
|
|
509
511
|
if (newLines.length > 0) {
|
|
510
|
-
|
|
512
|
+
log.info(`Auto-refresh: discovered ${newLines.length} new company(s)`);
|
|
511
513
|
for (const l of newLines)
|
|
512
|
-
|
|
514
|
+
log.info(l);
|
|
513
515
|
}
|
|
514
516
|
}).catch(() => { });
|
|
515
517
|
const heartbeatTimer = setInterval(sendHeartbeat, 30_000);
|
|
516
518
|
const transport = new StdioServerTransport();
|
|
517
519
|
await server.connect(transport);
|
|
518
|
-
|
|
520
|
+
log.info("CloudControl MCP server running");
|
|
519
521
|
process.on("SIGINT", () => { clearInterval(heartbeatTimer); process.exit(0); });
|
|
520
522
|
}
|
|
521
|
-
main().catch((err) => {
|
|
523
|
+
main().catch((err) => { log.error("Fatal:", { error: String(err) }); process.exit(1); });
|
|
522
524
|
//# sourceMappingURL=mcp-server.js.map
|
package/dist/model-router.js
CHANGED
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
import { ClaudeAdapter } from "./models/claude.js";
|
|
2
|
+
import { OpenAIAdapter } from "./models/openai.js";
|
|
3
|
+
import { GeminiAPIAdapter } from "./models/gemini-api.js";
|
|
2
4
|
import { OllamaAdapter } from "./models/ollama.js";
|
|
3
5
|
import { CliAdapter, KNOWN_CLIS, parseCliModels } from "./models/cli-adapter.js";
|
|
4
6
|
import { execSync } from "child_process";
|
|
7
|
+
import { createLogger } from "./logger.js";
|
|
8
|
+
const log = createLogger("router");
|
|
5
9
|
function isCommandInstalled(command) {
|
|
6
10
|
try {
|
|
7
11
|
execSync(`which ${command}`, { stdio: "ignore" });
|
|
@@ -45,7 +49,39 @@ export class ModelRouter {
|
|
|
45
49
|
available: true,
|
|
46
50
|
});
|
|
47
51
|
}
|
|
48
|
-
// 2.
|
|
52
|
+
// 2. OpenAI API models (require OPENAI_API_KEY)
|
|
53
|
+
const openaiKey = process.env.OPENAI_API_KEY;
|
|
54
|
+
if (openaiKey && openaiKey.length > 0) {
|
|
55
|
+
this.models.push({
|
|
56
|
+
name: "gpt-4o",
|
|
57
|
+
adapter: new OpenAIAdapter("gpt-4o", openaiKey),
|
|
58
|
+
traits: ["smartest", "code", "vision"],
|
|
59
|
+
available: true,
|
|
60
|
+
});
|
|
61
|
+
this.models.push({
|
|
62
|
+
name: "gpt-4o-mini",
|
|
63
|
+
adapter: new OpenAIAdapter("gpt-4o-mini", openaiKey),
|
|
64
|
+
traits: ["cheapest", "fastest"],
|
|
65
|
+
available: true,
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
// 3. Google Gemini API models (require GOOGLE_API_KEY)
|
|
69
|
+
const googleKey = process.env.GOOGLE_API_KEY;
|
|
70
|
+
if (googleKey && googleKey.length > 0) {
|
|
71
|
+
this.models.push({
|
|
72
|
+
name: "gemini-pro",
|
|
73
|
+
adapter: new GeminiAPIAdapter("gemini-2.5-pro", googleKey),
|
|
74
|
+
traits: ["smartest", "code", "vision"],
|
|
75
|
+
available: true,
|
|
76
|
+
});
|
|
77
|
+
this.models.push({
|
|
78
|
+
name: "gemini-flash",
|
|
79
|
+
adapter: new GeminiAPIAdapter("gemini-2.5-flash", googleKey),
|
|
80
|
+
traits: ["cheapest", "fastest"],
|
|
81
|
+
available: true,
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
// 5. Auto-detect installed CLIs from KNOWN_CLIS
|
|
49
85
|
const cliTraits = {
|
|
50
86
|
"claude-code": ["smartest", "code", "local"],
|
|
51
87
|
gemini: ["smartest", "code", "local"],
|
|
@@ -63,7 +99,7 @@ export class ModelRouter {
|
|
|
63
99
|
});
|
|
64
100
|
}
|
|
65
101
|
}
|
|
66
|
-
//
|
|
102
|
+
// 6. Custom CLI models from env var
|
|
67
103
|
// Format: "name:command:args;name2:command2:args"
|
|
68
104
|
// Or just names from KNOWN_CLIS: "gemini;aider"
|
|
69
105
|
const customClis = process.env.CLOUDCONTROL_CLI_MODELS;
|
|
@@ -81,7 +117,7 @@ export class ModelRouter {
|
|
|
81
117
|
});
|
|
82
118
|
}
|
|
83
119
|
}
|
|
84
|
-
//
|
|
120
|
+
// 7. Ollama models
|
|
85
121
|
const ollamaUrl = process.env.OLLAMA_URL || "http://localhost:11434";
|
|
86
122
|
const ollamaModels = process.env.CLOUDCONTROL_OLLAMA_MODELS?.split(",") || [];
|
|
87
123
|
for (const model of ollamaModels) {
|
|
@@ -99,8 +135,8 @@ export class ModelRouter {
|
|
|
99
135
|
const localModel = this.models.find((m) => m.traits.includes("local"));
|
|
100
136
|
const cheapApi = this.models.find((m) => m.name === "claude-haiku");
|
|
101
137
|
this.defaultModel = localModel?.name || cheapApi?.name || this.models[0]?.name || "";
|
|
102
|
-
|
|
103
|
-
|
|
138
|
+
log.info(`Available models: ${this.models.map((m) => m.name).join(", ") || "none"}`);
|
|
139
|
+
log.info(`Default model: ${this.defaultModel || "none"}`);
|
|
104
140
|
}
|
|
105
141
|
/**
|
|
106
142
|
* Select the best model for a task based on its modelHint.
|
|
@@ -120,7 +156,7 @@ export class ModelRouter {
|
|
|
120
156
|
if (candidates.length > 0) {
|
|
121
157
|
return { adapter: candidates[0].adapter, name: candidates[0].name };
|
|
122
158
|
}
|
|
123
|
-
|
|
159
|
+
log.info(`No model matches hint "${modelHint}", using default "${this.defaultModel}"`);
|
|
124
160
|
return this.getByName(this.defaultModel);
|
|
125
161
|
}
|
|
126
162
|
getByName(name) {
|
|
@@ -129,7 +165,7 @@ export class ModelRouter {
|
|
|
129
165
|
return { adapter: model.adapter, name: model.name };
|
|
130
166
|
const fallback = this.models.find((m) => m.available);
|
|
131
167
|
if (fallback) {
|
|
132
|
-
|
|
168
|
+
log.info(`Default "${name}" not available, falling back to "${fallback.name}"`);
|
|
133
169
|
return { adapter: fallback.adapter, name: fallback.name };
|
|
134
170
|
}
|
|
135
171
|
throw new Error("No AI models available. Install a CLI (claude, gemini, etc.), set ANTHROPIC_API_KEY, or configure Ollama.");
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Google Gemini API Adapter
|
|
3
|
+
*
|
|
4
|
+
* Calls the Google Generative AI API directly (no SDK dependency).
|
|
5
|
+
* Supports Gemini 2.5 Pro, Flash, and any model on the API.
|
|
6
|
+
*
|
|
7
|
+
* Requires: GOOGLE_API_KEY environment variable.
|
|
8
|
+
*/
|
|
9
|
+
import { type SandboxConfig } from "../sandbox.js";
|
|
10
|
+
import type { ExecutionResult } from "./claude.js";
|
|
11
|
+
export declare class GeminiAPIAdapter {
|
|
12
|
+
private model;
|
|
13
|
+
private apiKey;
|
|
14
|
+
private sandboxConfig;
|
|
15
|
+
constructor(model?: string, apiKey?: string, sandboxConfig?: SandboxConfig);
|
|
16
|
+
execute(task: {
|
|
17
|
+
title: string;
|
|
18
|
+
description?: string | null;
|
|
19
|
+
taskType?: string | null;
|
|
20
|
+
context?: Record<string, unknown> | null;
|
|
21
|
+
processHint?: string | null;
|
|
22
|
+
humanContext?: string | null;
|
|
23
|
+
}): Promise<ExecutionResult>;
|
|
24
|
+
}
|