claude-code-watcher 1.0.1
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 +139 -0
- package/bin/ccw.js +29 -0
- package/hooks/session-tracker.mjs +165 -0
- package/hooks/session-tracker.sh +2 -0
- package/hooks/session-tracker.test.mjs +411 -0
- package/package.json +35 -0
- package/src/commands/help.mjs +39 -0
- package/src/commands/sessions.mjs +8 -0
- package/src/commands/setup.mjs +125 -0
- package/src/commands/sound.mjs +52 -0
- package/src/commands/start.mjs +58 -0
- package/src/commands/status.mjs +56 -0
- package/src/core/config.mjs +26 -0
- package/src/core/paths.mjs +12 -0
- package/src/core/session.mjs +107 -0
- package/src/core/store.mjs +153 -0
- package/src/input/keyboard.mjs +126 -0
- package/src/ui/ansi.mjs +164 -0
- package/src/ui/format.mjs +84 -0
- package/src/ui/layout.mjs +10 -0
- package/src/ui/renderer.mjs +507 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 정재남[ 플레이스여행서비스개발 ]
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
# claude-code-watcher (ccw)
|
|
2
|
+
|
|
3
|
+

|
|
4
|
+
|
|
5
|
+
VS Code 터미널에서 실행 중인 Claude Code 세션을 실시간으로 모니터링하는 CLI 대시보드입니다.
|
|
6
|
+
|
|
7
|
+
여러 개의 Claude Code 세션을 동시에 열어 두고 작업할 때, 각 세션의 현재 상태(작업 중 / 대기 중 / 알림)를 한 화면에서 확인할 수 있습니다.
|
|
8
|
+
|
|
9
|
+
## 기능
|
|
10
|
+
|
|
11
|
+
- **실시간 세션 목록** — 열려 있는 모든 Claude Code 세션의 상태를 TTY 오름차순으로 표시
|
|
12
|
+
- **상태 추적** — `working` / `waiting` / `notification` / `stale` 상태 자동 전환
|
|
13
|
+
- **서브에이전트 추적** — Agent 툴 실행 중인 서브에이전트 수 표시
|
|
14
|
+
- **알림** — `Stop`(응답 완료) 또는 `Notification`(권한 요청) 이벤트 발생 시 소리 알림 (사운드 커스터마이징 가능)
|
|
15
|
+
- **세션 상세보기** — 선택한 세션의 마지막 메시지, 경로, 타임스탬프 등 상세 정보
|
|
16
|
+
- **프로세스 재시작** — `R` 키로 대시보드 자체를 재실행
|
|
17
|
+
|
|
18
|
+
## 요구사항
|
|
19
|
+
|
|
20
|
+
- Node.js 18 이상
|
|
21
|
+
- Claude Code CLI
|
|
22
|
+
|
|
23
|
+
## 설치
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
npm i -g claude-code-watcher
|
|
27
|
+
|
|
28
|
+
# 훅 등록 (최초 1회)
|
|
29
|
+
ccw setup
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
`ccw setup`은 다음 작업을 수행합니다.
|
|
33
|
+
|
|
34
|
+
- `~/.claude/hooks/` 에 훅 스크립트 복사
|
|
35
|
+
- `~/.claude/settings.json` 에 Claude Code 라이프사이클 훅 등록
|
|
36
|
+
- `~/.claude/dashboard/active/` 디렉토리 생성
|
|
37
|
+
|
|
38
|
+
## 사용법
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
# 1. Claude Code 세션 시작 (프로젝트 디렉토리에서)
|
|
42
|
+
claude
|
|
43
|
+
|
|
44
|
+
# 2. 별도 터미널 탭에서 대시보드 실행
|
|
45
|
+
ccw
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
### 명령어
|
|
49
|
+
|
|
50
|
+
| 명령어 | 설명 |
|
|
51
|
+
|--------|------|
|
|
52
|
+
| `ccw` / `ccw start` | 인터랙티브 대시보드 실행 |
|
|
53
|
+
| `ccw setup` | 훅 설치 및 디렉토리 생성 |
|
|
54
|
+
| `ccw status` | 일회성 세션 목록 출력 |
|
|
55
|
+
| `ccw sessions` | 세션 목록 JSON 출력 |
|
|
56
|
+
| `ccw /sound` | 알림 사운드 설정 보기/변경 |
|
|
57
|
+
| `ccw help` | 도움말 출력 |
|
|
58
|
+
|
|
59
|
+
### 사운드 커스터마이징
|
|
60
|
+
|
|
61
|
+
알림 이벤트별로 macOS 시스템 사운드를 변경할 수 있습니다.
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
# 현재 설정 및 사용 가능한 사운드 목록 보기
|
|
65
|
+
ccw /sound
|
|
66
|
+
|
|
67
|
+
# Notification(권한 요청) 사운드 변경
|
|
68
|
+
ccw /sound noti Funk
|
|
69
|
+
|
|
70
|
+
# Stop(응답 완료) 사운드 변경
|
|
71
|
+
ccw /sound stop Blow
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
사용 가능한 사운드: `Basso`, `Blow`, `Bottle`, `Frog`, `Funk`, `Glass`, `Hero`, `Morse`, `Ping`, `Pop`, `Purr`, `Sosumi`, `Submarine`, `Tink`
|
|
75
|
+
|
|
76
|
+
설정은 `~/.claude/dashboard/config.json`에 저장되며, 대시보드 재시작 없이 즉시 적용됩니다.
|
|
77
|
+
|
|
78
|
+
### 대시보드 키 조작
|
|
79
|
+
|
|
80
|
+
| 키 | 동작 |
|
|
81
|
+
|----|------|
|
|
82
|
+
| `↑` / `↓` | 세션 선택 이동 |
|
|
83
|
+
| `Enter` | 상세보기 ↔ 목록 전환 |
|
|
84
|
+
| `r` | 데이터 새로고침 |
|
|
85
|
+
| `R` | 대시보드 프로세스 재시작 |
|
|
86
|
+
| `q` | 종료 |
|
|
87
|
+
|
|
88
|
+
## 세션 상태
|
|
89
|
+
|
|
90
|
+
| 상태 | 색상 | 설명 |
|
|
91
|
+
|------|------|------|
|
|
92
|
+
| `working` | 노랑 | Claude가 응답을 처리 중 |
|
|
93
|
+
| `waiting` | 초록 | 사용자 입력 대기 중 |
|
|
94
|
+
| `notification` | 청록 | 권한 요청 등 알림 발생 |
|
|
95
|
+
| `stale` | 회색 | 10분 이상 업데이트 없음 |
|
|
96
|
+
| `error` | 빨강 | 오류 발생 |
|
|
97
|
+
|
|
98
|
+
## 동작 원리
|
|
99
|
+
|
|
100
|
+
Claude Code의 라이프사이클 훅을 통해 세션 상태를 추적합니다.
|
|
101
|
+
|
|
102
|
+
```
|
|
103
|
+
Claude Code 이벤트 발생
|
|
104
|
+
↓
|
|
105
|
+
~/.claude/hooks/session-tracker.mjs 실행
|
|
106
|
+
↓
|
|
107
|
+
~/.claude/dashboard/active/<sessionId>.json 업데이트
|
|
108
|
+
↓
|
|
109
|
+
ccw 대시보드가 파일 변경 감지 → 화면 갱신
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
등록되는 훅 이벤트:
|
|
113
|
+
|
|
114
|
+
| 이벤트 | 전환 상태 |
|
|
115
|
+
|--------|-----------|
|
|
116
|
+
| `SessionStart` | `waiting` |
|
|
117
|
+
| `UserPromptSubmit` | `working` |
|
|
118
|
+
| `PreToolUse` / `PostToolUse` | `notification` → `working` 복원 |
|
|
119
|
+
| `Stop` | `waiting` + 알림 |
|
|
120
|
+
| `Notification` | `notification` (working 중일 때만) |
|
|
121
|
+
| `SubagentStart` / `SubagentStop` | 서브에이전트 목록 관리 |
|
|
122
|
+
| `SessionEnd` | 세션 파일 삭제 |
|
|
123
|
+
|
|
124
|
+
## 알려진 제한사항
|
|
125
|
+
|
|
126
|
+
### 사용자 인터럽트 미지원
|
|
127
|
+
|
|
128
|
+
Claude Code 응답 중 Esc 키로 취소("Interrupted · What should Claude do instead?")를 하면 **상태가 `working`으로 유지됩니다.**
|
|
129
|
+
|
|
130
|
+
현재 Claude Code 훅 API는 사용자 취소 이벤트를 제공하지 않습니다. `Stop` 훅은 자연 완료 시에만 발생하며, 인터럽트 시에는 발생하지 않습니다. 다음 프롬프트를 제출하면 `working` 상태로 정상 복귀합니다.
|
|
131
|
+
|
|
132
|
+
### 강제 종료 세션
|
|
133
|
+
|
|
134
|
+
Claude Code를 강제 종료(`kill`, 터미널 강제 닫기)하면 `SessionEnd` 훅이 실행되지 않아 세션 파일이 남을 수 있습니다. 대시보드 실행 시 프로세스 생존 여부를 자동으로 확인하여 정리합니다.
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
## 라이선스
|
|
138
|
+
|
|
139
|
+
MIT
|
package/bin/ccw.js
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { dirname, join } from 'node:path';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
|
|
5
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
6
|
+
const args = process.argv.slice(2);
|
|
7
|
+
const command = args[0] || 'start';
|
|
8
|
+
|
|
9
|
+
const commands = {
|
|
10
|
+
start: () => import(join(__dirname, '../src/commands/start.mjs')),
|
|
11
|
+
setup: () => import(join(__dirname, '../src/commands/setup.mjs')),
|
|
12
|
+
status: () => import(join(__dirname, '../src/commands/status.mjs')),
|
|
13
|
+
sessions: () => import(join(__dirname, '../src/commands/sessions.mjs')),
|
|
14
|
+
help: () => import(join(__dirname, '../src/commands/help.mjs')),
|
|
15
|
+
'--help': () => import(join(__dirname, '../src/commands/help.mjs')),
|
|
16
|
+
'-h': () => import(join(__dirname, '../src/commands/help.mjs')),
|
|
17
|
+
'/sound': () => import(join(__dirname, '../src/commands/sound.mjs')),
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
if (!commands[command]) {
|
|
21
|
+
console.error(`Unknown command: ${command}`);
|
|
22
|
+
console.error('Run "ccw help" for usage.');
|
|
23
|
+
process.exit(1);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
commands[command]().catch(err => {
|
|
27
|
+
console.error(err.message);
|
|
28
|
+
process.exit(1);
|
|
29
|
+
});
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { execFileSync } from 'node:child_process';
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
|
|
6
|
+
const ACTIVE_DIR = process.env.ACTIVE_DIR
|
|
7
|
+
?? path.join(process.env.HOME ?? '', '.claude', 'dashboard', 'active');
|
|
8
|
+
|
|
9
|
+
// Read JSON payload from stdin synchronously
|
|
10
|
+
let raw;
|
|
11
|
+
try {
|
|
12
|
+
raw = JSON.parse(fs.readFileSync(0, 'utf8'));
|
|
13
|
+
} catch {
|
|
14
|
+
process.exit(0);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const sessionId = raw.session_id ?? '';
|
|
18
|
+
const hookEvent = raw.hook_event_name ?? '';
|
|
19
|
+
const cwd = raw.cwd ?? '';
|
|
20
|
+
const transcript = raw.transcript_path ?? '';
|
|
21
|
+
|
|
22
|
+
if (!sessionId) process.exit(0);
|
|
23
|
+
|
|
24
|
+
const sessionFile = path.join(ACTIVE_DIR, `${sessionId}.json`);
|
|
25
|
+
|
|
26
|
+
const now = new Date();
|
|
27
|
+
const pad = n => String(n).padStart(2, '0');
|
|
28
|
+
const timestamp = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())} `
|
|
29
|
+
+ `${pad(now.getHours())}:${pad(now.getMinutes())}:${pad(now.getSeconds())}`;
|
|
30
|
+
|
|
31
|
+
// Read existing session data
|
|
32
|
+
let existing = {};
|
|
33
|
+
try {
|
|
34
|
+
existing = JSON.parse(fs.readFileSync(sessionFile, 'utf8'));
|
|
35
|
+
} catch { /* new session or parse error */ }
|
|
36
|
+
|
|
37
|
+
// Fields that fall back to existing values when absent from the current event payload
|
|
38
|
+
const resolvedCwd = cwd || existing.cwd || '';
|
|
39
|
+
const resolvedTranscript = transcript || existing.transcript || '';
|
|
40
|
+
const project = resolvedCwd ? path.basename(resolvedCwd) : (existing.project ?? 'unknown');
|
|
41
|
+
|
|
42
|
+
// Capture ppid once; reuse on subsequent events (used to detect orphaned sessions)
|
|
43
|
+
const ppid = existing.ppid ?? process.ppid;
|
|
44
|
+
|
|
45
|
+
// Capture TTY once; reuse on subsequent events
|
|
46
|
+
let ttyPath = existing.tty ?? '';
|
|
47
|
+
if (!ttyPath) {
|
|
48
|
+
try {
|
|
49
|
+
const dev = execFileSync('ps', ['-o', 'tty=', '-p', String(process.ppid)], { encoding: 'utf8' }).trim();
|
|
50
|
+
if (dev && dev !== '??') ttyPath = `/dev/${dev}`;
|
|
51
|
+
} catch { /* ignore */ }
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
let startedAt = existing.startedAt ?? timestamp;
|
|
55
|
+
let message = existing.message ?? '';
|
|
56
|
+
let lastResponse = existing.lastResponse ?? '';
|
|
57
|
+
let alertAt = existing.alertAt ?? '';
|
|
58
|
+
let alertEvent = existing.alertEvent ?? '';
|
|
59
|
+
let subagents = Array.isArray(existing.subagents) ? existing.subagents : [];
|
|
60
|
+
let status;
|
|
61
|
+
|
|
62
|
+
switch (hookEvent) {
|
|
63
|
+
case 'SessionStart':
|
|
64
|
+
status = 'waiting';
|
|
65
|
+
message = 'Session started';
|
|
66
|
+
startedAt = timestamp;
|
|
67
|
+
break;
|
|
68
|
+
|
|
69
|
+
case 'UserPromptSubmit': {
|
|
70
|
+
status = 'working';
|
|
71
|
+
const prompt = raw.prompt ?? '';
|
|
72
|
+
message = prompt ? prompt.slice(0, 100) : 'Processing...';
|
|
73
|
+
break;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
case 'PreToolUse':
|
|
77
|
+
case 'PostToolUse': {
|
|
78
|
+
// Restore to 'working' from 'notification' (permission granted).
|
|
79
|
+
// Preserve 'waiting' and other states to avoid spurious transitions after Stop.
|
|
80
|
+
const cur = existing.status ?? 'working';
|
|
81
|
+
status = (cur === 'notification' || cur === 'working') ? 'working' : cur;
|
|
82
|
+
message = existing.message ?? 'Processing...';
|
|
83
|
+
break;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
case 'Stop': {
|
|
87
|
+
status = 'waiting';
|
|
88
|
+
alertAt = timestamp;
|
|
89
|
+
alertEvent = 'Stop';
|
|
90
|
+
lastResponse = (raw.last_assistant_message ?? '').slice(0, 200);
|
|
91
|
+
message = lastResponse ? lastResponse.slice(0, 100) : 'Ready';
|
|
92
|
+
subagents = [];
|
|
93
|
+
break;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
case 'Notification': {
|
|
97
|
+
// Only elevate to 'notification' when Claude is actively working (permission request).
|
|
98
|
+
// If already waiting, this is a background completion ping — keep 'waiting'.
|
|
99
|
+
const prevStatus = existing.status ?? 'waiting';
|
|
100
|
+
status = prevStatus === 'working' ? 'notification' : prevStatus;
|
|
101
|
+
alertAt = timestamp;
|
|
102
|
+
alertEvent = 'Notification';
|
|
103
|
+
if (status === 'notification') {
|
|
104
|
+
message = (raw.message ?? 'Notification').slice(0, 100);
|
|
105
|
+
}
|
|
106
|
+
break;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
case 'SessionEnd':
|
|
110
|
+
try { fs.unlinkSync(sessionFile); } catch { /* already gone */ }
|
|
111
|
+
process.exit(0);
|
|
112
|
+
break;
|
|
113
|
+
|
|
114
|
+
case 'SubagentStart': {
|
|
115
|
+
status = 'working';
|
|
116
|
+
message = existing.message ?? 'Processing...';
|
|
117
|
+
const agentId = raw.agent_id ?? '';
|
|
118
|
+
const agentType = raw.agent_type ?? '';
|
|
119
|
+
if (agentId && !subagents.some(s => s.agentId === agentId)) {
|
|
120
|
+
subagents.push({ agentId, agentType, status: 'working', startedAt: timestamp, completedAt: '' });
|
|
121
|
+
}
|
|
122
|
+
break;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
case 'SubagentStop': {
|
|
126
|
+
status = 'working';
|
|
127
|
+
message = existing.message ?? 'Processing...';
|
|
128
|
+
const agentId = raw.agent_id ?? '';
|
|
129
|
+
subagents = subagents.filter(s => s.agentId !== agentId);
|
|
130
|
+
break;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
default:
|
|
134
|
+
status = existing.status ?? 'waiting';
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
fs.mkdirSync(ACTIVE_DIR, { recursive: true });
|
|
138
|
+
|
|
139
|
+
const data = {
|
|
140
|
+
session: sessionId,
|
|
141
|
+
project,
|
|
142
|
+
cwd: resolvedCwd,
|
|
143
|
+
status,
|
|
144
|
+
message,
|
|
145
|
+
lastResponse,
|
|
146
|
+
sessionName: existing.sessionName ?? '',
|
|
147
|
+
transcript: resolvedTranscript,
|
|
148
|
+
updated: timestamp,
|
|
149
|
+
startedAt,
|
|
150
|
+
tty: ttyPath,
|
|
151
|
+
ppid,
|
|
152
|
+
alertAt,
|
|
153
|
+
alertEvent,
|
|
154
|
+
subagents,
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
// Atomic write: write to tmp then rename
|
|
158
|
+
const tmp = `${sessionFile}.tmp.${process.pid}`;
|
|
159
|
+
try {
|
|
160
|
+
fs.writeFileSync(tmp, JSON.stringify(data, null, 2), 'utf8');
|
|
161
|
+
fs.renameSync(tmp, sessionFile);
|
|
162
|
+
} catch (err) {
|
|
163
|
+
try { fs.unlinkSync(tmp); } catch { /* ignore */ }
|
|
164
|
+
throw err;
|
|
165
|
+
}
|