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/lib/jobs.sh
ADDED
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# ============================================================
|
|
3
|
+
# 백그라운드 작업(Job) 관리 모듈
|
|
4
|
+
# Session ID 기반으로 작업의 상태, 결과를 추적합니다.
|
|
5
|
+
# PID는 내부 프로세스 관리 전용으로만 사용합니다.
|
|
6
|
+
# ============================================================
|
|
7
|
+
|
|
8
|
+
# ── 안전한 .meta 파일 파서 ─────────────────────────────────
|
|
9
|
+
# source 대신 grep+sed로 필드를 추출하여 쉘 인젝션을 방지한다.
|
|
10
|
+
_get_meta_field() {
|
|
11
|
+
local file="$1" key="$2"
|
|
12
|
+
[[ -f "$file" ]] || return 1
|
|
13
|
+
grep "^${key}=" "$file" 2>/dev/null | head -1 | sed "s/^${key}=//" | sed "s/^['\"]//;s/['\"]$//"
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
# .meta 파일의 모든 필드를 로컬 변수로 로드 (source 대체)
|
|
17
|
+
# usage: _load_meta "$meta_file" → JOB_ID, STATUS, PID, PROMPT, ... 변수 설정
|
|
18
|
+
_load_meta() {
|
|
19
|
+
local file="$1"
|
|
20
|
+
JOB_ID=$(_get_meta_field "$file" "JOB_ID")
|
|
21
|
+
STATUS=$(_get_meta_field "$file" "STATUS")
|
|
22
|
+
PID=$(_get_meta_field "$file" "PID")
|
|
23
|
+
PROMPT=$(_get_meta_field "$file" "PROMPT")
|
|
24
|
+
CREATED_AT=$(_get_meta_field "$file" "CREATED_AT")
|
|
25
|
+
SESSION_ID=$(_get_meta_field "$file" "SESSION_ID")
|
|
26
|
+
UUID=$(_get_meta_field "$file" "UUID")
|
|
27
|
+
CWD=$(_get_meta_field "$file" "CWD")
|
|
28
|
+
WORKTREE=$(_get_meta_field "$file" "WORKTREE")
|
|
29
|
+
REPO=$(_get_meta_field "$file" "REPO")
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
# 디스크 기반 Job 카운터 (서브쉘에서도 안전하게 동작)
|
|
33
|
+
_JOB_COUNTER_FILE="${LOGS_DIR}/.job_counter"
|
|
34
|
+
|
|
35
|
+
# 카운터를 원자적으로 증가시키고 새 값을 stdout에 출력
|
|
36
|
+
_next_job_id() {
|
|
37
|
+
local lockfile="${_JOB_COUNTER_FILE}.lock"
|
|
38
|
+
local waited=0
|
|
39
|
+
# 스핀락 + stale lock 감지: 500회(≈5초) 대기 후 강제 해제
|
|
40
|
+
while ! mkdir "$lockfile" 2>/dev/null; do
|
|
41
|
+
sleep 0.01
|
|
42
|
+
(( waited++ )) || true
|
|
43
|
+
if [[ $waited -gt 500 ]]; then
|
|
44
|
+
rmdir "$lockfile" 2>/dev/null || rm -rf "$lockfile" 2>/dev/null || true
|
|
45
|
+
waited=0
|
|
46
|
+
fi
|
|
47
|
+
done
|
|
48
|
+
local current=0
|
|
49
|
+
[[ -f "$_JOB_COUNTER_FILE" ]] && current=$(cat "$_JOB_COUNTER_FILE")
|
|
50
|
+
local next=$(( current + 1 ))
|
|
51
|
+
echo "$next" > "$_JOB_COUNTER_FILE"
|
|
52
|
+
rmdir "$lockfile" 2>/dev/null || true
|
|
53
|
+
echo "$next"
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
# ── Job 생성 등록 ──────────────────────────────────────────
|
|
57
|
+
# usage: job_register <prompt_text>
|
|
58
|
+
# stdout: job_id
|
|
59
|
+
job_register() {
|
|
60
|
+
local prompt="$1"
|
|
61
|
+
local job_id
|
|
62
|
+
job_id=$(_next_job_id)
|
|
63
|
+
local ts
|
|
64
|
+
ts=$(date '+%Y-%m-%d %H:%M:%S')
|
|
65
|
+
|
|
66
|
+
local meta_file="${LOGS_DIR}/job_${job_id}.meta"
|
|
67
|
+
# 프롬프트 내의 특수문자를 이스케이프하여 안전하게 저장
|
|
68
|
+
local safe_prompt
|
|
69
|
+
safe_prompt=$(printf '%s' "$prompt" | head -c 500 | sed "s/'/'\\\\''/g")
|
|
70
|
+
cat > "$meta_file" <<EOF
|
|
71
|
+
JOB_ID=${job_id}
|
|
72
|
+
STATUS=running
|
|
73
|
+
PID=
|
|
74
|
+
PROMPT='${safe_prompt}'
|
|
75
|
+
CREATED_AT='${ts}'
|
|
76
|
+
SESSION_ID=
|
|
77
|
+
EOF
|
|
78
|
+
echo "$job_id"
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
# ── 원자적 meta 필드 갱신 (temp → rename) ─────────────────
|
|
82
|
+
_meta_set_field() {
|
|
83
|
+
local meta_file="$1" key="$2" value="$3"
|
|
84
|
+
[[ -f "$meta_file" ]] || return 1
|
|
85
|
+
local tmp_file="${meta_file}.tmp.$$"
|
|
86
|
+
sed "s/^${key}=.*/${key}=${value}/" "$meta_file" > "$tmp_file" && \
|
|
87
|
+
mv -f "$tmp_file" "$meta_file"
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
# ── PID / 세션 ID 갱신 ────────────────────────────────────
|
|
91
|
+
job_set_pid() {
|
|
92
|
+
local job_id="$1" pid="$2"
|
|
93
|
+
local meta_file="${LOGS_DIR}/job_${job_id}.meta"
|
|
94
|
+
_meta_set_field "$meta_file" "PID" "$pid"
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
job_set_session() {
|
|
98
|
+
local job_id="$1" session_id="$2"
|
|
99
|
+
local meta_file="${LOGS_DIR}/job_${job_id}.meta"
|
|
100
|
+
_meta_set_field "$meta_file" "SESSION_ID" "$session_id"
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
# ── 상태 변경 ──────────────────────────────────────────────
|
|
104
|
+
job_mark_done() {
|
|
105
|
+
local job_id="$1"
|
|
106
|
+
local meta_file="${LOGS_DIR}/job_${job_id}.meta"
|
|
107
|
+
_meta_set_field "$meta_file" "STATUS" "done"
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
job_mark_failed() {
|
|
111
|
+
local job_id="$1"
|
|
112
|
+
local meta_file="${LOGS_DIR}/job_${job_id}.meta"
|
|
113
|
+
_meta_set_field "$meta_file" "STATUS" "failed"
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
# ── 상태 조회 ──────────────────────────────────────────────
|
|
117
|
+
job_status() {
|
|
118
|
+
local job_id="$1"
|
|
119
|
+
local meta_file="${LOGS_DIR}/job_${job_id}.meta"
|
|
120
|
+
if [[ -f "$meta_file" ]]; then
|
|
121
|
+
local STATUS="" PID=""
|
|
122
|
+
STATUS=$(_get_meta_field "$meta_file" "STATUS")
|
|
123
|
+
PID=$(_get_meta_field "$meta_file" "PID")
|
|
124
|
+
# PID가 아직 살아있는지 확인 (내부 전용)
|
|
125
|
+
if [[ "$STATUS" == "running" && -n "$PID" ]]; then
|
|
126
|
+
if ! kill -0 "$PID" 2>/dev/null; then
|
|
127
|
+
job_mark_done "$job_id"
|
|
128
|
+
STATUS="done"
|
|
129
|
+
fi
|
|
130
|
+
fi
|
|
131
|
+
echo "$STATUS"
|
|
132
|
+
else
|
|
133
|
+
echo "not_found"
|
|
134
|
+
fi
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
# ── Session ID로 Job 찾기 ────────────────────────────────
|
|
138
|
+
# session_id로 가장 최신 job_id를 반환한다.
|
|
139
|
+
job_find_by_session() {
|
|
140
|
+
local target_sid="$1"
|
|
141
|
+
local best_jid=0 found_jid=""
|
|
142
|
+
for meta_file in "${LOGS_DIR}"/job_*.meta; do
|
|
143
|
+
[[ -f "$meta_file" ]] || continue
|
|
144
|
+
local sid
|
|
145
|
+
sid=$(_get_meta_field "$meta_file" "SESSION_ID")
|
|
146
|
+
if [[ "$sid" == "$target_sid" ]]; then
|
|
147
|
+
local jid
|
|
148
|
+
jid=$(_get_meta_field "$meta_file" "JOB_ID")
|
|
149
|
+
if [[ "$jid" -gt "$best_jid" ]] 2>/dev/null; then
|
|
150
|
+
best_jid="$jid"
|
|
151
|
+
found_jid="$jid"
|
|
152
|
+
fi
|
|
153
|
+
fi
|
|
154
|
+
done
|
|
155
|
+
if [[ -n "$found_jid" ]]; then
|
|
156
|
+
echo "$found_jid"
|
|
157
|
+
return 0
|
|
158
|
+
fi
|
|
159
|
+
return 1
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
# ── 실행 중인 Job의 Session ID 조기 추출 ────────────────
|
|
163
|
+
# stream-json 출력 파일에서 session_id를 탐색하여 meta에 캐싱한다.
|
|
164
|
+
job_get_session_id() {
|
|
165
|
+
local job_id="$1"
|
|
166
|
+
local meta_file="${LOGS_DIR}/job_${job_id}.meta"
|
|
167
|
+
|
|
168
|
+
# meta 파일에서 먼저 확인
|
|
169
|
+
local sid
|
|
170
|
+
sid=$(_get_meta_field "$meta_file" "SESSION_ID")
|
|
171
|
+
if [[ -n "$sid" ]]; then
|
|
172
|
+
echo "$sid"
|
|
173
|
+
return 0
|
|
174
|
+
fi
|
|
175
|
+
|
|
176
|
+
# stream 출력에서 조기 추출 시도
|
|
177
|
+
local out_file="${LOGS_DIR}/job_${job_id}.out"
|
|
178
|
+
if [[ -f "$out_file" ]]; then
|
|
179
|
+
sid=$(grep -m1 '"session_id"' "$out_file" 2>/dev/null | head -1 | jq -r '.session_id // empty' 2>/dev/null)
|
|
180
|
+
if [[ -n "$sid" ]]; then
|
|
181
|
+
job_set_session "$job_id" "$sid"
|
|
182
|
+
echo "$sid"
|
|
183
|
+
return 0
|
|
184
|
+
fi
|
|
185
|
+
fi
|
|
186
|
+
return 1
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
# ── 전체 작업 목록 ─────────────────────────────────────────
|
|
190
|
+
jobs_list() {
|
|
191
|
+
local meta_files=("${LOGS_DIR}"/job_*.meta)
|
|
192
|
+
if [[ ! -f "${meta_files[0]}" ]]; then
|
|
193
|
+
echo " (백그라운드 작업 없음)"
|
|
194
|
+
return
|
|
195
|
+
fi
|
|
196
|
+
|
|
197
|
+
printf " %-4s %-9s %-10s %-19s %s\n" "ID" "STATUS" "SESSION" "CREATED" "PROMPT"
|
|
198
|
+
printf " %-4s %-9s %-10s %-19s %s\n" "----" "---------" "----------" "-------------------" "--------------------"
|
|
199
|
+
|
|
200
|
+
# 최근 작업이 위로 오도록 JOB_ID 내림차순 정렬
|
|
201
|
+
local sorted_files
|
|
202
|
+
IFS=$'\n' sorted_files=($(
|
|
203
|
+
for f in "${meta_files[@]}"; do
|
|
204
|
+
[[ -f "$f" ]] || continue
|
|
205
|
+
local num="${f##*job_}"
|
|
206
|
+
num="${num%.meta}"
|
|
207
|
+
echo "${num} ${f}"
|
|
208
|
+
done | sort -t' ' -k1 -rn | cut -d' ' -f2-
|
|
209
|
+
))
|
|
210
|
+
unset IFS
|
|
211
|
+
|
|
212
|
+
for meta_file in "${sorted_files[@]}"; do
|
|
213
|
+
[[ -f "$meta_file" ]] || continue
|
|
214
|
+
(
|
|
215
|
+
local JOB_ID="" STATUS="" PID="" PROMPT="" CREATED_AT="" SESSION_ID=""
|
|
216
|
+
_load_meta "$meta_file"
|
|
217
|
+
# 프로세스 생존 확인 (내부 전용)
|
|
218
|
+
if [[ "$STATUS" == "running" && -n "$PID" ]]; then
|
|
219
|
+
if ! kill -0 "$PID" 2>/dev/null; then
|
|
220
|
+
STATUS="done"
|
|
221
|
+
fi
|
|
222
|
+
fi
|
|
223
|
+
# Session ID가 없으면 stream에서 조기 추출 시도
|
|
224
|
+
if [[ -z "$SESSION_ID" ]]; then
|
|
225
|
+
SESSION_ID=$(job_get_session_id "$JOB_ID" 2>/dev/null) || true
|
|
226
|
+
fi
|
|
227
|
+
local short_sid="${SESSION_ID:0:8}"
|
|
228
|
+
[[ ${#SESSION_ID} -gt 8 ]] && short_sid="${short_sid}.."
|
|
229
|
+
local short_prompt="${PROMPT:0:40}"
|
|
230
|
+
[[ ${#PROMPT} -gt 40 ]] && short_prompt="${short_prompt}..."
|
|
231
|
+
printf " %-4s %-9s %-10s %-19s %s\n" \
|
|
232
|
+
"$JOB_ID" "$STATUS" "${short_sid:-"-"}" "$CREATED_AT" "$short_prompt"
|
|
233
|
+
)
|
|
234
|
+
done
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
# ── 작업 결과 보기 ─────────────────────────────────────────
|
|
238
|
+
job_result() {
|
|
239
|
+
local job_id="$1"
|
|
240
|
+
local out_file="${LOGS_DIR}/job_${job_id}.out"
|
|
241
|
+
local meta_file="${LOGS_DIR}/job_${job_id}.meta"
|
|
242
|
+
|
|
243
|
+
if [[ ! -f "$meta_file" ]]; then
|
|
244
|
+
echo " [오류] Job #${job_id}를 찾을 수 없습니다."
|
|
245
|
+
return 1
|
|
246
|
+
fi
|
|
247
|
+
|
|
248
|
+
local status
|
|
249
|
+
status=$(job_status "$job_id")
|
|
250
|
+
|
|
251
|
+
if [[ "$status" == "running" ]]; then
|
|
252
|
+
echo " [진행 중] Job #${job_id}가 아직 실행 중입니다..."
|
|
253
|
+
return 0
|
|
254
|
+
fi
|
|
255
|
+
|
|
256
|
+
if [[ -f "$out_file" ]]; then
|
|
257
|
+
# JSON 출력에서 result 텍스트만 추출 시도
|
|
258
|
+
local result_text
|
|
259
|
+
result_text=$(jq -r '.result // empty' "$out_file" 2>/dev/null)
|
|
260
|
+
if [[ -n "$result_text" ]]; then
|
|
261
|
+
echo "$result_text"
|
|
262
|
+
else
|
|
263
|
+
cat "$out_file"
|
|
264
|
+
fi
|
|
265
|
+
else
|
|
266
|
+
echo " [오류] Job #${job_id}의 출력 파일이 없습니다."
|
|
267
|
+
return 1
|
|
268
|
+
fi
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
# ── 작업의 세션 ID 가져오기 ────────────────────────────────
|
|
272
|
+
job_session_id() {
|
|
273
|
+
local job_id="$1"
|
|
274
|
+
local out_file="${LOGS_DIR}/job_${job_id}.out"
|
|
275
|
+
if [[ -f "$out_file" ]]; then
|
|
276
|
+
jq -r '.session_id // empty' "$out_file" 2>/dev/null
|
|
277
|
+
fi
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
# ── 작업 강제 종료 ─────────────────────────────────────────
|
|
281
|
+
# job_id 또는 session_id로 호출 가능.
|
|
282
|
+
# session_id(UUID 형태)가 입력되면 자동으로 job_id로 변환한다.
|
|
283
|
+
job_kill() {
|
|
284
|
+
local identifier="$1"
|
|
285
|
+
local job_id="$identifier"
|
|
286
|
+
|
|
287
|
+
# UUID 형태(하이픈 포함 36자)이면 session_id로 간주
|
|
288
|
+
if [[ "$identifier" =~ ^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$ ]]; then
|
|
289
|
+
job_id=$(job_find_by_session "$identifier")
|
|
290
|
+
if [[ -z "$job_id" ]]; then
|
|
291
|
+
echo " [오류] Session ID '${identifier:0:8}...'에 해당하는 작업을 찾을 수 없습니다."
|
|
292
|
+
return 1
|
|
293
|
+
fi
|
|
294
|
+
fi
|
|
295
|
+
|
|
296
|
+
local meta_file="${LOGS_DIR}/job_${job_id}.meta"
|
|
297
|
+
|
|
298
|
+
if [[ ! -f "$meta_file" ]]; then
|
|
299
|
+
echo " [오류] Job #${job_id}를 찾을 수 없습니다."
|
|
300
|
+
return 1
|
|
301
|
+
fi
|
|
302
|
+
|
|
303
|
+
local STATUS="" PID="" SESSION_ID=""
|
|
304
|
+
_load_meta "$meta_file"
|
|
305
|
+
local sid_label="${SESSION_ID:+${SESSION_ID:0:8}..}"
|
|
306
|
+
|
|
307
|
+
if [[ "$STATUS" != "running" ]]; then
|
|
308
|
+
echo " Job #${job_id}${sid_label:+ (session: $sid_label)}는 이미 종료되었습니다. (status: $STATUS)"
|
|
309
|
+
return 0
|
|
310
|
+
fi
|
|
311
|
+
|
|
312
|
+
if [[ -n "$PID" ]] && kill -0 "$PID" 2>/dev/null; then
|
|
313
|
+
kill "$PID" 2>/dev/null
|
|
314
|
+
sleep 0.5
|
|
315
|
+
kill -0 "$PID" 2>/dev/null && kill -9 "$PID" 2>/dev/null
|
|
316
|
+
job_mark_failed "$job_id"
|
|
317
|
+
echo " Job #${job_id}${sid_label:+ (session: $sid_label)} 종료됨."
|
|
318
|
+
else
|
|
319
|
+
job_mark_done "$job_id"
|
|
320
|
+
echo " Job #${job_id}${sid_label:+ (session: $sid_label)}는 이미 종료된 프로세스입니다."
|
|
321
|
+
fi
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
# ── 로그 파일 정리 ─────────────────────────────────────────
|
|
325
|
+
jobs_clean() {
|
|
326
|
+
local count=0
|
|
327
|
+
for f in "${LOGS_DIR}"/job_*.meta "${LOGS_DIR}"/job_*.out "${LOGS_DIR}"/job_*.ext_id; do
|
|
328
|
+
if [[ -f "$f" ]]; then rm -f "$f"; (( count++ )) || true; fi
|
|
329
|
+
done
|
|
330
|
+
# 카운터 초기화
|
|
331
|
+
echo "0" > "$_JOB_COUNTER_FILE"
|
|
332
|
+
echo " ${count}개의 작업 파일 정리 완료."
|
|
333
|
+
}
|
package/lib/session.sh
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# ============================================================
|
|
3
|
+
# 세션 관리 모듈
|
|
4
|
+
# claude -p 의 세션 ID 를 추적하여 대화를 이어갈 수 있게 합니다.
|
|
5
|
+
# ============================================================
|
|
6
|
+
|
|
7
|
+
# 현재 활성 세션 ID (가장 최근 포그라운드 실행의 세션)
|
|
8
|
+
_CURRENT_SESSION_ID=""
|
|
9
|
+
|
|
10
|
+
# ── 세션 저장 ──────────────────────────────────────────────
|
|
11
|
+
session_save() {
|
|
12
|
+
local session_id="$1"
|
|
13
|
+
local prompt="$2"
|
|
14
|
+
local ts
|
|
15
|
+
ts=$(date '+%Y-%m-%d %H:%M:%S')
|
|
16
|
+
|
|
17
|
+
if [[ -n "$session_id" ]]; then
|
|
18
|
+
_CURRENT_SESSION_ID="$session_id"
|
|
19
|
+
echo "${ts}|${session_id}|${prompt:0:200}" >> "${SESSIONS_DIR}/history.log"
|
|
20
|
+
fi
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
# ── 현재 세션 ID 반환 ─────────────────────────────────────
|
|
24
|
+
session_current() {
|
|
25
|
+
echo "$_CURRENT_SESSION_ID"
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
# ── 세션 목록 보기 ─────────────────────────────────────────
|
|
29
|
+
session_list() {
|
|
30
|
+
local history_file="${SESSIONS_DIR}/history.log"
|
|
31
|
+
if [[ ! -f "$history_file" ]]; then
|
|
32
|
+
echo " (세션 기록 없음)"
|
|
33
|
+
return
|
|
34
|
+
fi
|
|
35
|
+
|
|
36
|
+
printf " %-19s %-40s %s\n" "TIME" "SESSION_ID" "PROMPT"
|
|
37
|
+
printf " %-19s %-40s %s\n" "-------------------" "----------------------------------------" "--------------------"
|
|
38
|
+
|
|
39
|
+
tail -20 "$history_file" | while IFS='|' read -r ts sid prompt; do
|
|
40
|
+
local short_prompt="${prompt:0:40}"
|
|
41
|
+
[[ ${#prompt} -gt 40 ]] && short_prompt="${short_prompt}..."
|
|
42
|
+
printf " %-19s %-40s %s\n" "$ts" "$sid" "$short_prompt"
|
|
43
|
+
done
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
# ── 특정 세션으로 전환 ─────────────────────────────────────
|
|
47
|
+
session_switch() {
|
|
48
|
+
local session_id="$1"
|
|
49
|
+
if [[ -n "$session_id" ]]; then
|
|
50
|
+
_CURRENT_SESSION_ID="$session_id"
|
|
51
|
+
echo " 세션 전환됨: $session_id"
|
|
52
|
+
else
|
|
53
|
+
echo " [오류] 세션 ID를 지정해주세요."
|
|
54
|
+
fi
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
# ── claude -p 에 세션 관련 플래그 생성 ─────────────────────
|
|
58
|
+
# --continue: 가장 최근 대화 이어가기
|
|
59
|
+
# --resume <id>: 특정 세션 이어가기
|
|
60
|
+
session_build_flags() {
|
|
61
|
+
local mode="$1" # "continue" | "resume" | ""
|
|
62
|
+
local flags=()
|
|
63
|
+
|
|
64
|
+
case "$mode" in
|
|
65
|
+
continue)
|
|
66
|
+
flags+=(--continue)
|
|
67
|
+
;;
|
|
68
|
+
resume)
|
|
69
|
+
if [[ -n "$_CURRENT_SESSION_ID" ]]; then
|
|
70
|
+
flags+=(--resume "$_CURRENT_SESSION_ID")
|
|
71
|
+
else
|
|
72
|
+
echo " [경고] 이어갈 세션이 없습니다. 새 세션으로 실행합니다." >&2
|
|
73
|
+
fi
|
|
74
|
+
;;
|
|
75
|
+
esac
|
|
76
|
+
|
|
77
|
+
echo "${flags[@]+"${flags[@]}"}"
|
|
78
|
+
}
|
package/lib/worktree.sh
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# ============================================================
|
|
3
|
+
# Git Worktree 관리 모듈
|
|
4
|
+
# 각 작업을 독립된 git worktree에서 실행하여 격리성을 보장합니다.
|
|
5
|
+
# ============================================================
|
|
6
|
+
|
|
7
|
+
# ── worktree 생성 ──────────────────────────────────────────
|
|
8
|
+
# usage: worktree_create <job_id> [repo_path]
|
|
9
|
+
# stdout: worktree 경로
|
|
10
|
+
worktree_create() {
|
|
11
|
+
local job_id="$1"
|
|
12
|
+
local repo="${2:-$TARGET_REPO}"
|
|
13
|
+
|
|
14
|
+
if [[ -z "$repo" ]]; then
|
|
15
|
+
echo "[오류] TARGET_REPO가 설정되지 않았습니다." >&2
|
|
16
|
+
return 1
|
|
17
|
+
fi
|
|
18
|
+
|
|
19
|
+
if [[ ! -d "$repo/.git" ]]; then
|
|
20
|
+
echo "[오류] git 저장소가 아닙니다: $repo" >&2
|
|
21
|
+
return 1
|
|
22
|
+
fi
|
|
23
|
+
|
|
24
|
+
mkdir -p "$WORKTREES_DIR"
|
|
25
|
+
|
|
26
|
+
local branch="controller/job-${job_id}"
|
|
27
|
+
local wt_path="${WORKTREES_DIR}/${branch//\//_}"
|
|
28
|
+
|
|
29
|
+
# 기존 브랜치/워크트리 정리
|
|
30
|
+
git -C "$repo" worktree remove "$wt_path" --force 2>/dev/null || true
|
|
31
|
+
git -C "$repo" branch -D "$branch" 2>/dev/null || true
|
|
32
|
+
|
|
33
|
+
# base branch에서 새 워크트리 생성
|
|
34
|
+
local base="${BASE_BRANCH:-main}"
|
|
35
|
+
git -C "$repo" fetch origin "$base" 2>/dev/null || true
|
|
36
|
+
|
|
37
|
+
if git -C "$repo" worktree add "$wt_path" -b "$branch" "origin/${base}" 2>/dev/null; then
|
|
38
|
+
echo "$wt_path"
|
|
39
|
+
return 0
|
|
40
|
+
fi
|
|
41
|
+
|
|
42
|
+
# fallback: 로컬 base branch
|
|
43
|
+
if git -C "$repo" worktree add "$wt_path" -b "$branch" "$base" 2>/dev/null; then
|
|
44
|
+
echo "$wt_path"
|
|
45
|
+
return 0
|
|
46
|
+
fi
|
|
47
|
+
|
|
48
|
+
echo "[오류] 워크트리 생성 실패: $wt_path" >&2
|
|
49
|
+
return 1
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
# ── worktree 삭제 ──────────────────────────────────────────
|
|
53
|
+
# usage: worktree_remove <job_id> [repo_path]
|
|
54
|
+
worktree_remove() {
|
|
55
|
+
local job_id="$1"
|
|
56
|
+
local repo="${2:-$TARGET_REPO}"
|
|
57
|
+
|
|
58
|
+
if [[ -z "$repo" ]]; then
|
|
59
|
+
return 1
|
|
60
|
+
fi
|
|
61
|
+
|
|
62
|
+
local branch="controller/job-${job_id}"
|
|
63
|
+
local wt_path="${WORKTREES_DIR}/${branch//\//_}"
|
|
64
|
+
|
|
65
|
+
if [[ -d "$wt_path" ]]; then
|
|
66
|
+
git -C "$repo" worktree remove "$wt_path" --force 2>/dev/null || true
|
|
67
|
+
fi
|
|
68
|
+
|
|
69
|
+
git -C "$repo" branch -D "$branch" 2>/dev/null || true
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
# ── 작업의 worktree 경로 조회 ─────────────────────────────
|
|
73
|
+
# usage: worktree_path_for_job <job_id>
|
|
74
|
+
worktree_path_for_job() {
|
|
75
|
+
local job_id="$1"
|
|
76
|
+
local meta_file="${LOGS_DIR}/job_${job_id}.meta"
|
|
77
|
+
|
|
78
|
+
if [[ -f "$meta_file" ]]; then
|
|
79
|
+
local WORKTREE=""
|
|
80
|
+
WORKTREE=$(_get_meta_field "$meta_file" "WORKTREE")
|
|
81
|
+
echo "${WORKTREE:-}"
|
|
82
|
+
fi
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
# ── 전체 worktree 목록 ─────────────────────────────────────
|
|
86
|
+
worktree_list() {
|
|
87
|
+
local repo="${1:-$TARGET_REPO}"
|
|
88
|
+
|
|
89
|
+
if [[ -z "$repo" || ! -d "$repo/.git" ]]; then
|
|
90
|
+
echo " (TARGET_REPO 미설정 또는 git 저장소 아님)"
|
|
91
|
+
return
|
|
92
|
+
fi
|
|
93
|
+
|
|
94
|
+
git -C "$repo" worktree list 2>/dev/null | while read -r line; do
|
|
95
|
+
echo " $line"
|
|
96
|
+
done
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
# ── 모든 controller worktree 정리 ──────────────────────────
|
|
100
|
+
worktree_clean_all() {
|
|
101
|
+
local repo="${1:-$TARGET_REPO}"
|
|
102
|
+
|
|
103
|
+
if [[ -z "$repo" ]]; then
|
|
104
|
+
echo " [오류] TARGET_REPO 미설정"
|
|
105
|
+
return 1
|
|
106
|
+
fi
|
|
107
|
+
|
|
108
|
+
local count=0
|
|
109
|
+
for meta_file in "${LOGS_DIR}"/job_*.meta; do
|
|
110
|
+
[[ -f "$meta_file" ]] || continue
|
|
111
|
+
local JOB_ID="" WORKTREE=""
|
|
112
|
+
JOB_ID=$(_get_meta_field "$meta_file" "JOB_ID")
|
|
113
|
+
WORKTREE=$(_get_meta_field "$meta_file" "WORKTREE")
|
|
114
|
+
if [[ -n "$WORKTREE" && -d "$WORKTREE" ]]; then
|
|
115
|
+
worktree_remove "$JOB_ID" "$repo"
|
|
116
|
+
(( count++ )) || true
|
|
117
|
+
fi
|
|
118
|
+
done
|
|
119
|
+
|
|
120
|
+
git -C "$repo" worktree prune 2>/dev/null || true
|
|
121
|
+
echo " ${count}개 워크트리 정리 완료."
|
|
122
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "claude-controller",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Claude Code headless daemon controller — FIFO-based async task dispatch, Git Worktree isolation, auto-checkpointing, and a web dashboard",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": "choiwon",
|
|
7
|
+
"keywords": [
|
|
8
|
+
"claude",
|
|
9
|
+
"claude-code",
|
|
10
|
+
"headless",
|
|
11
|
+
"controller",
|
|
12
|
+
"orchestration",
|
|
13
|
+
"daemon",
|
|
14
|
+
"worktree",
|
|
15
|
+
"checkpoint"
|
|
16
|
+
],
|
|
17
|
+
"bin": {
|
|
18
|
+
"claude-controller": "./bin/controller",
|
|
19
|
+
"claude-send": "./bin/send",
|
|
20
|
+
"claude-start": "./bin/start",
|
|
21
|
+
"claude-sh": "./bin/claude-sh"
|
|
22
|
+
},
|
|
23
|
+
"files": [
|
|
24
|
+
"bin/controller",
|
|
25
|
+
"bin/send",
|
|
26
|
+
"bin/start",
|
|
27
|
+
"bin/claude-sh",
|
|
28
|
+
"bin/app-launcher.sh",
|
|
29
|
+
"bin/native-app.py",
|
|
30
|
+
"lib/",
|
|
31
|
+
"service/controller.sh",
|
|
32
|
+
"web/*.py",
|
|
33
|
+
"web/static/index.html",
|
|
34
|
+
"web/static/app.js",
|
|
35
|
+
"web/static/styles.css",
|
|
36
|
+
"config.sh",
|
|
37
|
+
"README.md",
|
|
38
|
+
"LICENSE",
|
|
39
|
+
"postinstall.sh"
|
|
40
|
+
],
|
|
41
|
+
"scripts": {
|
|
42
|
+
"postinstall": "bash postinstall.sh",
|
|
43
|
+
"start": "bash bin/start",
|
|
44
|
+
"server": "python3 bin/native-app.py",
|
|
45
|
+
"service:start": "bash bin/controller start",
|
|
46
|
+
"service:stop": "bash bin/controller stop",
|
|
47
|
+
"service:status": "bash bin/controller status",
|
|
48
|
+
"send": "bash bin/send"
|
|
49
|
+
},
|
|
50
|
+
"os": [
|
|
51
|
+
"darwin",
|
|
52
|
+
"linux"
|
|
53
|
+
],
|
|
54
|
+
"engines": {
|
|
55
|
+
"node": ">=16.0.0"
|
|
56
|
+
},
|
|
57
|
+
"repository": {
|
|
58
|
+
"type": "git",
|
|
59
|
+
"url": ""
|
|
60
|
+
}
|
|
61
|
+
}
|
package/postinstall.sh
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# ============================================================
|
|
3
|
+
# postinstall — npm install 후 런타임 디렉토리 및 권한 설정
|
|
4
|
+
# ============================================================
|
|
5
|
+
set -euo pipefail
|
|
6
|
+
|
|
7
|
+
CONTROLLER_DIR="$(cd "$(dirname "$0")" && pwd)"
|
|
8
|
+
|
|
9
|
+
# 런타임 디렉토리 생성 (패키지에 포함되지 않는 것들)
|
|
10
|
+
for dir in logs sessions data queue worktrees uploads certs; do
|
|
11
|
+
mkdir -p "${CONTROLLER_DIR}/${dir}"
|
|
12
|
+
done
|
|
13
|
+
|
|
14
|
+
# bin 스크립트에 실행 권한 부여
|
|
15
|
+
chmod +x "${CONTROLLER_DIR}/bin/"* 2>/dev/null || true
|
|
16
|
+
chmod +x "${CONTROLLER_DIR}/service/"*.sh 2>/dev/null || true
|
|
17
|
+
|
|
18
|
+
echo ""
|
|
19
|
+
echo " claude-controller 설치 완료!"
|
|
20
|
+
echo ""
|
|
21
|
+
echo " 사용법:"
|
|
22
|
+
echo " claude-controller start 서비스 시작"
|
|
23
|
+
echo " claude-controller stop 서비스 중지"
|
|
24
|
+
echo " claude-controller status 상태 확인"
|
|
25
|
+
echo " claude-send \"프롬프트\" 작업 전송"
|
|
26
|
+
echo " claude-start 서비스 + TUI 실행"
|
|
27
|
+
echo ""
|
|
28
|
+
echo " 웹 대시보드:"
|
|
29
|
+
echo " npm run server 웹 서버 시작 (localhost:8420)"
|
|
30
|
+
echo ""
|