claude-controller 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +216 -0
- package/bin/app-launcher.sh +22 -0
- package/bin/claude-sh +19 -0
- package/bin/controller +37 -0
- package/bin/native-app.py +102 -0
- package/bin/send +185 -0
- package/bin/start +75 -0
- package/config.sh +74 -0
- package/lib/checkpoint.sh +237 -0
- package/lib/executor.sh +183 -0
- package/lib/jobs.sh +333 -0
- package/lib/session.sh +78 -0
- package/lib/worktree.sh +122 -0
- package/package.json +61 -0
- package/postinstall.sh +30 -0
- package/service/controller.sh +503 -0
- package/web/auth.py +46 -0
- package/web/checkpoint.py +175 -0
- package/web/config.py +65 -0
- package/web/handler.py +780 -0
- package/web/jobs.py +228 -0
- package/web/server.py +16 -0
- package/web/static/app.js +2013 -0
- package/web/static/index.html +219 -0
- package/web/static/styles.css +1942 -0
- package/web/utils.py +109 -0
package/web/handler.py
ADDED
|
@@ -0,0 +1,780 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Controller Service — HTTP REST API 핸들러
|
|
3
|
+
|
|
4
|
+
보안 계층:
|
|
5
|
+
1. Host 헤더 검증 — DNS Rebinding 방지
|
|
6
|
+
2. Origin 검증 — CORS를 허용된 출처로 제한
|
|
7
|
+
3. 토큰 인증 — API 요청마다 Authorization 헤더 필수
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import base64
|
|
11
|
+
import http.server
|
|
12
|
+
import json
|
|
13
|
+
import os
|
|
14
|
+
import re
|
|
15
|
+
import sys
|
|
16
|
+
import time
|
|
17
|
+
from urllib.parse import urlparse, parse_qs
|
|
18
|
+
|
|
19
|
+
from config import (
|
|
20
|
+
STATIC_DIR, LOGS_DIR, UPLOADS_DIR, DATA_DIR,
|
|
21
|
+
RECENT_DIRS_FILE, SETTINGS_FILE, SESSIONS_DIR,
|
|
22
|
+
CLAUDE_PROJECTS_DIR, FIFO_PATH,
|
|
23
|
+
ALLOWED_ORIGINS, ALLOWED_HOSTS,
|
|
24
|
+
AUTH_REQUIRED, AUTH_EXEMPT_PREFIXES, AUTH_EXEMPT_PATHS,
|
|
25
|
+
)
|
|
26
|
+
from utils import parse_meta_file, is_service_running, cwd_to_project_dir, scan_claude_sessions
|
|
27
|
+
from jobs import get_all_jobs, get_job_result, send_to_fifo, start_controller_service, stop_controller_service
|
|
28
|
+
from checkpoint import get_job_checkpoints, rewind_job
|
|
29
|
+
from auth import verify_token, get_token
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
# MIME 타입 맵 (업로드/정적 파일 공용)
|
|
33
|
+
MIME_TYPES = {
|
|
34
|
+
".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg",
|
|
35
|
+
".gif": "image/gif", ".webp": "image/webp", ".bmp": "image/bmp",
|
|
36
|
+
".txt": "text/plain", ".md": "text/markdown", ".csv": "text/csv",
|
|
37
|
+
".json": "application/json", ".xml": "application/xml",
|
|
38
|
+
".yaml": "text/yaml", ".yml": "text/yaml", ".toml": "text/plain",
|
|
39
|
+
".py": "text/x-python", ".js": "application/javascript",
|
|
40
|
+
".ts": "text/plain", ".jsx": "text/plain", ".tsx": "text/plain",
|
|
41
|
+
".html": "text/html", ".css": "text/css", ".scss": "text/plain",
|
|
42
|
+
".sh": "text/x-shellscript", ".bash": "text/x-shellscript",
|
|
43
|
+
".zsh": "text/plain", ".fish": "text/plain",
|
|
44
|
+
".c": "text/plain", ".cpp": "text/plain", ".h": "text/plain",
|
|
45
|
+
".hpp": "text/plain", ".java": "text/plain", ".kt": "text/plain",
|
|
46
|
+
".go": "text/plain", ".rs": "text/plain", ".rb": "text/plain",
|
|
47
|
+
".swift": "text/plain", ".m": "text/plain", ".r": "text/plain",
|
|
48
|
+
".sql": "text/plain", ".graphql": "text/plain",
|
|
49
|
+
".log": "text/plain", ".env": "text/plain",
|
|
50
|
+
".conf": "text/plain", ".ini": "text/plain", ".cfg": "text/plain",
|
|
51
|
+
".pdf": "application/pdf",
|
|
52
|
+
".doc": "application/msword", ".docx": "application/msword",
|
|
53
|
+
".xls": "application/vnd.ms-excel", ".xlsx": "application/vnd.ms-excel",
|
|
54
|
+
".pptx": "application/vnd.ms-powerpoint",
|
|
55
|
+
".zip": "application/zip", ".tar": "application/x-tar",
|
|
56
|
+
".gz": "application/gzip",
|
|
57
|
+
".svg": "image/svg+xml", ".ico": "image/x-icon",
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
# 업로드 허용 확장자
|
|
61
|
+
IMAGE_EXTS = {".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp"}
|
|
62
|
+
ALLOWED_UPLOAD_EXTS = IMAGE_EXTS | {
|
|
63
|
+
".txt", ".md", ".csv", ".json", ".xml", ".yaml", ".yml", ".toml",
|
|
64
|
+
".py", ".js", ".ts", ".jsx", ".tsx", ".html", ".css", ".scss",
|
|
65
|
+
".sh", ".bash", ".zsh", ".fish",
|
|
66
|
+
".c", ".cpp", ".h", ".hpp", ".java", ".kt", ".go", ".rs", ".rb",
|
|
67
|
+
".swift", ".m", ".r", ".sql", ".graphql",
|
|
68
|
+
".log", ".env", ".conf", ".ini", ".cfg",
|
|
69
|
+
".pdf", ".doc", ".docx", ".xls", ".xlsx", ".pptx",
|
|
70
|
+
".zip", ".tar", ".gz",
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class ControllerHandler(http.server.BaseHTTPRequestHandler):
|
|
75
|
+
"""Controller REST API + 정적 파일 서빙 핸들러"""
|
|
76
|
+
|
|
77
|
+
def log_message(self, format, *args):
|
|
78
|
+
sys.stderr.write(f" [{self.log_date_time_string()}] {format % args}\n")
|
|
79
|
+
|
|
80
|
+
# ════════════════════════════════════════════════
|
|
81
|
+
# 보안 미들웨어
|
|
82
|
+
# ════════════════════════════════════════════════
|
|
83
|
+
|
|
84
|
+
def _get_origin(self):
|
|
85
|
+
"""요청의 Origin 헤더를 반환한다."""
|
|
86
|
+
return self.headers.get("Origin", "")
|
|
87
|
+
|
|
88
|
+
def _set_cors_headers(self):
|
|
89
|
+
"""허용된 Origin만 CORS 응답에 포함한다."""
|
|
90
|
+
origin = self._get_origin()
|
|
91
|
+
if origin in ALLOWED_ORIGINS:
|
|
92
|
+
self.send_header("Access-Control-Allow-Origin", origin)
|
|
93
|
+
self.send_header("Vary", "Origin")
|
|
94
|
+
elif not origin:
|
|
95
|
+
# same-origin 요청 (Origin 헤더 없음) — 로컬 접근 허용
|
|
96
|
+
self.send_header("Access-Control-Allow-Origin", "null")
|
|
97
|
+
self.send_header("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS")
|
|
98
|
+
self.send_header("Access-Control-Allow-Headers", "Content-Type, Authorization")
|
|
99
|
+
self.send_header("Access-Control-Allow-Credentials", "true")
|
|
100
|
+
|
|
101
|
+
def _check_host(self) -> bool:
|
|
102
|
+
"""Host 헤더를 검증하여 DNS Rebinding 공격을 차단한다."""
|
|
103
|
+
host = self.headers.get("Host", "")
|
|
104
|
+
# 포트 번호 제거 후 호스트명만 비교
|
|
105
|
+
hostname = host.split(":")[0] if ":" in host else host
|
|
106
|
+
if hostname not in ALLOWED_HOSTS:
|
|
107
|
+
self._send_forbidden("잘못된 Host 헤더")
|
|
108
|
+
return False
|
|
109
|
+
return True
|
|
110
|
+
|
|
111
|
+
def _check_auth(self, path: str) -> bool:
|
|
112
|
+
"""토큰 인증을 검증한다. AUTH_REQUIRED=false면 항상 통과."""
|
|
113
|
+
if not AUTH_REQUIRED:
|
|
114
|
+
return True
|
|
115
|
+
|
|
116
|
+
# 정적 파일 및 면제 경로
|
|
117
|
+
if path in AUTH_EXEMPT_PATHS:
|
|
118
|
+
return True
|
|
119
|
+
for prefix in AUTH_EXEMPT_PREFIXES:
|
|
120
|
+
if path.startswith(prefix):
|
|
121
|
+
return True
|
|
122
|
+
|
|
123
|
+
# Authorization: Bearer <token>
|
|
124
|
+
auth_header = self.headers.get("Authorization", "")
|
|
125
|
+
if auth_header.startswith("Bearer "):
|
|
126
|
+
token = auth_header[7:]
|
|
127
|
+
if verify_token(token):
|
|
128
|
+
return True
|
|
129
|
+
|
|
130
|
+
self._send_unauthorized()
|
|
131
|
+
return False
|
|
132
|
+
|
|
133
|
+
def _send_forbidden(self, message="Forbidden"):
|
|
134
|
+
body = json.dumps({"error": message}, ensure_ascii=False).encode("utf-8")
|
|
135
|
+
self.send_response(403)
|
|
136
|
+
self.send_header("Content-Type", "application/json; charset=utf-8")
|
|
137
|
+
self.send_header("Content-Length", str(len(body)))
|
|
138
|
+
self.end_headers()
|
|
139
|
+
self.wfile.write(body)
|
|
140
|
+
|
|
141
|
+
def _send_unauthorized(self):
|
|
142
|
+
body = json.dumps({"error": "인증이 필요합니다. Authorization 헤더에 토큰을 포함하세요."}, ensure_ascii=False).encode("utf-8")
|
|
143
|
+
self.send_response(401)
|
|
144
|
+
self.send_header("Content-Type", "application/json; charset=utf-8")
|
|
145
|
+
self.send_header("WWW-Authenticate", "Bearer")
|
|
146
|
+
self.send_header("Content-Length", str(len(body)))
|
|
147
|
+
self._set_cors_headers()
|
|
148
|
+
self.end_headers()
|
|
149
|
+
self.wfile.write(body)
|
|
150
|
+
|
|
151
|
+
# ════════════════════════════════════════════════
|
|
152
|
+
# 공통 응답 헬퍼
|
|
153
|
+
# ════════════════════════════════════════════════
|
|
154
|
+
|
|
155
|
+
def _json_response(self, data, status=200):
|
|
156
|
+
body = json.dumps(data, ensure_ascii=False, indent=2).encode("utf-8")
|
|
157
|
+
self.send_response(status)
|
|
158
|
+
self.send_header("Content-Type", "application/json; charset=utf-8")
|
|
159
|
+
self._set_cors_headers()
|
|
160
|
+
self.send_header("Content-Length", str(len(body)))
|
|
161
|
+
self.end_headers()
|
|
162
|
+
self.wfile.write(body)
|
|
163
|
+
|
|
164
|
+
def _error_response(self, message, status=400):
|
|
165
|
+
self._json_response({"error": message}, status)
|
|
166
|
+
|
|
167
|
+
def _read_body(self):
|
|
168
|
+
length = int(self.headers.get("Content-Length", 0))
|
|
169
|
+
if length == 0:
|
|
170
|
+
return {}
|
|
171
|
+
raw = self.rfile.read(length)
|
|
172
|
+
try:
|
|
173
|
+
return json.loads(raw)
|
|
174
|
+
except json.JSONDecodeError:
|
|
175
|
+
return {}
|
|
176
|
+
|
|
177
|
+
def _serve_file(self, file_path, base_dir):
|
|
178
|
+
"""파일을 읽어 HTTP 응답으로 전송한다. 경로 탈출 방지 포함."""
|
|
179
|
+
try:
|
|
180
|
+
resolved = file_path.resolve()
|
|
181
|
+
if not str(resolved).startswith(str(base_dir.resolve())):
|
|
182
|
+
return self._error_response("접근 거부", 403)
|
|
183
|
+
except (ValueError, OSError):
|
|
184
|
+
return self._error_response("잘못된 경로", 400)
|
|
185
|
+
|
|
186
|
+
if not resolved.exists() or not resolved.is_file():
|
|
187
|
+
return self._error_response("파일을 찾을 수 없습니다", 404)
|
|
188
|
+
|
|
189
|
+
ext = resolved.suffix.lower()
|
|
190
|
+
mime = MIME_TYPES.get(ext, "application/octet-stream")
|
|
191
|
+
if mime.startswith("text/") or mime in ("application/json", "application/javascript"):
|
|
192
|
+
mime += "; charset=utf-8"
|
|
193
|
+
|
|
194
|
+
try:
|
|
195
|
+
data = resolved.read_bytes()
|
|
196
|
+
self.send_response(200)
|
|
197
|
+
self.send_header("Content-Type", mime)
|
|
198
|
+
self._set_cors_headers()
|
|
199
|
+
self.send_header("Content-Length", str(len(data)))
|
|
200
|
+
self.end_headers()
|
|
201
|
+
self.wfile.write(data)
|
|
202
|
+
except OSError:
|
|
203
|
+
self._error_response("파일 읽기 실패", 500)
|
|
204
|
+
|
|
205
|
+
# ── OPTIONS (CORS preflight) ──
|
|
206
|
+
def do_OPTIONS(self):
|
|
207
|
+
# preflight는 Host/Auth 검사 면제 (브라우저 자동 요청)
|
|
208
|
+
self.send_response(204)
|
|
209
|
+
self._set_cors_headers()
|
|
210
|
+
self.end_headers()
|
|
211
|
+
|
|
212
|
+
# ── GET ──
|
|
213
|
+
def do_GET(self):
|
|
214
|
+
parsed = urlparse(self.path)
|
|
215
|
+
path = parsed.path.rstrip("/") or "/"
|
|
216
|
+
|
|
217
|
+
# 보안 게이트
|
|
218
|
+
if not self._check_host():
|
|
219
|
+
return
|
|
220
|
+
if not self._check_auth(path):
|
|
221
|
+
return
|
|
222
|
+
|
|
223
|
+
if path == "/api/auth/verify":
|
|
224
|
+
return self._handle_auth_verify()
|
|
225
|
+
|
|
226
|
+
if path == "/api/status":
|
|
227
|
+
return self._handle_status()
|
|
228
|
+
if path == "/api/jobs":
|
|
229
|
+
return self._handle_jobs()
|
|
230
|
+
if path == "/api/sessions":
|
|
231
|
+
qs = parse_qs(parsed.query)
|
|
232
|
+
filter_cwd = qs.get("cwd", [None])[0]
|
|
233
|
+
return self._handle_sessions(filter_cwd=filter_cwd)
|
|
234
|
+
if path == "/api/config":
|
|
235
|
+
return self._handle_get_config()
|
|
236
|
+
if path == "/api/recent-dirs":
|
|
237
|
+
return self._handle_get_recent_dirs()
|
|
238
|
+
if path == "/api/dirs":
|
|
239
|
+
qs = parse_qs(parsed.query)
|
|
240
|
+
dir_path = qs.get("path", [os.path.expanduser("~")])[0]
|
|
241
|
+
return self._handle_dirs(dir_path)
|
|
242
|
+
|
|
243
|
+
match = re.match(r"^/api/jobs/(\w+)/result$", path)
|
|
244
|
+
if match:
|
|
245
|
+
return self._handle_job_result(match.group(1))
|
|
246
|
+
match = re.match(r"^/api/jobs/(\w+)/stream$", path)
|
|
247
|
+
if match:
|
|
248
|
+
return self._handle_job_stream(match.group(1))
|
|
249
|
+
match = re.match(r"^/api/jobs/(\w+)/checkpoints$", path)
|
|
250
|
+
if match:
|
|
251
|
+
return self._handle_job_checkpoints(match.group(1))
|
|
252
|
+
match = re.match(r"^/api/session/([a-f0-9-]+)/job$", path)
|
|
253
|
+
if match:
|
|
254
|
+
return self._handle_job_by_session(match.group(1))
|
|
255
|
+
match = re.match(r"^/uploads/(.+)$", path)
|
|
256
|
+
if match:
|
|
257
|
+
return self._serve_file(UPLOADS_DIR / match.group(1), UPLOADS_DIR)
|
|
258
|
+
|
|
259
|
+
# 정적 파일
|
|
260
|
+
self._serve_static(parsed.path)
|
|
261
|
+
|
|
262
|
+
# ── POST ──
|
|
263
|
+
def do_POST(self):
|
|
264
|
+
parsed = urlparse(self.path)
|
|
265
|
+
path = parsed.path.rstrip("/") or "/"
|
|
266
|
+
|
|
267
|
+
# 보안 게이트
|
|
268
|
+
if not self._check_host():
|
|
269
|
+
return
|
|
270
|
+
if not self._check_auth(path):
|
|
271
|
+
return
|
|
272
|
+
|
|
273
|
+
if path == "/api/auth/verify":
|
|
274
|
+
return self._handle_auth_verify()
|
|
275
|
+
|
|
276
|
+
if path == "/api/send":
|
|
277
|
+
return self._handle_send()
|
|
278
|
+
if path == "/api/upload":
|
|
279
|
+
return self._handle_upload()
|
|
280
|
+
if path == "/api/service/start":
|
|
281
|
+
return self._handle_service_start()
|
|
282
|
+
if path == "/api/service/stop":
|
|
283
|
+
return self._handle_service_stop()
|
|
284
|
+
if path == "/api/config":
|
|
285
|
+
return self._handle_save_config()
|
|
286
|
+
if path == "/api/recent-dirs":
|
|
287
|
+
return self._handle_save_recent_dirs()
|
|
288
|
+
|
|
289
|
+
match = re.match(r"^/api/jobs/(\w+)/rewind$", path)
|
|
290
|
+
if match:
|
|
291
|
+
return self._handle_job_rewind(match.group(1))
|
|
292
|
+
|
|
293
|
+
self._error_response("알 수 없는 엔드포인트", 404)
|
|
294
|
+
|
|
295
|
+
# ── DELETE ──
|
|
296
|
+
def do_DELETE(self):
|
|
297
|
+
parsed = urlparse(self.path)
|
|
298
|
+
path = parsed.path.rstrip("/") or "/"
|
|
299
|
+
|
|
300
|
+
# 보안 게이트
|
|
301
|
+
if not self._check_host():
|
|
302
|
+
return
|
|
303
|
+
if not self._check_auth(path):
|
|
304
|
+
return
|
|
305
|
+
|
|
306
|
+
match = re.match(r"^/api/jobs/(\w+)$", path)
|
|
307
|
+
if match:
|
|
308
|
+
return self._handle_delete_job(match.group(1))
|
|
309
|
+
if path == "/api/jobs":
|
|
310
|
+
return self._handle_delete_completed_jobs()
|
|
311
|
+
|
|
312
|
+
self._error_response("알 수 없는 엔드포인트", 404)
|
|
313
|
+
|
|
314
|
+
# ════════════════════════════════════════════════
|
|
315
|
+
# API 핸들러
|
|
316
|
+
# ════════════════════════════════════════════════
|
|
317
|
+
|
|
318
|
+
def _handle_auth_verify(self):
|
|
319
|
+
"""POST /api/auth/verify — 토큰 검증 엔드포인트.
|
|
320
|
+
프론트엔드에서 토큰 유효성을 확인할 때 사용한다."""
|
|
321
|
+
auth_header = self.headers.get("Authorization", "")
|
|
322
|
+
if auth_header.startswith("Bearer "):
|
|
323
|
+
token = auth_header[7:]
|
|
324
|
+
if verify_token(token):
|
|
325
|
+
return self._json_response({"valid": True})
|
|
326
|
+
self._json_response({"valid": False}, 401)
|
|
327
|
+
|
|
328
|
+
def _handle_status(self):
|
|
329
|
+
running, _ = is_service_running()
|
|
330
|
+
self._json_response({"running": running, "fifo": str(FIFO_PATH)})
|
|
331
|
+
|
|
332
|
+
def _handle_jobs(self):
|
|
333
|
+
self._json_response(get_all_jobs())
|
|
334
|
+
|
|
335
|
+
def _handle_job_result(self, job_id):
|
|
336
|
+
result, err = get_job_result(job_id)
|
|
337
|
+
if err:
|
|
338
|
+
self._error_response(err, 404)
|
|
339
|
+
else:
|
|
340
|
+
self._json_response(result)
|
|
341
|
+
|
|
342
|
+
def _handle_upload(self):
|
|
343
|
+
body = self._read_body()
|
|
344
|
+
data_b64 = body.get("data", "")
|
|
345
|
+
filename = body.get("filename", "file")
|
|
346
|
+
|
|
347
|
+
if not data_b64:
|
|
348
|
+
return self._error_response("data 필드가 필요합니다")
|
|
349
|
+
if "," in data_b64:
|
|
350
|
+
data_b64 = data_b64.split(",", 1)[1]
|
|
351
|
+
|
|
352
|
+
try:
|
|
353
|
+
raw = base64.b64decode(data_b64)
|
|
354
|
+
except Exception:
|
|
355
|
+
return self._error_response("잘못된 base64 데이터")
|
|
356
|
+
|
|
357
|
+
ext = os.path.splitext(filename)[1].lower()
|
|
358
|
+
if ext not in ALLOWED_UPLOAD_EXTS:
|
|
359
|
+
ext = ext if ext else ".bin"
|
|
360
|
+
prefix = "img" if ext in IMAGE_EXTS else "file"
|
|
361
|
+
safe_name = f"{prefix}_{int(time.time())}_{os.getpid()}_{id(raw) % 10000}{ext}"
|
|
362
|
+
|
|
363
|
+
UPLOADS_DIR.mkdir(parents=True, exist_ok=True)
|
|
364
|
+
filepath = UPLOADS_DIR / safe_name
|
|
365
|
+
filepath.write_bytes(raw)
|
|
366
|
+
|
|
367
|
+
is_image = ext in IMAGE_EXTS
|
|
368
|
+
self._json_response({
|
|
369
|
+
"path": str(filepath),
|
|
370
|
+
"filename": safe_name,
|
|
371
|
+
"originalName": filename,
|
|
372
|
+
"size": len(raw),
|
|
373
|
+
"isImage": is_image,
|
|
374
|
+
}, 201)
|
|
375
|
+
|
|
376
|
+
def _handle_send(self):
|
|
377
|
+
body = self._read_body()
|
|
378
|
+
prompt = body.get("prompt", "").strip()
|
|
379
|
+
if not prompt:
|
|
380
|
+
return self._error_response("prompt 필드가 필요합니다")
|
|
381
|
+
|
|
382
|
+
result, err = send_to_fifo(
|
|
383
|
+
prompt,
|
|
384
|
+
cwd=body.get("cwd") or None,
|
|
385
|
+
job_id=body.get("id") or None,
|
|
386
|
+
images=body.get("images") or None,
|
|
387
|
+
session=body.get("session") or None,
|
|
388
|
+
)
|
|
389
|
+
if err:
|
|
390
|
+
self._error_response(err, 502)
|
|
391
|
+
else:
|
|
392
|
+
self._json_response(result, 201)
|
|
393
|
+
|
|
394
|
+
def _handle_service_start(self):
|
|
395
|
+
ok, _ = start_controller_service()
|
|
396
|
+
if ok:
|
|
397
|
+
self._json_response({"started": True})
|
|
398
|
+
else:
|
|
399
|
+
self._error_response("서비스 시작 실패", 500)
|
|
400
|
+
|
|
401
|
+
def _handle_service_stop(self):
|
|
402
|
+
ok, err = stop_controller_service()
|
|
403
|
+
if ok:
|
|
404
|
+
self._json_response({"stopped": True})
|
|
405
|
+
else:
|
|
406
|
+
self._error_response(err or "서비스 종료 실패", 500)
|
|
407
|
+
|
|
408
|
+
def _handle_delete_job(self, job_id):
|
|
409
|
+
meta_file = LOGS_DIR / f"job_{job_id}.meta"
|
|
410
|
+
out_file = LOGS_DIR / f"job_{job_id}.out"
|
|
411
|
+
|
|
412
|
+
if not meta_file.exists():
|
|
413
|
+
return self._error_response("작업을 찾을 수 없습니다", 404)
|
|
414
|
+
|
|
415
|
+
meta = parse_meta_file(meta_file)
|
|
416
|
+
if meta and meta.get("STATUS") == "running":
|
|
417
|
+
pid = meta.get("PID")
|
|
418
|
+
if pid:
|
|
419
|
+
try:
|
|
420
|
+
os.kill(int(pid), 0)
|
|
421
|
+
return self._error_response("실행 중인 작업은 삭제할 수 없습니다", 409)
|
|
422
|
+
except (ProcessLookupError, ValueError, OSError):
|
|
423
|
+
pass
|
|
424
|
+
|
|
425
|
+
try:
|
|
426
|
+
if meta_file.exists():
|
|
427
|
+
meta_file.unlink()
|
|
428
|
+
if out_file.exists():
|
|
429
|
+
out_file.unlink()
|
|
430
|
+
self._json_response({"deleted": True, "job_id": job_id})
|
|
431
|
+
except OSError as e:
|
|
432
|
+
self._error_response(f"삭제 실패: {e}", 500)
|
|
433
|
+
|
|
434
|
+
def _handle_delete_completed_jobs(self):
|
|
435
|
+
deleted = []
|
|
436
|
+
for mf in list(LOGS_DIR.glob("job_*.meta")):
|
|
437
|
+
meta = parse_meta_file(mf)
|
|
438
|
+
if not meta:
|
|
439
|
+
continue
|
|
440
|
+
status = meta.get("STATUS", "")
|
|
441
|
+
if status in ("done", "failed"):
|
|
442
|
+
pid = meta.get("PID")
|
|
443
|
+
if pid and status == "running":
|
|
444
|
+
try:
|
|
445
|
+
os.kill(int(pid), 0)
|
|
446
|
+
continue
|
|
447
|
+
except (ProcessLookupError, ValueError, OSError):
|
|
448
|
+
pass
|
|
449
|
+
job_id = meta.get("JOB_ID", "")
|
|
450
|
+
out_file = LOGS_DIR / f"job_{job_id}.out"
|
|
451
|
+
try:
|
|
452
|
+
mf.unlink()
|
|
453
|
+
if out_file.exists():
|
|
454
|
+
out_file.unlink()
|
|
455
|
+
deleted.append(job_id)
|
|
456
|
+
except OSError:
|
|
457
|
+
pass
|
|
458
|
+
self._json_response({"deleted": deleted, "count": len(deleted)})
|
|
459
|
+
|
|
460
|
+
def _handle_job_stream(self, job_id):
|
|
461
|
+
out_file = LOGS_DIR / f"job_{job_id}.out"
|
|
462
|
+
meta_file = LOGS_DIR / f"job_{job_id}.meta"
|
|
463
|
+
|
|
464
|
+
if not meta_file.exists():
|
|
465
|
+
return self._error_response("작업을 찾을 수 없습니다", 404)
|
|
466
|
+
|
|
467
|
+
parsed = urlparse(self.path)
|
|
468
|
+
qs = parse_qs(parsed.query)
|
|
469
|
+
offset = int(qs.get("offset", [0])[0])
|
|
470
|
+
|
|
471
|
+
if not out_file.exists():
|
|
472
|
+
return self._json_response({"events": [], "offset": 0, "done": False})
|
|
473
|
+
|
|
474
|
+
try:
|
|
475
|
+
with open(out_file, "r") as f:
|
|
476
|
+
f.seek(offset)
|
|
477
|
+
new_data = f.read()
|
|
478
|
+
new_offset = f.tell()
|
|
479
|
+
|
|
480
|
+
events = []
|
|
481
|
+
for line in new_data.strip().split("\n"):
|
|
482
|
+
if not line.strip():
|
|
483
|
+
continue
|
|
484
|
+
try:
|
|
485
|
+
evt = json.loads(line)
|
|
486
|
+
evt_type = evt.get("type", "")
|
|
487
|
+
if evt_type == "assistant":
|
|
488
|
+
msg = evt.get("message", {})
|
|
489
|
+
content = msg.get("content", [])
|
|
490
|
+
text_parts = [c.get("text", "") for c in content if c.get("type") == "text"]
|
|
491
|
+
if text_parts:
|
|
492
|
+
events.append({"type": "text", "text": "".join(text_parts)})
|
|
493
|
+
tool_parts = [c for c in content if c.get("type") == "tool_use"]
|
|
494
|
+
for tp in tool_parts:
|
|
495
|
+
events.append({
|
|
496
|
+
"type": "tool_use",
|
|
497
|
+
"tool": tp.get("name", ""),
|
|
498
|
+
"input": str(tp.get("input", ""))[:200]
|
|
499
|
+
})
|
|
500
|
+
elif evt_type == "result":
|
|
501
|
+
events.append({
|
|
502
|
+
"type": "result",
|
|
503
|
+
"result": evt.get("result", ""),
|
|
504
|
+
"cost_usd": evt.get("total_cost_usd"),
|
|
505
|
+
"duration_ms": evt.get("duration_ms"),
|
|
506
|
+
"is_error": evt.get("is_error", False)
|
|
507
|
+
})
|
|
508
|
+
except json.JSONDecodeError:
|
|
509
|
+
continue
|
|
510
|
+
|
|
511
|
+
meta = parse_meta_file(meta_file)
|
|
512
|
+
done = meta.get("STATUS", "") in ("done", "failed")
|
|
513
|
+
|
|
514
|
+
self._json_response({"events": events, "offset": new_offset, "done": done})
|
|
515
|
+
except OSError as e:
|
|
516
|
+
self._error_response(f"스트림 읽기 실패: {e}", 500)
|
|
517
|
+
|
|
518
|
+
def _handle_job_checkpoints(self, job_id):
|
|
519
|
+
checkpoints, err = get_job_checkpoints(job_id)
|
|
520
|
+
if err:
|
|
521
|
+
self._error_response(err, 404)
|
|
522
|
+
else:
|
|
523
|
+
self._json_response(checkpoints)
|
|
524
|
+
|
|
525
|
+
def _handle_job_by_session(self, session_id):
|
|
526
|
+
"""Session ID로 가장 최근 job을 찾아 반환한다."""
|
|
527
|
+
jobs = get_all_jobs()
|
|
528
|
+
matched = [j for j in jobs if j.get("session_id") == session_id]
|
|
529
|
+
if not matched:
|
|
530
|
+
return self._error_response(
|
|
531
|
+
f"Session ID '{session_id[:8]}...'에 해당하는 작업을 찾을 수 없습니다", 404)
|
|
532
|
+
self._json_response(matched[0])
|
|
533
|
+
|
|
534
|
+
def _handle_job_rewind(self, job_id):
|
|
535
|
+
body = self._read_body()
|
|
536
|
+
checkpoint_hash = body.get("checkpoint", "").strip()
|
|
537
|
+
new_prompt = body.get("prompt", "").strip()
|
|
538
|
+
|
|
539
|
+
if not checkpoint_hash:
|
|
540
|
+
return self._error_response("checkpoint 필드가 필요합니다")
|
|
541
|
+
if not new_prompt:
|
|
542
|
+
return self._error_response("prompt 필드가 필요합니다")
|
|
543
|
+
|
|
544
|
+
result, err = rewind_job(job_id, checkpoint_hash, new_prompt)
|
|
545
|
+
if err:
|
|
546
|
+
self._error_response(err, 400 if "찾을 수 없습니다" in err else 500)
|
|
547
|
+
else:
|
|
548
|
+
self._json_response(result, 201)
|
|
549
|
+
|
|
550
|
+
def _handle_sessions(self, filter_cwd=None):
|
|
551
|
+
"""Claude Code 네이티브 세션 + history.log + job meta 파일을 합쳐 세션 목록을 반환한다."""
|
|
552
|
+
seen = {}
|
|
553
|
+
|
|
554
|
+
# 0) Claude Code 네이티브 세션 스캔
|
|
555
|
+
if filter_cwd:
|
|
556
|
+
proj_name = cwd_to_project_dir(filter_cwd)
|
|
557
|
+
project_dirs = [CLAUDE_PROJECTS_DIR / proj_name]
|
|
558
|
+
else:
|
|
559
|
+
if CLAUDE_PROJECTS_DIR.exists():
|
|
560
|
+
all_dirs = sorted(
|
|
561
|
+
(d for d in CLAUDE_PROJECTS_DIR.iterdir() if d.is_dir()),
|
|
562
|
+
key=lambda d: d.stat().st_mtime,
|
|
563
|
+
reverse=True,
|
|
564
|
+
)
|
|
565
|
+
project_dirs = all_dirs[:15]
|
|
566
|
+
else:
|
|
567
|
+
project_dirs = []
|
|
568
|
+
|
|
569
|
+
for pd in project_dirs:
|
|
570
|
+
native = scan_claude_sessions(pd, limit=60)
|
|
571
|
+
for sid, info in native.items():
|
|
572
|
+
if sid not in seen:
|
|
573
|
+
seen[sid] = info
|
|
574
|
+
|
|
575
|
+
# 1) Job meta 파일에서 보강
|
|
576
|
+
if LOGS_DIR.exists():
|
|
577
|
+
meta_files = sorted(
|
|
578
|
+
LOGS_DIR.glob("job_*.meta"),
|
|
579
|
+
key=lambda f: int(f.stem.split("_")[1]),
|
|
580
|
+
reverse=True,
|
|
581
|
+
)
|
|
582
|
+
for mf in meta_files:
|
|
583
|
+
meta = parse_meta_file(mf)
|
|
584
|
+
if not meta:
|
|
585
|
+
continue
|
|
586
|
+
sid = meta.get("SESSION_ID", "").strip()
|
|
587
|
+
if not sid:
|
|
588
|
+
continue
|
|
589
|
+
|
|
590
|
+
status = meta.get("STATUS", "unknown")
|
|
591
|
+
if status == "running" and meta.get("PID"):
|
|
592
|
+
try:
|
|
593
|
+
os.kill(int(meta["PID"]), 0)
|
|
594
|
+
except (ProcessLookupError, ValueError, OSError):
|
|
595
|
+
status = "done"
|
|
596
|
+
|
|
597
|
+
job_id = meta.get("JOB_ID", "")
|
|
598
|
+
cost_usd = None
|
|
599
|
+
if status in ("done", "failed"):
|
|
600
|
+
out_file = LOGS_DIR / f"job_{job_id}.out"
|
|
601
|
+
if out_file.exists():
|
|
602
|
+
try:
|
|
603
|
+
for line in open(out_file, "r"):
|
|
604
|
+
try:
|
|
605
|
+
obj = json.loads(line.strip())
|
|
606
|
+
if obj.get("type") == "result":
|
|
607
|
+
cost_usd = obj.get("total_cost_usd")
|
|
608
|
+
except json.JSONDecodeError:
|
|
609
|
+
continue
|
|
610
|
+
except OSError:
|
|
611
|
+
pass
|
|
612
|
+
|
|
613
|
+
entry = {
|
|
614
|
+
"session_id": sid,
|
|
615
|
+
"job_id": job_id,
|
|
616
|
+
"prompt": meta.get("PROMPT", ""),
|
|
617
|
+
"timestamp": meta.get("CREATED_AT", ""),
|
|
618
|
+
"status": status,
|
|
619
|
+
"cwd": meta.get("CWD", ""),
|
|
620
|
+
"cost_usd": cost_usd,
|
|
621
|
+
"slug": "",
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
if sid not in seen:
|
|
625
|
+
seen[sid] = entry
|
|
626
|
+
else:
|
|
627
|
+
existing = seen[sid]
|
|
628
|
+
if existing.get("job_id") is None:
|
|
629
|
+
existing.update({"job_id": job_id, "status": status, "cost_usd": cost_usd})
|
|
630
|
+
else:
|
|
631
|
+
try:
|
|
632
|
+
if int(job_id) > int(existing.get("job_id", 0)):
|
|
633
|
+
seen[sid] = entry
|
|
634
|
+
except (ValueError, TypeError):
|
|
635
|
+
pass
|
|
636
|
+
|
|
637
|
+
# 2) history.log 보충
|
|
638
|
+
history_file = SESSIONS_DIR / "history.log"
|
|
639
|
+
if history_file.exists():
|
|
640
|
+
try:
|
|
641
|
+
for line in history_file.read_text("utf-8").strip().split("\n"):
|
|
642
|
+
parts = line.split("|", 2)
|
|
643
|
+
if len(parts) >= 2:
|
|
644
|
+
ts, sid = parts[0].strip(), parts[1].strip()
|
|
645
|
+
if not sid:
|
|
646
|
+
continue
|
|
647
|
+
prompt = parts[2].strip() if len(parts) > 2 else ""
|
|
648
|
+
if sid not in seen:
|
|
649
|
+
seen[sid] = {
|
|
650
|
+
"session_id": sid, "job_id": None,
|
|
651
|
+
"prompt": prompt, "timestamp": ts,
|
|
652
|
+
"status": "done", "cwd": None,
|
|
653
|
+
"cost_usd": None, "slug": "",
|
|
654
|
+
}
|
|
655
|
+
except OSError:
|
|
656
|
+
pass
|
|
657
|
+
|
|
658
|
+
# cwd 필터 적용
|
|
659
|
+
if filter_cwd:
|
|
660
|
+
norm = os.path.normpath(filter_cwd)
|
|
661
|
+
seen = {
|
|
662
|
+
sid: s for sid, s in seen.items()
|
|
663
|
+
if s.get("cwd") and os.path.normpath(s["cwd"]) == norm
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
sessions = sorted(seen.values(), key=lambda s: s.get("timestamp") or "", reverse=True)
|
|
667
|
+
self._json_response(sessions[:50])
|
|
668
|
+
|
|
669
|
+
def _handle_get_config(self):
|
|
670
|
+
defaults = {
|
|
671
|
+
"skip_permissions": True,
|
|
672
|
+
"allowed_tools": "Bash,Read,Write,Edit,Glob,Grep,Agent,NotebookEdit,WebFetch,WebSearch",
|
|
673
|
+
"model": "",
|
|
674
|
+
"max_jobs": 10,
|
|
675
|
+
"append_system_prompt": "",
|
|
676
|
+
"target_repo": "",
|
|
677
|
+
"base_branch": "main",
|
|
678
|
+
"checkpoint_interval": 5,
|
|
679
|
+
"locale": "ko",
|
|
680
|
+
}
|
|
681
|
+
try:
|
|
682
|
+
if SETTINGS_FILE.exists():
|
|
683
|
+
saved = json.loads(SETTINGS_FILE.read_text("utf-8"))
|
|
684
|
+
defaults.update(saved)
|
|
685
|
+
except (json.JSONDecodeError, OSError):
|
|
686
|
+
pass
|
|
687
|
+
self._json_response(defaults)
|
|
688
|
+
|
|
689
|
+
def _handle_save_config(self):
|
|
690
|
+
body = self._read_body()
|
|
691
|
+
if not body or not isinstance(body, dict):
|
|
692
|
+
return self._error_response("설정 데이터가 필요합니다")
|
|
693
|
+
|
|
694
|
+
current = {}
|
|
695
|
+
try:
|
|
696
|
+
if SETTINGS_FILE.exists():
|
|
697
|
+
current = json.loads(SETTINGS_FILE.read_text("utf-8"))
|
|
698
|
+
except (json.JSONDecodeError, OSError):
|
|
699
|
+
pass
|
|
700
|
+
|
|
701
|
+
allowed_keys = {
|
|
702
|
+
"skip_permissions", "allowed_tools", "model", "max_jobs",
|
|
703
|
+
"append_system_prompt", "target_repo", "base_branch",
|
|
704
|
+
"checkpoint_interval", "locale",
|
|
705
|
+
}
|
|
706
|
+
for k, v in body.items():
|
|
707
|
+
if k in allowed_keys:
|
|
708
|
+
current[k] = v
|
|
709
|
+
|
|
710
|
+
try:
|
|
711
|
+
DATA_DIR.mkdir(parents=True, exist_ok=True)
|
|
712
|
+
SETTINGS_FILE.write_text(
|
|
713
|
+
json.dumps(current, ensure_ascii=False, indent=2), "utf-8"
|
|
714
|
+
)
|
|
715
|
+
self._json_response({"ok": True, "config": current})
|
|
716
|
+
except OSError as e:
|
|
717
|
+
self._error_response(f"설정 저장 실패: {e}", 500)
|
|
718
|
+
|
|
719
|
+
def _handle_get_recent_dirs(self):
|
|
720
|
+
try:
|
|
721
|
+
if RECENT_DIRS_FILE.exists():
|
|
722
|
+
data = json.loads(RECENT_DIRS_FILE.read_text("utf-8"))
|
|
723
|
+
else:
|
|
724
|
+
data = []
|
|
725
|
+
self._json_response(data)
|
|
726
|
+
except (json.JSONDecodeError, OSError):
|
|
727
|
+
self._json_response([])
|
|
728
|
+
|
|
729
|
+
def _handle_save_recent_dirs(self):
|
|
730
|
+
body = self._read_body()
|
|
731
|
+
dirs = body.get("dirs")
|
|
732
|
+
if not isinstance(dirs, list):
|
|
733
|
+
return self._error_response("dirs 배열이 필요합니다")
|
|
734
|
+
dirs = [d for d in dirs if isinstance(d, str)][:8]
|
|
735
|
+
try:
|
|
736
|
+
DATA_DIR.mkdir(parents=True, exist_ok=True)
|
|
737
|
+
RECENT_DIRS_FILE.write_text(json.dumps(dirs, ensure_ascii=False), "utf-8")
|
|
738
|
+
self._json_response({"ok": True})
|
|
739
|
+
except OSError as e:
|
|
740
|
+
self._error_response(f"저장 실패: {e}", 500)
|
|
741
|
+
|
|
742
|
+
def _handle_dirs(self, dir_path):
|
|
743
|
+
try:
|
|
744
|
+
dir_path = os.path.abspath(os.path.expanduser(dir_path))
|
|
745
|
+
if not os.path.isdir(dir_path):
|
|
746
|
+
return self._error_response("디렉토리가 아닙니다", 400)
|
|
747
|
+
|
|
748
|
+
entries = []
|
|
749
|
+
try:
|
|
750
|
+
items = sorted(os.listdir(dir_path))
|
|
751
|
+
except PermissionError:
|
|
752
|
+
return self._error_response("접근 권한 없음", 403)
|
|
753
|
+
|
|
754
|
+
parent = os.path.dirname(dir_path)
|
|
755
|
+
if parent != dir_path:
|
|
756
|
+
entries.append({"name": "..", "path": parent, "type": "dir"})
|
|
757
|
+
|
|
758
|
+
for item in items:
|
|
759
|
+
if item.startswith("."):
|
|
760
|
+
continue
|
|
761
|
+
full = os.path.join(dir_path, item)
|
|
762
|
+
entry = {"name": item, "path": full}
|
|
763
|
+
if os.path.isdir(full):
|
|
764
|
+
entry["type"] = "dir"
|
|
765
|
+
else:
|
|
766
|
+
entry["type"] = "file"
|
|
767
|
+
try:
|
|
768
|
+
entry["size"] = os.path.getsize(full)
|
|
769
|
+
except OSError:
|
|
770
|
+
entry["size"] = 0
|
|
771
|
+
entries.append(entry)
|
|
772
|
+
|
|
773
|
+
self._json_response({"current": dir_path, "entries": entries})
|
|
774
|
+
except Exception as e:
|
|
775
|
+
self._error_response(f"디렉토리 읽기 실패: {e}", 500)
|
|
776
|
+
|
|
777
|
+
def _serve_static(self, url_path):
|
|
778
|
+
if url_path in ("/", ""):
|
|
779
|
+
url_path = "/index.html"
|
|
780
|
+
self._serve_file(STATIC_DIR / url_path.lstrip("/"), STATIC_DIR)
|