@jcjeon/integration-cli 0.2.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.
Files changed (264) hide show
  1. package/.gitignore +23 -0
  2. package/.npmignore +21 -0
  3. package/.prettierignore +6 -0
  4. package/.prettierrc +26 -0
  5. package/AGENTS.md +10 -0
  6. package/CLAUDE.md +10 -0
  7. package/README.md +384 -0
  8. package/apps/server/README.md +294 -0
  9. package/apps/server/eslint.config.mjs +20 -0
  10. package/apps/server/nest-cli.json +8 -0
  11. package/apps/server/package.json +89 -0
  12. package/apps/server/scripts/postinstall.js +53 -0
  13. package/apps/server/src/__mocks__/glob.js +6 -0
  14. package/apps/server/src/__mocks__/uuid.js +5 -0
  15. package/apps/server/src/app.controller.spec.ts +24 -0
  16. package/apps/server/src/app.controller.ts +13 -0
  17. package/apps/server/src/app.module.ts +18 -0
  18. package/apps/server/src/app.service.ts +8 -0
  19. package/apps/server/src/common/ji-paths.ts +41 -0
  20. package/apps/server/src/database/database.module.ts +27 -0
  21. package/apps/server/src/database/entities/agent-changelog.entity.ts +39 -0
  22. package/apps/server/src/database/entities/agent-session.entity.ts +29 -0
  23. package/apps/server/src/database/entities/conversation.entity.ts +41 -0
  24. package/apps/server/src/database/entities/session.entity.ts +16 -0
  25. package/apps/server/src/database/entities/task-agent-run.entity.ts +40 -0
  26. package/apps/server/src/database/entities/task-agent.entity.ts +42 -0
  27. package/apps/server/src/database/entities/task-requirement.entity.ts +27 -0
  28. package/apps/server/src/database/entities/task-run.entity.ts +41 -0
  29. package/apps/server/src/database/entities/task.entity.ts +44 -0
  30. package/apps/server/src/main.ts +65 -0
  31. package/apps/server/src/modules/agents/agent-model-settings.spec.ts +80 -0
  32. package/apps/server/src/modules/agents/agents.module.ts +11 -0
  33. package/apps/server/src/modules/agents/claude/claude-auth.manager.ts +83 -0
  34. package/apps/server/src/modules/agents/claude/claude-pty.manager.ts +380 -0
  35. package/apps/server/src/modules/agents/claude/claude.controller.ts +85 -0
  36. package/apps/server/src/modules/agents/claude/claude.gateway.ts +158 -0
  37. package/apps/server/src/modules/agents/claude/claude.module.ts +18 -0
  38. package/apps/server/src/modules/agents/claude/claude.service.ts +67 -0
  39. package/apps/server/src/modules/agents/claude/dto/create-session.dto.ts +24 -0
  40. package/apps/server/src/modules/agents/claude/dto/resize-session.dto.ts +13 -0
  41. package/apps/server/src/modules/agents/claude/dto/send-input.dto.ts +9 -0
  42. package/apps/server/src/modules/agents/claude/interfaces/claude-session.interface.ts +26 -0
  43. package/apps/server/src/modules/agents/claude/interfaces/pty-event.interface.ts +10 -0
  44. package/apps/server/src/modules/agents/claude/interfaces/stream-event.interface.ts +61 -0
  45. package/apps/server/src/modules/agents/codex/codex-auth.manager.ts +107 -0
  46. package/apps/server/src/modules/agents/codex/codex-session.manager.ts +357 -0
  47. package/apps/server/src/modules/agents/codex/codex.controller.ts +64 -0
  48. package/apps/server/src/modules/agents/codex/codex.gateway.ts +97 -0
  49. package/apps/server/src/modules/agents/codex/codex.module.ts +17 -0
  50. package/apps/server/src/modules/agents/codex/dto/configure-auth.dto.ts +7 -0
  51. package/apps/server/src/modules/agents/gemini/dto/configure-auth.dto.ts +15 -0
  52. package/apps/server/src/modules/agents/gemini/dto/create-session.dto.ts +9 -0
  53. package/apps/server/src/modules/agents/gemini/dto/send-input.dto.ts +9 -0
  54. package/apps/server/src/modules/agents/gemini/gemini-auth.manager.ts +157 -0
  55. package/apps/server/src/modules/agents/gemini/gemini-session.manager.ts +287 -0
  56. package/apps/server/src/modules/agents/gemini/gemini.controller.ts +93 -0
  57. package/apps/server/src/modules/agents/gemini/gemini.gateway.ts +149 -0
  58. package/apps/server/src/modules/agents/gemini/gemini.module.ts +17 -0
  59. package/apps/server/src/modules/agents/gemini/interfaces/gemini-session.interface.ts +18 -0
  60. package/apps/server/src/modules/agents/gemini/interfaces/stream-event.interface.ts +14 -0
  61. package/apps/server/src/modules/agents/session-termination.spec.ts +103 -0
  62. package/apps/server/src/modules/changelog/changelog.controller.ts +20 -0
  63. package/apps/server/src/modules/changelog/changelog.module.ts +14 -0
  64. package/apps/server/src/modules/changelog/changelog.service.spec.ts +531 -0
  65. package/apps/server/src/modules/changelog/changelog.service.ts +690 -0
  66. package/apps/server/src/modules/conversations/conversation.controller.spec.ts +106 -0
  67. package/apps/server/src/modules/conversations/conversation.controller.ts +60 -0
  68. package/apps/server/src/modules/conversations/conversation.module.ts +14 -0
  69. package/apps/server/src/modules/conversations/conversation.service.spec.ts +176 -0
  70. package/apps/server/src/modules/conversations/conversation.service.ts +54 -0
  71. package/apps/server/src/modules/conversations/dto/create-conversation.dto.ts +37 -0
  72. package/apps/server/src/modules/conversations/enums/conversation.enum.ts +13 -0
  73. package/apps/server/src/modules/fs/fs.controller.ts +29 -0
  74. package/apps/server/src/modules/fs/fs.module.ts +8 -0
  75. package/apps/server/src/modules/harness/dto/save-harness.dto.ts +9 -0
  76. package/apps/server/src/modules/harness/harness.controller.spec.ts +95 -0
  77. package/apps/server/src/modules/harness/harness.controller.ts +35 -0
  78. package/apps/server/src/modules/harness/harness.module.ts +11 -0
  79. package/apps/server/src/modules/harness/harness.service.spec.ts +217 -0
  80. package/apps/server/src/modules/harness/harness.service.ts +112 -0
  81. package/apps/server/src/modules/sessions/session.controller.spec.ts +68 -0
  82. package/apps/server/src/modules/sessions/session.controller.ts +43 -0
  83. package/apps/server/src/modules/sessions/session.module.ts +14 -0
  84. package/apps/server/src/modules/sessions/session.service.spec.ts +106 -0
  85. package/apps/server/src/modules/sessions/session.service.ts +35 -0
  86. package/apps/server/src/modules/tasks/dto/create-task.dto.ts +54 -0
  87. package/apps/server/src/modules/tasks/dto/execute-task.dto.ts +22 -0
  88. package/apps/server/src/modules/tasks/dto/merge-file.dto.ts +7 -0
  89. package/apps/server/src/modules/tasks/dto/rerun-task.dto.ts +14 -0
  90. package/apps/server/src/modules/tasks/dto/update-task.dto.ts +55 -0
  91. package/apps/server/src/modules/tasks/task-execution.service.ts +978 -0
  92. package/apps/server/src/modules/tasks/task.gateway.ts +140 -0
  93. package/apps/server/src/modules/tasks/tasks.controller.spec.ts +210 -0
  94. package/apps/server/src/modules/tasks/tasks.controller.ts +139 -0
  95. package/apps/server/src/modules/tasks/tasks.module.ts +30 -0
  96. package/apps/server/src/modules/tasks/tasks.service.spec.ts +552 -0
  97. package/apps/server/src/modules/tasks/tasks.service.ts +333 -0
  98. package/apps/server/test/app.e2e-spec.ts +28 -0
  99. package/apps/server/test/jest-e2e.json +9 -0
  100. package/apps/server/tsconfig.build.json +4 -0
  101. package/apps/server/tsconfig.json +13 -0
  102. package/apps/web/AGENTS.md +7 -0
  103. package/apps/web/CLAUDE.md +1 -0
  104. package/apps/web/README.md +36 -0
  105. package/apps/web/eslint.config.mjs +21 -0
  106. package/apps/web/next-env.d.ts +6 -0
  107. package/apps/web/next.config.ts +7 -0
  108. package/apps/web/package.json +49 -0
  109. package/apps/web/postcss.config.mjs +7 -0
  110. package/apps/web/public/file.svg +1 -0
  111. package/apps/web/public/globe.svg +1 -0
  112. package/apps/web/public/next.svg +1 -0
  113. package/apps/web/public/vercel.svg +1 -0
  114. package/apps/web/public/window.svg +1 -0
  115. package/apps/web/src/app/claude/page.tsx +5 -0
  116. package/apps/web/src/app/codex/page.tsx +126 -0
  117. package/apps/web/src/app/favicon.ico +0 -0
  118. package/apps/web/src/app/gemini/page.tsx +130 -0
  119. package/apps/web/src/app/globals.css +149 -0
  120. package/apps/web/src/app/layout.tsx +40 -0
  121. package/apps/web/src/app/login/page.tsx +67 -0
  122. package/apps/web/src/app/page.tsx +497 -0
  123. package/apps/web/src/app/task/[id]/page.tsx +11 -0
  124. package/apps/web/src/app/test/page.tsx +298 -0
  125. package/apps/web/src/components/ui/Modal.tsx +78 -0
  126. package/apps/web/src/components/ui/WorkingDirPicker.tsx +195 -0
  127. package/apps/web/src/components/ui/__tests__/Modal.test.tsx +68 -0
  128. package/apps/web/src/features/auth/api/__tests__/auth.api.test.ts +83 -0
  129. package/apps/web/src/features/auth/api/auth.api.ts +81 -0
  130. package/apps/web/src/features/auth/hooks/__tests__/useClaudeAuth.test.ts +166 -0
  131. package/apps/web/src/features/auth/hooks/__tests__/useCodexAuth.test.ts +127 -0
  132. package/apps/web/src/features/auth/hooks/__tests__/useGeminiAuth.test.ts +120 -0
  133. package/apps/web/src/features/auth/hooks/useClaudeAuth.ts +88 -0
  134. package/apps/web/src/features/auth/hooks/useCodexAuth.ts +149 -0
  135. package/apps/web/src/features/auth/hooks/useGeminiAuth.ts +125 -0
  136. package/apps/web/src/features/auth/ui/CodexLoginPanel.tsx +302 -0
  137. package/apps/web/src/features/auth/ui/GeminiLoginPanel.tsx +316 -0
  138. package/apps/web/src/features/auth/ui/LoginForm.tsx +190 -0
  139. package/apps/web/src/features/auth/ui/LoginPanel.tsx +114 -0
  140. package/apps/web/src/features/auth/ui/__tests__/LoginPanel.test.tsx +105 -0
  141. package/apps/web/src/features/chat/api/__tests__/sessions.api.test.ts +187 -0
  142. package/apps/web/src/features/chat/api/sessions.api.ts +161 -0
  143. package/apps/web/src/features/chat/container/ClaudePageContainer.tsx +152 -0
  144. package/apps/web/src/features/chat/hooks/__tests__/useCodexSessions.test.ts +131 -0
  145. package/apps/web/src/features/chat/hooks/__tests__/useGeminiSessions.test.ts +130 -0
  146. package/apps/web/src/features/chat/hooks/useAgentModelSettings.ts +54 -0
  147. package/apps/web/src/features/chat/hooks/useClaudeSessions.ts +323 -0
  148. package/apps/web/src/features/chat/hooks/useCodexSessions.ts +275 -0
  149. package/apps/web/src/features/chat/hooks/useGeminiSessions.ts +255 -0
  150. package/apps/web/src/features/chat/hooks/useSessionCommand.ts +66 -0
  151. package/apps/web/src/features/chat/hooks/useSessionRename.ts +61 -0
  152. package/apps/web/src/features/chat/hooks/useSessionWorkingDirectories.ts +34 -0
  153. package/apps/web/src/features/chat/hooks/useUnifiedSessions.ts +156 -0
  154. package/apps/web/src/features/chat/lib/agentModelOptions.ts +72 -0
  155. package/apps/web/src/features/chat/ui/AgentModelPicker.tsx +134 -0
  156. package/apps/web/src/features/chat/ui/AgentSelectModal.tsx +236 -0
  157. package/apps/web/src/features/chat/ui/ChatInput.tsx +162 -0
  158. package/apps/web/src/features/chat/ui/ChatMessage.tsx +204 -0
  159. package/apps/web/src/features/chat/ui/ChatWorkspace.tsx +207 -0
  160. package/apps/web/src/features/chat/ui/CheckingSkeleton.tsx +44 -0
  161. package/apps/web/src/features/chat/ui/ClaudeLoginView.tsx +44 -0
  162. package/apps/web/src/features/chat/ui/PermissionCard.tsx +37 -0
  163. package/apps/web/src/features/chat/ui/SessionSidebar.tsx +280 -0
  164. package/apps/web/src/features/chat/ui/__tests__/AgentSelectModal.test.tsx +58 -0
  165. package/apps/web/src/features/chat/ui/__tests__/ChatInput.test.tsx +134 -0
  166. package/apps/web/src/features/chat/ui/__tests__/ChatMessage.test.tsx +106 -0
  167. package/apps/web/src/features/chat/ui/__tests__/ChatWorkspace.test.tsx +66 -0
  168. package/apps/web/src/features/diff/ui/DiffFileRow.tsx +73 -0
  169. package/apps/web/src/features/diff/ui/DiffHunk.tsx +61 -0
  170. package/apps/web/src/features/diff/ui/FileChangeBadge.tsx +23 -0
  171. package/apps/web/src/features/diff/ui/__tests__/DiffFileRow.test.tsx +40 -0
  172. package/apps/web/src/features/diff/ui/__tests__/DiffHunk.test.tsx +24 -0
  173. package/apps/web/src/features/diff/ui/__tests__/FileChangeBadge.test.tsx +16 -0
  174. package/apps/web/src/features/fs/api/fs.api.ts +14 -0
  175. package/apps/web/src/features/fs/hooks/useDirBrowser.ts +50 -0
  176. package/apps/web/src/features/harness/api/__tests__/harness.api.test.ts +73 -0
  177. package/apps/web/src/features/harness/api/harness.api.ts +46 -0
  178. package/apps/web/src/features/harness/hooks/__tests__/useHarness.test.ts +65 -0
  179. package/apps/web/src/features/harness/hooks/useHarness.ts +66 -0
  180. package/apps/web/src/features/harness/ui/HarnessModal.tsx +171 -0
  181. package/apps/web/src/features/harness/ui/__tests__/HarnessModal.test.tsx +46 -0
  182. package/apps/web/src/features/status/ui/AgentStatusModal.tsx +267 -0
  183. package/apps/web/src/features/status/ui/__tests__/AgentStatusModal.test.tsx +71 -0
  184. package/apps/web/src/features/tasks/api/__tests__/changelog.api.test.ts +89 -0
  185. package/apps/web/src/features/tasks/api/__tests__/tasks.api.test.ts +282 -0
  186. package/apps/web/src/features/tasks/api/changelog.api.ts +52 -0
  187. package/apps/web/src/features/tasks/api/tasks.api.ts +175 -0
  188. package/apps/web/src/features/tasks/container/TaskDetailPageContainer.tsx +69 -0
  189. package/apps/web/src/features/tasks/hooks/__tests__/useChangelogCodeCopy.test.ts +48 -0
  190. package/apps/web/src/features/tasks/hooks/__tests__/useTaskChangelog.test.ts +48 -0
  191. package/apps/web/src/features/tasks/hooks/__tests__/useTaskCreate.test.ts +217 -0
  192. package/apps/web/src/features/tasks/hooks/__tests__/useTaskEdit.test.ts +152 -0
  193. package/apps/web/src/features/tasks/hooks/__tests__/useTaskExecution.test.ts +143 -0
  194. package/apps/web/src/features/tasks/hooks/__tests__/useTaskList.test.ts +168 -0
  195. package/apps/web/src/features/tasks/hooks/__tests__/useTaskNotification.test.ts +125 -0
  196. package/apps/web/src/features/tasks/hooks/__tests__/useTaskRuns.test.ts +51 -0
  197. package/apps/web/src/features/tasks/hooks/useChangelogCodeCopy.ts +52 -0
  198. package/apps/web/src/features/tasks/hooks/useCopyToClipboard.ts +47 -0
  199. package/apps/web/src/features/tasks/hooks/useTaskChangelog.ts +32 -0
  200. package/apps/web/src/features/tasks/hooks/useTaskCreate.ts +137 -0
  201. package/apps/web/src/features/tasks/hooks/useTaskDetail.ts +217 -0
  202. package/apps/web/src/features/tasks/hooks/useTaskEdit.ts +130 -0
  203. package/apps/web/src/features/tasks/hooks/useTaskExecution.ts +137 -0
  204. package/apps/web/src/features/tasks/hooks/useTaskList.ts +159 -0
  205. package/apps/web/src/features/tasks/hooks/useTaskNotification.ts +80 -0
  206. package/apps/web/src/features/tasks/hooks/useTaskRuns.ts +32 -0
  207. package/apps/web/src/features/tasks/ui/AgentOutputPanel.tsx +203 -0
  208. package/apps/web/src/features/tasks/ui/AgentRoleSelect.tsx +97 -0
  209. package/apps/web/src/features/tasks/ui/ChangelogPanel.tsx +321 -0
  210. package/apps/web/src/features/tasks/ui/RunHistoryPanel.tsx +193 -0
  211. package/apps/web/src/features/tasks/ui/TaskCreateModal.tsx +205 -0
  212. package/apps/web/src/features/tasks/ui/TaskDetailView.tsx +413 -0
  213. package/apps/web/src/features/tasks/ui/TaskEditModal.tsx +165 -0
  214. package/apps/web/src/features/tasks/ui/TaskListModal.tsx +591 -0
  215. package/apps/web/src/features/tasks/ui/__tests__/AgentRoleSelect.test.tsx +91 -0
  216. package/apps/web/src/features/tasks/ui/__tests__/ChangelogPanel.test.tsx +94 -0
  217. package/apps/web/src/features/tasks/ui/__tests__/RunHistoryPanel.test.tsx +71 -0
  218. package/apps/web/src/features/tasks/ui/__tests__/TaskCreateModal.test.tsx +153 -0
  219. package/apps/web/src/features/tasks/ui/__tests__/TaskEditModal.test.tsx +75 -0
  220. package/apps/web/src/features/tasks/ui/__tests__/TaskListModal.test.tsx +243 -0
  221. package/apps/web/src/hooks/useWorkingDir.ts +28 -0
  222. package/apps/web/src/lib/__tests__/ansi.test.ts +88 -0
  223. package/apps/web/src/lib/ansi.ts +105 -0
  224. package/apps/web/src/lib/constants.ts +4 -0
  225. package/apps/web/src/lib/quota.ts +22 -0
  226. package/apps/web/src/lib/theme.tsx +78 -0
  227. package/apps/web/src/lib/toast.tsx +175 -0
  228. package/apps/web/src/store/agentStatusStore.ts +38 -0
  229. package/apps/web/tsconfig.json +18 -0
  230. package/apps/web/vitest.config.ts +25 -0
  231. package/apps/web/vitest.setup.ts +10 -0
  232. package/package.json +85 -0
  233. package/packages/cli/dist/commands/check.d.ts +1 -0
  234. package/packages/cli/dist/commands/check.js +89 -0
  235. package/packages/cli/dist/commands/init.d.ts +5 -0
  236. package/packages/cli/dist/commands/init.js +183 -0
  237. package/packages/cli/dist/commands/start.d.ts +4 -0
  238. package/packages/cli/dist/commands/start.js +188 -0
  239. package/packages/cli/dist/index.d.ts +2 -0
  240. package/packages/cli/dist/index.js +71 -0
  241. package/packages/cli/dist/utils/agent-tools.d.ts +28 -0
  242. package/packages/cli/dist/utils/agent-tools.js +193 -0
  243. package/packages/cli/dist/utils/project-init.d.ts +12 -0
  244. package/packages/cli/dist/utils/project-init.js +258 -0
  245. package/packages/cli/dist/utils/proxy.d.ts +8 -0
  246. package/packages/cli/dist/utils/proxy.js +138 -0
  247. package/packages/cli/package.json +30 -0
  248. package/packages/cli/src/commands/check.ts +77 -0
  249. package/packages/cli/src/commands/init.ts +209 -0
  250. package/packages/cli/src/commands/start.ts +183 -0
  251. package/packages/cli/src/index.ts +91 -0
  252. package/packages/cli/src/utils/agent-tools.ts +201 -0
  253. package/packages/cli/src/utils/project-init.ts +252 -0
  254. package/packages/cli/src/utils/proxy.ts +123 -0
  255. package/packages/cli/tsconfig.json +14 -0
  256. package/packages/eslint-config/base.mjs +31 -0
  257. package/packages/eslint-config/nest.mjs +55 -0
  258. package/packages/eslint-config/next.mjs +23 -0
  259. package/packages/eslint-config/package.json +20 -0
  260. package/packages/typescript-config/base.json +16 -0
  261. package/packages/typescript-config/nestjs.json +17 -0
  262. package/packages/typescript-config/nextjs.json +15 -0
  263. package/packages/typescript-config/package.json +11 -0
  264. package/turbo.json +28 -0
