tycono-server 0.1.0-beta.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/bin/cli.js +35 -0
- package/bin/server.ts +160 -0
- package/package.json +50 -0
- package/src/api/package.json +31 -0
- package/src/api/src/create-app.ts +90 -0
- package/src/api/src/create-server.ts +251 -0
- package/src/api/src/engine/agent-loop.ts +738 -0
- package/src/api/src/engine/authority-validator.ts +149 -0
- package/src/api/src/engine/context-assembler.ts +912 -0
- package/src/api/src/engine/index.ts +27 -0
- package/src/api/src/engine/knowledge-gate.ts +365 -0
- package/src/api/src/engine/llm-adapter.ts +304 -0
- package/src/api/src/engine/org-tree.ts +270 -0
- package/src/api/src/engine/role-lifecycle.ts +369 -0
- package/src/api/src/engine/runners/claude-cli.ts +796 -0
- package/src/api/src/engine/runners/direct-api.ts +66 -0
- package/src/api/src/engine/runners/index.ts +30 -0
- package/src/api/src/engine/runners/types.ts +95 -0
- package/src/api/src/engine/skill-template.ts +134 -0
- package/src/api/src/engine/tools/definitions.ts +201 -0
- package/src/api/src/engine/tools/executor.ts +611 -0
- package/src/api/src/routes/active-sessions.ts +134 -0
- package/src/api/src/routes/coins.ts +153 -0
- package/src/api/src/routes/company.ts +57 -0
- package/src/api/src/routes/cost.ts +141 -0
- package/src/api/src/routes/engine.ts +220 -0
- package/src/api/src/routes/execute.ts +1075 -0
- package/src/api/src/routes/git.ts +211 -0
- package/src/api/src/routes/knowledge.ts +378 -0
- package/src/api/src/routes/operations.ts +309 -0
- package/src/api/src/routes/preferences.ts +63 -0
- package/src/api/src/routes/presets.ts +123 -0
- package/src/api/src/routes/projects.ts +82 -0
- package/src/api/src/routes/quests.ts +41 -0
- package/src/api/src/routes/roles.ts +112 -0
- package/src/api/src/routes/save.ts +152 -0
- package/src/api/src/routes/sessions.ts +288 -0
- package/src/api/src/routes/setup.ts +437 -0
- package/src/api/src/routes/skills.ts +357 -0
- package/src/api/src/routes/speech.ts +959 -0
- package/src/api/src/routes/supervision.ts +136 -0
- package/src/api/src/routes/sync.ts +165 -0
- package/src/api/src/server.ts +59 -0
- package/src/api/src/services/activity-stream.ts +184 -0
- package/src/api/src/services/activity-tracker.ts +115 -0
- package/src/api/src/services/claude-md-manager.ts +94 -0
- package/src/api/src/services/company-config.ts +115 -0
- package/src/api/src/services/database.ts +77 -0
- package/src/api/src/services/digest-engine.ts +313 -0
- package/src/api/src/services/execution-manager.ts +1036 -0
- package/src/api/src/services/file-reader.ts +77 -0
- package/src/api/src/services/git-save.ts +614 -0
- package/src/api/src/services/job-manager.ts +16 -0
- package/src/api/src/services/knowledge-importer.ts +466 -0
- package/src/api/src/services/markdown-parser.ts +173 -0
- package/src/api/src/services/port-registry.ts +222 -0
- package/src/api/src/services/preferences.ts +150 -0
- package/src/api/src/services/preset-loader.ts +149 -0
- package/src/api/src/services/pricing.ts +34 -0
- package/src/api/src/services/scaffold.ts +546 -0
- package/src/api/src/services/session-store.ts +340 -0
- package/src/api/src/services/supervisor-heartbeat.ts +897 -0
- package/src/api/src/services/team-recommender.ts +382 -0
- package/src/api/src/services/token-ledger.ts +127 -0
- package/src/api/src/services/wave-messages.ts +194 -0
- package/src/api/src/services/wave-multiplexer.ts +356 -0
- package/src/api/src/services/wave-tracker.ts +359 -0
- package/src/api/src/utils/role-level.ts +31 -0
- package/src/core/scaffolder.ts +620 -0
- package/src/shared/types.ts +224 -0
- package/templates/CLAUDE.md.tmpl +239 -0
- package/templates/company.md.tmpl +17 -0
- package/templates/gitignore.tmpl +28 -0
- package/templates/roles.md.tmpl +8 -0
- package/templates/skills/_manifest.json +23 -0
- package/templates/skills/agent-browser/SKILL.md +159 -0
- package/templates/skills/agent-browser/meta.json +19 -0
- package/templates/skills/akb-linter/SKILL.md +125 -0
- package/templates/skills/akb-linter/meta.json +12 -0
- package/templates/skills/knowledge-gate/SKILL.md +120 -0
- package/templates/skills/knowledge-gate/meta.json +12 -0
- package/templates/teams/agency.json +58 -0
- package/templates/teams/research.json +58 -0
- package/templates/teams/startup.json +58 -0
package/bin/cli.js
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
4
|
+
import { dirname, join } from 'node:path';
|
|
5
|
+
import { createRequire } from 'node:module';
|
|
6
|
+
import { execFileSync } from 'node:child_process';
|
|
7
|
+
|
|
8
|
+
// Auto-increase heap if not already set
|
|
9
|
+
if (!process.env.__TYCONO_HEAP_SET && !process.execArgv.some(a => a.includes('max-old-space-size'))) {
|
|
10
|
+
process.env.__TYCONO_HEAP_SET = '1';
|
|
11
|
+
try {
|
|
12
|
+
execFileSync(process.execPath, [
|
|
13
|
+
'--max-old-space-size=4096',
|
|
14
|
+
...process.execArgv,
|
|
15
|
+
fileURLToPath(import.meta.url),
|
|
16
|
+
...process.argv.slice(2),
|
|
17
|
+
], { stdio: 'inherit', env: process.env });
|
|
18
|
+
} catch (e) {
|
|
19
|
+
process.exit(e.status ?? 1);
|
|
20
|
+
}
|
|
21
|
+
process.exit(0);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
25
|
+
const __dirname = dirname(__filename);
|
|
26
|
+
|
|
27
|
+
// Resolve tsx
|
|
28
|
+
const require = createRequire(import.meta.url);
|
|
29
|
+
const tsxApiPath = pathToFileURL(require.resolve('tsx/esm/api')).href;
|
|
30
|
+
const tsx = await import(tsxApiPath);
|
|
31
|
+
tsx.register();
|
|
32
|
+
|
|
33
|
+
const entryPath = pathToFileURL(join(__dirname, 'server.ts')).href;
|
|
34
|
+
const { main } = await import(entryPath);
|
|
35
|
+
await main(process.argv.slice(2));
|
package/bin/server.ts
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import net from 'node:net';
|
|
4
|
+
import { execSync } from 'node:child_process';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
6
|
+
|
|
7
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
8
|
+
const __dirname = path.dirname(__filename);
|
|
9
|
+
|
|
10
|
+
const VERSION = JSON.parse(
|
|
11
|
+
fs.readFileSync(path.resolve(__dirname, '..', 'package.json'), 'utf-8')
|
|
12
|
+
).version;
|
|
13
|
+
|
|
14
|
+
function printHelp(): void {
|
|
15
|
+
console.log(`
|
|
16
|
+
tycono-server v${VERSION}
|
|
17
|
+
|
|
18
|
+
AI team orchestration server. Headless by default.
|
|
19
|
+
|
|
20
|
+
Usage:
|
|
21
|
+
tycono-server [path] Start API server (default: current directory)
|
|
22
|
+
tycono-server --help Show this help
|
|
23
|
+
tycono-server --version Show version
|
|
24
|
+
|
|
25
|
+
Examples:
|
|
26
|
+
tycono-server Start in current directory
|
|
27
|
+
tycono-server ./my-company Start with company directory
|
|
28
|
+
PORT=3001 tycono-server Use specific port
|
|
29
|
+
|
|
30
|
+
Environment:
|
|
31
|
+
PORT Server port (default: auto-detect free port)
|
|
32
|
+
COMPANY_ROOT Company directory (default: current directory)
|
|
33
|
+
`);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function findFreePort(): Promise<number> {
|
|
37
|
+
return new Promise((resolve, reject) => {
|
|
38
|
+
const server = net.createServer();
|
|
39
|
+
server.listen(0, () => {
|
|
40
|
+
const addr = server.address();
|
|
41
|
+
if (addr && typeof addr === 'object') {
|
|
42
|
+
const port = addr.port;
|
|
43
|
+
server.close(() => resolve(port));
|
|
44
|
+
} else {
|
|
45
|
+
reject(new Error('Could not find free port'));
|
|
46
|
+
}
|
|
47
|
+
});
|
|
48
|
+
server.on('error', reject);
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function detectEngine(): string {
|
|
53
|
+
try {
|
|
54
|
+
execSync('claude --version', { stdio: 'pipe' });
|
|
55
|
+
return 'claude-cli';
|
|
56
|
+
} catch {}
|
|
57
|
+
if (process.env.ANTHROPIC_API_KEY) return 'direct-api';
|
|
58
|
+
return 'none';
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export async function main(args: string[]): Promise<void> {
|
|
62
|
+
const command = args[0];
|
|
63
|
+
|
|
64
|
+
if (command === '--help' || command === '-h') {
|
|
65
|
+
printHelp();
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (command === '--version' || command === '-v') {
|
|
70
|
+
console.log(VERSION);
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Load .env
|
|
75
|
+
const dotenvPath = path.resolve(process.cwd(), '.env');
|
|
76
|
+
if (fs.existsSync(dotenvPath)) {
|
|
77
|
+
const { config } = await import('dotenv');
|
|
78
|
+
config({ path: dotenvPath });
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Company root
|
|
82
|
+
if (command && !command.startsWith('-')) {
|
|
83
|
+
process.env.COMPANY_ROOT = path.resolve(command);
|
|
84
|
+
}
|
|
85
|
+
if (!process.env.COMPANY_ROOT) {
|
|
86
|
+
process.env.COMPANY_ROOT = process.cwd();
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Check for knowledge/ subdirectory pattern
|
|
90
|
+
const knowledgeDir = path.join(process.env.COMPANY_ROOT, 'knowledge');
|
|
91
|
+
if (fs.existsSync(path.join(knowledgeDir, 'CLAUDE.md'))) {
|
|
92
|
+
// Company root is the parent of knowledge/
|
|
93
|
+
} else if (!fs.existsSync(path.join(process.env.COMPANY_ROOT, 'CLAUDE.md'))) {
|
|
94
|
+
// Scan one level deep
|
|
95
|
+
try {
|
|
96
|
+
const entries = fs.readdirSync(process.env.COMPANY_ROOT, { withFileTypes: true });
|
|
97
|
+
for (const entry of entries) {
|
|
98
|
+
if (!entry.isDirectory() || entry.name.startsWith('.')) continue;
|
|
99
|
+
if (fs.existsSync(path.join(process.env.COMPANY_ROOT, entry.name, '.tycono', 'config.json'))) {
|
|
100
|
+
process.env.COMPANY_ROOT = path.join(process.env.COMPANY_ROOT, entry.name);
|
|
101
|
+
break;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
} catch {}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
process.env.NODE_ENV = 'production';
|
|
108
|
+
|
|
109
|
+
// Detect engine
|
|
110
|
+
const engine = detectEngine();
|
|
111
|
+
process.env.EXECUTION_ENGINE = engine;
|
|
112
|
+
|
|
113
|
+
// Port
|
|
114
|
+
const port = process.env.PORT ? Number(process.env.PORT) : await findFreePort();
|
|
115
|
+
process.env.PORT = String(port);
|
|
116
|
+
|
|
117
|
+
// Company name
|
|
118
|
+
let companyName = 'My Company';
|
|
119
|
+
try {
|
|
120
|
+
const claudeMdPath = path.join(process.env.COMPANY_ROOT, 'CLAUDE.md');
|
|
121
|
+
if (fs.existsSync(claudeMdPath)) {
|
|
122
|
+
const content = fs.readFileSync(claudeMdPath, 'utf-8');
|
|
123
|
+
const match = content.match(/^#\s+(.+)/m);
|
|
124
|
+
if (match) companyName = match[1].trim();
|
|
125
|
+
}
|
|
126
|
+
} catch {}
|
|
127
|
+
|
|
128
|
+
const url = `http://localhost:${port}`;
|
|
129
|
+
|
|
130
|
+
console.log(` tycono-server v${VERSION}`);
|
|
131
|
+
console.log(` API: ${url}`);
|
|
132
|
+
console.log(` Company: ${companyName}`);
|
|
133
|
+
console.log(` Engine: ${engine}`);
|
|
134
|
+
console.log(` PID: ${process.pid}`);
|
|
135
|
+
|
|
136
|
+
// Write headless.json for discovery
|
|
137
|
+
const headlessInfo = { port, pid: process.pid, url, startedAt: new Date().toISOString() };
|
|
138
|
+
const headlessPath = path.join(process.env.COMPANY_ROOT, '.tycono', 'headless.json');
|
|
139
|
+
try {
|
|
140
|
+
fs.mkdirSync(path.dirname(headlessPath), { recursive: true });
|
|
141
|
+
fs.writeFileSync(headlessPath, JSON.stringify(headlessInfo, null, 2));
|
|
142
|
+
} catch {}
|
|
143
|
+
|
|
144
|
+
// Start server
|
|
145
|
+
const { createHttpServer } = await import('../src/api/src/create-server.js');
|
|
146
|
+
const server = createHttpServer();
|
|
147
|
+
|
|
148
|
+
const host = process.env.HOST || '0.0.0.0';
|
|
149
|
+
server.listen(port, host);
|
|
150
|
+
|
|
151
|
+
// Graceful shutdown
|
|
152
|
+
const shutdown = () => {
|
|
153
|
+
console.log('\n Shutting down...');
|
|
154
|
+
try { fs.unlinkSync(headlessPath); } catch {}
|
|
155
|
+
server.close(() => process.exit(0));
|
|
156
|
+
setTimeout(() => process.exit(1), 5000);
|
|
157
|
+
};
|
|
158
|
+
process.on('SIGINT', shutdown);
|
|
159
|
+
process.on('SIGTERM', shutdown);
|
|
160
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "tycono-server",
|
|
3
|
+
"version": "0.1.0-beta.0",
|
|
4
|
+
"description": "Tycono AI team orchestration server. Dispatch, supervise, and observe AI agents working as a team.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"tycono-server": "./bin/cli.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"bin/",
|
|
11
|
+
"src/",
|
|
12
|
+
"templates/"
|
|
13
|
+
],
|
|
14
|
+
"engines": {
|
|
15
|
+
"node": ">=18"
|
|
16
|
+
},
|
|
17
|
+
"scripts": {
|
|
18
|
+
"start": "node bin/cli.js"
|
|
19
|
+
},
|
|
20
|
+
"dependencies": {
|
|
21
|
+
"@anthropic-ai/sdk": "^0.78.0",
|
|
22
|
+
"better-sqlite3": "^12.8.0",
|
|
23
|
+
"cors": "^2.8.5",
|
|
24
|
+
"dotenv": "^16.4.7",
|
|
25
|
+
"express": "^5.0.1",
|
|
26
|
+
"glob": "^13.0.6",
|
|
27
|
+
"gray-matter": "^4.0.3",
|
|
28
|
+
"marked": "^15.0.6",
|
|
29
|
+
"tsx": "^4.19.3",
|
|
30
|
+
"yaml": "^2.7.0"
|
|
31
|
+
},
|
|
32
|
+
"devDependencies": {
|
|
33
|
+
"@types/better-sqlite3": "^7.6.13",
|
|
34
|
+
"@types/cors": "^2.8.17",
|
|
35
|
+
"@types/express": "^5.0.0",
|
|
36
|
+
"@types/node": "^22.13.4",
|
|
37
|
+
"typescript": "^5.7.3"
|
|
38
|
+
},
|
|
39
|
+
"keywords": [
|
|
40
|
+
"ai", "tycono", "agent", "multi-agent", "orchestration",
|
|
41
|
+
"dispatch", "supervision", "agentic", "team"
|
|
42
|
+
],
|
|
43
|
+
"author": "Tycono <hello@tycono.ai>",
|
|
44
|
+
"license": "MIT",
|
|
45
|
+
"repository": {
|
|
46
|
+
"type": "git",
|
|
47
|
+
"url": "https://github.com/seongsu-kang/tycono.git",
|
|
48
|
+
"directory": "packages/server"
|
|
49
|
+
}
|
|
50
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "tycono-api",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"private": true,
|
|
5
|
+
"type": "module",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"dev": "tsx watch src/server.ts",
|
|
8
|
+
"start": "tsx src/server.ts",
|
|
9
|
+
"test": "vitest run tests/smoke.test.ts",
|
|
10
|
+
"test:watch": "vitest tests/smoke.test.ts"
|
|
11
|
+
},
|
|
12
|
+
"dependencies": {
|
|
13
|
+
"@anthropic-ai/sdk": "^0.78.0",
|
|
14
|
+
"cors": "^2.8.5",
|
|
15
|
+
"express": "^5.0.1",
|
|
16
|
+
"glob": "^11.0.1",
|
|
17
|
+
"gray-matter": "^4.0.3",
|
|
18
|
+
"marked": "^15.0.6",
|
|
19
|
+
"yaml": "^2.7.0"
|
|
20
|
+
},
|
|
21
|
+
"devDependencies": {
|
|
22
|
+
"@types/cors": "^2.8.17",
|
|
23
|
+
"@types/express": "^5.0.0",
|
|
24
|
+
"@types/node": "^22.13.4",
|
|
25
|
+
"@types/supertest": "^7.2.0",
|
|
26
|
+
"supertest": "^7.2.2",
|
|
27
|
+
"tsx": "^4.19.3",
|
|
28
|
+
"typescript": "^5.7.3",
|
|
29
|
+
"vitest": "^4.0.18"
|
|
30
|
+
}
|
|
31
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* create-app.ts — Express 앱 팩토리
|
|
3
|
+
*
|
|
4
|
+
* server.ts에서 분리하여 테스트에서 재사용 가능하게 한다.
|
|
5
|
+
* supertest 등에서 import 후 테스트용 앱으로 활용.
|
|
6
|
+
*/
|
|
7
|
+
import express from 'express';
|
|
8
|
+
import cors from 'cors';
|
|
9
|
+
import fs from 'node:fs';
|
|
10
|
+
import path from 'node:path';
|
|
11
|
+
import { COMPANY_ROOT } from './services/file-reader.js';
|
|
12
|
+
import { rolesRouter } from './routes/roles.js';
|
|
13
|
+
import { projectsRouter } from './routes/projects.js';
|
|
14
|
+
import { operationsRouter } from './routes/operations.js';
|
|
15
|
+
import { companyRouter } from './routes/company.js';
|
|
16
|
+
import { engineRouter } from './routes/engine.js';
|
|
17
|
+
import { sessionsRouter } from './routes/sessions.js';
|
|
18
|
+
import { setupRouter } from './routes/setup.js';
|
|
19
|
+
import { skillsRouter } from './routes/skills.js';
|
|
20
|
+
import { presetsRouter } from './routes/presets.js';
|
|
21
|
+
|
|
22
|
+
export function createApp() {
|
|
23
|
+
const app = express();
|
|
24
|
+
|
|
25
|
+
const corsOrigin = process.env.NODE_ENV === 'production' ? true : /^http:\/\/localhost:\d+$/;
|
|
26
|
+
app.use(cors({ origin: corsOrigin }));
|
|
27
|
+
app.use(express.json());
|
|
28
|
+
|
|
29
|
+
// Suppress favicon 404
|
|
30
|
+
app.get('/favicon.ico', (_req, res) => res.status(204).end());
|
|
31
|
+
|
|
32
|
+
// Setup / onboarding (always available)
|
|
33
|
+
app.use('/api/setup', setupRouter);
|
|
34
|
+
|
|
35
|
+
// Status — frontend checks this to decide wizard vs office
|
|
36
|
+
app.get('/api/status', (_req, res) => {
|
|
37
|
+
const claudeMdPath = path.join(COMPANY_ROOT, 'CLAUDE.md');
|
|
38
|
+
const initialized = fs.existsSync(claudeMdPath);
|
|
39
|
+
let companyName: string | null = null;
|
|
40
|
+
if (initialized) {
|
|
41
|
+
try {
|
|
42
|
+
const content = fs.readFileSync(claudeMdPath, 'utf-8');
|
|
43
|
+
const match = content.match(/^#\s+(.+)/m);
|
|
44
|
+
if (match) companyName = match[1].trim();
|
|
45
|
+
} catch { /* ignore */ }
|
|
46
|
+
}
|
|
47
|
+
res.json({ initialized, companyName, engine: process.env.EXECUTION_ENGINE || 'none', companyRoot: COMPANY_ROOT });
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
app.use('/api/roles', rolesRouter);
|
|
51
|
+
app.use('/api/projects', projectsRouter);
|
|
52
|
+
app.use('/api/operations', operationsRouter);
|
|
53
|
+
app.use('/api/company', companyRouter);
|
|
54
|
+
app.use('/api/engine', engineRouter);
|
|
55
|
+
app.use('/api/sessions', sessionsRouter);
|
|
56
|
+
app.use('/api/skills', skillsRouter);
|
|
57
|
+
app.use('/api/presets', presetsRouter);
|
|
58
|
+
|
|
59
|
+
app.get('/api/health', (_req, res) => {
|
|
60
|
+
res.json({ status: 'ok', companyRoot: COMPANY_ROOT });
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
// Debug: memory stats for leak detection
|
|
64
|
+
app.get('/api/debug/memory', (_req, res) => {
|
|
65
|
+
const mem = process.memoryUsage();
|
|
66
|
+
// Import dynamically to avoid circular deps
|
|
67
|
+
import('./services/execution-manager.js').then(({ executionManager }) => {
|
|
68
|
+
import('./services/session-store.js').then(({ listSessions }) => {
|
|
69
|
+
res.json({
|
|
70
|
+
heap: { used: Math.round(mem.heapUsed / 1024 / 1024), total: Math.round(mem.heapTotal / 1024 / 1024) },
|
|
71
|
+
rss: Math.round(mem.rss / 1024 / 1024),
|
|
72
|
+
execManager: executionManager.getMemoryStats(),
|
|
73
|
+
sessions: listSessions().length,
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
}).catch(() => {
|
|
77
|
+
res.json({
|
|
78
|
+
heap: { used: Math.round(mem.heapUsed / 1024 / 1024), total: Math.round(mem.heapTotal / 1024 / 1024) },
|
|
79
|
+
rss: Math.round(mem.rss / 1024 / 1024),
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
app.use((err: Error, _req: express.Request, res: express.Response, _next: express.NextFunction) => {
|
|
85
|
+
const status = err.name === 'FileNotFoundError' ? 404 : 500;
|
|
86
|
+
res.status(status).json({ error: err.message });
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
return app;
|
|
90
|
+
}
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* create-server.ts — HTTP 서버 팩토리
|
|
3
|
+
*
|
|
4
|
+
* server.ts에서 서버 생성 로직을 분리하여 테스트에서 재사용 가능하게 한다.
|
|
5
|
+
* 반환된 서버 인스턴스는 listen()을 직접 호출하지 않으므로,
|
|
6
|
+
* 호출자가 포트를 지정해서 기동할 수 있다.
|
|
7
|
+
*/
|
|
8
|
+
import fs from 'node:fs';
|
|
9
|
+
import http from 'node:http';
|
|
10
|
+
import path from 'node:path';
|
|
11
|
+
import { fileURLToPath } from 'node:url';
|
|
12
|
+
import express from 'express';
|
|
13
|
+
import cors from 'cors';
|
|
14
|
+
import { COMPANY_ROOT } from './services/file-reader.js';
|
|
15
|
+
import { rolesRouter } from './routes/roles.js';
|
|
16
|
+
import { projectsRouter } from './routes/projects.js';
|
|
17
|
+
import { operationsRouter } from './routes/operations.js';
|
|
18
|
+
import { companyRouter } from './routes/company.js';
|
|
19
|
+
import { handleExecRequest } from './routes/execute.js';
|
|
20
|
+
import { engineRouter } from './routes/engine.js';
|
|
21
|
+
import { sessionsRouter } from './routes/sessions.js';
|
|
22
|
+
import { setupRouter } from './routes/setup.js';
|
|
23
|
+
// activity-tracker removed — executionManager resets on restart
|
|
24
|
+
import { knowledgeRouter } from './routes/knowledge.js';
|
|
25
|
+
import { preferencesRouter } from './routes/preferences.js';
|
|
26
|
+
import { saveRouter } from './routes/save.js';
|
|
27
|
+
import { speechRouter } from './routes/speech.js';
|
|
28
|
+
import { costRouter } from './routes/cost.js';
|
|
29
|
+
import { syncRouter } from './routes/sync.js';
|
|
30
|
+
import { gitRouter } from './routes/git.js';
|
|
31
|
+
import { skillsRouter } from './routes/skills.js';
|
|
32
|
+
import { questsRouter } from './routes/quests.js';
|
|
33
|
+
import { coinsRouter } from './routes/coins.js';
|
|
34
|
+
import { activeSessionsRouter } from './routes/active-sessions.js';
|
|
35
|
+
import { supervisionRouter } from './routes/supervision.js';
|
|
36
|
+
import { presetsRouter } from './routes/presets.js';
|
|
37
|
+
import { importKnowledge } from './services/knowledge-importer.js';
|
|
38
|
+
import { AnthropicProvider, type LLMProvider } from './engine/llm-adapter.js';
|
|
39
|
+
import { readConfig } from './services/company-config.js';
|
|
40
|
+
import { ensureClaudeMd } from './services/claude-md-manager.js';
|
|
41
|
+
|
|
42
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
43
|
+
const __dirname = path.dirname(__filename);
|
|
44
|
+
const isProd = process.env.NODE_ENV === 'production';
|
|
45
|
+
const corsOrigin = isProd ? true : /^http:\/\/localhost:\d+$/;
|
|
46
|
+
|
|
47
|
+
/* ─── Raw HTTP handler for import-knowledge SSE ─── */
|
|
48
|
+
|
|
49
|
+
function handleImportKnowledge(req: http.IncomingMessage, res: http.ServerResponse): void {
|
|
50
|
+
let data = '';
|
|
51
|
+
req.on('data', (chunk) => { data += chunk; });
|
|
52
|
+
req.on('end', () => {
|
|
53
|
+
let body: Record<string, unknown>;
|
|
54
|
+
try { body = JSON.parse(data); } catch { body = {}; }
|
|
55
|
+
|
|
56
|
+
const importPaths = body.paths as string[] | undefined;
|
|
57
|
+
if (!importPaths || !Array.isArray(importPaths) || importPaths.length === 0) {
|
|
58
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
59
|
+
res.end(JSON.stringify({ error: 'paths array is required' }));
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const root = (body.companyRoot as string) || COMPANY_ROOT;
|
|
64
|
+
|
|
65
|
+
res.writeHead(200, {
|
|
66
|
+
'Content-Type': 'text/event-stream',
|
|
67
|
+
'Cache-Control': 'no-cache',
|
|
68
|
+
'Connection': 'keep-alive',
|
|
69
|
+
'X-Accel-Buffering': 'no',
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
const sendSSE = (event: string, eventData: unknown) => {
|
|
73
|
+
res.write(`event: ${event}\ndata: ${JSON.stringify(eventData)}\n\n`);
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
// Build LLMProvider from env if available
|
|
77
|
+
const llm: LLMProvider | undefined = process.env.ANTHROPIC_API_KEY
|
|
78
|
+
? new AnthropicProvider({ model: 'claude-haiku-4-5-20251001' })
|
|
79
|
+
: undefined;
|
|
80
|
+
|
|
81
|
+
importKnowledge(importPaths, root, {
|
|
82
|
+
onScanning: (scanPath, fileCount) => sendSSE('scanning', { path: scanPath, fileCount }),
|
|
83
|
+
onProcessing: (file, index, total) => sendSSE('processing', { file, index, total }),
|
|
84
|
+
onCreated: (filePath, title, summary) => sendSSE('created', { path: filePath, title, summary }),
|
|
85
|
+
onSkipped: (file, reason) => sendSSE('skipped', { file, reason }),
|
|
86
|
+
onDone: (stats) => { sendSSE('done', stats); res.end(); },
|
|
87
|
+
onError: (message) => { sendSSE('error', { message }); res.end(); },
|
|
88
|
+
}, llm).catch((err) => {
|
|
89
|
+
sendSSE('error', { message: err instanceof Error ? err.message : 'Import failed' });
|
|
90
|
+
res.end();
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function createHttpServer(): http.Server {
|
|
96
|
+
// Only cleanup/ensure if a company is already initialized (avoid creating dirs in CWD)
|
|
97
|
+
if (COMPANY_ROOT && fs.existsSync(path.join(COMPANY_ROOT, 'knowledge', 'CLAUDE.md'))) {
|
|
98
|
+
ensureClaudeMd(COMPANY_ROOT);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const app = createExpressApp();
|
|
102
|
+
|
|
103
|
+
const server = http.createServer((req, res) => {
|
|
104
|
+
const url = req.url ?? '';
|
|
105
|
+
const method = req.method ?? '';
|
|
106
|
+
|
|
107
|
+
// GET /api/waves/active — restore active waves after refresh
|
|
108
|
+
if (url === '/api/waves/active' && method === 'GET') {
|
|
109
|
+
setExecCors(req, res);
|
|
110
|
+
handleExecRequest(req, res);
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// SSE multiplexed wave stream (GET /api/waves/:waveId/stream)
|
|
115
|
+
if (url.match(/^\/api\/waves\/[^/]+\/stream/) && method === 'GET') {
|
|
116
|
+
setExecCors(req, res);
|
|
117
|
+
handleExecRequest(req, res);
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// D-014: POST /api/sessions/:id/message — delegate to execute handler for SSE streaming
|
|
122
|
+
const sessionMessageMatch = url.match(/^\/api\/sessions\/([^/]+)\/message$/);
|
|
123
|
+
if (sessionMessageMatch && method === 'POST') {
|
|
124
|
+
setExecCors(req, res);
|
|
125
|
+
// Rewrite URL to legacy format for handleExecRequest
|
|
126
|
+
req.url = `/api/exec/session/${sessionMessageMatch[1]}/message`;
|
|
127
|
+
handleExecRequest(req, res);
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// SSE 엔드포인트: Express 우회하여 raw HTTP로 처리
|
|
132
|
+
// BUG-008: /api/waves/:waveId/directive and /api/waves/:waveId/question POST도 포함
|
|
133
|
+
if ((url.startsWith('/api/exec/') || url.startsWith('/api/jobs') || url.startsWith('/api/waves/') || url === '/api/waves/save' || url === '/api/setup/import-knowledge') && method === 'POST') {
|
|
134
|
+
setExecCors(req, res);
|
|
135
|
+
if (url === '/api/setup/import-knowledge') {
|
|
136
|
+
handleImportKnowledge(req, res);
|
|
137
|
+
} else {
|
|
138
|
+
handleExecRequest(req, res);
|
|
139
|
+
}
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// CORS preflight for exec/jobs/sessions endpoints
|
|
144
|
+
if ((url.startsWith('/api/exec/') || url.startsWith('/api/jobs') || url.match(/^\/api\/sessions\/[^/]+\/message$/)) && method === 'OPTIONS') {
|
|
145
|
+
setExecCors(req, res);
|
|
146
|
+
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, DELETE, OPTIONS');
|
|
147
|
+
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
|
|
148
|
+
res.writeHead(204);
|
|
149
|
+
res.end();
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// Non-SSE exec/jobs endpoints (GET, DELETE)
|
|
154
|
+
if ((url.startsWith('/api/exec/') || url.startsWith('/api/jobs')) && (method === 'GET' || method === 'DELETE')) {
|
|
155
|
+
setExecCors(req, res);
|
|
156
|
+
handleExecRequest(req, res);
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// 나머지는 Express 처리
|
|
161
|
+
(app as (req: http.IncomingMessage, res: http.ServerResponse) => void)(req, res);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
server.timeout = 0;
|
|
165
|
+
server.requestTimeout = 0;
|
|
166
|
+
server.headersTimeout = 0;
|
|
167
|
+
|
|
168
|
+
return server;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export function createExpressApp(): express.Application {
|
|
172
|
+
const app = express();
|
|
173
|
+
|
|
174
|
+
app.use(cors({ origin: corsOrigin }));
|
|
175
|
+
app.use(express.json());
|
|
176
|
+
|
|
177
|
+
// Setup / onboarding
|
|
178
|
+
app.use('/api/setup', setupRouter);
|
|
179
|
+
|
|
180
|
+
// Status — frontend checks this to decide wizard vs office
|
|
181
|
+
app.get('/api/status', (_req, res) => {
|
|
182
|
+
const config = readConfig(COMPANY_ROOT);
|
|
183
|
+
const tyconoDir = path.join(COMPANY_ROOT, '.tycono', 'config.json');
|
|
184
|
+
const initialized = fs.existsSync(tyconoDir);
|
|
185
|
+
let companyName: string | null = null;
|
|
186
|
+
if (initialized) {
|
|
187
|
+
try {
|
|
188
|
+
// Read company name from knowledge/company.md (user-owned data)
|
|
189
|
+
const companyMdPath = path.join(COMPANY_ROOT, 'knowledge', 'company.md');
|
|
190
|
+
const content = fs.readFileSync(companyMdPath, 'utf-8');
|
|
191
|
+
const match = content.match(/^#\s+(.+)/m);
|
|
192
|
+
if (match) companyName = match[1].trim();
|
|
193
|
+
} catch { /* ignore */ }
|
|
194
|
+
}
|
|
195
|
+
res.json({ initialized, companyName, engine: config.engine || process.env.EXECUTION_ENGINE || 'none', companyRoot: COMPANY_ROOT, codeRoot: config.codeRoot || null, hasApiKey: !!process.env.ANTHROPIC_API_KEY });
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
app.use('/api/roles', rolesRouter);
|
|
199
|
+
app.use('/api/projects', projectsRouter);
|
|
200
|
+
app.use('/api/operations', operationsRouter);
|
|
201
|
+
app.use('/api/company', companyRouter);
|
|
202
|
+
app.use('/api/engine', engineRouter);
|
|
203
|
+
app.use('/api/sessions', sessionsRouter);
|
|
204
|
+
app.use('/api/knowledge', knowledgeRouter);
|
|
205
|
+
app.use('/api/preferences', preferencesRouter);
|
|
206
|
+
app.use('/api/speech', speechRouter);
|
|
207
|
+
app.use('/api/save', saveRouter);
|
|
208
|
+
app.use('/api/cost', costRouter);
|
|
209
|
+
app.use('/api/sync', syncRouter);
|
|
210
|
+
app.use('/api/git', gitRouter);
|
|
211
|
+
app.use('/api/skills', skillsRouter);
|
|
212
|
+
app.use('/api/quests', questsRouter);
|
|
213
|
+
app.use('/api/coins', coinsRouter);
|
|
214
|
+
app.use('/api/active-sessions', activeSessionsRouter);
|
|
215
|
+
app.use('/api/supervision', supervisionRouter);
|
|
216
|
+
app.use('/api/presets', presetsRouter);
|
|
217
|
+
|
|
218
|
+
app.get('/api/health', (_req, res) => {
|
|
219
|
+
res.json({ status: 'ok', companyRoot: COMPANY_ROOT });
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
// Production: serve web build as static files (SPA fallback)
|
|
223
|
+
if (isProd) {
|
|
224
|
+
const distPath = path.resolve(__dirname, '../../web/dist');
|
|
225
|
+
app.use(express.static(distPath));
|
|
226
|
+
app.use((_req, res) => {
|
|
227
|
+
res.sendFile(path.join(distPath, 'index.html'));
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
app.use((err: Error, req: express.Request, res: express.Response, _next: express.NextFunction) => {
|
|
232
|
+
const status = err.name === 'FileNotFoundError' ? 404 : 500;
|
|
233
|
+
// Log server errors via console.error (redirected to log file in TUI mode)
|
|
234
|
+
// 404 errors are expected (e.g. fresh install, no company.md) — skip
|
|
235
|
+
if (status >= 500) {
|
|
236
|
+
console.error(`[ERROR] ${req.method} ${req.url} — ${err.message}`);
|
|
237
|
+
}
|
|
238
|
+
res.status(status).json({ error: err.message });
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
return app;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function setExecCors(req: http.IncomingMessage, res: http.ServerResponse): void {
|
|
245
|
+
const origin = req.headers.origin;
|
|
246
|
+
if (!origin) return;
|
|
247
|
+
if (isProd || /^http:\/\/localhost:\d+$/.test(origin)) {
|
|
248
|
+
res.setHeader('Access-Control-Allow-Origin', origin);
|
|
249
|
+
res.setHeader('Access-Control-Allow-Credentials', 'true');
|
|
250
|
+
}
|
|
251
|
+
}
|