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/config.sh ADDED
@@ -0,0 +1,74 @@
1
+ #!/usr/bin/env bash
2
+ # ============================================================
3
+ # Controller Service — Configuration
4
+ # ============================================================
5
+
6
+ # Claude CLI 경로
7
+ # 1) 환경변수 CLAUDE_BIN이 있으면 사용
8
+ # 2) PATH에서 claude를 찾음
9
+ # 3) macOS 앱 기본 경로
10
+ CLAUDE_BIN="${CLAUDE_BIN:-$(command -v claude 2>/dev/null || echo "/Applications/cmux.app/Contents/Resources/bin/claude")}"
11
+
12
+ # 기본 출력 형식 (stream-json: 실시간 토큰 스트리밍)
13
+ DEFAULT_OUTPUT_FORMAT="${DEFAULT_OUTPUT_FORMAT:-stream-json}"
14
+
15
+ # 모든 도구 권한 허용
16
+ DEFAULT_ALLOWED_TOOLS="${DEFAULT_ALLOWED_TOOLS:-Bash,Read,Write,Edit,Glob,Grep,Agent,NotebookEdit,WebFetch,WebSearch}"
17
+
18
+ # 모델 설정
19
+ DEFAULT_MODEL="${DEFAULT_MODEL:-}"
20
+
21
+ # 디렉토리 경로
22
+ CONTROLLER_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
23
+ LOGS_DIR="${CONTROLLER_DIR}/logs"
24
+ SESSIONS_DIR="${CONTROLLER_DIR}/sessions"
25
+ QUEUE_DIR="${CONTROLLER_DIR}/queue"
26
+
27
+ # FIFO 파이프 경로 — 외부에서 이 파이프에 쓰면 서비스가 수신
28
+ FIFO_PATH="${CONTROLLER_DIR}/queue/controller.pipe"
29
+
30
+ # PID 파일
31
+ PID_FILE="${CONTROLLER_DIR}/service/controller.pid"
32
+
33
+ # 최대 동시 백그라운드 작업 수
34
+ MAX_BACKGROUND_JOBS="${MAX_BACKGROUND_JOBS:-10}"
35
+
36
+ # 시스템 프롬프트 추가
37
+ APPEND_SYSTEM_PROMPT="${APPEND_SYSTEM_PROMPT:-}"
38
+
39
+ # 작업 디렉토리 — claude -p 실행 시 --cwd
40
+ WORKING_DIR="${WORKING_DIR:-}"
41
+
42
+ # ── Worktree 설정 ─────────────────────────────────────────
43
+ # 대상 Git 저장소 (worktree 생성 원본)
44
+ TARGET_REPO="${TARGET_REPO:-}"
45
+
46
+ # 워크트리 기준 브랜치
47
+ BASE_BRANCH="${BASE_BRANCH:-main}"
48
+
49
+ # 워크트리 저장 디렉토리
50
+ WORKTREES_DIR="${CONTROLLER_DIR}/worktrees"
51
+
52
+ # ── 권한 설정 ──────────────────────────────────────────────
53
+ # true로 설정 시 --dangerously-skip-permissions 사용 (모든 도구 무제한 허용)
54
+ SKIP_PERMISSIONS="${SKIP_PERMISSIONS:-true}"
55
+
56
+ # ── Checkpoint 설정 ────────────────────────────────────────
57
+ # 체크포인트 감시 주기 (초) — 이 간격으로 worktree 변경을 확인
58
+ CHECKPOINT_INTERVAL="${CHECKPOINT_INTERVAL:-5}"
59
+
60
+ # ── settings.json 오버라이드 ────────────────────────────────
61
+ # data/settings.json이 존재하면 해당 값으로 기본값을 덮어씀
62
+ SETTINGS_FILE="${CONTROLLER_DIR}/data/settings.json"
63
+ if [[ -f "$SETTINGS_FILE" ]] && command -v jq &>/dev/null; then
64
+ _s() { jq -r "$1 // empty" "$SETTINGS_FILE" 2>/dev/null; }
65
+ _v=$(_s '.skip_permissions'); [[ -n "$_v" ]] && SKIP_PERMISSIONS="$_v"
66
+ _v=$(_s '.allowed_tools'); [[ -n "$_v" ]] && DEFAULT_ALLOWED_TOOLS="$_v"
67
+ _v=$(_s '.model'); [[ -n "$_v" ]] && DEFAULT_MODEL="$_v"
68
+ _v=$(_s '.max_jobs'); [[ -n "$_v" ]] && MAX_BACKGROUND_JOBS="$_v"
69
+ _v=$(_s '.append_system_prompt'); [[ -n "$_v" ]] && APPEND_SYSTEM_PROMPT="$_v"
70
+ _v=$(_s '.target_repo'); [[ -n "$_v" ]] && TARGET_REPO="$_v"
71
+ _v=$(_s '.base_branch'); [[ -n "$_v" ]] && BASE_BRANCH="$_v"
72
+ _v=$(_s '.checkpoint_interval'); [[ -n "$_v" ]] && CHECKPOINT_INTERVAL="$_v"
73
+ unset -f _s; unset _v
74
+ fi
@@ -0,0 +1,237 @@
1
+ #!/usr/bin/env bash
2
+ # ============================================================
3
+ # Checkpoint 관리 모듈
4
+ # Worktree에서 실행 중인 job의 파일 변경을 자동 커밋(checkpoint)하고
5
+ # 특정 체크포인트로 되돌리는(rewind) 기능을 제공합니다.
6
+ #
7
+ # 의존: jobs.sh (_get_meta_field), config.sh (LOGS_DIR)
8
+ # ============================================================
9
+
10
+ # ── 설정 ────────────────────────────────────────────────────
11
+ CHECKPOINT_INTERVAL="${CHECKPOINT_INTERVAL:-5}"
12
+ CHECKPOINT_PREFIX="ckpt"
13
+
14
+ # ── 체크포인트 워처 루프 ────────────────────────────────────
15
+ # 백그라운드에서 worktree를 감시하며 파일 변경이 안정화되면 자동 커밋.
16
+ # 호출 시 반드시 & (백그라운드)로 실행할 것.
17
+ #
18
+ # Usage: checkpoint_watcher_loop <worktree_path> <job_id> <meta_file> &
19
+ checkpoint_watcher_loop() {
20
+ local wt_path="$1"
21
+ local job_id="$2"
22
+ local meta_file="$3"
23
+ local turn=0
24
+ local prev_hash=""
25
+
26
+ cd "$wt_path" 2>/dev/null || return 1
27
+
28
+ # worktree 로컬 git 설정 (커밋용)
29
+ git config user.email "checkpoint@controller.local" 2>/dev/null
30
+ git config user.name "Controller Checkpoint" 2>/dev/null
31
+
32
+ # 초기 상태에 untracked 파일이 있으면 커밋
33
+ git add -A 2>/dev/null
34
+ if ! git diff --cached --quiet 2>/dev/null; then
35
+ git commit -m "${CHECKPOINT_PREFIX}:${job_id}:0:init" --no-verify 2>/dev/null || true
36
+ fi
37
+
38
+ while true; do
39
+ sleep "$CHECKPOINT_INTERVAL"
40
+
41
+ # job 상태 확인 — running이 아니면 최종 커밋 후 종료
42
+ local status=""
43
+ [[ -f "$meta_file" ]] && status=$(grep '^STATUS=' "$meta_file" 2>/dev/null | tail -1 | sed 's/^STATUS=//')
44
+ if [[ "$status" != "running" ]]; then
45
+ _ckpt_commit_if_dirty "$wt_path" "$job_id" "$turn" "final"
46
+ break
47
+ fi
48
+
49
+ # 현재 변경사항 해시 (diff + untracked files)
50
+ local curr_hash
51
+ curr_hash=$( { git diff 2>/dev/null; git ls-files --others --exclude-standard 2>/dev/null; } | md5 2>/dev/null || echo "none" )
52
+
53
+ # 빈 해시 = 변경 없음
54
+ local empty_hash
55
+ empty_hash=$(echo -n | md5 2>/dev/null || echo "none")
56
+ if [[ "$curr_hash" == "$empty_hash" ]]; then
57
+ prev_hash=""
58
+ continue
59
+ fi
60
+
61
+ # 안정화 대기: 이전 체크와 해시가 다르면 아직 변경 진행 중
62
+ if [[ "$curr_hash" != "$prev_hash" ]]; then
63
+ prev_hash="$curr_hash"
64
+ continue
65
+ fi
66
+
67
+ # 2회 연속 동일 해시 = 변경 완료, 커밋 수행
68
+ (( turn++ )) || true
69
+ _ckpt_commit_if_dirty "$wt_path" "$job_id" "$turn" ""
70
+ prev_hash=""
71
+ done
72
+ }
73
+
74
+ # ── 내부: 변경이 있으면 커밋 ────────────────────────────────
75
+ _ckpt_commit_if_dirty() {
76
+ local wt_path="$1" job_id="$2" turn="$3" suffix="$4"
77
+
78
+ cd "$wt_path" 2>/dev/null || return 1
79
+
80
+ git add -A 2>/dev/null
81
+
82
+ if ! git diff --cached --quiet 2>/dev/null; then
83
+ local msg="${CHECKPOINT_PREFIX}:${job_id}:${turn}"
84
+ [[ -n "$suffix" ]] && msg="${msg}:${suffix}"
85
+
86
+ # 변경 파일 목록 (최대 5개)
87
+ local changed
88
+ changed=$(git diff --cached --name-only 2>/dev/null | head -5 | tr '\n' ', ')
89
+ [[ -n "$changed" ]] && msg="${msg} [${changed%,}]"
90
+
91
+ git commit -m "$msg" --no-verify 2>/dev/null || true
92
+ fi
93
+ }
94
+
95
+ # ── 체크포인트 목록 조회 ────────────────────────────────────
96
+ # worktree의 git log에서 이 job의 checkpoint 커밋들을 JSON 배열로 반환.
97
+ #
98
+ # Usage: checkpoint_list <worktree_path> <job_id>
99
+ # Output: JSON array
100
+ checkpoint_list() {
101
+ local wt_path="$1"
102
+ local job_id="$2"
103
+
104
+ # worktree 유효성 확인
105
+ if [[ ! -d "$wt_path" ]] || ! (cd "$wt_path" && git rev-parse --git-dir >/dev/null 2>&1); then
106
+ echo "[]"
107
+ return
108
+ fi
109
+
110
+ cd "$wt_path" 2>/dev/null || { echo "[]"; return; }
111
+
112
+ local result="["
113
+ local first=true
114
+
115
+ while IFS='|' read -r hash ts msg; do
116
+ [[ -z "$hash" ]] && continue
117
+
118
+ # 턴 번호 추출
119
+ local turn_num
120
+ turn_num=$(echo "$msg" | sed -n "s/.*${CHECKPOINT_PREFIX}:${job_id}:\([0-9]*\).*/\1/p")
121
+ [[ -z "$turn_num" ]] && turn_num=0
122
+
123
+ # 변경 파일 수
124
+ local file_count
125
+ file_count=$(git diff-tree --no-commit-id --name-only -r "$hash" 2>/dev/null | wc -l | tr -d ' ')
126
+
127
+ # 변경 파일 목록
128
+ local file_list
129
+ file_list=$(git diff-tree --no-commit-id --name-only -r "$hash" 2>/dev/null | head -10 | jq -R . 2>/dev/null | jq -s . 2>/dev/null)
130
+ [[ -z "$file_list" ]] && file_list="[]"
131
+
132
+ [[ "$first" == "true" ]] && first=false || result="${result},"
133
+ result="${result}{\"hash\":\"${hash}\",\"turn\":${turn_num},\"timestamp\":\"${ts}\",\"message\":$(echo "$msg" | jq -R .),\"files_changed\":${file_count},\"files\":${file_list}}"
134
+ done < <(git log --format='%H|%aI|%s' --grep="${CHECKPOINT_PREFIX}:${job_id}:" 2>/dev/null)
135
+
136
+ result="${result}]"
137
+ echo "$result"
138
+ }
139
+
140
+ # ── 대화 컨텍스트 추출 ──────────────────────────────────────
141
+ # stream-json .out 파일에서 assistant 텍스트 + tool_use 요약을 추출.
142
+ # 최대 max_chars 글자까지만 반환하여 프롬프트 크기를 제한.
143
+ #
144
+ # Usage: checkpoint_extract_context <out_file> [max_chars]
145
+ # Output: plain text
146
+ checkpoint_extract_context() {
147
+ local out_file="$1"
148
+ local max_chars="${2:-4000}"
149
+
150
+ [[ -f "$out_file" ]] || return 0
151
+
152
+ # jq로 한 번에 추출 (성능 + 안정성)
153
+ jq -r '
154
+ if .type == "assistant" then
155
+ [.message.content[]? |
156
+ if .type == "text" then "[\(.type)] \(.text[0:300])"
157
+ elif .type == "tool_use" then "[tool: \(.name)] \(.input | tostring[0:150])"
158
+ else empty end
159
+ ] | join("\n")
160
+ elif .type == "result" then
161
+ "[result] \(.result[0:300] // "")"
162
+ else empty end
163
+ ' "$out_file" 2>/dev/null | head -c "$max_chars"
164
+ }
165
+
166
+ # ── Rewind 실행 ──────────────────────────────────────────────
167
+ # 1. 실행 중인 job 종료
168
+ # 2. worktree를 지정된 checkpoint로 git reset --hard
169
+ # 3. 대화 컨텍스트 추출
170
+ # 4. 새 프롬프트 생성 (FIFO 전송은 호출자가 담당)
171
+ #
172
+ # Usage: checkpoint_rewind <job_id> <checkpoint_hash> <new_prompt>
173
+ # Output: 생성된 full_prompt (stdout), 실패 시 exit 1
174
+ checkpoint_rewind() {
175
+ local job_id="$1"
176
+ local ckpt_hash="$2"
177
+ local new_prompt="$3"
178
+
179
+ local meta_file="${LOGS_DIR}/job_${job_id}.meta"
180
+ local out_file="${LOGS_DIR}/job_${job_id}.out"
181
+
182
+ # meta 파일 확인
183
+ if [[ ! -f "$meta_file" ]]; then
184
+ echo "[오류] Job #${job_id}를 찾을 수 없습니다." >&2
185
+ return 1
186
+ fi
187
+
188
+ # 실행 중이면 종료
189
+ local status
190
+ status=$(_get_meta_field "$meta_file" "STATUS")
191
+ if [[ "$status" == "running" ]]; then
192
+ job_kill "$job_id" >/dev/null 2>&1
193
+ sleep 1
194
+ fi
195
+
196
+ # worktree 경로 확인
197
+ local wt_path
198
+ wt_path=$(_get_meta_field "$meta_file" "WORKTREE")
199
+ if [[ -z "$wt_path" || ! -d "$wt_path" ]]; then
200
+ echo "[오류] 워크트리를 찾을 수 없습니다." >&2
201
+ return 1
202
+ fi
203
+
204
+ # checkpoint 커밋 유효성 확인
205
+ if ! (cd "$wt_path" && git cat-file -t "$ckpt_hash" >/dev/null 2>&1); then
206
+ echo "[오류] 유효하지 않은 체크포인트: $ckpt_hash" >&2
207
+ return 1
208
+ fi
209
+
210
+ # 체크포인트로 reset
211
+ (cd "$wt_path" && git reset --hard "$ckpt_hash" 2>/dev/null) || {
212
+ echo "[오류] git reset 실패" >&2
213
+ return 1
214
+ }
215
+
216
+ # 대화 컨텍스트 추출
217
+ local context=""
218
+ if [[ -f "$out_file" ]]; then
219
+ context=$(checkpoint_extract_context "$out_file" 4000)
220
+ fi
221
+
222
+ # 새 프롬프트 구성
223
+ if [[ -n "$context" ]]; then
224
+ cat <<REWIND_PROMPT
225
+ [이전 작업 컨텍스트 — 아래는 이전 세션에서 수행된 작업 요약입니다]
226
+ ${context}
227
+
228
+ [Rewind 지시사항]
229
+ 파일 상태가 위 작업 중간의 체크포인트 시점으로 복원되었습니다.
230
+ 이전 작업 내용을 참고하되, 이어서 다음을 수행하세요:
231
+
232
+ ${new_prompt}
233
+ REWIND_PROMPT
234
+ else
235
+ echo "$new_prompt"
236
+ fi
237
+ }
@@ -0,0 +1,183 @@
1
+ #!/usr/bin/env bash
2
+ # ============================================================
3
+ # 실행 엔진 — 서비스 데몬 패턴
4
+ # FIFO로 수신된 JSON 메시지를 파싱하여 claude -p 를 백그라운드로 실행합니다.
5
+ # 모든 실행은 비동기이며, 결과는 $LOGS_DIR/job_<id>.out 에 저장됩니다.
6
+ # ============================================================
7
+
8
+ # ── claude -p 실행 (내부 전용) ─────────────────────────────
9
+ # JSON에서 파싱한 개별 값을 받아 claude CLI를 호출합니다.
10
+ # eval 대신 인자 배열을 직접 사용하여 글로브 확장·인젝션 방지
11
+ _run_claude() {
12
+ local prompt="$1"
13
+ local cwd="$2"
14
+ local out_file="$3"
15
+ local session_mode="${4:-}"
16
+ local session_id="${5:-}"
17
+
18
+ local args=()
19
+ args+=(-p "$prompt")
20
+ args+=(--output-format json)
21
+
22
+ # 모든 도구 권한 허용
23
+ if [[ -n "${DEFAULT_ALLOWED_TOOLS:-}" ]]; then
24
+ args+=(--allowedTools "$DEFAULT_ALLOWED_TOOLS")
25
+ fi
26
+
27
+ # 모델 지정
28
+ if [[ -n "${DEFAULT_MODEL:-}" ]]; then
29
+ args+=(--model "$DEFAULT_MODEL")
30
+ fi
31
+
32
+ # 시스템 프롬프트 추가
33
+ if [[ -n "${APPEND_SYSTEM_PROMPT:-}" ]]; then
34
+ args+=(--append-system-prompt "$APPEND_SYSTEM_PROMPT")
35
+ fi
36
+
37
+ # 작업 디렉토리 — JSON에서 받은 cwd 우선, 없으면 글로벌 WORKING_DIR
38
+ local effective_cwd="${cwd:-${WORKING_DIR:-}}"
39
+ if [[ -n "$effective_cwd" ]]; then
40
+ args+=(--cwd "$effective_cwd")
41
+ fi
42
+
43
+ # 세션 이어가기 플래그
44
+ case "${session_mode}" in
45
+ continue)
46
+ args+=(--continue)
47
+ ;;
48
+ resume)
49
+ if [[ -n "$session_id" ]]; then
50
+ args+=(--resume "$session_id")
51
+ fi
52
+ ;;
53
+ esac
54
+
55
+ # 직접 실행 — 배열로 안전하게 호출
56
+ "$CLAUDE_BIN" "${args[@]}" > "$out_file" 2>&1
57
+ }
58
+
59
+ # ── JSON 기반 실행 (서비스 데몬의 진입점) ──────────────────
60
+ # FIFO에서 수신한 JSON 문자열을 파싱하여 백그라운드로 claude -p 실행
61
+ #
62
+ # JSON 필드:
63
+ # id (필수) — 작업 식별자. 외부에서 부여한 고유 ID
64
+ # prompt (필수) — claude에 전달할 프롬프트
65
+ # cwd (선택) — 작업 디렉토리. 미지정 시 WORKING_DIR 사용
66
+ # session (선택) — "continue" | "resume:<session_id>"
67
+ #
68
+ # 반환: stdout에 job_id 출력
69
+ execute_from_json() {
70
+ local json_string="$1"
71
+
72
+ # ── JSON 유효성 검사 ──
73
+ if ! echo "$json_string" | jq empty 2>/dev/null; then
74
+ echo "[오류] 유효하지 않은 JSON: $json_string" >&2
75
+ return 1
76
+ fi
77
+
78
+ # ── 필수 필드 파싱 ──
79
+ local job_id prompt cwd session_raw images_json
80
+ job_id=$(echo "$json_string" | jq -r '.id // empty')
81
+ prompt=$(echo "$json_string" | jq -r '.prompt // empty')
82
+ cwd=$(echo "$json_string" | jq -r '.cwd // empty')
83
+ session_raw=$(echo "$json_string" | jq -r '.session // empty')
84
+ images_json=$(echo "$json_string" | jq -r '.images // empty')
85
+
86
+ if [[ -z "$job_id" ]]; then
87
+ echo "[오류] JSON에 'id' 필드가 없습니다." >&2
88
+ return 1
89
+ fi
90
+
91
+ if [[ -z "$prompt" ]]; then
92
+ echo "[오류] JSON에 'prompt' 필드가 없습니다." >&2
93
+ return 1
94
+ fi
95
+
96
+ # ── 첨부 이미지가 있으면 프롬프트에 경로 삽입 ──
97
+ if [[ -n "$images_json" && "$images_json" != "null" ]]; then
98
+ local img_count
99
+ img_count=$(echo "$images_json" | jq -r 'length')
100
+ if [[ "$img_count" -gt 0 ]]; then
101
+ local img_lines=""
102
+ for i in $(seq 0 $(( img_count - 1 ))); do
103
+ local img_path
104
+ img_path=$(echo "$images_json" | jq -r ".[$i]")
105
+ img_lines="${img_lines}
106
+ - ${img_path}"
107
+ done
108
+ prompt="[첨부 파일 — Read 도구로 확인하세요]${img_lines}
109
+
110
+ ${prompt}"
111
+ fi
112
+ fi
113
+
114
+ # ── 세션 모드 분리 (예: "resume:abc-123") ──
115
+ local session_mode="" session_id=""
116
+ if [[ -n "$session_raw" ]]; then
117
+ case "$session_raw" in
118
+ resume:*)
119
+ session_mode="resume"
120
+ session_id="${session_raw#resume:}"
121
+ ;;
122
+ continue)
123
+ session_mode="continue"
124
+ ;;
125
+ *)
126
+ echo "[경고] 알 수 없는 session 값: $session_raw" >&2
127
+ ;;
128
+ esac
129
+ fi
130
+
131
+ # ── 최대 동시 작업 수 확인 ──
132
+ local running_count=0
133
+ for meta_file in "${LOGS_DIR}"/job_*.meta; do
134
+ [[ -f "$meta_file" ]] || continue
135
+ local STATUS=""
136
+ STATUS=$(_get_meta_field "$meta_file" "STATUS")
137
+ if [[ "$STATUS" == "running" ]]; then
138
+ (( running_count++ )) || true
139
+ fi
140
+ done
141
+
142
+ if [[ $running_count -ge $MAX_BACKGROUND_JOBS ]]; then
143
+ echo "[오류] 최대 동시 작업 수($MAX_BACKGROUND_JOBS)에 도달. job_id=$job_id 거부됨" >&2
144
+ return 1
145
+ fi
146
+
147
+ # ── Job 등록 (jobs.sh 모듈 사용) ──
148
+ # job_register는 내부 카운터 ID를 반환하지만,
149
+ # 외부 ID(job_id)를 파일명에 직접 사용하여 추적 일관성 확보
150
+ local internal_id
151
+ internal_id=$(job_register "$prompt")
152
+
153
+ # 외부 ID ↔ 내부 ID 매핑 저장
154
+ echo "$job_id" > "${LOGS_DIR}/job_${internal_id}.ext_id"
155
+
156
+ local out_file="${LOGS_DIR}/job_${internal_id}.out"
157
+
158
+ # ── 서브쉘에서 백그라운드 실행 ──
159
+ (
160
+ _run_claude "$prompt" "$cwd" "$out_file" "$session_mode" "$session_id"
161
+ local exit_code=$?
162
+
163
+ # 세션 ID 추출 (JSON 출력에서)
164
+ if [[ -f "$out_file" ]]; then
165
+ local sid
166
+ sid=$(jq -r '.session_id // empty' "$out_file" 2>/dev/null)
167
+ [[ -n "$sid" ]] && job_set_session "$internal_id" "$sid"
168
+ fi
169
+
170
+ # 완료 상태 갱신
171
+ if [[ $exit_code -eq 0 ]]; then
172
+ job_mark_done "$internal_id"
173
+ else
174
+ job_mark_failed "$internal_id"
175
+ fi
176
+ ) &
177
+
178
+ local bg_pid=$!
179
+ job_set_pid "$internal_id" "$bg_pid"
180
+
181
+ # 호출자(서비스 데몬)에게 내부 ID 반환
182
+ echo "$internal_id"
183
+ }