@@ -0,0 +1,380 @@
1
+ import { spawn, type ChildProcess } from 'child_process';
2
+ import { EventEmitter } from 'events';
3
+ import * as path from 'path';
4
+
5
+ import { Injectable, Logger, NotFoundException, OnModuleDestroy } from '@nestjs/common';
6
+ import { InjectRepository } from '@nestjs/typeorm';
7
+ import { Repository } from 'typeorm';
8
+ import { v4 as uuidv4 } from 'uuid';
9
+
10
+ import { AgentSessionEntity } from '../../../database/entities/agent-session.entity';
11
+ import { SessionEntity } from '../../../database/entities/session.entity';
12
+ import type { ClaudeSession, SessionInfo } from './interfaces/claude-session.interface';
13
+ import type {
14
+ ClaudeAssistantEvent,
15
+ ClaudeInitEvent,
16
+ ClaudeResultEvent,
17
+ ClaudeStreamEvent,
18
+ ResultEvent,
19
+ SessionExitEvent,
20
+ TextDeltaEvent,
21
+ ToolUseEvent,
22
+ } from './interfaces/stream-event.interface';
23
+
24
+ const CLAUDE_REASONING_LEVELS = new Set(['low', 'medium', 'high', 'xhigh', 'max']);
25
+
26
+ function normalizeModel(value?: string | null): string | null {
27
+ const trimmed = value?.trim();
28
+ if (!trimmed || trimmed === 'default') return null;
29
+ return trimmed;
30
+ }
31
+
32
+ function normalizeReasoning(value?: string | null): string | null {
33
+ const trimmed = value?.trim();
34
+ if (!trimmed || trimmed === 'default') return null;
35
+ return CLAUDE_REASONING_LEVELS.has(trimmed) ? trimmed : null;
36
+ }
37
+
38
+ @Injectable()
39
+ export class ClaudePtyManager extends EventEmitter implements OnModuleDestroy {
40
+ private readonly logger = new Logger(ClaudePtyManager.name);
41
+ private readonly sessions = new Map<string, ClaudeSession>();
42
+ private readonly processes = new Map<string, ChildProcess>();
43
+
44
+ constructor(
45
+ @InjectRepository(AgentSessionEntity)
46
+ private readonly agentSessionRepo: Repository<AgentSessionEntity>,
47
+ @InjectRepository(SessionEntity)
48
+ private readonly sessionRepo: Repository<SessionEntity>,
49
+ ) {
50
+ super();
51
+ }
52
+
53
+ // ─── 세션 생명주기 ───────────────────────────────────────────────────
54
+
55
+ createSession(workingDirectory = process.cwd(), model?: string, reasoning?: string): SessionInfo {
56
+ const id = uuidv4();
57
+ const now = new Date();
58
+ const session: ClaudeSession = {
59
+ id,
60
+ claudeSessionId: null,
61
+ status: 'idle',
62
+ workingDirectory,
63
+ model: normalizeModel(model),
64
+ reasoning: normalizeReasoning(reasoning),
65
+ createdAt: now,
66
+ lastActivity: now,
67
+ persisted: false,
68
+ };
69
+ this.sessions.set(id, session);
70
+
71
+ // DB 적재는 첫 메시지 전송 시 수행 (sendMessage 참고)
72
+ this.logger.log(`Created in-memory session ${id} (workingDirectory: ${workingDirectory})`);
73
+ return this.toSessionInfo(session);
74
+ }
75
+
76
+ /** 세션을 DB에 처음으로 적재한다. persisted 플래그를 세우기 전에 호출해야 한다. */
77
+ private async persistSession(session: ClaudeSession): Promise<void> {
78
+ const title = path.basename(session.workingDirectory) || session.workingDirectory;
79
+ await Promise.all([
80
+ this.agentSessionRepo.save({
81
+ id: session.id,
82
+ claudeSessionId: null,
83
+ status: 'processing',
84
+ workingDirectory: session.workingDirectory,
85
+ model: session.model,
86
+ reasoning: session.reasoning,
87
+ }),
88
+ this.sessionRepo.save({ sessionId: session.id, title, agentType: 'claude' }),
89
+ ]);
90
+ session.persisted = true;
91
+ this.logger.log(`Persisted session ${session.id} (${title})`);
92
+ }
93
+
94
+ terminateSession(sessionId: string): void {
95
+ const session = this.requireSession(sessionId);
96
+ session.status = 'terminated';
97
+ this.killProcess(sessionId);
98
+ this.sessions.delete(sessionId);
99
+
100
+ if (session.persisted) {
101
+ void this.agentSessionRepo.update(sessionId, { status: 'terminated' });
102
+ }
103
+
104
+ this.logger.log(`Terminated session ${sessionId}`);
105
+ }
106
+
107
+ // ─── 메시지 전송 ─────────────────────────────────────────────────────
108
+
109
+ sendMessage(sessionId: string, message: string): void {
110
+ const existing = this.sessions.get(sessionId);
111
+ if (!existing) {
112
+ void this.restoreAndSend(sessionId, message);
113
+ return;
114
+ }
115
+
116
+ if (existing.status === 'processing') {
117
+ throw new Error(`Session ${sessionId} is already processing`);
118
+ }
119
+
120
+ existing.status = 'processing';
121
+ existing.lastActivity = new Date();
122
+
123
+ if (!existing.persisted) {
124
+ void this.persistSession(existing).then(() => {
125
+ if (existing.status === 'terminated' || !this.sessions.has(sessionId)) {
126
+ void this.agentSessionRepo.update(sessionId, { status: 'terminated' });
127
+ return;
128
+ }
129
+ this.spawnClaude(existing, message);
130
+ });
131
+ return;
132
+ }
133
+
134
+ void this.agentSessionRepo.update(sessionId, { status: 'processing' });
135
+ this.spawnClaude(existing, message);
136
+ }
137
+
138
+ private async restoreAndSend(
139
+ sessionId: string,
140
+ message: string,
141
+ model?: string,
142
+ reasoning?: string,
143
+ ): Promise<void> {
144
+ const record = await this.agentSessionRepo.findOne({ where: { id: sessionId } });
145
+ if (!record) {
146
+ this.emit('error', { sessionId, message: '세션을 찾을 수 없습니다.' });
147
+ return;
148
+ }
149
+ const now = new Date();
150
+ const session: ClaudeSession = {
151
+ id: sessionId,
152
+ claudeSessionId: record.claudeSessionId,
153
+ status: 'processing',
154
+ workingDirectory: record.workingDirectory,
155
+ model: normalizeModel(record.model),
156
+ reasoning: normalizeReasoning(record.reasoning),
157
+ createdAt: record.createdAt,
158
+ lastActivity: now,
159
+ persisted: true,
160
+ };
161
+ this.updateModelSettings(session, model, reasoning);
162
+ this.sessions.set(sessionId, session);
163
+ this.logger.log(`Restored Claude session ${sessionId} (claudeSessionId: ${record.claudeSessionId ?? 'none'})`);
164
+ void this.agentSessionRepo.update(sessionId, { status: 'processing' });
165
+ this.spawnClaude(session, message);
166
+ }
167
+
168
+ private updateModelSettings(session: ClaudeSession, model?: string, reasoning?: string): void {
169
+ const nextModel = normalizeModel(model);
170
+ const nextReasoning = normalizeReasoning(reasoning);
171
+ if (model !== undefined) session.model = nextModel;
172
+ if (reasoning !== undefined) session.reasoning = nextReasoning;
173
+ if (session.persisted && (model !== undefined || reasoning !== undefined)) {
174
+ void this.agentSessionRepo.update(session.id, {
175
+ model: session.model,
176
+ reasoning: session.reasoning,
177
+ });
178
+ }
179
+ }
180
+
181
+ sendMessageWithSettings(sessionId: string, message: string, model?: string, reasoning?: string): void {
182
+ const existing = this.sessions.get(sessionId);
183
+ if (existing) {
184
+ this.updateModelSettings(existing, model, reasoning);
185
+ this.sendMessage(sessionId, message);
186
+ return;
187
+ }
188
+ void this.restoreAndSend(sessionId, message, model, reasoning);
189
+ }
190
+
191
+ private spawnClaude(session: ClaudeSession, message: string): void {
192
+ const sessionId = session.id;
193
+
194
+ const args = ['--output-format', 'stream-json', '--verbose'];
195
+ if (session.model) {
196
+ args.push('--model', session.model);
197
+ }
198
+ if (session.reasoning) {
199
+ args.push('--effort', session.reasoning);
200
+ }
201
+ args.push('--print', '-p', message);
202
+ if (session.claudeSessionId) {
203
+ args.push('--resume', session.claudeSessionId);
204
+ }
205
+
206
+ const proc = spawn('claude', args, {
207
+ cwd: session.workingDirectory,
208
+ env: process.env,
209
+ stdio: ['ignore', 'pipe', 'pipe'],
210
+ });
211
+ this.processes.set(sessionId, proc);
212
+
213
+ let buffer = '';
214
+
215
+ proc.stdout.on('data', (chunk: Buffer) => {
216
+ buffer += chunk.toString();
217
+ const lines = buffer.split('\n');
218
+ buffer = lines.pop() ?? '';
219
+
220
+ for (const line of lines) {
221
+ const trimmed = line.trim();
222
+ if (!trimmed) continue;
223
+ this.handleLine(sessionId, session, trimmed);
224
+ }
225
+ });
226
+
227
+ proc.stderr.on('data', (chunk: Buffer) => {
228
+ this.logger.warn(`[${sessionId}] stderr: ${chunk.toString()}`);
229
+ });
230
+
231
+ proc.on('close', (exitCode) => {
232
+ this.processes.delete(sessionId);
233
+ const event: SessionExitEvent = { sessionId, exitCode: exitCode ?? -1 };
234
+
235
+ if (session.status === 'terminated' || !this.sessions.has(sessionId)) {
236
+ this.emit('exit', event);
237
+ return;
238
+ }
239
+
240
+ if (buffer.trim()) {
241
+ this.handleLine(sessionId, session, buffer.trim());
242
+ }
243
+ buffer = '';
244
+ session.status = 'idle';
245
+
246
+ void this.agentSessionRepo.update(sessionId, { status: 'idle' });
247
+
248
+ this.emit('exit', event);
249
+ });
250
+
251
+ proc.on('error', (err) => {
252
+ this.processes.delete(sessionId);
253
+ if (session.status === 'terminated' || !this.sessions.has(sessionId)) {
254
+ return;
255
+ }
256
+
257
+ session.status = 'idle';
258
+ void this.agentSessionRepo.update(sessionId, { status: 'idle' });
259
+ this.logger.error(`[${sessionId}] spawn error: ${err.message}`);
260
+ this.emit('error', { sessionId, message: err.message });
261
+ });
262
+ }
263
+
264
+ // ─── NDJSON 파싱 ─────────────────────────────────────────────────────
265
+
266
+ private handleLine(sessionId: string, session: ClaudeSession, line: string): void {
267
+ let event: ClaudeStreamEvent;
268
+ try {
269
+ event = JSON.parse(line) as ClaudeStreamEvent;
270
+ } catch {
271
+ this.logger.debug(`[${sessionId}] non-JSON line: ${line}`);
272
+ return;
273
+ }
274
+
275
+ switch (event.type) {
276
+ case 'system': {
277
+ const e = event as ClaudeInitEvent;
278
+ if (e.subtype === 'init') {
279
+ session.claudeSessionId = e.session_id;
280
+ void this.agentSessionRepo.update(sessionId, { claudeSessionId: e.session_id });
281
+ this.logger.debug(`[${sessionId}] Claude session: ${e.session_id}, model: ${e.model}`);
282
+ }
283
+ break;
284
+ }
285
+
286
+ case 'assistant': {
287
+ const e = event as ClaudeAssistantEvent;
288
+ for (const block of e.message.content) {
289
+ if (block.type === 'text') {
290
+ const textEvent: TextDeltaEvent = { sessionId, text: block.text };
291
+ this.emit('text-delta', textEvent);
292
+ } else if (block.type === 'tool_use') {
293
+ const toolEvent: ToolUseEvent = {
294
+ sessionId,
295
+ tool: block.name,
296
+ input: block.input,
297
+ };
298
+ this.emit('tool-use', toolEvent);
299
+ }
300
+ }
301
+ break;
302
+ }
303
+
304
+ case 'result': {
305
+ const e = event as ClaudeResultEvent;
306
+ const resultEvent: ResultEvent = {
307
+ sessionId,
308
+ result: e.result,
309
+ isError: e.is_error,
310
+ durationMs: e.duration_ms,
311
+ costUsd: e.total_cost_usd,
312
+ };
313
+ this.emit('result', resultEvent);
314
+ break;
315
+ }
316
+
317
+ default:
318
+ break;
319
+ }
320
+ }
321
+
322
+ // ─── 조회 ────────────────────────────────────────────────────────────
323
+
324
+ getSessionInfo(sessionId: string): SessionInfo {
325
+ const session = this.sessions.get(sessionId);
326
+ if (!session) throw new NotFoundException(`Session ${sessionId} not found`);
327
+ return this.toSessionInfo(session);
328
+ }
329
+
330
+ listSessions(): SessionInfo[] {
331
+ return [...this.sessions.values()].map((s) => this.toSessionInfo(s));
332
+ }
333
+
334
+ // ─── 정리 ────────────────────────────────────────────────────────────
335
+
336
+ onModuleDestroy(): void {
337
+ for (const sessionId of this.processes.keys()) {
338
+ this.killProcess(sessionId);
339
+ }
340
+ for (const session of this.sessions.values()) {
341
+ session.status = 'terminated';
342
+ }
343
+ this.sessions.clear();
344
+ this.logger.log('All sessions cleared');
345
+ }
346
+
347
+ // ─── Private ─────────────────────────────────────────────────────────
348
+
349
+ private requireSession(sessionId: string): ClaudeSession {
350
+ const session = this.sessions.get(sessionId);
351
+ if (!session) throw new NotFoundException(`Session ${sessionId} not found`);
352
+ if (session.status === 'terminated') throw new Error(`Session ${sessionId} is terminated`);
353
+ return session;
354
+ }
355
+
356
+ private killProcess(sessionId: string): void {
357
+ const proc = this.processes.get(sessionId);
358
+ if (!proc) return;
359
+
360
+ this.processes.delete(sessionId);
361
+ try {
362
+ if (!proc.killed) proc.kill('SIGTERM');
363
+ } catch (err) {
364
+ this.logger.warn(`Failed to kill Claude session ${sessionId}: ${err instanceof Error ? err.message : String(err)}`);
365
+ }
366
+ }
367
+
368
+ private toSessionInfo(session: ClaudeSession): SessionInfo {
369
+ return {
370
+ id: session.id,
371
+ claudeSessionId: session.claudeSessionId,
372
+ status: session.status,
373
+ workingDirectory: session.workingDirectory,
374
+ model: session.model,
375
+ reasoning: session.reasoning,
376
+ createdAt: session.createdAt,
377
+ lastActivity: session.lastActivity,
378
+ };
379
+ }
380
+ }
@@ -0,0 +1,85 @@
1
+ import {
2
+ Body,
3
+ Controller,
4
+ Delete,
5
+ Get,
6
+ HttpCode,
7
+ HttpStatus,
8
+ Param,
9
+ Post,
10
+ UsePipes,
11
+ ValidationPipe,
12
+ } from '@nestjs/common';
13
+ import { ApiNoContentResponse, ApiOkResponse, ApiOperation, ApiTags } from '@nestjs/swagger';
14
+
15
+ import { ClaudeAuthManager } from './claude-auth.manager';
16
+ import type { AuthStatus } from './claude-auth.manager';
17
+ import { ClaudeService } from './claude.service';
18
+ import type { ClaudeStatus } from './claude.service';
19
+ import { ClaudeCreateSessionDto as CreateSessionDto } from './dto/create-session.dto';
20
+ import { SendInputDto } from './dto/send-input.dto';
21
+ import type { SessionInfo } from './interfaces/claude-session.interface';
22
+
23
+ @ApiTags('agents/claude')
24
+ @UsePipes(new ValidationPipe({ transform: true, whitelist: true }))
25
+ @Controller('agents/claude')
26
+ export class ClaudeController {
27
+ constructor(
28
+ private readonly claudeService: ClaudeService,
29
+ private readonly authManager: ClaudeAuthManager,
30
+ ) {}
31
+
32
+ @ApiOperation({ summary: 'Claude Code 인증 상태 조회' })
33
+ @ApiOkResponse({ description: '인증 상태 반환' })
34
+ @Get('auth/status')
35
+ getAuthStatus(): Promise<AuthStatus> {
36
+ return this.authManager.getAuthStatus();
37
+ }
38
+
39
+ @ApiOperation({ summary: 'Claude Code 통합 상태 조회 (버전·인증·세션 수)' })
40
+ @ApiOkResponse({ description: '버전, 플랫폼, 인증, 활성 세션 수' })
41
+ @Get('status')
42
+ getStatus(): Promise<ClaudeStatus> {
43
+ return this.claudeService.getStatus();
44
+ }
45
+
46
+ @ApiOperation({ summary: '새 Claude 세션 생성' })
47
+ @ApiOkResponse({ description: '생성된 세션 정보' })
48
+ @Post('sessions')
49
+ createSession(@Body() dto: CreateSessionDto): SessionInfo {
50
+ return this.claudeService.createSession(dto);
51
+ }
52
+
53
+ @ApiOperation({ summary: '활성 Claude 세션 목록 조회' })
54
+ @ApiOkResponse({ description: '세션 목록' })
55
+ @Get('sessions')
56
+ listSessions(): SessionInfo[] {
57
+ return this.claudeService.listSessions();
58
+ }
59
+
60
+ @ApiOperation({ summary: '특정 Claude 세션 조회' })
61
+ @ApiOkResponse({ description: '세션 정보' })
62
+ @Get('sessions/:id')
63
+ getSession(@Param('id') id: string): SessionInfo {
64
+ return this.claudeService.getSession(id);
65
+ }
66
+
67
+ @ApiOperation({
68
+ summary: '세션에 메시지 전송',
69
+ description: '응답은 WebSocket `/agents/claude` 네임스페이스의 `session:text` / `session:result` 이벤트로 수신',
70
+ })
71
+ @ApiNoContentResponse({ description: '전송 완료 (응답 본문 없음)' })
72
+ @Post('sessions/:id/message')
73
+ @HttpCode(HttpStatus.NO_CONTENT)
74
+ sendMessage(@Param('id') id: string, @Body() dto: SendInputDto): void {
75
+ this.claudeService.sendMessage(id, dto.input);
76
+ }
77
+
78
+ @ApiOperation({ summary: '세션 종료' })
79
+ @ApiNoContentResponse({ description: '종료 완료 (응답 본문 없음)' })
80
+ @Delete('sessions/:id')
81
+ @HttpCode(HttpStatus.NO_CONTENT)
82
+ terminateSession(@Param('id') id: string): void {
83
+ this.claudeService.terminateSession(id);
84
+ }
85
+ }
@@ -0,0 +1,158 @@
1
+ import { Logger, UsePipes, ValidationPipe } from '@nestjs/common';
2
+ import {
3
+ ConnectedSocket,
4
+ MessageBody,
5
+ OnGatewayConnection,
6
+ OnGatewayDisconnect,
7
+ OnGatewayInit,
8
+ SubscribeMessage,
9
+ WebSocketGateway,
10
+ WebSocketServer,
11
+ WsException,
12
+ } from '@nestjs/websockets';
13
+ import { Server, Socket } from 'socket.io';
14
+
15
+ import { ClaudeAuthManager } from './claude-auth.manager';
16
+ import { ClaudePtyManager } from './claude-pty.manager';
17
+ import type { ClaudeCreateSessionDto as CreateSessionDto } from './dto/create-session.dto';
18
+ import type { SendInputDto } from './dto/send-input.dto';
19
+ import type {
20
+ ResultEvent,
21
+ SessionExitEvent,
22
+ TextDeltaEvent,
23
+ ToolUseEvent,
24
+ } from './interfaces/stream-event.interface';
25
+
26
+ /**
27
+ * WebSocket 이벤트 프로토콜
28
+ *
29
+ * Client → Server
30
+ * session:create { workingDirectory? } 세션 생성
31
+ * session:message { sessionId, input } 메시지 전송 → claude 실행
32
+ * session:terminate { sessionId } 세션 종료
33
+ *
34
+ * Server → Client
35
+ * session:created { id, status, ... } 세션 생성 완료
36
+ * session:text { sessionId, text } 텍스트 스트리밍 (delta)
37
+ * session:tool { sessionId, tool, input } 도구 사용 알림
38
+ * session:result { sessionId, result, ... } 응답 완료 + 메타데이터
39
+ * session:exit { sessionId, exitCode } 프로세스 종료
40
+ * error { message } 오류
41
+ */
42
+ @UsePipes(new ValidationPipe({ transform: true, whitelist: true }))
43
+ @WebSocketGateway({ namespace: '/agents/claude', cors: { origin: '*' } })
44
+ export class ClaudeGateway implements OnGatewayInit, OnGatewayConnection, OnGatewayDisconnect {
45
+ @WebSocketServer()
46
+ private readonly server!: Server;
47
+
48
+ private readonly logger = new Logger(ClaudeGateway.name);
49
+ private readonly subscriptions = new Map<string, Set<string>>();
50
+
51
+ constructor(
52
+ private readonly ptyManager: ClaudePtyManager,
53
+ private readonly authManager: ClaudeAuthManager,
54
+ ) {}
55
+
56
+ // ─── Gateway hooks ───────────────────────────────────────────────────
57
+
58
+ afterInit(): void {
59
+ this.ptyManager.on('text-delta', (event: TextDeltaEvent) => {
60
+ this.server.to(event.sessionId).emit('session:text', event);
61
+ });
62
+
63
+ this.ptyManager.on('tool-use', (event: ToolUseEvent) => {
64
+ this.server.to(event.sessionId).emit('session:tool', event);
65
+ });
66
+
67
+ this.ptyManager.on('result', (event: ResultEvent) => {
68
+ this.server.to(event.sessionId).emit('session:result', event);
69
+ });
70
+
71
+ this.ptyManager.on('exit', (event: SessionExitEvent) => {
72
+ this.server.to(event.sessionId).emit('session:exit', event);
73
+ });
74
+
75
+ this.ptyManager.on('error', (event: { sessionId: string; message: string }) => {
76
+ this.server.to(event.sessionId).emit('error', { message: event.message });
77
+ });
78
+
79
+ this.logger.log('ClaudeGateway initialised — namespace: /agents/claude');
80
+ }
81
+
82
+ handleConnection(client: Socket): void {
83
+ this.subscriptions.set(client.id, new Set());
84
+ }
85
+
86
+ handleDisconnect(client: Socket): void {
87
+ this.authManager.cancelLogin(client.id);
88
+ this.subscriptions.delete(client.id);
89
+ }
90
+
91
+ // ─── Message handlers ────────────────────────────────────────────────
92
+
93
+ @SubscribeMessage('session:create')
94
+ handleCreate(
95
+ @ConnectedSocket() client: Socket,
96
+ @MessageBody() dto: CreateSessionDto,
97
+ ): void {
98
+ try {
99
+ const session = this.ptyManager.createSession(dto.workingDirectory, dto.model, dto.reasoning);
100
+ void client.join(session.id);
101
+ this.subscriptions.get(client.id)?.add(session.id);
102
+ client.emit('session:created', session);
103
+ } catch (err) {
104
+ this.emitError(client, err);
105
+ }
106
+ }
107
+
108
+ @SubscribeMessage('session:message')
109
+ handleMessage(
110
+ @ConnectedSocket() client: Socket,
111
+ @MessageBody() body: { sessionId: string; model?: string; reasoning?: string } & SendInputDto,
112
+ ): void {
113
+ try {
114
+ void client.join(body.sessionId);
115
+ this.subscriptions.get(client.id)?.add(body.sessionId);
116
+ this.ptyManager.sendMessageWithSettings(body.sessionId, body.input, body.model, body.reasoning);
117
+ } catch (err) {
118
+ this.emitError(client, err);
119
+ }
120
+ }
121
+
122
+ @SubscribeMessage('session:terminate')
123
+ handleTerminate(
124
+ @ConnectedSocket() client: Socket,
125
+ @MessageBody('sessionId') sessionId: string,
126
+ ): void {
127
+ try {
128
+ this.ptyManager.terminateSession(sessionId);
129
+ } catch (err) {
130
+ this.emitError(client, err);
131
+ }
132
+ }
133
+
134
+ // ─── Auth ────────────────────────────────────────────────────────────
135
+
136
+ @SubscribeMessage('auth:login:start')
137
+ handleAuthLoginStart(@ConnectedSocket() client: Socket): void {
138
+ this.authManager.startLogin(
139
+ client.id,
140
+ (text) => client.emit('auth:output', { text }),
141
+ (success) => client.emit('auth:done', { success }),
142
+ );
143
+ }
144
+
145
+ @SubscribeMessage('auth:login:cancel')
146
+ handleAuthLoginCancel(@ConnectedSocket() client: Socket): void {
147
+ this.authManager.cancelLogin(client.id);
148
+ }
149
+
150
+ // ─── Private ─────────────────────────────────────────────────────────
151
+
152
+ private emitError(client: Socket, err: unknown): void {
153
+ const message = err instanceof Error ? err.message : 'Unknown error';
154
+ this.logger.error(`[${client.id}] ${message}`);
155
+ client.emit('error', { message });
156
+ throw new WsException(message);
157
+ }
158
+ }
@@ -0,0 +1,18 @@
1
+ import { Module } from '@nestjs/common';
2
+ import { TypeOrmModule } from '@nestjs/typeorm';
3
+
4
+ import { AgentSessionEntity } from '../../../database/entities/agent-session.entity';
5
+ import { SessionEntity } from '../../../database/entities/session.entity';
6
+ import { ClaudeAuthManager } from './claude-auth.manager';
7
+ import { ClaudePtyManager } from './claude-pty.manager';
8
+ import { ClaudeController } from './claude.controller';
9
+ import { ClaudeGateway } from './claude.gateway';
10
+ import { ClaudeService } from './claude.service';
11
+
12
+ @Module({
13
+ imports: [TypeOrmModule.forFeature([AgentSessionEntity, SessionEntity])],
14
+ controllers: [ClaudeController],
15
+ providers: [ClaudeService, ClaudePtyManager, ClaudeAuthManager, ClaudeGateway],
16
+ exports: [ClaudeService, ClaudePtyManager],
17
+ })
18
+ export class ClaudeModule {}